diff --git a/.core_files.yaml b/.core_files.yaml index 6fd3a74df92..2624c4432be 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -6,6 +6,7 @@ core: &core - homeassistant/helpers/** - homeassistant/package_constraints.txt - homeassistant/util/** + - mypy.ini - pyproject.toml - requirements.txt - setup.cfg @@ -131,6 +132,7 @@ tests: &tests - tests/components/conftest.py - tests/components/diagnostics/** - tests/components/history/** + - tests/components/light/common.py - tests/components/logbook/** - tests/components/recorder/** - tests/components/repairs/** diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 44c38afdec6..29d5a95ea01 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -62,7 +62,7 @@ "json.schemas": [ { "fileMatch": ["homeassistant/components/*/manifest.json"], - "url": "./script/json_schemas/manifest_schema.json" + "url": "${containerWorkspaceFolder}/script/json_schemas/manifest_schema.json" } ] } diff --git a/.gitattributes b/.gitattributes index eca98fc228f..6a18819be9d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,14 @@ *.pcm binary Dockerfile.dev linguist-language=Dockerfile + +# Generated files +CODEOWNERS linguist-generated=true +Dockerfile linguist-generated=true +homeassistant/generated/*.py linguist-generated=true +mypy.ini linguist-generated=true +requirements.txt linguist-generated=true +requirements_all.txt linguist-generated=true +requirements_test_all.txt linguist-generated=true +requirements_test_pre_commit.txt linguist-generated=true +script/hassfest/docker/Dockerfile linguist-generated=true diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index cc100c48fd8..5b1cf48df68 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -69,7 +69,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: translations path: translations.tar.gz @@ -94,7 +94,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v6 + uses: dawidd6/action-download-artifact@v8 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -105,7 +105,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v6 + uses: dawidd6/action-download-artifact@v8 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 + uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -517,12 +517,12 @@ jobs: tags: ${{ env.HASSFEST_IMAGE_TAG }} - name: Run hassfest against core - run: docker run --rm -v ${{ github.workspace }}/homeassistant:/github/workspace/homeassistant ${{ env.HASSFEST_IMAGE_TAG }} --core-integrations-path=/github/workspace/homeassistant/components + run: docker run --rm -v ${{ github.workspace }}:/github/workspace ${{ env.HASSFEST_IMAGE_TAG }} --core-path=/github/workspace - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 + uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1.4.4 + uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b9e5b91aff2..6527a09e15f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,9 +40,9 @@ env: CACHE_VERSION: 11 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2024.12" - DEFAULT_PYTHON: "3.12" - ALL_PYTHON_VERSIONS: "['3.12', '3.13']" + HA_SHORT_VERSION: "2025.2" + DEFAULT_PYTHON: "3.13" + ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) # 10.6 is the current long-term-support @@ -240,7 +240,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.1.2 + uses: actions/cache@v4.2.0 with: path: venv key: >- @@ -256,7 +256,7 @@ jobs: uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.1.2 + uses: actions/cache@v4.2.0 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -286,7 +286,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -295,7 +295,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -326,7 +326,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -335,7 +335,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -366,7 +366,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -375,7 +375,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -482,16 +482,15 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.1.2 + uses: actions/cache@v4.2.0 with: path: venv - lookup-only: true key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v4.1.2 + uses: actions/cache@v4.2.0 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -531,6 +530,26 @@ jobs: python -m script.gen_requirements_all ci uv pip install -r requirements_all_pytest.txt -r requirements_test.txt uv pip install -e . --config-settings editable_mode=compat + - name: Dump pip freeze + run: | + python -m venv venv + . venv/bin/activate + python --version + uv pip freeze >> pip_freeze.txt + - name: Upload pip_freeze artifact + uses: actions/upload-artifact@v4.6.0 + with: + name: pip-freeze-${{ matrix.python-version }} + path: pip_freeze.txt + overwrite: true + - name: Remove pip_freeze + run: rm pip_freeze.txt + - name: Remove generated requirements_all + if: steps.cache-venv.outputs.cache-hit != 'true' + run: rm requirements_all_pytest.txt requirements_all_wheels_*.txt + - name: Check dirty + run: | + ./script/check_dirty hassfest: name: Check hassfest @@ -559,7 +578,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -592,7 +611,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -630,7 +649,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -642,7 +661,7 @@ jobs: . venv/bin/activate python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json - name: Upload licenses - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -673,7 +692,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -720,7 +739,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -772,7 +791,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -780,7 +799,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.1.2 + uses: actions/cache@v4.2.0 with: path: .mypy_cache key: >- @@ -819,6 +838,12 @@ jobs: needs: - info - base + - gen-requirements-all + - hassfest + - lint-other + - lint-ruff + - lint-ruff-format + - mypy name: Split tests for full run steps: - name: Install additional OS dependencies @@ -840,7 +865,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -852,7 +877,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: pytest_buckets path: pytest_buckets.txt @@ -904,7 +929,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -954,14 +979,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-full.conclusion == 'failure' - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1025,7 +1050,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -1081,7 +1106,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1089,7 +1114,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1154,7 +1179,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -1211,7 +1236,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1219,7 +1244,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1248,7 +1273,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.3.0 with: fail_ci_if_error: true flags: full-suite @@ -1300,7 +1325,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.1.2 + uses: actions/cache/restore@v4.2.0 with: path: venv fail-on-cache-miss: true @@ -1353,14 +1378,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1386,7 +1411,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.3.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4977139f5dc..ee7fad4bb4e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.27.5 + uses: github/codeql-action/init@v3.28.4 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.27.5 + uses: github/codeql-action/analyze@v3.28.4 with: category: "/language:python" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b51550767b8..11c87266525 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: # - No PRs marked as no-stale # - No issues (-1) - name: 60 days stale PRs policy - uses: actions/stale@v9.0.0 + uses: actions/stale@v9.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 60 @@ -57,7 +57,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v9.0.0 + uses: actions/stale@v9.1.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -87,7 +87,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v9.0.0 + uses: actions/stale@v9.1.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 3fffc41e60c..fa3c2305190 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -10,7 +10,7 @@ on: - "**strings.json" env: - DEFAULT_PYTHON: "3.12" + DEFAULT_PYTHON: "3.13" jobs: upload: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b9f54bba081..00f0c507414 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -17,7 +17,7 @@ on: - "script/gen_requirements_all.py" env: - DEFAULT_PYTHON: "3.12" + DEFAULT_PYTHON: "3.13" concurrency: group: ${{ github.workflow }}-${{ github.ref_name}} @@ -76,18 +76,37 @@ jobs: # Use C-Extension for SQLAlchemy echo "REQUIRE_SQLALCHEMY_CEXT=1" + + # Add additional pip wheel build constraints + echo "PIP_CONSTRAINT=build_constraints.txt" ) > .env_file + - name: Write pip wheel build constraints + run: | + ( + # ninja 1.11.1.2 + 1.11.1.3 seem to be broken on at least armhf + # this caused the numpy builds to fail + # https://github.com/scikit-build/ninja-python-distributions/issues/274 + echo "ninja==1.11.1.1" + ) > build_constraints.txt + - name: Upload env_file - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: env_file path: ./.env_file include-hidden-files: true overwrite: true + - name: Upload build_constraints + uses: actions/upload-artifact@v4.6.0 + with: + name: build_constraints + path: ./build_constraints.txt + overwrite: true + - name: Upload requirements_diff - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: requirements_diff path: ./requirements_diff.txt @@ -99,7 +118,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt @@ -123,6 +142,11 @@ jobs: with: name: env_file + - name: Download build_constraints + uses: actions/download-artifact@v4.1.8 + with: + name: build_constraints + - name: Download requirements_diff uses: actions/download-artifact@v4.1.8 with: @@ -142,8 +166,8 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev" - skip-binary: aiohttp;multidict;yarl + apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev" + skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" @@ -167,6 +191,11 @@ jobs: with: name: env_file + - name: Download build_constraints + uses: actions/download-artifact@v4.1.8 + with: + name: build_constraints + - name: Download requirements_diff uses: actions/download-artifact@v4.1.8 with: @@ -197,33 +226,6 @@ jobs: split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt - - name: Create requirements for cython<3 - if: matrix.abi == 'cp312' - run: | - # Some dependencies still require 'cython<3' - # and don't yet use isolated build environments. - # Build these first. - # pydantic: https://github.com/pydantic/pydantic/issues/7689 - - touch requirements_old-cython.txt - cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt - - - name: Build wheels (old cython) - uses: home-assistant/wheels@2024.11.0 - if: matrix.abi == 'cp312' - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_old-cython.txt" - pip: "'cython<3'" - - name: Build wheels (part 1) uses: home-assistant/wheels@2024.11.0 with: @@ -232,7 +234,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -246,7 +248,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -260,7 +262,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3a20276c881..805e3ac4dbd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.0 + rev: v0.9.1 hooks: - id: ruff args: @@ -12,7 +12,7 @@ repos: hooks: - id: codespell args: - - --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn + - --ignore-words-list=aiport,astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 exclude_types: [csv, json, html] @@ -61,13 +61,14 @@ repos: name: mypy entry: script/run-in-env.sh mypy language: script - types_or: [python, pyi] require_serial: true + types_or: [python, pyi] files: ^(homeassistant|pylint)/.+\.(py|pyi)$ - id: pylint name: pylint - entry: script/run-in-env.sh pylint -j 0 --ignore-missing-annotations=y + entry: script/run-in-env.sh pylint --ignore-missing-annotations=y language: script + require_serial: true types_or: [python, pyi] files: ^(homeassistant|tests)/.+\.(py|pyi)$ - id: gen_requirements_all diff --git a/.strict-typing b/.strict-typing index cb0cab984ee..1c0456a745d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -41,6 +41,7 @@ homeassistant.util.unit_system # --- Add components below this line --- homeassistant.components homeassistant.components.abode.* +homeassistant.components.acaia.* homeassistant.components.accuweather.* homeassistant.components.acer_projector.* homeassistant.components.acmeda.* @@ -136,6 +137,7 @@ homeassistant.components.co2signal.* homeassistant.components.command_line.* homeassistant.components.config.* homeassistant.components.configurator.* +homeassistant.components.cookidoo.* homeassistant.components.counter.* homeassistant.components.cover.* homeassistant.components.cpuspeed.* @@ -168,6 +170,7 @@ homeassistant.components.easyenergy.* homeassistant.components.ecovacs.* homeassistant.components.ecowitt.* homeassistant.components.efergy.* +homeassistant.components.eheimdigital.* homeassistant.components.electrasmart.* homeassistant.components.electric_kiwi.* homeassistant.components.elevenlabs.* @@ -221,6 +224,7 @@ homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* +homeassistant.components.habitica.* homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* @@ -233,6 +237,7 @@ homeassistant.components.homeassistant_green.* homeassistant.components.homeassistant_hardware.* homeassistant.components.homeassistant_sky_connect.* homeassistant.components.homeassistant_yellow.* +homeassistant.components.homee.* homeassistant.components.homekit.* homeassistant.components.homekit_controller homeassistant.components.homekit_controller.alarm_control_panel @@ -258,6 +263,7 @@ homeassistant.components.image_processing.* homeassistant.components.image_upload.* homeassistant.components.imap.* homeassistant.components.imgw_pib.* +homeassistant.components.incomfort.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.input_text.* @@ -268,6 +274,7 @@ homeassistant.components.ios.* homeassistant.components.iotty.* homeassistant.components.ipp.* homeassistant.components.iqvia.* +homeassistant.components.iron_os.* homeassistant.components.islamic_prayer_times.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* @@ -287,6 +294,7 @@ homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* homeassistant.components.led_ble.* homeassistant.components.lektrico.* +homeassistant.components.letpot.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* @@ -301,12 +309,15 @@ homeassistant.components.logbook.* homeassistant.components.logger.* homeassistant.components.london_underground.* homeassistant.components.lookin.* +homeassistant.components.lovelace.* homeassistant.components.luftdaten.* homeassistant.components.madvr.* homeassistant.components.manual.* homeassistant.components.mastodon.* homeassistant.components.matrix.* homeassistant.components.matter.* +homeassistant.components.mcp_server.* +homeassistant.components.mealie.* homeassistant.components.media_extractor.* homeassistant.components.media_player.* homeassistant.components.media_source.* @@ -357,13 +368,18 @@ homeassistant.components.openuv.* homeassistant.components.oralb.* homeassistant.components.otbr.* homeassistant.components.overkiz.* +homeassistant.components.overseerr.* homeassistant.components.p1_monitor.* +homeassistant.components.pandora.* homeassistant.components.panel_custom.* +homeassistant.components.peblar.* homeassistant.components.peco.* homeassistant.components.persistent_notification.* +homeassistant.components.person.* homeassistant.components.pi_hole.* homeassistant.components.ping.* homeassistant.components.plugwise.* +homeassistant.components.powerfox.* homeassistant.components.powerwall.* homeassistant.components.private_ble_device.* homeassistant.components.prometheus.* @@ -373,6 +389,8 @@ homeassistant.components.pure_energie.* homeassistant.components.purpleair.* homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* +homeassistant.components.python_script.* +homeassistant.components.qbus.* homeassistant.components.qnap_qsw.* homeassistant.components.rabbitair.* homeassistant.components.radarr.* @@ -400,11 +418,13 @@ homeassistant.components.romy.* homeassistant.components.rpi_power.* homeassistant.components.rss_feed_template.* homeassistant.components.rtsp_to_webrtc.* +homeassistant.components.russound_rio.* homeassistant.components.ruuvi_gateway.* homeassistant.components.ruuvitag_ble.* homeassistant.components.samsungtv.* homeassistant.components.scene.* homeassistant.components.schedule.* +homeassistant.components.schlage.* homeassistant.components.scrape.* homeassistant.components.script.* homeassistant.components.search.* @@ -437,7 +457,6 @@ homeassistant.components.ssdp.* homeassistant.components.starlink.* homeassistant.components.statistics.* homeassistant.components.steamist.* -homeassistant.components.stookalert.* homeassistant.components.stookwijzer.* homeassistant.components.stream.* homeassistant.components.streamlabswater.* diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json index ace0a988bf5..8c57059959b 100644 --- a/.vscode/settings.default.json +++ b/.vscode/settings.default.json @@ -1,5 +1,5 @@ { - // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json + // Please keep this file (mostly!) in sync with settings in home-assistant/.devcontainer/devcontainer.json // Added --no-cov to work around TypeError: message must be set // https://github.com/microsoft/vscode-python/issues/14067 "python.testing.pytestArgs": ["--no-cov"], @@ -12,6 +12,7 @@ "fileMatch": [ "homeassistant/components/*/manifest.json" ], + // This value differs between working with devcontainer and locally, therefor this value should NOT be in sync! "url": "./script/json_schemas/manifest_schema.json" } ] diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 2b02916a73e..7425e7a2533 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -16,7 +16,7 @@ { "label": "Pytest", "type": "shell", - "command": "python3 -m pytest --timeout=10 tests", + "command": "${command:python.interpreterPath} -m pytest --timeout=10 tests", "dependsOn": ["Install all Test Requirements"], "group": { "kind": "test", @@ -31,7 +31,7 @@ { "label": "Pytest (changed tests only)", "type": "shell", - "command": "python3 -m pytest --timeout=10 --picked", + "command": "${command:python.interpreterPath} -m pytest --timeout=10 --picked", "group": { "kind": "test", "isDefault": true @@ -56,6 +56,20 @@ }, "problemMatcher": [] }, + { + "label": "Pre-commit", + "type": "shell", + "command": "pre-commit run --show-diff-on-failure", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, { "label": "Pylint", "type": "shell", @@ -75,7 +89,7 @@ "label": "Code Coverage", "detail": "Generate code coverage report for a given integration.", "type": "shell", - "command": "python3 -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", + "command": "${command:python.interpreterPath} -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", "dependsOn": ["Compile English translations"], "group": { "kind": "test", @@ -91,7 +105,7 @@ "label": "Update syrupy snapshots", "detail": "Update syrupy snapshots for a given integration.", "type": "shell", - "command": "python3 -m pytest ./tests/components/${input:integrationName} --snapshot-update", + "command": "${command:python.interpreterPath} -m pytest ./tests/components/${input:integrationName} --snapshot-update", "dependsOn": ["Compile English translations"], "group": { "kind": "test", @@ -149,7 +163,7 @@ "label": "Compile English translations", "detail": "In order to test changes to translation files, the translation strings must be compiled into Home Assistant's translation directories.", "type": "shell", - "command": "python3 -m script.translations develop --all", + "command": "${command:python.interpreterPath} -m script.translations develop --all", "group": { "kind": "build", "isDefault": true @@ -159,7 +173,7 @@ "label": "Run scaffold", "detail": "Add new functionality to a integration using a scaffold.", "type": "shell", - "command": "python3 -m script.scaffold ${input:scaffoldName} --integration ${input:integrationName}", + "command": "${command:python.interpreterPath} -m script.scaffold ${input:scaffoldName} --integration ${input:integrationName}", "group": { "kind": "build", "isDefault": true @@ -169,7 +183,7 @@ "label": "Create new integration", "detail": "Use the scaffold to create a new integration.", "type": "shell", - "command": "python3 -m script.scaffold integration", + "command": "${command:python.interpreterPath} -m script.scaffold integration", "group": { "kind": "build", "isDefault": true diff --git a/CODEOWNERS b/CODEOWNERS index ba233c0c141..489b848c772 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -284,6 +284,8 @@ build.json @home-assistant/supervisor /tests/components/control4/ @lawtancool /homeassistant/components/conversation/ @home-assistant/core @synesthesiam /tests/components/conversation/ @home-assistant/core @synesthesiam +/homeassistant/components/cookidoo/ @miaucl +/tests/components/cookidoo/ @miaucl /homeassistant/components/coolmaster/ @OnFreund /tests/components/coolmaster/ @OnFreund /homeassistant/components/counter/ @fabaff @@ -385,6 +387,8 @@ build.json @home-assistant/supervisor /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt +/homeassistant/components/eheimdigital/ @autinerd +/tests/components/eheimdigital/ @autinerd /homeassistant/components/electrasmart/ @jafar-atili /tests/components/electrasmart/ @jafar-atili /homeassistant/components/electric_kiwi/ @mikey0000 @@ -574,8 +578,8 @@ build.json @home-assistant/supervisor /tests/components/google_tasks/ @allenporter /homeassistant/components/google_travel_time/ @eifinger /tests/components/google_travel_time/ @eifinger -/homeassistant/components/govee_ble/ @bdraco @PierreAronnax -/tests/components/govee_ble/ @bdraco @PierreAronnax +/homeassistant/components/govee_ble/ @bdraco +/tests/components/govee_ble/ @bdraco /homeassistant/components/govee_light_local/ @Galorhallen /tests/components/govee_light_local/ @Galorhallen /homeassistant/components/gpsd/ @fabaff @jrieger @@ -633,6 +637,8 @@ build.json @home-assistant/supervisor /tests/components/homeassistant_sky_connect/ @home-assistant/core /homeassistant/components/homeassistant_yellow/ @home-assistant/core /tests/components/homeassistant_yellow/ @home-assistant/core +/homeassistant/components/homee/ @Taraman17 +/tests/components/homee/ @Taraman17 /homeassistant/components/homekit/ @bdraco /tests/components/homekit/ @bdraco /homeassistant/components/homekit_controller/ @Jc2k @bdraco @@ -676,12 +682,12 @@ build.json @home-assistant/supervisor /homeassistant/components/iammeter/ @lewei50 /homeassistant/components/iaqualink/ @flz /tests/components/iaqualink/ @flz -/homeassistant/components/ibeacon/ @bdraco -/tests/components/ibeacon/ @bdraco /homeassistant/components/icloud/ @Quentame @nzapponi /tests/components/icloud/ @Quentame @nzapponi /homeassistant/components/idasen_desk/ @abmantis /tests/components/idasen_desk/ @abmantis +/homeassistant/components/igloohome/ @keithle888 +/tests/components/igloohome/ @keithle888 /homeassistant/components/ign_sismologia/ @exxamalte /tests/components/ign_sismologia/ @exxamalte /homeassistant/components/image/ @home-assistant/core @@ -727,8 +733,8 @@ build.json @home-assistant/supervisor /tests/components/ios/ @robbiet480 /homeassistant/components/iotawatt/ @gtdiehl @jyavenard /tests/components/iotawatt/ @gtdiehl @jyavenard -/homeassistant/components/iotty/ @pburgio @shapournemati-iotty -/tests/components/iotty/ @pburgio @shapournemati-iotty +/homeassistant/components/iotty/ @shapournemati-iotty +/tests/components/iotty/ @shapournemati-iotty /homeassistant/components/iperf3/ @rohankapoorcom /homeassistant/components/ipma/ @dgomes /tests/components/ipma/ @dgomes @@ -753,6 +759,8 @@ build.json @home-assistant/supervisor /tests/components/ista_ecotrend/ @tr4nt0r /homeassistant/components/isy994/ @bdraco @shbatm /tests/components/isy994/ @bdraco @shbatm +/homeassistant/components/ituran/ @shmuelzon +/tests/components/ituran/ @shmuelzon /homeassistant/components/izone/ @Swamp-Ig /tests/components/izone/ @Swamp-Ig /homeassistant/components/jellyfin/ @j-stienstra @ctalkington @@ -821,6 +829,8 @@ build.json @home-assistant/supervisor /tests/components/led_ble/ @bdraco /homeassistant/components/lektrico/ @lektrico /tests/components/lektrico/ @lektrico +/homeassistant/components/letpot/ @jpelgrom +/tests/components/letpot/ @jpelgrom /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 /homeassistant/components/lg_thinq/ @LG-ThinQ-Integration @@ -881,6 +891,8 @@ build.json @home-assistant/supervisor /tests/components/matrix/ @PaarthShah /homeassistant/components/matter/ @home-assistant/matter /tests/components/matter/ @home-assistant/matter +/homeassistant/components/mcp_server/ @allenporter +/tests/components/mcp_server/ @allenporter /homeassistant/components/mealie/ @joostlek @andrew-codechimp /tests/components/mealie/ @joostlek @andrew-codechimp /homeassistant/components/meater/ @Sotolotl @emontnemery @@ -1004,11 +1016,12 @@ build.json @home-assistant/supervisor /tests/components/nice_go/ @IceBotYT /homeassistant/components/nightscout/ @marciogranzotto /tests/components/nightscout/ @marciogranzotto +/homeassistant/components/niko_home_control/ @VandeurenGlenn +/tests/components/niko_home_control/ @VandeurenGlenn /homeassistant/components/nilu/ @hfurubotten /homeassistant/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum /homeassistant/components/nissan_leaf/ @filcole -/homeassistant/components/nmbs/ @thibmaek /homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /tests/components/nobo_hub/ @echoromeo @oyvindwe @@ -1045,6 +1058,8 @@ build.json @home-assistant/supervisor /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 +/homeassistant/components/ohme/ @dan-r +/tests/components/ohme/ @dan-r /homeassistant/components/ollama/ @synesthesiam /tests/components/ollama/ @synesthesiam /homeassistant/components/ombi/ @larssont @@ -1056,10 +1071,10 @@ build.json @home-assistant/supervisor /tests/components/ondilo_ico/ @JeromeHXP /homeassistant/components/onewire/ @garbled1 @epenet /tests/components/onewire/ @garbled1 @epenet -/homeassistant/components/onkyo/ @arturpragacz -/tests/components/onkyo/ @arturpragacz -/homeassistant/components/onvif/ @hunterjm -/tests/components/onvif/ @hunterjm +/homeassistant/components/onkyo/ @arturpragacz @eclair4151 +/tests/components/onkyo/ @arturpragacz @eclair4151 +/homeassistant/components/onvif/ @hunterjm @jterrace +/tests/components/onvif/ @hunterjm @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck /homeassistant/components/openai_conversation/ @balloob @@ -1093,8 +1108,10 @@ build.json @home-assistant/supervisor /tests/components/otbr/ @home-assistant/core /homeassistant/components/ourgroceries/ @OnFreund /tests/components/ourgroceries/ @OnFreund -/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14 -/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14 +/homeassistant/components/overkiz/ @imicknl +/tests/components/overkiz/ @imicknl +/homeassistant/components/overseerr/ @joostlek +/tests/components/overseerr/ @joostlek /homeassistant/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001 /homeassistant/components/p1_monitor/ @klaasnicolaas @@ -1103,6 +1120,8 @@ build.json @home-assistant/supervisor /tests/components/palazzetti/ @dotvav /homeassistant/components/panel_custom/ @home-assistant/frontend /tests/components/panel_custom/ @home-assistant/frontend +/homeassistant/components/peblar/ @frenck +/tests/components/peblar/ @frenck /homeassistant/components/peco/ @IceBotYT /tests/components/peco/ @IceBotYT /homeassistant/components/pegel_online/ @mib1185 @@ -1123,14 +1142,16 @@ build.json @home-assistant/supervisor /tests/components/plaato/ @JohNan /homeassistant/components/plex/ @jjlawren /tests/components/plex/ @jjlawren -/homeassistant/components/plugwise/ @CoMPaTech @bouwew @frenck -/tests/components/plugwise/ @CoMPaTech @bouwew @frenck +/homeassistant/components/plugwise/ @CoMPaTech @bouwew +/tests/components/plugwise/ @CoMPaTech @bouwew /homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa /tests/components/plum_lightpad/ @ColinHarrington @prystupa /homeassistant/components/point/ @fredrike /tests/components/point/ @fredrike /homeassistant/components/poolsense/ @haemishkyd /tests/components/poolsense/ @haemishkyd +/homeassistant/components/powerfox/ @klaasnicolaas +/tests/components/powerfox/ @klaasnicolaas /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson /tests/components/powerwall/ @bdraco @jrester @daniel-simpson /homeassistant/components/private_ble_device/ @Jc2k @@ -1168,6 +1189,8 @@ build.json @home-assistant/supervisor /tests/components/pyload/ @tr4nt0r /homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39 /tests/components/qbittorrent/ @geoffreylagaisse @finder39 +/homeassistant/components/qbus/ @Qbus-iot @thomasddn +/tests/components/qbus/ @Qbus-iot @thomasddn /homeassistant/components/qingping/ @bdraco /tests/components/qingping/ @bdraco /homeassistant/components/qld_bushfire/ @exxamalte @@ -1244,8 +1267,8 @@ build.json @home-assistant/supervisor /tests/components/rituals_perfume_genie/ @milanmeu @frenck /homeassistant/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi -/homeassistant/components/roborock/ @Lash-L -/tests/components/roborock/ @Lash-L +/homeassistant/components/roborock/ @Lash-L @allenporter +/tests/components/roborock/ @Lash-L @allenporter /homeassistant/components/roku/ @ctalkington /tests/components/roku/ @ctalkington /homeassistant/components/romy/ @xeniter @@ -1264,6 +1287,7 @@ build.json @home-assistant/supervisor /tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /homeassistant/components/russound_rio/ @noahhusby /tests/components/russound_rio/ @noahhusby +/homeassistant/components/russound_rnet/ @noahhusby /homeassistant/components/ruuvi_gateway/ @akx /tests/components/ruuvi_gateway/ @akx /homeassistant/components/ruuvitag_ble/ @akx @@ -1353,10 +1377,12 @@ build.json @home-assistant/supervisor /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 +/homeassistant/components/slide_local/ @dontinelli +/tests/components/slide_local/ @dontinelli /homeassistant/components/slimproto/ @marcelveldt /tests/components/slimproto/ @marcelveldt -/homeassistant/components/sma/ @kellerza @rklomp -/tests/components/sma/ @kellerza @rklomp +/homeassistant/components/sma/ @kellerza @rklomp @erwindouna +/tests/components/sma/ @kellerza @rklomp @erwindouna /homeassistant/components/smappee/ @bsmappee /tests/components/smappee/ @bsmappee /homeassistant/components/smart_meter_texas/ @grahamwetzler @@ -1411,15 +1437,13 @@ build.json @home-assistant/supervisor /tests/components/starline/ @anonym-tsk /homeassistant/components/starlink/ @boswelja /tests/components/starlink/ @boswelja -/homeassistant/components/statistics/ @ThomDietrich -/tests/components/statistics/ @ThomDietrich +/homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST +/tests/components/statistics/ @ThomDietrich @gjohansson-ST /homeassistant/components/steam_online/ @tkdrob /tests/components/steam_online/ @tkdrob /homeassistant/components/steamist/ @bdraco /tests/components/steamist/ @bdraco /homeassistant/components/stiebel_eltron/ @fucm -/homeassistant/components/stookalert/ @fwestenberg @frenck -/tests/components/stookalert/ @fwestenberg @frenck /homeassistant/components/stookwijzer/ @fwestenberg /tests/components/stookwijzer/ @fwestenberg /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter @@ -1464,8 +1488,8 @@ build.json @home-assistant/supervisor /tests/components/system_bridge/ @timmo001 /homeassistant/components/systemmonitor/ @gjohansson-ST /tests/components/systemmonitor/ @gjohansson-ST -/homeassistant/components/tado/ @chiefdragon @erwindouna -/tests/components/tado/ @chiefdragon @erwindouna +/homeassistant/components/tado/ @erwindouna +/tests/components/tado/ @erwindouna /homeassistant/components/tag/ @balloob @dmulcahey /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck @@ -1559,8 +1583,8 @@ build.json @home-assistant/supervisor /tests/components/triggercmd/ @rvmey /homeassistant/components/tts/ @home-assistant/core /tests/components/tts/ @home-assistant/core -/homeassistant/components/tuya/ @Tuya @zlinoliver @frenck -/tests/components/tuya/ @Tuya @zlinoliver @frenck +/homeassistant/components/tuya/ @Tuya @zlinoliver +/tests/components/tuya/ @Tuya @zlinoliver /homeassistant/components/twentemilieu/ @frenck /tests/components/twentemilieu/ @frenck /homeassistant/components/twinkly/ @dr1rrb @Robbie1221 @Olen @@ -1604,15 +1628,15 @@ build.json @home-assistant/supervisor /tests/components/valve/ @home-assistant/core /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra -/homeassistant/components/velux/ @Julius2342 @DeerMaximum -/tests/components/velux/ @Julius2342 @DeerMaximum +/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio +/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus -/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja -/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja +/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak +/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak /homeassistant/components/vicare/ @CFenner /tests/components/vicare/ @CFenner /homeassistant/components/vilfo/ @ManneW @@ -1642,6 +1666,8 @@ build.json @home-assistant/supervisor /tests/components/waqi/ @joostlek /homeassistant/components/water_heater/ @home-assistant/core /tests/components/water_heater/ @home-assistant/core +/homeassistant/components/watergate/ @adam-the-hero +/tests/components/watergate/ @adam-the-hero /homeassistant/components/watson_tts/ @rutkai /homeassistant/components/watttime/ @bachya /tests/components/watttime/ @bachya @@ -1726,6 +1752,7 @@ build.json @home-assistant/supervisor /tests/components/youless/ @gjong /homeassistant/components/youtube/ @joostlek /tests/components/youtube/ @joostlek +/homeassistant/components/zabbix/ @kruton /homeassistant/components/zamg/ @killer0071234 /tests/components/zamg/ @killer0071234 /homeassistant/components/zengge/ @emontnemery diff --git a/Dockerfile b/Dockerfile index 61d64212b40..171d08731a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.5.4 +RUN pip3 install uv==0.5.21 WORKDIR /usr/src @@ -55,7 +55,7 @@ RUN \ "armv7") go2rtc_suffix='arm' ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \ esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && chmod +x /bin/go2rtc \ # Verify go2rtc can be executed && go2rtc --version diff --git a/Dockerfile.dev b/Dockerfile.dev index 48f582a1581..5a3f1a2ae64 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/python:1-3.12 +FROM mcr.microsoft.com/devcontainers/python:1-3.13 SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/build.yaml b/build.yaml index a8755bbbf5c..e6e149cf700 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.11.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.11.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.11.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.11.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.11.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.12.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.12.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.12.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.12.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.12.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 21a4b6113d0..afe3b2d7aa3 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -115,7 +115,7 @@ class AuthManagerFlowManager( *, context: AuthFlowContext | None = None, data: dict[str, Any] | None = None, - ) -> LoginFlow: + ) -> LoginFlow[Any]: """Create a login flow.""" auth_provider = self.auth_manager.get_auth_provider(*handler_key) if not auth_provider: diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index fc47a7d71e9..1c2e8b0dfab 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -308,7 +308,7 @@ class AuthStore: credentials.data = data self._async_schedule_save() - async def async_load(self) -> None: # noqa: C901 + async def async_load(self) -> None: """Load the users.""" if self._loaded: raise RuntimeError("Auth storage is already loaded") diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index d57a274c7ff..0edc187e24d 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -71,7 +71,7 @@ class MultiFactorAuthModule: """Return a voluptuous schema to define mfa auth module's input.""" raise NotImplementedError - async def async_setup_flow(self, user_id: str) -> SetupFlow: + async def async_setup_flow(self, user_id: str) -> SetupFlow[Any]: """Return a data entry flow handler for setup module. Mfa module should extend SetupFlow @@ -95,11 +95,16 @@ class MultiFactorAuthModule: raise NotImplementedError -class SetupFlow(data_entry_flow.FlowHandler): +class SetupFlow[_MultiFactorAuthModuleT: MultiFactorAuthModule = MultiFactorAuthModule]( + data_entry_flow.FlowHandler +): """Handler for the setup flow.""" def __init__( - self, auth_module: MultiFactorAuthModule, setup_schema: vol.Schema, user_id: str + self, + auth_module: _MultiFactorAuthModuleT, + setup_schema: vol.Schema, + user_id: str, ) -> None: """Initialize the setup flow.""" self._auth_module = auth_module diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index d2010dc2c9d..b60a3012aac 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -162,7 +162,7 @@ class NotifyAuthModule(MultiFactorAuthModule): return sorted(unordered_services) - async def async_setup_flow(self, user_id: str) -> SetupFlow: + async def async_setup_flow(self, user_id: str) -> NotifySetupFlow: """Return a data entry flow handler for setup module. Mfa module should extend SetupFlow @@ -268,7 +268,7 @@ class NotifyAuthModule(MultiFactorAuthModule): await self.hass.services.async_call("notify", notify_service, data) -class NotifySetupFlow(SetupFlow): +class NotifySetupFlow(SetupFlow[NotifyAuthModule]): """Handler for the setup flow.""" def __init__( @@ -280,8 +280,6 @@ class NotifySetupFlow(SetupFlow): ) -> None: """Initialize the setup flow.""" super().__init__(auth_module, setup_schema, user_id) - # to fix typing complaint - self._auth_module: NotifyAuthModule = auth_module self._available_notify_services = available_notify_services self._secret: str | None = None self._count: int | None = None diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 3306f76217f..625b273f39a 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -114,7 +114,7 @@ class TotpAuthModule(MultiFactorAuthModule): self._users[user_id] = ota_secret # type: ignore[index] return ota_secret - async def async_setup_flow(self, user_id: str) -> SetupFlow: + async def async_setup_flow(self, user_id: str) -> TotpSetupFlow: """Return a data entry flow handler for setup module. Mfa module should extend SetupFlow @@ -174,10 +174,9 @@ class TotpAuthModule(MultiFactorAuthModule): return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1)) -class TotpSetupFlow(SetupFlow): +class TotpSetupFlow(SetupFlow[TotpAuthModule]): """Handler for the setup flow.""" - _auth_module: TotpAuthModule _ota_secret: str _url: str _image: str diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 6f45dab2b36..7dcccbb1a1e 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -11,7 +11,7 @@ import uuid import attr from attr import Attribute from attr.setters import validate -from propcache import cached_property +from propcache.api import cached_property from homeassistant.const import __version__ from homeassistant.data_entry_flow import FlowContext, FlowResult diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 9c2c7e500ca..6498483a19a 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -17,12 +17,12 @@ POLICY_SCHEMA = vol.Schema({vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA}) __all__ = [ "POLICY_SCHEMA", - "merge_policies", - "PermissionLookup", - "PolicyType", "AbstractPermissions", - "PolicyPermissions", "OwnerPermissions", + "PermissionLookup", + "PolicyPermissions", + "PolicyType", + "merge_policies", ] diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 34278c47df7..1155e77b407 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -105,7 +105,7 @@ class AuthProvider: # Implement by extending class - async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: + async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow[Any]: """Return the data flow for logging in with auth provider. Auth provider should extend LoginFlow and return an instance. @@ -192,12 +192,14 @@ async def load_auth_provider_module( return module -class LoginFlow(FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]]): +class LoginFlow[_AuthProviderT: AuthProvider = AuthProvider]( + FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]], +): """Handler for the login flow.""" _flow_result = AuthFlowResult - def __init__(self, auth_provider: AuthProvider) -> None: + def __init__(self, auth_provider: _AuthProviderT) -> None: """Initialize the login flow.""" self._auth_provider = auth_provider self._auth_module_id: str | None = None diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index 12447bc8c18..74630d925e1 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Mapping import logging import os -from typing import Any, cast +from typing import Any import voluptuous as vol @@ -59,7 +59,9 @@ class CommandLineAuthProvider(AuthProvider): super().__init__(*args, **kwargs) self._user_meta: dict[str, dict[str, Any]] = {} - async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: + async def async_login_flow( + self, context: AuthFlowContext | None + ) -> CommandLineLoginFlow: """Return a flow to login.""" return CommandLineLoginFlow(self) @@ -133,7 +135,7 @@ class CommandLineAuthProvider(AuthProvider): ) -class CommandLineLoginFlow(LoginFlow): +class CommandLineLoginFlow(LoginFlow[CommandLineAuthProvider]): """Handler for the login flow.""" async def async_step_init( @@ -145,9 +147,9 @@ class CommandLineLoginFlow(LoginFlow): if user_input is not None: user_input["username"] = user_input["username"].strip() try: - await cast( - CommandLineAuthProvider, self._auth_provider - ).async_validate_login(user_input["username"], user_input["password"]) + await self._auth_provider.async_validate_login( + user_input["username"], user_input["password"] + ) except InvalidAuthError: errors["base"] = "invalid_auth" diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index e5dded74762..522e5d77a29 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -305,7 +305,7 @@ class HassAuthProvider(AuthProvider): await data.async_load() self.data = data - async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: + async def async_login_flow(self, context: AuthFlowContext | None) -> HassLoginFlow: """Return a flow to login.""" return HassLoginFlow(self) @@ -400,7 +400,7 @@ class HassAuthProvider(AuthProvider): pass -class HassLoginFlow(LoginFlow): +class HassLoginFlow(LoginFlow[HassAuthProvider]): """Handler for the login flow.""" async def async_step_init( @@ -411,7 +411,7 @@ class HassLoginFlow(LoginFlow): if user_input is not None: try: - await cast(HassAuthProvider, self._auth_provider).async_validate_login( + await self._auth_provider.async_validate_login( user_input["username"], user_input["password"] ) except InvalidAuth: diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index a7dced851a3..a92f5b55848 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Mapping import hmac -from typing import cast import voluptuous as vol @@ -36,7 +35,9 @@ class InvalidAuthError(HomeAssistantError): class ExampleAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" - async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: + async def async_login_flow( + self, context: AuthFlowContext | None + ) -> ExampleLoginFlow: """Return a flow to login.""" return ExampleLoginFlow(self) @@ -93,7 +94,7 @@ class ExampleAuthProvider(AuthProvider): return UserMeta(name=name, is_active=True) -class ExampleLoginFlow(LoginFlow): +class ExampleLoginFlow(LoginFlow[ExampleAuthProvider]): """Handler for the login flow.""" async def async_step_init( @@ -104,7 +105,7 @@ class ExampleLoginFlow(LoginFlow): if user_input is not None: try: - cast(ExampleAuthProvider, self._auth_provider).async_validate_login( + self._auth_provider.async_validate_login( user_input["username"], user_input["password"] ) except InvalidAuthError: diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index f32c35d4bd5..799fd4d2e16 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -104,7 +104,9 @@ class TrustedNetworksAuthProvider(AuthProvider): """Trusted Networks auth provider does not support MFA.""" return False - async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: + async def async_login_flow( + self, context: AuthFlowContext | None + ) -> TrustedNetworksLoginFlow: """Return a flow to login.""" assert context is not None ip_addr = cast(IPAddress, context.get("ip_address")) @@ -214,7 +216,7 @@ class TrustedNetworksAuthProvider(AuthProvider): self.async_validate_access(ip_address(remote_ip)) -class TrustedNetworksLoginFlow(LoginFlow): +class TrustedNetworksLoginFlow(LoginFlow[TrustedNetworksAuthProvider]): """Handler for the login flow.""" def __init__( @@ -235,9 +237,7 @@ class TrustedNetworksLoginFlow(LoginFlow): ) -> AuthFlowResult: """Handle the step of the form.""" try: - cast( - TrustedNetworksAuthProvider, self._auth_provider - ).async_validate_access(self._ip_address) + self._auth_provider.async_validate_access(self._ip_address) except InvalidAuthError: return self.async_abort(reason="not_allowed") diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 32991dfb2d3..3d24d807a06 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -1,6 +1,10 @@ """Home Assistant module to handle restoring backups.""" +from __future__ import annotations + +from collections.abc import Iterable from dataclasses import dataclass +import hashlib import json import logging from pathlib import Path @@ -14,7 +18,12 @@ import securetar from .const import __version__ as HA_VERSION RESTORE_BACKUP_FILE = ".HA_RESTORE" -KEEP_PATHS = ("backups",) +KEEP_BACKUPS = ("backups",) +KEEP_DATABASE = ( + "home-assistant_v2.db", + "home-assistant_v2.db-wal", +) + _LOGGER = logging.getLogger(__name__) @@ -24,6 +33,21 @@ class RestoreBackupFileContent: """Definition for restore backup file content.""" backup_file_path: Path + password: str | None + remove_after_restore: bool + restore_database: bool + restore_homeassistant: bool + + +def password_to_key(password: str) -> bytes: + """Generate a AES Key from password. + + Matches the implementation in supervisor.backups.utils.password_to_key. + """ + key: bytes = password.encode() + for _ in range(100): + key = hashlib.sha256(key).digest() + return key[:16] def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None: @@ -32,20 +56,27 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | try: instruction_content = json.loads(instruction_path.read_text(encoding="utf-8")) return RestoreBackupFileContent( - backup_file_path=Path(instruction_content["path"]) + backup_file_path=Path(instruction_content["path"]), + password=instruction_content["password"], + remove_after_restore=instruction_content["remove_after_restore"], + restore_database=instruction_content["restore_database"], + restore_homeassistant=instruction_content["restore_homeassistant"], ) - except (FileNotFoundError, json.JSONDecodeError): + except (FileNotFoundError, KeyError, json.JSONDecodeError): return None + finally: + # Always remove the backup instruction file to prevent a boot loop + instruction_path.unlink(missing_ok=True) -def _clear_configuration_directory(config_dir: Path) -> None: - """Delete all files and directories in the config directory except for the backups directory.""" - keep_paths = [config_dir.joinpath(path) for path in KEEP_PATHS] - config_contents = sorted( - [entry for entry in config_dir.iterdir() if entry not in keep_paths] +def _clear_configuration_directory(config_dir: Path, keep: Iterable[str]) -> None: + """Delete all files and directories in the config directory except entries in the keep list.""" + keep_paths = [config_dir.joinpath(path) for path in keep] + entries_to_remove = sorted( + entry for entry in config_dir.iterdir() if entry not in keep_paths ) - for entry in config_contents: + for entry in entries_to_remove: entrypath = config_dir.joinpath(entry) if entrypath.is_file(): @@ -54,12 +85,15 @@ def _clear_configuration_directory(config_dir: Path) -> None: shutil.rmtree(entrypath) -def _extract_backup(config_dir: Path, backup_file_path: Path) -> None: +def _extract_backup( + config_dir: Path, + restore_content: RestoreBackupFileContent, +) -> None: """Extract the backup file to the config directory.""" with ( TemporaryDirectory() as tempdir, securetar.SecureTarFile( - backup_file_path, + restore_content.backup_file_path, gzip=False, mode="r", ) as ostf, @@ -85,25 +119,44 @@ def _extract_backup(config_dir: Path, backup_file_path: Path) -> None: Path( tempdir, "extracted", - f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}", + f"homeassistant.tar{'.gz' if backup_meta['compressed'] else ''}", ), gzip=backup_meta["compressed"], + key=password_to_key(restore_content.password) + if restore_content.password is not None + else None, mode="r", ) as istf: - for member in istf.getmembers(): - if member.name == "data": - continue - member.name = member.name.replace("data/", "") - _clear_configuration_directory(config_dir) istf.extractall( - path=config_dir, - members=[ - member - for member in securetar.secure_path(istf) - if member.name != "data" - ], + path=Path(tempdir, "homeassistant"), + members=securetar.secure_path(istf), filter="fully_trusted", ) + if restore_content.restore_homeassistant: + keep = list(KEEP_BACKUPS) + if not restore_content.restore_database: + keep.extend(KEEP_DATABASE) + _clear_configuration_directory(config_dir, keep) + shutil.copytree( + Path(tempdir, "homeassistant", "data"), + config_dir, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns(*(keep)), + ) + elif restore_content.restore_database: + for entry in KEEP_DATABASE: + entrypath = config_dir / entry + + if entrypath.is_file(): + entrypath.unlink() + elif entrypath.is_dir(): + shutil.rmtree(entrypath) + + for entry in KEEP_DATABASE: + shutil.copy( + Path(tempdir, "homeassistant", "data", entry), + config_dir, + ) def restore_backup(config_dir_path: str) -> bool: @@ -119,8 +172,13 @@ def restore_backup(config_dir_path: str) -> bool: backup_file_path = restore_content.backup_file_path _LOGGER.info("Restoring %s", backup_file_path) try: - _extract_backup(config_dir, backup_file_path) + _extract_backup( + config_dir=config_dir, + restore_content=restore_content, + ) except FileNotFoundError as err: raise ValueError(f"Backup file {backup_file_path} does not exist") from err + if restore_content.remove_after_restore: + backup_file_path.unlink(missing_ok=True) _LOGGER.info("Restore complete, restarting") return True diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index 7a68b2515e9..d224b0b151d 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -31,7 +31,7 @@ def _check_import_call_allowed(mapped_args: dict[str, Any]) -> bool: def _check_file_allowed(mapped_args: dict[str, Any]) -> bool: # If the file is in /proc we can ignore it. args = mapped_args["args"] - path = args[0] if type(args[0]) is str else str(args[0]) # noqa: E721 + path = args[0] if type(args[0]) is str else str(args[0]) return path.startswith(ALLOWED_FILE_PREFIXES) @@ -50,6 +50,12 @@ def _check_sleep_call_allowed(mapped_args: dict[str, Any]) -> bool: return False +def _check_load_verify_locations_call_allowed(mapped_args: dict[str, Any]) -> bool: + # If only cadata is passed, we can ignore it + kwargs = mapped_args.get("kwargs") + return bool(kwargs and len(kwargs) == 1 and "cadata" in kwargs) + + @dataclass(slots=True, frozen=True) class BlockingCall: """Class to hold information about a blocking call.""" @@ -158,7 +164,7 @@ _BLOCKING_CALLS: tuple[BlockingCall, ...] = ( original_func=SSLContext.load_verify_locations, object=SSLContext, function="load_verify_locations", - check_allowed=None, + check_allowed=_check_load_verify_locations_call_allowed, strict=False, strict_core=False, skip_for_tests=True, diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 1034223051c..d89a9595868 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -89,7 +89,7 @@ from .helpers import ( ) from .helpers.dispatcher import async_dispatcher_send_internal from .helpers.storage import get_internal_store_manager -from .helpers.system_info import async_get_system_info, is_official_image +from .helpers.system_info import async_get_system_info from .helpers.typing import ConfigType from .setup import ( # _setup_started is marked as protected to make it clear @@ -106,11 +106,17 @@ from .util.async_ import create_eager_task from .util.hass_dict import HassKey from .util.logging import async_activate_log_queue_handler from .util.package import async_get_user_site, is_docker_env, is_virtual_env +from .util.system_info import is_official_image with contextlib.suppress(ImportError): # Ensure anyio backend is imported to avoid it being imported in the event loop from anyio._backends import _asyncio # noqa: F401 +with contextlib.suppress(ImportError): + # httpx will import trio if it is installed which does + # blocking I/O in the event loop. We want to avoid that. + import trio # noqa: F401 + if TYPE_CHECKING: from .runner import RuntimeConfig @@ -252,6 +258,7 @@ PRELOAD_STORAGE = [ "assist_pipeline.pipelines", "core.analytics", "auth_module.totp", + "backup", ] diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index 9da24e76f19..4d9eb5f95f3 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -2,6 +2,7 @@ "domain": "microsoft", "name": "Microsoft", "integrations": [ + "azure_data_explorer", "azure_devops", "azure_event_hub", "azure_service_bus", diff --git a/homeassistant/brands/slide.json b/homeassistant/brands/slide.json new file mode 100644 index 00000000000..808a54affc3 --- /dev/null +++ b/homeassistant/brands/slide.json @@ -0,0 +1,5 @@ +{ + "domain": "slide", + "name": "Slide", + "integrations": ["slide", "slide_local"] +} diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index d69aad80875..e2d0a331f0a 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -9,18 +9,16 @@ from jaraco.abode.devices.light import Light from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ColorMode, LightEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, -) from . import AbodeSystem from .const import DOMAIN @@ -44,13 +42,13 @@ class AbodeLight(AbodeDevice, LightEntity): _device: Light _attr_name = None + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN def turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - if ATTR_COLOR_TEMP in kwargs and self._device.is_color_capable: - self._device.set_color_temp( - int(color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])) - ) + if ATTR_COLOR_TEMP_KELVIN in kwargs and self._device.is_color_capable: + self._device.set_color_temp(kwargs[ATTR_COLOR_TEMP_KELVIN]) return if ATTR_HS_COLOR in kwargs and self._device.is_color_capable: @@ -85,10 +83,10 @@ class AbodeLight(AbodeDevice, LightEntity): return None @property - def color_temp(self) -> int | None: + def color_temp_kelvin(self) -> int | None: """Return the color temp of the light.""" if self._device.has_color: - return color_temperature_kelvin_to_mired(self._device.color_temp) + return int(self._device.color_temp) return None @property diff --git a/homeassistant/components/abode/strings.json b/homeassistant/components/abode/strings.json index b3d57042754..c6887d78042 100644 --- a/homeassistant/components/abode/strings.json +++ b/homeassistant/components/abode/strings.json @@ -34,17 +34,17 @@ "services": { "capture_image": { "name": "Capture image", - "description": "Request a new image capture from a camera device.", + "description": "Requests a new image capture from a camera device.", "fields": { "entity_id": { "name": "Entity", - "description": "Entity id of the camera to request an image." + "description": "Entity ID of the camera to request an image from." } } }, "change_setting": { "name": "Change setting", - "description": "Change an Abode system setting.", + "description": "Changes an Abode system setting.", "fields": { "setting": { "name": "Setting", @@ -58,11 +58,11 @@ }, "trigger_automation": { "name": "Trigger automation", - "description": "Trigger an Abode automation.", + "description": "Triggers an Abode automation.", "fields": { "entity_id": { "name": "Entity", - "description": "Entity id of the automation to trigger." + "description": "Entity ID of the automation to trigger." } } } diff --git a/homeassistant/components/acaia/binary_sensor.py b/homeassistant/components/acaia/binary_sensor.py index 9aa4b92e932..ecb7ac06eb5 100644 --- a/homeassistant/components/acaia/binary_sensor.py +++ b/homeassistant/components/acaia/binary_sensor.py @@ -16,6 +16,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import AcaiaConfigEntry from .entity import AcaiaEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(kw_only=True, frozen=True) class AcaiaBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/acaia/config_flow.py b/homeassistant/components/acaia/config_flow.py index 36727059c8a..fb2639fc886 100644 --- a/homeassistant/components/acaia/config_flow.py +++ b/homeassistant/components/acaia/config_flow.py @@ -42,7 +42,7 @@ class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - mac = format_mac(user_input[CONF_ADDRESS]) + mac = user_input[CONF_ADDRESS] try: is_new_style_scale = await is_new_scale(mac) except AcaiaDeviceNotFound: @@ -53,12 +53,12 @@ class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN): except AcaiaUnknownDevice: return self.async_abort(reason="unsupported_device") else: - await self.async_set_unique_id(mac) + await self.async_set_unique_id(format_mac(mac)) self._abort_if_unique_id_configured() if not errors: return self.async_create_entry( - title=self._discovered_devices[user_input[CONF_ADDRESS]], + title=self._discovered_devices[mac], data={ CONF_ADDRESS: mac, CONF_IS_NEW_STYLE_SCALE: is_new_style_scale, @@ -99,10 +99,10 @@ class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a discovered Bluetooth device.""" - self._discovered[CONF_ADDRESS] = mac = format_mac(discovery_info.address) + self._discovered[CONF_ADDRESS] = discovery_info.address self._discovered[CONF_NAME] = discovery_info.name - await self.async_set_unique_id(mac) + await self.async_set_unique_id(format_mac(discovery_info.address)) self._abort_if_unique_id_configured() try: diff --git a/homeassistant/components/acaia/entity.py b/homeassistant/components/acaia/entity.py index 8a2108d2687..bef1ac313ca 100644 --- a/homeassistant/components/acaia/entity.py +++ b/homeassistant/components/acaia/entity.py @@ -2,7 +2,11 @@ from dataclasses import dataclass -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceInfo, + format_mac, +) from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,13 +29,15 @@ class AcaiaEntity(CoordinatorEntity[AcaiaCoordinator]): super().__init__(coordinator) self.entity_description = entity_description self._scale = coordinator.scale - self._attr_unique_id = f"{self._scale.mac}_{entity_description.key}" + formatted_mac = format_mac(self._scale.mac) + self._attr_unique_id = f"{formatted_mac}_{entity_description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._scale.mac)}, + identifiers={(DOMAIN, formatted_mac)}, manufacturer="Acaia", model=self._scale.model, suggested_area="Kitchen", + connections={(CONNECTION_BLUETOOTH, self._scale.mac)}, ) @property diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json index 49b3489cf9a..f39511ad41a 100644 --- a/homeassistant/components/acaia/manifest.json +++ b/homeassistant/components/acaia/manifest.json @@ -25,5 +25,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioacaia"], - "requirements": ["aioacaia==0.1.9"] + "quality_scale": "platinum", + "requirements": ["aioacaia==0.1.14"] } diff --git a/homeassistant/components/acaia/quality_scale.yaml b/homeassistant/components/acaia/quality_scale.yaml new file mode 100644 index 00000000000..62573e38799 --- /dev/null +++ b/homeassistant/components/acaia/quality_scale.yaml @@ -0,0 +1,106 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: exempt + comment: | + Device is expected to be offline most of the time, but needs to connect quickly once available. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: | + Handled by coordinator. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + No authentication required. + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + No IP discovery. + discovery: + status: done + comment: | + Bluetooth discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No noisy/non-essential entities. + entity-translations: done + exception-translations: + status: exempt + comment: | + No custom exceptions. + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + Only parameter that could be changed (MAC = unique_id) would force a new config entry. + repair-issues: + status: exempt + comment: | + No repairs/issues. + stale-devices: + status: exempt + comment: | + Device type integration. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + Bluetooth connection. + strict-typing: done diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py index 6e6ce6afcb8..7ba44958eca 100644 --- a/homeassistant/components/acaia/sensor.py +++ b/homeassistant/components/acaia/sensor.py @@ -21,6 +21,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import AcaiaConfigEntry from .entity import AcaiaEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(kw_only=True, frozen=True) class AcaiaSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/acmeda/hub.py b/homeassistant/components/acmeda/hub.py index a5daf27f445..4f2e4f4f63f 100644 --- a/homeassistant/components/acmeda/hub.py +++ b/homeassistant/components/acmeda/hub.py @@ -70,7 +70,7 @@ class PulseHub: async def async_notify_update(self, update_type: aiopulse.UpdateType) -> None: """Evaluate entities when hub reports that update has occurred.""" - LOGGER.debug("Hub {update_type.name} updated") + LOGGER.debug("Hub %s updated", update_type.name) if update_type == aiopulse.UpdateType.rollers: await update_devices(self.hass, self.config_entry, self.api.rollers) diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index b1b9c81c674..273ca6a772f 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -3,9 +3,9 @@ from __future__ import annotations import logging -import telnetlib # pylint: disable=deprecated-module from typing import Final +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.device_tracker import ( diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index ac381ff46d5..15022ba3c9f 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -75,7 +75,6 @@ class AdaxDevice(ClimateEntity): ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: """Initialize the heater.""" diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index 9e531c683da..f8ddeba6767 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -34,9 +34,12 @@ from .const import ( SERVICE_REMOVE_URL, ) -SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url}) +SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): vol.Any(cv.url, cv.path)}) SERVICE_ADD_URL_SCHEMA = vol.Schema( - {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url} + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_URL): vol.Any(cv.url, cv.path), + } ) SERVICE_REFRESH_SCHEMA = vol.Schema( {vol.Optional(CONF_FORCE, default=False): cv.boolean} diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index 541f8bfc82c..c7b0f4f2f8a 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -37,7 +37,7 @@ STATE_KEY_POSITION = "position" PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_ADS_VAR): cv.string, + vol.Required(CONF_ADS_VAR): cv.string, vol.Optional(CONF_ADS_VAR_POSITION): cv.string, vol.Optional(CONF_ADS_VAR_SET_POS): cv.string, vol.Optional(CONF_ADS_VAR_CLOSE): cv.string, diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index 2ad8c2217a2..601b10aeb4a 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -66,7 +66,7 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Motion sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} motion' + self._attr_name = f"{self._zone['name']} motion" self._attr_unique_id += "-motion" @property @@ -84,7 +84,7 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone MyZone sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} myZone' + self._attr_name = f"{self._zone['name']} myZone" self._attr_unique_id += "-myzone" @property diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 8da46cc7463..d07a3182ed7 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -102,7 +102,6 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): _attr_max_temp = 32 _attr_min_temp = 16 _attr_name = None - _enable_turn_on_off_backwards_compatibility = False _support_preset = ClimateEntityFeature(0) def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: @@ -261,7 +260,6 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 - _enable_turn_on_off_backwards_compatibility = False def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an AdvantageAir Zone control.""" diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index bd3fa970fb9..ab1a1c4f9a0 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -103,7 +103,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Vent Sensor.""" super().__init__(instance, ac_key, zone_key=zone_key) - self._attr_name = f'{self._zone["name"]} vent' + self._attr_name = f"{self._zone['name']} vent" self._attr_unique_id += "-vent" @property @@ -131,7 +131,7 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone wireless signal sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} signal' + self._attr_name = f"{self._zone['name']} signal" self._attr_unique_id += "-signal" @property @@ -165,7 +165,7 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity): def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an Advantage Air Zone Temp Sensor.""" super().__init__(instance, ac_key, zone_key) - self._attr_name = f'{self._zone["name"]} temperature' + self._attr_name = f"{self._zone['name']} temperature" self._attr_unique_id += "-temp" @property diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 9ec52faec00..4bd9dd03eea 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,6 +1,7 @@ """The AEMET OpenData component.""" import logging +import shutil from aemet_opendata.exceptions import AemetError, TownNotFound from aemet_opendata.interface import AEMET, ConnectionOptions, UpdateFeature @@ -10,8 +11,9 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CON from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.storage import STORAGE_DIR -from .const import CONF_STATION_UPDATES, PLATFORMS +from .const import CONF_RADAR_UPDATES, CONF_STATION_UPDATES, DOMAIN, PLATFORMS from .coordinator import AemetConfigEntry, AemetData, WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -24,11 +26,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> boo latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] update_features: int = UpdateFeature.FORECAST + if entry.options.get(CONF_RADAR_UPDATES, False): + update_features |= UpdateFeature.RADAR if entry.options.get(CONF_STATION_UPDATES, True): update_features |= UpdateFeature.STATION options = ConnectionOptions(api_key, update_features) aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options) + aemet.set_api_data_dir(hass.config.path(STORAGE_DIR, f"{DOMAIN}-{entry.unique_id}")) + try: await aemet.select_coordinates(latitude, longitude) except TownNotFound as err: @@ -57,3 +63,11 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + await hass.async_add_executor_job( + shutil.rmtree, + hass.config.path(STORAGE_DIR, f"{DOMAIN}-{entry.unique_id}"), + ) diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index e2b0b436c8c..80b5c07e6bd 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -17,10 +17,11 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaOptionsFlowHandler, ) -from .const import CONF_STATION_UPDATES, DEFAULT_NAME, DOMAIN +from .const import CONF_RADAR_UPDATES, CONF_STATION_UPDATES, DEFAULT_NAME, DOMAIN OPTIONS_SCHEMA = vol.Schema( { + vol.Required(CONF_RADAR_UPDATES, default=False): bool, vol.Required(CONF_STATION_UPDATES, default=True): bool, } ) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 665075c4093..b79a94d209d 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -51,8 +51,9 @@ from homeassistant.components.weather import ( from homeassistant.const import Platform ATTRIBUTION = "Powered by AEMET OpenData" +CONF_RADAR_UPDATES = "radar_updates" CONF_STATION_UPDATES = "station_updates" -PLATFORMS = [Platform.SENSOR, Platform.WEATHER] +PLATFORMS = [Platform.IMAGE, Platform.SENSOR, Platform.WEATHER] DEFAULT_NAME = "AEMET" DOMAIN = "aemet" diff --git a/homeassistant/components/aemet/diagnostics.py b/homeassistant/components/aemet/diagnostics.py index bc366fc6d44..b072309d4b8 100644 --- a/homeassistant/components/aemet/diagnostics.py +++ b/homeassistant/components/aemet/diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from aemet_opendata.const import AOD_COORDS +from aemet_opendata.const import AOD_COORDS, AOD_IMG_BYTES from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import ( @@ -26,6 +26,7 @@ TO_REDACT_CONFIG = [ TO_REDACT_COORD = [ AOD_COORDS, + AOD_IMG_BYTES, ] diff --git a/homeassistant/components/aemet/image.py b/homeassistant/components/aemet/image.py new file mode 100644 index 00000000000..ffc53022e4c --- /dev/null +++ b/homeassistant/components/aemet/image.py @@ -0,0 +1,86 @@ +"""Support for the AEMET OpenData images.""" + +from __future__ import annotations + +from typing import Final + +from aemet_opendata.const import AOD_DATETIME, AOD_IMG_BYTES, AOD_IMG_TYPE, AOD_RADAR +from aemet_opendata.helpers import dict_nested_value + +from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator +from .entity import AemetEntity + +AEMET_IMAGES: Final[tuple[ImageEntityDescription, ...]] = ( + ImageEntityDescription( + key=AOD_RADAR, + translation_key="weather_radar", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AemetConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AEMET OpenData image entities based on a config entry.""" + domain_data = config_entry.runtime_data + name = domain_data.name + coordinator = domain_data.coordinator + + unique_id = config_entry.unique_id + assert unique_id is not None + + async_add_entities( + AemetImage( + hass, + name, + coordinator, + description, + unique_id, + ) + for description in AEMET_IMAGES + if dict_nested_value(coordinator.data["lib"], [description.key]) is not None + ) + + +class AemetImage(AemetEntity, ImageEntity): + """Implementation of an AEMET OpenData image.""" + + entity_description: ImageEntityDescription + + def __init__( + self, + hass: HomeAssistant, + name: str, + coordinator: WeatherUpdateCoordinator, + description: ImageEntityDescription, + unique_id: str, + ) -> None: + """Initialize the image.""" + super().__init__(coordinator, name, unique_id) + ImageEntity.__init__(self, hass) + self.entity_description = description + self._attr_unique_id = f"{unique_id}-{description.key}" + + self._async_update_attrs() + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update image attributes.""" + image_data = self.get_aemet_value([self.entity_description.key]) + self._cached_image = Image( + content_type=image_data.get(AOD_IMG_TYPE), + content=image_data.get(AOD_IMG_BYTES), + ) + self._attr_image_last_updated = image_data.get(AOD_DATETIME) diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index 5c9d1ff7e5a..24ca0099091 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.6.3"] + "requirements": ["AEMET-OpenData==0.6.4"] } diff --git a/homeassistant/components/aemet/strings.json b/homeassistant/components/aemet/strings.json index 75c810978ad..d65c546b050 100644 --- a/homeassistant/components/aemet/strings.json +++ b/homeassistant/components/aemet/strings.json @@ -18,10 +18,18 @@ } } }, + "entity": { + "image": { + "weather_radar": { + "name": "Weather radar" + } + } + }, "options": { "step": { "init": { "data": { + "radar_updates": "Gather data from AEMET weather radar", "station_updates": "Gather data from AEMET weather stations" } } diff --git a/homeassistant/components/airgradient/button.py b/homeassistant/components/airgradient/button.py index 32a9b5adedf..ea7b12062e8 100644 --- a/homeassistant/components/airgradient/button.py +++ b/homeassistant/components/airgradient/button.py @@ -18,7 +18,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN from .coordinator import AirGradientCoordinator -from .entity import AirGradientEntity +from .entity import AirGradientEntity, exception_handler + +PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) @@ -100,6 +102,7 @@ class AirGradientButton(AirGradientEntity, ButtonEntity): self.entity_description = description self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + @exception_handler async def async_press(self) -> None: """Press the button.""" await self.entity_description.press_fn(self.coordinator.client) diff --git a/homeassistant/components/airgradient/config_flow.py b/homeassistant/components/airgradient/config_flow.py index 70fa8a1755b..fa3e77beeca 100644 --- a/homeassistant/components/airgradient/config_flow.py +++ b/homeassistant/components/airgradient/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Airgradient.""" +from collections.abc import Mapping from typing import Any from airgradient import ( @@ -11,10 +12,15 @@ from airgradient import ( from awesomeversion import AwesomeVersion import voluptuous as vol -from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -37,7 +43,7 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): await self.client.set_configuration_control(ConfigurationControl.LOCAL) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.data[CONF_HOST] = host = discovery_info.host @@ -95,10 +101,18 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( current_measures.serial_number, raise_on_progress=False ) - self._abort_if_unique_id_configured() + if self.source == SOURCE_USER: + self._abort_if_unique_id_configured() + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() await self.set_configuration_source() - return self.async_create_entry( - title=current_measures.model, + if self.source == SOURCE_USER: + return self.async_create_entry( + title=current_measures.model, + data={CONF_HOST: user_input[CONF_HOST]}, + ) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data={CONF_HOST: user_input[CONF_HOST]}, ) return self.async_show_form( @@ -106,3 +120,9 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user() diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py index 03d58645853..d2fc2a9de1b 100644 --- a/homeassistant/components/airgradient/coordinator.py +++ b/homeassistant/components/airgradient/coordinator.py @@ -55,7 +55,11 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]): measures = await self.client.get_current_measures() config = await self.client.get_config() except AirGradientError as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(error)}, + ) from error if measures.firmware_version != self._current_version: device_registry = dr.async_get(self.hass) device_entry = device_registry.async_get_device( diff --git a/homeassistant/components/airgradient/entity.py b/homeassistant/components/airgradient/entity.py index 588a799610b..51256051259 100644 --- a/homeassistant/components/airgradient/entity.py +++ b/homeassistant/components/airgradient/entity.py @@ -1,7 +1,11 @@ """Base class for AirGradient entities.""" -from airgradient import get_model_name +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate +from airgradient import AirGradientConnectionError, AirGradientError, get_model_name + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -26,3 +30,31 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]): serial_number=coordinator.serial_number, sw_version=measures.firmware_version, ) + + +def exception_handler[_EntityT: AirGradientEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate AirGradient calls to handle exceptions. + + A decorator that wraps the passed in function, catches AirGradient errors. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except AirGradientConnectionError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(error)}, + ) from error + + except AirGradientError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"error": str(error)}, + ) from error + + return handler diff --git a/homeassistant/components/airgradient/number.py b/homeassistant/components/airgradient/number.py index 7fd282ddd8b..4265215fa25 100644 --- a/homeassistant/components/airgradient/number.py +++ b/homeassistant/components/airgradient/number.py @@ -19,7 +19,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN from .coordinator import AirGradientCoordinator -from .entity import AirGradientEntity +from .entity import AirGradientEntity, exception_handler + +PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) @@ -121,6 +123,7 @@ class AirGradientNumber(AirGradientEntity, NumberEntity): """Return the state of the number.""" return self.entity_description.value_fn(self.coordinator.data.config) + @exception_handler async def async_set_native_value(self, value: float) -> None: """Set the selected value.""" await self.entity_description.set_value_fn(self.coordinator.client, int(value)) diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml index 8d62e8515fc..7a7f8d5ee1d 100644 --- a/homeassistant/components/airgradient/quality_scale.yaml +++ b/homeassistant/components/airgradient/quality_scale.yaml @@ -29,24 +29,30 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo + docs-configuration-parameters: + status: exempt + comment: No options to configure docs-installation-parameters: todo entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: status: exempt comment: | This integration does not require authentication. - test-coverage: done + test-coverage: todo # Gold devices: done diagnostics: done - discovery-update-info: done - discovery: done + discovery-update-info: + status: todo + comment: DHCP is still possible + discovery: + status: todo + comment: DHCP is still possible docs-data-update: todo docs-examples: todo docs-known-limitations: todo @@ -62,9 +68,9 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py index af56802d842..8c15102ad3a 100644 --- a/homeassistant/components/airgradient/select.py +++ b/homeassistant/components/airgradient/select.py @@ -19,7 +19,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN, PM_STANDARD, PM_STANDARD_REVERSE from .coordinator import AirGradientCoordinator -from .entity import AirGradientEntity +from .entity import AirGradientEntity, exception_handler + +PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) @@ -216,6 +218,7 @@ class AirGradientSelect(AirGradientEntity, SelectEntity): """Return the state of the select.""" return self.entity_description.value_fn(self.coordinator.data.config) + @exception_handler async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.set_value_fn(self.coordinator.client, option) diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py index 497d4cc0488..3b20b31f923 100644 --- a/homeassistant/components/airgradient/sensor.py +++ b/homeassistant/components/airgradient/sensor.py @@ -35,6 +35,8 @@ from .const import PM_STANDARD, PM_STANDARD_REVERSE from .coordinator import AirGradientCoordinator from .entity import AirGradientEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class AirGradientMeasurementSensorEntityDescription(SensorEntityDescription): @@ -137,6 +139,15 @@ MEASUREMENT_SENSOR_TYPES: tuple[AirGradientMeasurementSensorEntityDescription, . entity_registry_enabled_default=False, value_fn=lambda status: status.raw_total_volatile_organic_component, ), + AirGradientMeasurementSensorEntityDescription( + key="pm02_raw", + translation_key="raw_pm02", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda status: status.raw_pm02, + ), ) CONFIG_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index 6bf7242f2f1..4cf3a6a34ea 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -17,7 +17,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1." + "invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please ensure you reconfigure against the same device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -119,6 +121,9 @@ "raw_nitrogen": { "name": "Raw NOx" }, + "raw_pm02": { + "name": "Raw PM2.5" + }, "display_pm_standard": { "name": "[%key:component::airgradient::entity::select::display_pm_standard::name%]", "state": { @@ -162,5 +167,16 @@ "name": "Post data to Airgradient" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Airgradient device: {error}" + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Airgradient device: {error}" + }, + "update_error": { + "message": "An error occurred while communicating with the Airgradient device: {error}" + } } } diff --git a/homeassistant/components/airgradient/switch.py b/homeassistant/components/airgradient/switch.py index 329f704e755..55835fa30a6 100644 --- a/homeassistant/components/airgradient/switch.py +++ b/homeassistant/components/airgradient/switch.py @@ -20,7 +20,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN from .coordinator import AirGradientCoordinator -from .entity import AirGradientEntity +from .entity import AirGradientEntity, exception_handler + +PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) @@ -99,11 +101,13 @@ class AirGradientSwitch(AirGradientEntity, SwitchEntity): """Return the state of the switch.""" return self.entity_description.value_fn(self.coordinator.data.config) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.entity_description.set_value_fn(self.coordinator.client, True) await self.coordinator.async_request_refresh() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.entity_description.set_value_fn(self.coordinator.client, False) diff --git a/homeassistant/components/airgradient/update.py b/homeassistant/components/airgradient/update.py index 47e71cb4e65..12cec65f791 100644 --- a/homeassistant/components/airgradient/update.py +++ b/homeassistant/components/airgradient/update.py @@ -2,7 +2,7 @@ from datetime import timedelta -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.update import UpdateDeviceClass, UpdateEntity from homeassistant.core import HomeAssistant @@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry, AirGradientCoordinator from .entity import AirGradientEntity +PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(hours=1) diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index 32185080d25..9434d368dbe 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -21,7 +21,6 @@ from .const import ( ATTR_API_CAT_DESCRIPTION, ATTR_API_CAT_LEVEL, ATTR_API_CATEGORY, - ATTR_API_PM25, ATTR_API_POLLUTANT, ATTR_API_REPORT_DATE, ATTR_API_REPORT_HOUR, @@ -91,18 +90,16 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): max_aqi_desc = obv[ATTR_API_CATEGORY][ATTR_API_CAT_DESCRIPTION] max_aqi_poll = pollutant - # Copy other data from PM2.5 Value - if obv[ATTR_API_AQI_PARAM] == ATTR_API_PM25: - # Copy Report Details - data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] - data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] - data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ] + # Copy Report Details + data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] + data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] + data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ] - # Copy Station Details - data[ATTR_API_STATE] = obv[ATTR_API_STATE] - data[ATTR_API_STATION] = obv[ATTR_API_STATION] - data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE] - data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE] + # Copy Station Details + data[ATTR_API_STATE] = obv[ATTR_API_STATE] + data[ATTR_API_STATION] = obv[ATTR_API_STATION] + data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE] + data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE] # Store Overall AQI data[ATTR_API_AQI] = max_aqi diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 74d712ccfc6..1b604d72032 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -39,45 +39,54 @@ SENSORS: dict[str, SensorEntityDescription] = { key="temp", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, ), "battery": SensorEntityDescription( key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), "co2": SensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, ), "voc": SensorEntityDescription( key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, ), "light": SensorEntityDescription( key="light", native_unit_of_measurement=PERCENTAGE, translation_key="light", + state_class=SensorStateClass.MEASUREMENT, ), "virusRisk": SensorEntityDescription( key="virusRisk", translation_key="virus_risk", + state_class=SensorStateClass.MEASUREMENT, ), "mold": SensorEntityDescription( key="mold", translation_key="mold", + state_class=SensorStateClass.MEASUREMENT, ), "rssi": SensorEntityDescription( key="rssi", @@ -85,16 +94,19 @@ SENSORS: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), "pm1": SensorEntityDescription( key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, ), "pm25": SensorEntityDescription( key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, ), } @@ -143,8 +155,7 @@ class AirthingsHeaterEnergySensor( self._id = airthings_device.device_id self._attr_device_info = DeviceInfo( configuration_url=( - "https://dashboard.airthings.com/devices/" - f"{airthings_device.device_id}" + f"https://dashboard.airthings.com/devices/{airthings_device.device_id}" ), identifiers={(DOMAIN, airthings_device.device_id)}, name=airthings_device.name, diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 0dfd82a38c4..248561706a3 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -67,18 +67,21 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), "battery": SensorEntityDescription( key="battery", @@ -86,24 +89,28 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, ), "co2": SensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "voc": SensorEntityDescription( key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "illuminance": SensorEntityDescription( key="illuminance", translation_key="illuminance", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), } diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index dbb6f02859b..0af920bd7a9 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -95,7 +95,6 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, ac_number, info): """Initialize the climate device.""" @@ -205,7 +204,6 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = AT_GROUP_MODES - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, group_number, info): """Initialize the climate device.""" diff --git a/homeassistant/components/airtouch5/climate.py b/homeassistant/components/airtouch5/climate.py index dfc34c1beaf..16566f5d664 100644 --- a/homeassistant/components/airtouch5/climate.py +++ b/homeassistant/components/airtouch5/climate.py @@ -124,7 +124,6 @@ class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity): _attr_translation_key = DOMAIN _attr_target_temperature_step = 1 _attr_name = None - _enable_turn_on_off_backwards_compatibility = False class Airtouch5AC(Airtouch5ClimateEntity): diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 66726832843..58ad730bc31 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -50,7 +50,7 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, value_fn=lambda settings, status, measurements, history: int( history.get( - f'Outdoor {"AQI(US)" if settings["is_aqi_usa"] else "AQI(CN)"}', -1 + f"Outdoor {'AQI(US)' if settings['is_aqi_usa'] else 'AQI(CN)'}", -1 ) ), translation_key="outdoor_air_quality_index", diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index 5d1f9f051a3..aa168dce858 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -5,7 +5,14 @@ from __future__ import annotations import logging from typing import Any -from aioairzone.const import AZD_MAC, AZD_WEBSERVER, DEFAULT_SYSTEM_ID +from aioairzone.const import ( + AZD_FIRMWARE, + AZD_FULL_NAME, + AZD_MAC, + AZD_MODEL, + AZD_WEBSERVER, + DEFAULT_SYSTEM_ID, +) from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions from homeassistant.config_entries import ConfigEntry @@ -17,6 +24,7 @@ from homeassistant.helpers import ( entity_registry as er, ) +from .const import DOMAIN, MANUFACTURER from .coordinator import AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -78,7 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b options = ConnectionOptions( entry.data[CONF_HOST], entry.data[CONF_PORT], - entry.data.get(CONF_ID, DEFAULT_SYSTEM_ID), + entry.data[CONF_ID], ) airzone = AirzoneLocalApi(aiohttp_client.async_get_clientsession(hass), options) @@ -88,6 +96,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b entry.runtime_data = coordinator + device_registry = dr.async_get(hass) + + ws_data: dict[str, Any] | None = coordinator.data.get(AZD_WEBSERVER) + if ws_data is not None: + mac = ws_data.get(AZD_MAC, "") + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={(DOMAIN, f"{entry.entry_id}_ws")}, + manufacturer=MANUFACTURER, + model=ws_data.get(AZD_MODEL), + name=ws_data.get(AZD_FULL_NAME), + sw_version=ws_data.get(AZD_FIRMWARE), + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -96,3 +120,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> bool: + """Migrate an old entry.""" + if entry.version == 1 and entry.minor_version < 2: + # Add missing CONF_ID + system_id = entry.data.get(CONF_ID, DEFAULT_SYSTEM_ID) + new_data = entry.data.copy() + new_data[CONF_ID] = system_id + hass.config_entries.async_update_entry( + entry, + data=new_data, + minor_version=2, + ) + + _LOGGER.info( + "Migration to configuration version %s.%s successful", + entry.version, + entry.minor_version, + ) + + return True diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 6be7416bbb0..4ed54286cff 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -136,7 +136,6 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): _attr_name = None _speeds: dict[int, str] = {} _speeds_reverse: dict[str, int] = {} - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/airzone/config_flow.py b/homeassistant/components/airzone/config_flow.py index 406fd72a6db..c4088e950e9 100644 --- a/homeassistant/components/airzone/config_flow.py +++ b/homeassistant/components/airzone/config_flow.py @@ -10,12 +10,12 @@ from aioairzone.exceptions import AirzoneError, InvalidSystem from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -44,6 +44,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN): _discovered_ip: str | None = None _discovered_mac: str | None = None + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -53,6 +54,9 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: + if CONF_ID not in user_input: + user_input[CONF_ID] = DEFAULT_SYSTEM_ID + self._async_abort_entries_match(user_input) airzone = AirzoneLocalApi( @@ -60,7 +64,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN): ConnectionOptions( user_input[CONF_HOST], user_input[CONF_PORT], - user_input.get(CONF_ID, DEFAULT_SYSTEM_ID), + user_input[CONF_ID], ), ) @@ -84,6 +88,9 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN): ) title = f"Airzone {user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + if user_input[CONF_ID] != DEFAULT_SYSTEM_ID: + title += f" #{user_input[CONF_ID]}" + return self.async_create_entry(title=title, data=user_input) return self.async_show_form( @@ -93,7 +100,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" self._discovered_ip = discovery_info.ip diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 61f79eabf52..59d58fb62b0 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -68,8 +68,9 @@ class AirzoneSystemEntity(AirzoneEntity): model=self.get_airzone_value(AZD_MODEL), name=f"System {self.system_id}", sw_version=self.get_airzone_value(AZD_FIRMWARE), - via_device=(DOMAIN, f"{entry.entry_id}_ws"), ) + if AZD_WEBSERVER in self.coordinator.data: + self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws") self._attr_unique_id = entry.unique_id or entry.entry_id @property @@ -102,8 +103,9 @@ class AirzoneHotWaterEntity(AirzoneEntity): manufacturer=MANUFACTURER, model="DHW", name=self.get_airzone_value(AZD_NAME), - via_device=(DOMAIN, f"{entry.entry_id}_ws"), ) + if AZD_WEBSERVER in self.coordinator.data: + self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws") self._attr_unique_id = entry.unique_id or entry.entry_id def get_airzone_value(self, key: str) -> Any: diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 01fde7eb2fb..95ed9d200f4 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.7"] + "requirements": ["aioairzone==0.9.9"] } diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index d32b070ad8c..b98473072e4 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -177,7 +177,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def _init_attributes(self) -> None: """Init common climate device attributes.""" @@ -194,12 +193,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) - if ( - self.get_airzone_value(AZD_SPEED) is not None - and self.get_airzone_value(AZD_SPEEDS) is not None - ): - self._initialize_fan_speeds() - @callback def _handle_coordinator_update(self) -> None: """Update attributes when the coordinator updates.""" @@ -214,8 +207,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[ self.get_airzone_value(AZD_ACTION) ] - if self.supported_features & ClimateEntityFeature.FAN_MODE: - self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) if self.get_airzone_value(AZD_POWER): self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ self.get_airzone_value(AZD_MODE) @@ -252,6 +243,22 @@ class AirzoneDeviceClimate(AirzoneClimate): _speeds: dict[int, str] _speeds_reverse: dict[str, int] + def _init_attributes(self) -> None: + """Init common climate device attributes.""" + super()._init_attributes() + if ( + self.get_airzone_value(AZD_SPEED) is not None + and self.get_airzone_value(AZD_SPEEDS) is not None + ): + self._initialize_fan_speeds() + + @callback + def _async_update_attrs(self) -> None: + """Update climate attributes.""" + super()._async_update_attrs() + if self.supported_features & ClimateEntityFeature.FAN_MODE: + self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) + def _initialize_fan_speeds(self) -> None: """Initialize fan speeds.""" azd_speeds: dict[int, int] = self.get_airzone_value(AZD_SPEEDS) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 4389e3a9ad9..80a676a40fa 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -4,11 +4,10 @@ from __future__ import annotations import asyncio from datetime import timedelta -from functools import partial import logging from typing import TYPE_CHECKING, Any, Final, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -27,26 +26,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema -from homeassistant.helpers.deprecation import ( - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey -from .const import ( # noqa: F401 - _DEPRECATED_FORMAT_NUMBER, - _DEPRECATED_FORMAT_TEXT, - _DEPRECATED_SUPPORT_ALARM_ARM_AWAY, - _DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - _DEPRECATED_SUPPORT_ALARM_ARM_HOME, - _DEPRECATED_SUPPORT_ALARM_ARM_NIGHT, - _DEPRECATED_SUPPORT_ALARM_ARM_VACATION, - _DEPRECATED_SUPPORT_ALARM_TRIGGER, +from .const import ( ATTR_CHANGED_BY, ATTR_CODE_ARM_REQUIRED, DOMAIN, @@ -163,7 +150,6 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A _alarm_control_panel_option_default_code: str | None = None __alarm_legacy_state: bool = False - __alarm_legacy_state_reported: bool = False def __init_subclass__(cls, **kwargs: Any) -> None: """Post initialisation processing.""" @@ -180,9 +166,7 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A unless already reported. """ if name == "_attr_state": - if self.__alarm_legacy_state_reported is not True: - self._report_deprecated_alarm_state_handling() - self.__alarm_legacy_state_reported = True + self._report_deprecated_alarm_state_handling() return super().__setattr__(name, value) @callback @@ -194,7 +178,7 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A ) -> None: """Start adding an entity to a platform.""" super().add_to_platform_start(hass, platform, parallel_updates) - if self.__alarm_legacy_state and not self.__alarm_legacy_state_reported: + if self.__alarm_legacy_state: self._report_deprecated_alarm_state_handling() @callback @@ -203,19 +187,16 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A Integrations should implement alarm_state instead of using state directly. """ - self.__alarm_legacy_state_reported = True - if "custom_components" in type(self).__module__: - # Do not report on core integrations as they have been fixed. - report_issue = "report it to the custom integration author." - _LOGGER.warning( - "Entity %s (%s) is setting state directly" - " which will stop working in HA Core 2025.11." - " Entities should implement the 'alarm_state' property and" - " return its state using the AlarmControlPanelState enum, please %s", - self.entity_id, - type(self), - report_issue, - ) + report_usage( + "is setting state directly." + f" Entity {self.entity_id} ({type(self)}) should implement the 'alarm_state'" + " property and return its state using the AlarmControlPanelState enum", + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.11", + integration_domain=self.platform.platform_name if self.platform else None, + exclude_integrations={DOMAIN}, + ) @final @property @@ -374,12 +355,7 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A @cached_property def supported_features(self) -> AlarmControlPanelEntityFeature: """Return the list of supported features.""" - features = self._attr_supported_features - if type(features) is int: # noqa: E721 - new_features = AlarmControlPanelEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features + return self._attr_supported_features @final @property @@ -417,13 +393,3 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A self._alarm_control_panel_option_default_code = default_code return self._alarm_control_panel_option_default_code = None - - -# As we import constants of the const module here, we need to add the following -# functions to check for deprecated constants again -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py index f3218626ead..f9a5887513c 100644 --- a/homeassistant/components/alarm_control_panel/const.py +++ b/homeassistant/components/alarm_control_panel/const.py @@ -1,16 +1,8 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum -from functools import partial from typing import Final -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) - DOMAIN: Final = "alarm_control_panel" ATTR_CHANGED_BY: Final = "changed_by" @@ -39,12 +31,6 @@ class CodeFormat(StrEnum): NUMBER = "number" -# These constants are deprecated as of Home Assistant 2022.5, can be removed in 2025.1 -# Please use the CodeFormat enum instead. -_DEPRECATED_FORMAT_TEXT: Final = DeprecatedConstantEnum(CodeFormat.TEXT, "2025.1") -_DEPRECATED_FORMAT_NUMBER: Final = DeprecatedConstantEnum(CodeFormat.NUMBER, "2025.1") - - class AlarmControlPanelEntityFeature(IntFlag): """Supported features of the alarm control panel entity.""" @@ -56,27 +42,6 @@ class AlarmControlPanelEntityFeature(IntFlag): ARM_VACATION = 32 -# These constants are deprecated as of Home Assistant 2022.5 -# Please use the AlarmControlPanelEntityFeature enum instead. -_DEPRECATED_SUPPORT_ALARM_ARM_HOME: Final = DeprecatedConstantEnum( - AlarmControlPanelEntityFeature.ARM_HOME, "2025.1" -) -_DEPRECATED_SUPPORT_ALARM_ARM_AWAY: Final = DeprecatedConstantEnum( - AlarmControlPanelEntityFeature.ARM_AWAY, "2025.1" -) -_DEPRECATED_SUPPORT_ALARM_ARM_NIGHT: Final = DeprecatedConstantEnum( - AlarmControlPanelEntityFeature.ARM_NIGHT, "2025.1" -) -_DEPRECATED_SUPPORT_ALARM_TRIGGER: Final = DeprecatedConstantEnum( - AlarmControlPanelEntityFeature.TRIGGER, "2025.1" -) -_DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = DeprecatedConstantEnum( - AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, "2025.1" -) -_DEPRECATED_SUPPORT_ALARM_ARM_VACATION: Final = DeprecatedConstantEnum( - AlarmControlPanelEntityFeature.ARM_VACATION, "2025.1" -) - CONDITION_TRIGGERED: Final = "is_triggered" CONDITION_DISARMED: Final = "is_disarmed" CONDITION_ARMED_HOME: Final = "is_armed_home" @@ -84,10 +49,3 @@ CONDITION_ARMED_AWAY: Final = "is_armed_away" CONDITION_ARMED_NIGHT: Final = "is_armed_night" CONDITION_ARMED_VACATION: Final = "is_armed_vacation" CONDITION_ARMED_CUSTOM_BYPASS: Final = "is_armed_custom_bypass" - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index b2cda8ad76e..c5b4ad15904 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -317,6 +317,7 @@ class Alexa(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -403,6 +404,7 @@ class AlexaPowerController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -436,7 +438,7 @@ class AlexaPowerController(AlexaCapability): elif self.entity.domain == remote.DOMAIN: is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN) elif self.entity.domain == vacuum.DOMAIN: - is_on = self.entity.state == vacuum.STATE_CLEANING + is_on = self.entity.state == vacuum.VacuumActivity.CLEANING elif self.entity.domain == timer.DOMAIN: is_on = self.entity.state != STATE_IDLE elif self.entity.domain == water_heater.DOMAIN: @@ -469,6 +471,7 @@ class AlexaLockController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -523,6 +526,7 @@ class AlexaSceneController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -562,6 +566,7 @@ class AlexaBrightnessController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -611,6 +616,7 @@ class AlexaColorController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -669,6 +675,7 @@ class AlexaColorTemperatureController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -715,6 +722,7 @@ class AlexaSpeaker(AlexaCapability): "fr-FR", # Not documented as of 2021-12-04, see PR #60489 "it-IT", "ja-JP", + "nl-NL", } def name(self) -> str: @@ -772,6 +780,7 @@ class AlexaStepSpeaker(AlexaCapability): "es-ES", "fr-FR", # Not documented as of 2021-12-04, see PR #60489 "it-IT", + "nl-NL", } def name(self) -> str: @@ -801,6 +810,7 @@ class AlexaPlaybackController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -859,6 +869,7 @@ class AlexaInputController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -1104,6 +1115,7 @@ class AlexaThermostatController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -1245,6 +1257,7 @@ class AlexaPowerLevelController(AlexaCapability): "fr-CA", "fr-FR", "it-IT", + "nl-NL", "ja-JP", } @@ -1723,6 +1736,7 @@ class AlexaRangeController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -2066,6 +2080,7 @@ class AlexaToggleController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -2212,6 +2227,7 @@ class AlexaPlaybackStateReporter(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -2267,6 +2283,7 @@ class AlexaSeekController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -2360,6 +2377,7 @@ class AlexaEqualizerController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } @@ -2470,6 +2488,7 @@ class AlexaCameraStreamController(AlexaCapability): "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", } diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 4862e4d8a8c..27e9bbd5b67 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -59,6 +59,7 @@ CONF_SUPPORTED_LOCALES = ( "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", ) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 8c139d66369..6a0b1830b7e 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -474,25 +474,30 @@ class ClimateCapabilities(AlexaEntity): # If we support two modes, one being off, we allow turning on too. supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if ( - self.entity.domain == climate.DOMAIN - and climate.HVACMode.OFF - in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []) - or self.entity.domain == climate.DOMAIN - and ( - supported_features - & ( - climate.ClimateEntityFeature.TURN_ON - | climate.ClimateEntityFeature.TURN_OFF + ( + self.entity.domain == climate.DOMAIN + and climate.HVACMode.OFF + in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []) + ) + or ( + self.entity.domain == climate.DOMAIN + and ( + supported_features + & ( + climate.ClimateEntityFeature.TURN_ON + | climate.ClimateEntityFeature.TURN_OFF + ) ) ) - or self.entity.domain == water_heater.DOMAIN - and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) + or ( + self.entity.domain == water_heater.DOMAIN + and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) + ) ): yield AlexaPowerController(self.entity) - if ( - self.entity.domain == climate.DOMAIN - or self.entity.domain == water_heater.DOMAIN + if self.entity.domain == climate.DOMAIN or ( + self.entity.domain == water_heater.DOMAIN and ( supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 89e47673f07..04bef105546 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -359,7 +359,7 @@ async def async_api_set_color_temperature( await hass.services.async_call( entity.domain, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_KELVIN: kelvin}, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: kelvin}, blocking=False, context=context, ) @@ -376,14 +376,14 @@ async def async_api_decrease_color_temp( ) -> AlexaResponse: """Process a decrease color temperature request.""" entity = directive.entity - current = int(entity.attributes[light.ATTR_COLOR_TEMP]) - max_mireds = int(entity.attributes[light.ATTR_MAX_MIREDS]) + current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN]) + min_kelvin = int(entity.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN]) - value = min(max_mireds, current + 50) + value = max(min_kelvin, current - 500) await hass.services.async_call( entity.domain, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value}, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: value}, blocking=False, context=context, ) @@ -400,14 +400,14 @@ async def async_api_increase_color_temp( ) -> AlexaResponse: """Process an increase color temperature request.""" entity = directive.entity - current = int(entity.attributes[light.ATTR_COLOR_TEMP]) - min_mireds = int(entity.attributes[light.ATTR_MIN_MIREDS]) + current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN]) + max_kelvin = int(entity.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN]) - value = max(min_mireds, current - 50) + value = min(max_kelvin, current + 500) await hass.services.async_call( entity.domain, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value}, + {ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP_KELVIN: value}, blocking=False, context=context, ) @@ -527,6 +527,7 @@ async def async_api_unlock( "hi-IN", "it-IT", "ja-JP", + "nl-NL", "pt-BR", }: msg = ( diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 3eb761dacde..03b6a22007c 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -317,9 +317,8 @@ async def async_enable_proactive_mode( if should_doorbell: old_state = data["old_state"] - if ( - new_state.domain == event.DOMAIN - or new_state.state == STATE_ON + if new_state.domain == event.DOMAIN or ( + new_state.state == STATE_ON and (old_state is None or old_state.state != STATE_ON) ): await async_send_doorbell_event_message( diff --git a/homeassistant/components/amcrest/strings.json b/homeassistant/components/amcrest/strings.json index 816511bf05e..807c75e1ac8 100644 --- a/homeassistant/components/amcrest/strings.json +++ b/homeassistant/components/amcrest/strings.json @@ -41,7 +41,7 @@ } }, "enable_motion_recording": { - "name": "Enables motion recording", + "name": "Enable motion recording", "description": "Enables recording a clip to camera storage when motion is detected.", "fields": { "entity_id": { @@ -51,8 +51,8 @@ } }, "disable_motion_recording": { - "name": "Disables motion recording", - "description": "Disable recording a clip to camera storage when motion is detected.", + "name": "Disable motion recording", + "description": "Disables recording a clip to camera storage when motion is detected.", "fields": { "entity_id": { "name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]", diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index c36755f5403..da77a35f789 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -11,12 +11,7 @@ from python_homeassistant_analytics import ( from python_homeassistant_analytics.models import IntegrationType import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( @@ -25,6 +20,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) +from . import AnalyticsInsightsConfigEntry from .const import ( CONF_TRACKED_ADDONS, CONF_TRACKED_CUSTOM_INTEGRATIONS, @@ -46,7 +42,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: AnalyticsInsightsConfigEntry, ) -> HomeassistantAnalyticsOptionsFlowHandler: """Get the options flow for this handler.""" return HomeassistantAnalyticsOptionsFlowHandler() diff --git a/homeassistant/components/analytics_insights/manifest.json b/homeassistant/components/analytics_insights/manifest.json index 841cf1caf42..bf99d89e073 100644 --- a/homeassistant/components/analytics_insights/manifest.json +++ b/homeassistant/components/analytics_insights/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["python_homeassistant_analytics"], - "requirements": ["python-homeassistant-analytics==0.8.0"], + "requirements": ["python-homeassistant-analytics==0.8.1"], "single_config_entry": true } diff --git a/homeassistant/components/analytics_insights/quality_scale.yaml b/homeassistant/components/analytics_insights/quality_scale.yaml new file mode 100644 index 00000000000..ff999d97d03 --- /dev/null +++ b/homeassistant/components/analytics_insights/quality_scale.yaml @@ -0,0 +1,100 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: + status: done + comment: | + The coordinator handles this. + integration-owner: done + log-when-unavailable: + status: done + comment: | + The coordinator handles this. + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and thus does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and thus does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration has a fixed single service. + entity-category: done + entity-device-class: + status: exempt + comment: | + This integration does not have entities with device classes. + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: exempt + comment: All the options of this integration are managed via the options flow + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: | + This integration has a fixed single service. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index 34c4212c913..4ffa0e24777 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -110,7 +110,7 @@ def _setup_androidtv( adb_log = f"using Python ADB implementation with adbkey='{adbkey}'" else: - # Use "pure-python-adb" (communicate with ADB server) + # Communicate via ADB server signer = None adb_log = ( "using ADB server at" @@ -135,15 +135,16 @@ async def async_connect_androidtv( ) aftv = await async_androidtv_setup( - config[CONF_HOST], - config[CONF_PORT], - adbkey, - config.get(CONF_ADB_SERVER_IP), - config.get(CONF_ADB_SERVER_PORT, DEFAULT_ADB_SERVER_PORT), - state_detection_rules, - config[CONF_DEVICE_CLASS], - timeout, - signer, + host=config[CONF_HOST], + port=config[CONF_PORT], + adbkey=adbkey, + adb_server_ip=config.get(CONF_ADB_SERVER_IP), + adb_server_port=config.get(CONF_ADB_SERVER_PORT, DEFAULT_ADB_SERVER_PORT), + state_detection_rules=state_detection_rules, + device_class=config[CONF_DEVICE_CLASS], + auth_timeout_s=timeout, + signer=signer, + log_errors=False, ) if not aftv.available: diff --git a/homeassistant/components/androidtv/entity.py b/homeassistant/components/androidtv/entity.py index 626dd0f7794..fa583bb2777 100644 --- a/homeassistant/components/androidtv/entity.py +++ b/homeassistant/components/androidtv/entity.py @@ -151,5 +151,5 @@ class AndroidTVEntity(Entity): # Using "adb_shell" (Python ADB implementation) self.exceptions = ADB_PYTHON_EXCEPTIONS else: - # Using "pure-python-adb" (communicate with ADB server) + # Communicate via ADB server self.exceptions = ADB_TCP_EXCEPTIONS diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 2d0b062c750..e30d03fc2d5 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -6,10 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/androidtv", "integration_type": "device", "iot_class": "local_polling", - "loggers": ["adb_shell", "androidtv", "pure_python_adb"], - "requirements": [ - "adb-shell[async]==0.4.4", - "androidtv[async]==0.0.73", - "pure-python-adb[async]==0.3.0.dev0" - ] + "loggers": ["adb_shell", "androidtv"], + "requirements": ["adb-shell[async]==0.4.4", "androidtv[async]==0.0.75"] } diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json index b6f5d494d0f..ce921072e27 100644 --- a/homeassistant/components/androidtv/strings.json +++ b/homeassistant/components/androidtv/strings.json @@ -21,7 +21,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "invalid_unique_id": "Impossible to determine a valid unique id for the device" + "invalid_unique_id": "Impossible to determine a valid unique ID for the device" } }, "options": { @@ -38,17 +38,17 @@ } }, "apps": { - "title": "Configure Android Apps", - "description": "Configure application id {app_id}", + "title": "Configure Android apps", + "description": "Configure application ID {app_id}", "data": { - "app_name": "Application Name", + "app_name": "Application name", "app_id": "Application ID", "app_delete": "Check to delete this application" } }, "rules": { "title": "Configure Android state detection rules", - "description": "Configure detection rule for application id {rule_id}", + "description": "Configure detection rule for application ID {rule_id}", "data": { "rule_id": "[%key:component::androidtv::options::step::apps::data::app_id%]", "rule_values": "List of state detection rules (see documentation)", diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 3500e4ff47b..78f24fc498c 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -14,7 +14,6 @@ from androidtvremote2 import ( ) import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -31,6 +30,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN from .helpers import create_api, get_enable_ime @@ -142,7 +142,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" _LOGGER.debug("Android TV device found via zeroconf: %s", discovery_info) @@ -156,7 +156,12 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): # and one of them, which could end up being in discovery_info.host, is from a # different device. If any of the discovery_info.ip_addresses matches the # existing host, don't update the host. - if existing_config_entry and len(discovery_info.ip_addresses) > 1: + if ( + existing_config_entry + # Ignored entries don't have host + and CONF_HOST in existing_config_entry.data + and len(discovery_info.ip_addresses) > 1 + ): existing_host = existing_config_entry.data[CONF_HOST] if existing_host != self.host: if existing_host in [ diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 33970171d40..e41cbcf9a76 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -44,12 +44,12 @@ } }, "apps": { - "title": "Configure Android Apps", - "description": "Configure application id {app_id}", + "title": "Configure Android apps", + "description": "Configure application ID {app_id}", "data": { - "app_name": "Application Name", + "app_name": "Application name", "app_id": "Application ID", - "app_icon": "Application Icon", + "app_icon": "Application icon", "app_delete": "Check to delete this application" } } diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 20e555e9592..e45e849adf6 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -27,7 +27,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import device_registry as dr, intent, llm, template from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from . import AnthropicConfigEntry from .const import ( @@ -164,7 +164,7 @@ class AnthropicConversationEntity( ] if user_input.conversation_id is None: - conversation_id = ulid.ulid_now() + conversation_id = ulid_util.ulid_now() messages = [] elif user_input.conversation_id in self.history: @@ -177,8 +177,8 @@ class AnthropicConversationEntity( # a new conversation was started. If the user picks their own, they # want to track a conversation and we respect it. try: - ulid.ulid_to_bytes(user_input.conversation_id) - conversation_id = ulid.ulid_now() + ulid_util.ulid_to_bytes(user_input.conversation_id) + conversation_id = ulid_util.ulid_now() except ValueError: conversation_id = user_input.conversation_id diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 7d51c458e4d..b5cbb36c034 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["anthropic==0.31.2"] + "requirements": ["anthropic==0.44.0"] } diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index eae7981d5b9..a928a6677cb 100644 --- a/homeassistant/components/aosmith/manifest.json +++ b/homeassistant/components/aosmith/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aosmith", "iot_class": "cloud_polling", - "requirements": ["py-aosmith==1.0.11"] + "requirements": ["py-aosmith==1.0.12"] } diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index 768e9605967..1ae12d8c4b0 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -44,7 +44,10 @@ class APCUPSdData(dict[str, str]): @property def serial_no(self) -> str | None: """Return the unique serial number of the UPS, if available.""" - return self.get("SERIALNO") + sn = self.get("SERIALNO") + # We had user reports that some UPS models simply return "Blank" as serial number, in + # which case we fall back to `None` to indicate that it is actually not available. + return None if sn == "Blank" else sn class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]): diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index b0741cc9c61..5c317755d05 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -34,6 +34,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN @@ -98,7 +99,6 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 scan_filter: str | None = None - all_identifiers: set[str] atv: BaseConfig | None = None atv_identifiers: list[str] | None = None _host: str # host in zeroconf discovery info, should not be accessed by other flows @@ -118,6 +118,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize a new AppleTVConfigFlow.""" self.credentials: dict[int, str | None] = {} # Protocol -> credentials + self.all_identifiers: set[str] = set() @property def device_identifier(self) -> str | None: @@ -204,7 +205,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle device found via zeroconf.""" if discovery_info.ip_address.version == 6: diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index b4e1b354878..b10a14af32b 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.15.1"], + "requirements": ["pyatv==0.16.0"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 50b272cc1fa..58146818624 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -38,7 +38,7 @@ from homeassistant.loader import ( from homeassistant.util import slugify from homeassistant.util.hass_dict import HassKey -__all__ = ["ClientCredential", "AuthorizationServer", "async_import_client_credential"] +__all__ = ["AuthorizationServer", "ClientCredential", "async_import_client_credential"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 4f3c4d7ef4e..ebe27d42471 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["apprise"], "quality_scale": "legacy", - "requirements": ["apprise==1.9.0"] + "requirements": ["apprise==1.9.1"] } diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py index 737fd768140..6b132cfcc95 100644 --- a/homeassistant/components/aprilaire/coordinator.py +++ b/homeassistant/components/aprilaire/coordinator.py @@ -120,6 +120,8 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol): """Wait for the client to be ready.""" if not self.data or Attribute.MAC_ADDRESS not in self.data: + await self.client.read_mac_address() + data = await self.client.wait_for_response( FunctionalDomain.IDENTIFICATION, 2, WAIT_TIMEOUT ) @@ -130,12 +132,9 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol): return False - if not self.data or Attribute.NAME not in self.data: - await self.client.wait_for_response( - FunctionalDomain.IDENTIFICATION, 4, WAIT_TIMEOUT - ) - if not self.data or Attribute.THERMOSTAT_MODES not in self.data: + await self.client.read_thermostat_iaq_available() + await self.client.wait_for_response( FunctionalDomain.CONTROL, 7, WAIT_TIMEOUT ) @@ -144,10 +143,16 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol): not self.data or Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS not in self.data ): + await self.client.read_sensors() + await self.client.wait_for_response( FunctionalDomain.SENSORS, 2, WAIT_TIMEOUT ) + await self.client.read_thermostat_status() + + await self.client.read_iaq_status() + await ready_callback(True) return True diff --git a/homeassistant/components/aprilaire/humidifier.py b/homeassistant/components/aprilaire/humidifier.py index 254cc0ac789..8a173e5e95e 100644 --- a/homeassistant/components/aprilaire/humidifier.py +++ b/homeassistant/components/aprilaire/humidifier.py @@ -50,7 +50,7 @@ async def async_setup_entry( descriptions: list[AprilaireHumidifierDescription] = [] - if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (0, 1, 2): + if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (1, 2): descriptions.append( AprilaireHumidifierDescription( key="humidifier", @@ -67,7 +67,7 @@ async def async_setup_entry( ) ) - if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) in (0, 1): + if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) == 1: descriptions.append( AprilaireHumidifierDescription( key="dehumidifier", diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json index 179a101885b..577de8ae88d 100644 --- a/homeassistant/components/aprilaire/manifest.json +++ b/homeassistant/components/aprilaire/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pyaprilaire"], - "requirements": ["pyaprilaire==0.7.4"] + "requirements": ["pyaprilaire==0.7.7"] } diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py index b6e951343f7..2535c66c4ac 100644 --- a/homeassistant/components/apsystems/coordinator.py +++ b/homeassistant/components/apsystems/coordinator.py @@ -5,12 +5,17 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from APsystemsEZ1 import APsystemsEZ1M, ReturnAlarmInfo, ReturnOutputData +from APsystemsEZ1 import ( + APsystemsEZ1M, + InverterReturnedError, + ReturnAlarmInfo, + ReturnOutputData, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import LOGGER +from .const import DOMAIN, LOGGER @dataclass @@ -24,6 +29,8 @@ class ApSystemsSensorData: class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): """Coordinator used for all sensors.""" + device_version: str + def __init__(self, hass: HomeAssistant, api: APsystemsEZ1M) -> None: """Initialize my coordinator.""" super().__init__( @@ -41,8 +48,14 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): raise UpdateFailed from None self.api.max_power = device_info.maxPower self.api.min_power = device_info.minPower + self.device_version = device_info.devVer async def _async_update_data(self) -> ApSystemsSensorData: - output_data = await self.api.get_output_data() - alarm_info = await self.api.get_alarm_info() + try: + output_data = await self.api.get_output_data() + alarm_info = await self.api.get_alarm_info() + except InverterReturnedError: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="inverter_error" + ) from None return ApSystemsSensorData(output_data=output_data, alarm_info=alarm_info) diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py index 519f4fffb61..7770b451680 100644 --- a/homeassistant/components/apsystems/entity.py +++ b/homeassistant/components/apsystems/entity.py @@ -21,7 +21,8 @@ class ApSystemsEntity(Entity): """Initialize the APsystems entity.""" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, data.device_id)}, - serial_number=data.device_id, manufacturer="APsystems", model="EZ1-M", + serial_number=data.device_id, + sw_version=data.coordinator.device_version.split(" ")[1], ) diff --git a/homeassistant/components/apsystems/number.py b/homeassistant/components/apsystems/number.py index 01e991f5188..b5ed60a7754 100644 --- a/homeassistant/components/apsystems/number.py +++ b/homeassistant/components/apsystems/number.py @@ -2,6 +2,8 @@ from __future__ import annotations +from aiohttp import ClientConnectorError + from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant @@ -20,7 +22,7 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" - add_entities([ApSystemsMaxOutputNumber(config_entry.runtime_data)]) + add_entities([ApSystemsMaxOutputNumber(config_entry.runtime_data)], True) class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity): @@ -45,7 +47,13 @@ class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity): async def async_update(self) -> None: """Set the state with the value fetched from the inverter.""" - self._attr_native_value = await self._api.get_max_power() + try: + status = await self._api.get_max_power() + except (TimeoutError, ClientConnectorError): + self._attr_available = False + else: + self._attr_available = True + self._attr_native_value = status async def async_set_native_value(self, value: float) -> None: """Set the desired output power.""" diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json index e02f86c2730..b3a10ca49a7 100644 --- a/homeassistant/components/apsystems/strings.json +++ b/homeassistant/components/apsystems/strings.json @@ -72,5 +72,10 @@ "name": "Inverter status" } } + }, + "exceptions": { + "inverter_error": { + "message": "Inverter returned an error" + } } } diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json index 6cce7554dd1..ac45e352bb6 100644 --- a/homeassistant/components/aranet/manifest.json +++ b/homeassistant/components/aranet/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/aranet", "integration_type": "device", "iot_class": "local_push", - "requirements": ["aranet4==2.4.0"] + "requirements": ["aranet4==2.5.0"] } diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index d7fbd0e4b3b..b5187cba1f4 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -22,6 +22,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_NAME, ATTR_SW_VERSION, CONCENTRATION_PARTS_PER_MILLION, @@ -142,6 +143,7 @@ def _sensor_device_info_to_hass( if adv.readings and adv.readings.name: hass_device_info[ATTR_NAME] = adv.readings.name hass_device_info[ATTR_MANUFACTURER] = ARANET_MANUFACTURER_NAME + hass_device_info[ATTR_MODEL] = adv.readings.type.model if adv.manufacturer_data: hass_device_info[ATTR_SW_VERSION] = str(adv.manufacturer_data.version) return hass_device_info diff --git a/homeassistant/components/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py index 6c037591688..e1886a1db60 100644 --- a/homeassistant/components/arcam_fmj/config_flow.py +++ b/homeassistant/components/arcam_fmj/config_flow.py @@ -9,10 +9,10 @@ from arcam.fmj.client import Client, ConnectionFailed from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN @@ -88,12 +88,12 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered device.""" host = str(urlparse(discovery_info.ssdp_location).hostname) port = DEFAULT_PORT - uuid = get_uniqueid_from_udn(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + uuid = get_uniqueid_from_udn(discovery_info.upnp[ATTR_UPNP_UDN]) if not uuid: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index ef622ef9826..911fab441e5 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -90,7 +90,7 @@ class ArubaDeviceScanner(DeviceScanner): """Retrieve data from Aruba Access Point and return parsed result.""" connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa" - ssh = pexpect.spawn(connect) + ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8") query = ssh.expect( [ "password:", @@ -125,12 +125,12 @@ class ArubaDeviceScanner(DeviceScanner): ssh.expect("#") ssh.sendline("show clients") ssh.expect("#") - devices_result = ssh.before.split(b"\r\n") + devices_result = (ssh.before or "").splitlines() ssh.sendline("exit") devices: dict[str, dict[str, str]] = {} for device in devices_result: - if match := _DEVICES_REGEX.search(device.decode("utf-8")): + if match := _DEVICES_REGEX.search(device): devices[match.group("ip")] = { "ip": match.group("ip"), "mac": match.group("mac").upper(), diff --git a/homeassistant/components/aruba/manifest.json b/homeassistant/components/aruba/manifest.json index c98dda754cd..29fba6d9a58 100644 --- a/homeassistant/components/aruba/manifest.json +++ b/homeassistant/components/aruba/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pexpect", "ptyprocess"], "quality_scale": "legacy", - "requirements": ["pexpect==4.6.0"] + "requirements": ["pexpect==4.9.0"] } diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index ec6d8a646b6..cc7ecc1c426 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -46,24 +46,24 @@ from .websocket_api import async_register_websocket_api __all__ = ( "DOMAIN", - "async_create_default_pipeline", - "async_get_pipelines", - "async_migrate_engine", - "async_setup", - "async_pipeline_from_audio_stream", - "async_update_pipeline", + "EVENT_RECORDING", + "OPTION_PREFERRED", + "SAMPLES_PER_CHUNK", + "SAMPLE_CHANNELS", + "SAMPLE_RATE", + "SAMPLE_WIDTH", "AudioSettings", "Pipeline", "PipelineEvent", "PipelineEventType", "PipelineNotFound", "WakeWordSettings", - "EVENT_RECORDING", - "OPTION_PREFERRED", - "SAMPLES_PER_CHUNK", - "SAMPLE_RATE", - "SAMPLE_WIDTH", - "SAMPLE_CHANNELS", + "async_create_default_pipeline", + "async_get_pipelines", + "async_migrate_engine", + "async_pipeline_from_audio_stream", + "async_setup", + "async_update_pipeline", ) CONFIG_SCHEMA = vol.Schema( @@ -108,6 +108,7 @@ async def async_pipeline_from_audio_stream( device_id: str | None = None, start_stage: PipelineStage = PipelineStage.STT, end_stage: PipelineStage = PipelineStage.TTS, + conversation_extra_system_prompt: str | None = None, ) -> None: """Create an audio pipeline from an audio stream. @@ -119,6 +120,7 @@ async def async_pipeline_from_audio_stream( stt_metadata=stt_metadata, stt_stream=stt_stream, wake_word_phrase=wake_word_phrase, + conversation_extra_system_prompt=conversation_extra_system_prompt, run=PipelineRun( hass, context=context, diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 96beaf792a7..9353bbe0007 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -16,6 +16,7 @@ import time from typing import Any, Literal, cast import wave +import hass_nabucasa import voluptuous as vol from homeassistant.components import ( @@ -29,6 +30,7 @@ from homeassistant.components import ( from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) +from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent @@ -48,6 +50,7 @@ from homeassistant.util import ( language as language_util, ulid as ulid_util, ) +from homeassistant.util.hass_dict import HassKey from homeassistant.util.limited_size_dict import LimitedSizeDict from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer @@ -89,6 +92,8 @@ ENGINE_LANGUAGE_PAIRS = ( ("tts_engine", "tts_language"), ) +KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN) + def validate_language(data: dict[str, Any]) -> Any: """Validate language settings.""" @@ -246,7 +251,7 @@ async def async_create_default_pipeline( The default pipeline will use the homeassistant conversation agent and the specified stt / tts engines. """ - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] pipeline_store = pipeline_data.pipeline_store pipeline_settings = _async_resolve_default_pipeline_settings( hass, @@ -281,7 +286,7 @@ def _async_get_pipeline_from_conversation_entity( @callback def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> Pipeline: """Get a pipeline by id or the preferred pipeline.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] if pipeline_id is None: # A pipeline was not specified, use the preferred one @@ -304,7 +309,7 @@ def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> P @callback def async_get_pipelines(hass: HomeAssistant) -> list[Pipeline]: """Get all pipelines.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] return list(pipeline_data.pipeline_store.data.values()) @@ -327,7 +332,7 @@ async def async_update_pipeline( prefer_local_intents: bool | UndefinedType = UNDEFINED, ) -> None: """Update a pipeline.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] updates: dict[str, Any] = pipeline.to_json() updates.pop("id") @@ -585,7 +590,7 @@ class PipelineRun: ): raise InvalidPipelineStagesError(self.start_stage, self.end_stage) - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] if self.pipeline.id not in pipeline_data.pipeline_debug: pipeline_data.pipeline_debug[self.pipeline.id] = LimitedSizeDict( size_limit=STORED_PIPELINE_RUNS @@ -613,7 +618,7 @@ class PipelineRun: def process_event(self, event: PipelineEvent) -> None: """Log an event and call listener.""" self.event_callback(event) - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] if self.id not in pipeline_data.pipeline_debug[self.pipeline.id]: # This run has been evicted from the logged pipeline runs already return @@ -648,7 +653,7 @@ class PipelineRun: ) ) - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] pipeline_data.pipeline_runs.remove_run(self) async def prepare_wake_word_detection(self) -> None: @@ -917,6 +922,11 @@ class PipelineRun: ) except (asyncio.CancelledError, TimeoutError): raise # expected + except hass_nabucasa.auth.Unauthenticated as src_error: + raise SpeechToTextError( + code="cloud-auth-failed", + message="Home Assistant Cloud authentication failed", + ) from src_error except Exception as src_error: _LOGGER.exception("Unexpected error during speech-to-text") raise SpeechToTextError( @@ -1003,21 +1013,42 @@ class PipelineRun: self.intent_agent = agent_info.id async def recognize_intent( - self, intent_input: str, conversation_id: str | None, device_id: str | None + self, + intent_input: str, + conversation_id: str | None, + device_id: str | None, + conversation_extra_system_prompt: str | None, ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" if self.intent_agent is None: raise RuntimeError("Recognize intent was not prepared") + if self.pipeline.conversation_language == MATCH_ALL: + # LLMs support all languages ('*') so use languages from the + # pipeline for intent fallback. + # + # We prioritize the STT and TTS languages because they may be more + # specific, such as "zh-CN" instead of just "zh". This is necessary + # for languages whose intents are split out by region when + # preferring local intent matching. + input_language = ( + self.pipeline.stt_language + or self.pipeline.tts_language + or self.pipeline.language + ) + else: + input_language = self.pipeline.conversation_language + self.process_event( PipelineEvent( PipelineEventType.INTENT_START, { "engine": self.intent_agent, - "language": self.pipeline.conversation_language, + "language": input_language, "intent_input": intent_input, "conversation_id": conversation_id, "device_id": device_id, + "prefer_local_intents": self.pipeline.prefer_local_intents, }, ) ) @@ -1028,11 +1059,14 @@ class PipelineRun: context=self.context, conversation_id=conversation_id, device_id=device_id, - language=self.pipeline.language, + language=input_language, agent_id=self.intent_agent, + extra_system_prompt=conversation_extra_system_prompt, ) + processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT - conversation_result: conversation.ConversationResult | None = None + agent_id = user_input.agent_id + intent_response: intent.IntentResponse | None = None if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT: # Sentence triggers override conversation agent if ( @@ -1040,16 +1074,14 @@ class PipelineRun: := await conversation.async_handle_sentence_triggers( self.hass, user_input ) - ): + ) is not None: # Sentence trigger matched - trigger_response = intent.IntentResponse( + agent_id = "sentence_trigger" + intent_response = intent.IntentResponse( self.pipeline.conversation_language ) - trigger_response.async_set_speech(trigger_response_text) - conversation_result = conversation.ConversationResult( - response=trigger_response, - conversation_id=user_input.conversation_id, - ) + intent_response.async_set_speech(trigger_response_text) + # Try local intents first, if preferred. elif self.pipeline.prefer_local_intents and ( intent_response := await conversation.async_handle_intents( @@ -1057,12 +1089,31 @@ class PipelineRun: ) ): # Local intent matched + agent_id = conversation.HOME_ASSISTANT_AGENT + processed_locally = True + + # It was already handled, create response and add to chat history + if intent_response is not None: + async with conversation.async_get_chat_session( + self.hass, user_input + ) as chat_session: + speech: str = intent_response.speech.get("plain", {}).get( + "speech", "" + ) + chat_session.async_add_message( + conversation.ChatMessage( + role="assistant", + agent_id=agent_id, + content=speech, + native=intent_response, + ) + ) conversation_result = conversation.ConversationResult( response=intent_response, - conversation_id=user_input.conversation_id, + conversation_id=chat_session.conversation_id, ) - if conversation_result is None: + else: # Fall back to pipeline conversation agent conversation_result = await conversation.async_converse( hass=self.hass, @@ -1073,6 +1124,10 @@ class PipelineRun: language=user_input.language, agent_id=user_input.agent_id, ) + speech = conversation_result.response.speech.get("plain", {}).get( + "speech", "" + ) + except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") raise IntentRecognitionError( @@ -1085,14 +1140,13 @@ class PipelineRun: self.process_event( PipelineEvent( PipelineEventType.INTENT_END, - {"intent_output": conversation_result.as_dict()}, + { + "processed_locally": processed_locally, + "intent_output": conversation_result.as_dict(), + }, ) ) - speech: str = conversation_result.response.speech.get("plain", {}).get( - "speech", "" - ) - return speech async def prepare_text_to_speech(self) -> None: @@ -1193,7 +1247,7 @@ class PipelineRun: return # Forward to device audio capture - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] audio_queue = pipeline_data.device_audio_queues.get(self._device_id) if audio_queue is None: return @@ -1372,8 +1426,13 @@ class PipelineInput: """Input for text-to-speech. Required when start_stage = tts.""" conversation_id: str | None = None + """Identifier for the conversation.""" + + conversation_extra_system_prompt: str | None = None + """Extra prompt information for the conversation agent.""" device_id: str | None = None + """Identifier of the device that is processing the input/output of the pipeline.""" async def execute(self) -> None: """Run pipeline.""" @@ -1433,9 +1492,9 @@ class PipelineInput: if stt_audio_buffer: # Send audio in the buffer first to speech-to-text, then move on to stt_stream. # This is basically an async itertools.chain. - async def buffer_then_audio_stream() -> ( - AsyncGenerator[EnhancedAudioChunk] - ): + async def buffer_then_audio_stream() -> AsyncGenerator[ + EnhancedAudioChunk + ]: # Buffered audio for chunk in stt_audio_buffer: yield chunk @@ -1463,6 +1522,7 @@ class PipelineInput: intent_input, self.conversation_id, self.device_id, + self.conversation_extra_system_prompt, ) if tts_input.strip(): current_stage = PipelineStage.TTS @@ -1844,7 +1904,7 @@ class PipelineStore(Store[SerializedPipelineStorageCollection]): return old_data -@singleton(DOMAIN) +@singleton(KEY_ASSIST_PIPELINE, async_=True) async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: """Set up the pipeline storage collection.""" pipeline_store = PipelineStorageCollection( diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index c7e4846aad7..a590f30fc7a 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -9,8 +9,8 @@ from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection, entity_registry as er, restore_state -from .const import DOMAIN, OPTION_PREFERRED -from .pipeline import AssistDevice, PipelineData, PipelineStorageCollection +from .const import OPTION_PREFERRED +from .pipeline import KEY_ASSIST_PIPELINE, AssistDevice from .vad import VadSensitivity @@ -30,7 +30,7 @@ def get_chosen_pipeline( if state is None or state.state == OPTION_PREFERRED: return None - pipeline_store: PipelineStorageCollection = hass.data[DOMAIN].pipeline_store + pipeline_store = hass.data[KEY_ASSIST_PIPELINE].pipeline_store return next( (item.id for item in pipeline_store.async_items() if item.name == state.state), None, @@ -80,7 +80,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): """When entity is added to Home Assistant.""" await super().async_added_to_hass() - pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE] pipeline_store = pipeline_data.pipeline_store self.async_on_remove( pipeline_store.async_add_change_set_listener(self._pipelines_updated) @@ -116,9 +116,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): @callback def _update_options(self) -> None: """Handle pipeline update.""" - pipeline_store: PipelineStorageCollection = self.hass.data[ - DOMAIN - ].pipeline_store + pipeline_store = self.hass.data[KEY_ASSIST_PIPELINE].pipeline_store options = [OPTION_PREFERRED] options.extend(sorted(item.name for item in pipeline_store.async_items())) self._attr_options = options diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index deae5b9b7b3..d4647fafe2a 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -75,7 +75,7 @@ class AudioBuffer: class VoiceCommandSegmenter: """Segments an audio stream into voice commands.""" - speech_seconds: float = 0.1 + speech_seconds: float = 0.3 """Seconds of speech before voice command has started.""" command_seconds: float = 1.0 @@ -140,7 +140,7 @@ class VoiceCommandSegmenter: self._timeout_seconds_left -= chunk_seconds if self._timeout_seconds_left <= 0: - _LOGGER.warning( + _LOGGER.debug( "VAD end of speech detection timed out after %s seconds", self.timeout_seconds, ) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index c96af655589..69f917fcf83 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -1,9 +1,6 @@ """Assist pipeline Websocket API.""" import asyncio - -# Suppressing disable=deprecated-module is needed for Python 3.11 -import audioop # pylint: disable=deprecated-module import base64 from collections.abc import AsyncGenerator, Callable import contextlib @@ -11,6 +8,7 @@ import logging import math from typing import Any, Final +import audioop # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api @@ -22,7 +20,6 @@ from homeassistant.util import language as language_util from .const import ( DEFAULT_PIPELINE_TIMEOUT, DEFAULT_WAKE_WORD_TIMEOUT, - DOMAIN, EVENT_RECORDING, SAMPLE_CHANNELS, SAMPLE_RATE, @@ -30,9 +27,9 @@ from .const import ( ) from .error import PipelineNotFound from .pipeline import ( + KEY_ASSIST_PIPELINE, AudioSettings, DeviceAudioQueue, - PipelineData, PipelineError, PipelineEvent, PipelineEventType, @@ -284,7 +281,7 @@ def websocket_list_runs( msg: dict[str, Any], ) -> None: """List pipeline runs for which debug data is available.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] pipeline_id = msg["pipeline_id"] if pipeline_id not in pipeline_data.pipeline_debug: @@ -320,7 +317,7 @@ def websocket_list_devices( msg: dict[str, Any], ) -> None: """List assist devices.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] ent_reg = er.async_get(hass) connection.send_result( msg["id"], @@ -351,7 +348,7 @@ def websocket_get_run( msg: dict[str, Any], ) -> None: """Get debug data for a pipeline run.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] pipeline_id = msg["pipeline_id"] pipeline_run_id = msg["pipeline_run_id"] @@ -456,7 +453,7 @@ async def websocket_device_capture( msg: dict[str, Any], ) -> None: """Capture raw audio from a satellite device and forward to client.""" - pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] device_id = msg["device_id"] # Number of seconds to record audio in wall clock time diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index dd940e8cdbe..47b0123a244 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -30,8 +30,8 @@ from .websocket_api import async_register_websocket_api __all__ = [ "DOMAIN", "AssistSatelliteAnnouncement", - "AssistSatelliteEntity", "AssistSatelliteConfiguration", + "AssistSatelliteEntity", "AssistSatelliteEntityDescription", "AssistSatelliteEntityFeature", "AssistSatelliteWakeWord", diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index ba8b54f7da2..e9a5d22c0d0 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -96,7 +96,11 @@ class AssistSatelliteAnnouncement: media_id: str """Media ID to be played.""" + original_media_id: str + """The raw media ID before processing.""" + media_id_source: Literal["url", "media_id", "tts"] + """Source of the media ID.""" class AssistSatelliteEntity(entity.Entity): @@ -187,47 +191,10 @@ class AssistSatelliteEntity(entity.Entity): """ await self._cancel_running_pipeline() - media_id_source: Literal["url", "media_id", "tts"] | None = None - if message is None: message = "" - if not media_id: - media_id_source = "tts" - # Synthesize audio and get URL - pipeline_id = self._resolve_pipeline() - pipeline = async_get_pipeline(self.hass, pipeline_id) - - tts_options: dict[str, Any] = {} - if pipeline.tts_voice is not None: - tts_options[tts.ATTR_VOICE] = pipeline.tts_voice - - if self.tts_options is not None: - tts_options.update(self.tts_options) - - media_id = tts_generate_media_source_id( - self.hass, - message, - engine=pipeline.tts_engine, - language=pipeline.tts_language, - options=tts_options, - ) - - if media_source.is_media_source_id(media_id): - if not media_id_source: - media_id_source = "media_id" - media = await media_source.async_resolve_media( - self.hass, - media_id, - None, - ) - media_id = media.url - - if not media_id_source: - media_id_source = "url" - - # Resolve to full URL - media_id = async_process_play_media_url(self.hass, media_id) + announcement = await self._resolve_announcement_media_id(message, media_id) if self._is_announcing: raise SatelliteBusyError @@ -237,9 +204,7 @@ class AssistSatelliteEntity(entity.Entity): try: # Block until announcement is finished - await self.async_announce( - AssistSatelliteAnnouncement(message, media_id, media_id_source) - ) + await self.async_announce(announcement) finally: self._is_announcing = False self._set_state(AssistSatelliteState.IDLE) @@ -428,3 +393,54 @@ class AssistSatelliteEntity(entity.Entity): vad_sensitivity = vad.VadSensitivity(vad_sensitivity_state.state) return vad.VadSensitivity.to_seconds(vad_sensitivity) + + async def _resolve_announcement_media_id( + self, message: str, media_id: str | None + ) -> AssistSatelliteAnnouncement: + """Resolve the media ID.""" + media_id_source: Literal["url", "media_id", "tts"] | None = None + + if media_id: + original_media_id = media_id + + else: + media_id_source = "tts" + # Synthesize audio and get URL + pipeline_id = self._resolve_pipeline() + pipeline = async_get_pipeline(self.hass, pipeline_id) + + tts_options: dict[str, Any] = {} + if pipeline.tts_voice is not None: + tts_options[tts.ATTR_VOICE] = pipeline.tts_voice + + if self.tts_options is not None: + tts_options.update(self.tts_options) + + media_id = tts_generate_media_source_id( + self.hass, + message, + engine=pipeline.tts_engine, + language=pipeline.tts_language, + options=tts_options, + ) + original_media_id = media_id + + if media_source.is_media_source_id(media_id): + if not media_id_source: + media_id_source = "media_id" + media = await media_source.async_resolve_media( + self.hass, + media_id, + None, + ) + media_id = media.url + + if not media_id_source: + media_id_source = "url" + + # Resolve to full URL + media_id = async_process_play_media_url(self.hass, media_id) + + return AssistSatelliteAnnouncement( + message, media_id, original_media_id, media_id_source + ) diff --git a/homeassistant/components/assist_satellite/intent.py b/homeassistant/components/assist_satellite/intent.py new file mode 100644 index 00000000000..75396cf138f --- /dev/null +++ b/homeassistant/components/assist_satellite/intent.py @@ -0,0 +1,69 @@ +"""Assist Satellite intents.""" + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, intent + +from .const import DOMAIN, AssistSatelliteEntityFeature + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the intents.""" + intent.async_register(hass, BroadcastIntentHandler()) + + +class BroadcastIntentHandler(intent.IntentHandler): + """Broadcast a message.""" + + intent_type = intent.INTENT_BROADCAST + description = "Broadcast a message through the home" + + @property + def slot_schema(self) -> dict | None: + """Return a slot schema.""" + return {vol.Required("message"): str} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Broadcast a message.""" + hass = intent_obj.hass + ent_reg = er.async_get(hass) + + # Find all assist satellite entities that are not the one invoking the intent + entities = { + entity: entry + for entity in hass.states.async_entity_ids(DOMAIN) + if (entry := ent_reg.async_get(entity)) + and entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE + } + + if intent_obj.device_id: + entities = { + entity: entry + for entity, entry in entities.items() + if entry.device_id != intent_obj.device_id + } + + await hass.services.async_call( + DOMAIN, + "announce", + {"message": intent_obj.slots["message"]["value"]}, + blocking=True, + context=intent_obj.context, + target={"entity_id": list(entities)}, + ) + + response = intent_obj.create_response() + response.async_set_speech("Done") + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_results( + success_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + id=entity, + name=state.name if (state := hass.states.get(entity)) else entity, + ) + for entity in entities + ] + ) + return response diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index bab40f281f5..9d50f50c7e9 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -31,8 +31,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "invalid_unique_id": "Impossible to determine a valid unique id for the device", - "no_unique_id": "A device without a valid unique id is already configured. Configuration of multiple instance is not possible" + "invalid_unique_id": "Impossible to determine a valid unique ID for the device", + "no_unique_id": "A device without a valid unique ID is already configured. Configuration of multiple instances is not possible" } }, "options": { @@ -42,7 +42,7 @@ "consider_home": "Seconds to wait before considering a device away", "track_unknown": "Track unknown / unnamed devices", "interface": "The interface that you want statistics from (e.g. eth0, eth1 etc)", - "dnsmasq": "The location in the router of the dnsmasq.leases files", + "dnsmasq": "The location of the dnsmasq.leases file in the router", "require_ip": "Devices must have IP (for access point mode)" } } diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index daeb64f7f0a..a362b71fbc8 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -46,7 +46,6 @@ class AtagThermostat(AtagEntity, ClimateEntity): _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: AtagDataUpdateCoordinator, atag_id: str) -> None: """Initialize an Atag climate device.""" diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 96ed982e4ec..652f1a7b966 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.1"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"] } diff --git a/homeassistant/components/aussie_broadband/manifest.json b/homeassistant/components/aussie_broadband/manifest.json index 877a46a3650..ea402f03b0e 100644 --- a/homeassistant/components/aussie_broadband/manifest.json +++ b/homeassistant/components/aussie_broadband/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aussie_broadband", "iot_class": "cloud_polling", "loggers": ["aussiebb"], - "requirements": ["pyaussiebb==0.0.15"] + "requirements": ["pyaussiebb==0.1.5"] } diff --git a/homeassistant/components/autarco/__init__.py b/homeassistant/components/autarco/__init__.py index 0e29b25ad80..f42bfdf4a0e 100644 --- a/homeassistant/components/autarco/__init__.py +++ b/homeassistant/components/autarco/__init__.py @@ -4,11 +4,12 @@ from __future__ import annotations import asyncio -from autarco import Autarco +from autarco import Autarco, AutarcoConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import AutarcoDataUpdateCoordinator @@ -25,7 +26,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutarcoConfigEntry) -> b password=entry.data[CONF_PASSWORD], session=async_get_clientsession(hass), ) - account_sites = await client.get_account() + + try: + account_sites = await client.get_account() + except AutarcoConnectionError as err: + await client.close() + raise ConfigEntryNotReady from err coordinators: list[AutarcoDataUpdateCoordinator] = [ AutarcoDataUpdateCoordinator(hass, client, site) for site in account_sites diff --git a/homeassistant/components/autarco/config_flow.py b/homeassistant/components/autarco/config_flow.py index a66f14047a7..294fa685fb8 100644 --- a/homeassistant/components/autarco/config_flow.py +++ b/homeassistant/components/autarco/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from autarco import Autarco, AutarcoAuthenticationError, AutarcoConnectionError @@ -20,6 +21,12 @@ DATA_SCHEMA = vol.Schema( } ) +STEP_REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } +) + class AutarcoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Autarco.""" @@ -55,3 +62,40 @@ class AutarcoConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, data_schema=DATA_SCHEMA, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication request from Autarco.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication confirmation.""" + errors = {} + + reauth_entry = self._get_reauth_entry() + if user_input is not None: + client = Autarco( + email=reauth_entry.data[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + await client.get_account() + except AutarcoAuthenticationError: + errors["base"] = "invalid_auth" + except AutarcoConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"email": reauth_entry.data[CONF_EMAIL]}, + data_schema=STEP_REAUTH_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/autarco/coordinator.py b/homeassistant/components/autarco/coordinator.py index 5dd19478ae8..dd8786bca25 100644 --- a/homeassistant/components/autarco/coordinator.py +++ b/homeassistant/components/autarco/coordinator.py @@ -7,6 +7,7 @@ from typing import NamedTuple from autarco import ( AccountSite, Autarco, + AutarcoAuthenticationError, AutarcoConnectionError, Battery, Inverter, @@ -16,6 +17,7 @@ from autarco import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, SCAN_INTERVAL @@ -60,8 +62,10 @@ class AutarcoDataUpdateCoordinator(DataUpdateCoordinator[AutarcoData]): inverters = await self.client.get_inverters(self.account_site.public_key) if site.has_battery: battery = await self.client.get_battery(self.account_site.public_key) - except AutarcoConnectionError as error: - raise UpdateFailed(error) from error + except AutarcoAuthenticationError as err: + raise ConfigEntryAuthFailed(err) from err + except AutarcoConnectionError as err: + raise UpdateFailed(err) from err return AutarcoData( solar=solar, inverters=inverters, diff --git a/homeassistant/components/autarco/quality_scale.yaml b/homeassistant/components/autarco/quality_scale.yaml index f0eb4771447..d2e1455af7e 100644 --- a/homeassistant/components/autarco/quality_scale.yaml +++ b/homeassistant/components/autarco/quality_scale.yaml @@ -51,7 +51,7 @@ rules: This integration only polls data using a coordinator. Since the integration is read-only and poll-only (only provide sensor data), there is no need to implement parallel updates. - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/autarco/strings.json b/homeassistant/components/autarco/strings.json index 8eda5fe0411..a053cd36e09 100644 --- a/homeassistant/components/autarco/strings.json +++ b/homeassistant/components/autarco/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Connect to your Autarco account to get information about your solar panels.", + "description": "Connect to your Autarco account, to get information about your sites.", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" @@ -11,6 +11,16 @@ "email": "The email address of your Autarco account.", "password": "The password of your Autarco account." } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The password for {email} is no longer valid.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::autarco::config::step::user::data_description::password%]" + } } }, "error": { @@ -18,7 +28,8 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 4fcd8a1416d..4e6b098ef1e 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -6,11 +6,10 @@ from abc import ABC, abstractmethod import asyncio from collections.abc import Callable, Mapping from dataclasses import dataclass -from functools import partial import logging from typing import Any, Protocol, cast -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import websocket_api @@ -51,12 +50,6 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.deprecation import ( - DeprecatedConstant, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.issue_registry import ( @@ -86,12 +79,7 @@ from homeassistant.helpers.trace import ( trace_get, trace_path, ) -from homeassistant.helpers.trigger import ( - TriggerActionType, - TriggerData, - TriggerInfo, - async_initialize_triggers, -) +from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.dt import parse_datetime @@ -137,20 +125,6 @@ class IfAction(Protocol): """AND all conditions.""" -# AutomationActionType, AutomationTriggerData, -# and AutomationTriggerInfo are deprecated as of 2022.9. -# Can be removed in 2025.1 -_DEPRECATED_AutomationActionType = DeprecatedConstant( - TriggerActionType, "TriggerActionType", "2025.1" -) -_DEPRECATED_AutomationTriggerData = DeprecatedConstant( - TriggerData, "TriggerData", "2025.1" -) -_DEPRECATED_AutomationTriggerInfo = DeprecatedConstant( - TriggerInfo, "TriggerInfo", "2025.1" -) - - @bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return true if specified automation entity_id is on. @@ -477,6 +451,7 @@ class UnavailableAutomationEntity(BaseAutomationEntity): ) async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() async_delete_issue( self.hass, DOMAIN, f"{self.entity_id}_validation_{self._validation_status}" @@ -661,9 +636,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): alias = "" if "trigger" in run_variables: if "description" in run_variables["trigger"]: - reason = f' by {run_variables["trigger"]["description"]}' + reason = f" by {run_variables['trigger']['description']}" if "alias" in run_variables["trigger"]: - alias = f' trigger \'{run_variables["trigger"]["alias"]}\'' + alias = f" trigger '{run_variables['trigger']['alias']}'" self._logger.debug("Automation%s triggered%s", alias, reason) # Create a new context referring to the old context. @@ -1219,11 +1194,3 @@ def websocket_config( "config": automation.raw_config, }, ) - - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py index 88985b0db10..429187e1f5b 100644 --- a/homeassistant/components/awair/config_flow.py +++ b/homeassistant/components/awair/config_flow.py @@ -11,11 +11,12 @@ from python_awair.exceptions import AuthError, AwairError from python_awair.user import AwairUser import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DEVICE, CONF_HOST from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -29,7 +30,7 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): host: str async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 592b1e2d41f..9f801882387 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -10,7 +10,6 @@ from urllib.parse import urlsplit import voluptuous as vol -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_REAUTH, @@ -32,6 +31,14 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from homeassistant.util.network import is_link_local @@ -190,7 +197,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): return await self.async_step_user() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Axis device.""" return await self._process_discovered_device( @@ -203,21 +210,21 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a SSDP discovered Axis device.""" - url = urlsplit(discovery_info.upnp[ssdp.ATTR_UPNP_PRESENTATION_URL]) + url = urlsplit(discovery_info.upnp[ATTR_UPNP_PRESENTATION_URL]) return await self._process_discovered_device( { CONF_HOST: url.hostname, - CONF_MAC: format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]), - CONF_NAME: f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]}", + CONF_MAC: format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]), + CONF_NAME: f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]}", CONF_PORT: url.port, } ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a Zeroconf discovered Axis device.""" return await self._process_discovered_device( diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 7163437361a..9758af60178 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "requirements": ["axis==63"], + "requirements": ["axis==64"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/azure_data_explorer/strings.json b/homeassistant/components/azure_data_explorer/strings.json index c8ec158a844..b4734ad2262 100644 --- a/homeassistant/components/azure_data_explorer/strings.json +++ b/homeassistant/components/azure_data_explorer/strings.json @@ -2,10 +2,10 @@ "config": { "step": { "user": { - "title": "Setup your Azure Data Explorer integration", + "title": "Set up Azure Data Explorer", "description": "Enter connection details", "data": { - "cluster_ingest_uri": "Cluster Ingest URI", + "cluster_ingest_uri": "Cluster ingestion URI", "authority_id": "Authority ID", "client_id": "Client ID", "client_secret": "Client secret", @@ -14,7 +14,7 @@ "use_queued_ingestion": "Use queued ingestion" }, "data_description": { - "cluster_ingest_uri": "Ingest-URI of the cluster", + "cluster_ingest_uri": "Ingestion URI of the cluster", "use_queued_ingestion": "Must be enabled when using ADX free cluster" } } diff --git a/homeassistant/components/azure_event_hub/strings.json b/homeassistant/components/azure_event_hub/strings.json index d17c4a385c0..8ec559ac8b7 100644 --- a/homeassistant/components/azure_event_hub/strings.json +++ b/homeassistant/components/azure_event_hub/strings.json @@ -2,26 +2,26 @@ "config": { "step": { "user": { - "title": "Set up your Azure Event Hub integration", + "title": "Set up Azure Event Hub", "data": { - "event_hub_instance_name": "Event Hub Instance Name", - "use_connection_string": "Use Connection String" + "event_hub_instance_name": "Event Hub instance name", + "use_connection_string": "Use connection string" } }, "conn_string": { - "title": "Connection String method", + "title": "Connection string method", "description": "Please enter the connection string for: {event_hub_instance_name}", "data": { - "event_hub_connection_string": "Event Hub Connection String" + "event_hub_connection_string": "Event Hub connection string" } }, "sas": { - "title": "SAS Credentials method", + "title": "SAS credentials method", "description": "Please enter the SAS (shared access signature) credentials for: {event_hub_instance_name}", "data": { - "event_hub_namespace": "Event Hub Namespace", - "event_hub_sas_policy": "Event Hub SAS Policy", - "event_hub_sas_key": "Event Hub SAS Key" + "event_hub_namespace": "Event Hub namespace", + "event_hub_sas_policy": "Event Hub SAS policy", + "event_hub_sas_key": "Event Hub SAS key" } } }, @@ -38,7 +38,7 @@ "options": { "step": { "init": { - "title": "Options for the Azure Event Hub.", + "title": "Options for Azure Event Hub.", "data": { "send_interval": "Interval between sending batches to the hub." } diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 200cb4a3f65..8d25a0c25cb 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -5,36 +5,109 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType -from .const import DATA_MANAGER, DOMAIN, LOGGER +# Pre-import backup to avoid it being imported +# later when the import executor is busy and delaying +# startup +from . import backup # noqa: F401 +from .agent import ( + BackupAgent, + BackupAgentError, + BackupAgentPlatformProtocol, + LocalBackupAgent, +) +from .const import DATA_MANAGER, DOMAIN from .http import async_register_http_views -from .manager import BackupManager +from .manager import ( + BackupManager, + BackupPlatformProtocol, + BackupReaderWriter, + BackupReaderWriterError, + CoreBackupReaderWriter, + CreateBackupEvent, + IncorrectPasswordError, + ManagerBackup, + NewBackup, + RestoreBackupEvent, + WrittenBackup, +) +from .models import AddonInfo, AgentBackup, Folder from .websocket import async_register_websocket_handlers +__all__ = [ + "AddonInfo", + "AgentBackup", + "BackupAgent", + "BackupAgentError", + "BackupAgentPlatformProtocol", + "BackupPlatformProtocol", + "BackupReaderWriter", + "BackupReaderWriterError", + "CreateBackupEvent", + "Folder", + "IncorrectPasswordError", + "LocalBackupAgent", + "ManagerBackup", + "NewBackup", + "RestoreBackupEvent", + "WrittenBackup", +] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Backup integration.""" - backup_manager = BackupManager(hass) - hass.data[DATA_MANAGER] = backup_manager - with_hassio = is_hassio(hass) + reader_writer: BackupReaderWriter + if not with_hassio: + reader_writer = CoreBackupReaderWriter(hass) + else: + # pylint: disable-next=import-outside-toplevel, hass-component-root-import + from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter + + reader_writer = SupervisorBackupReaderWriter(hass) + + backup_manager = BackupManager(hass, reader_writer) + hass.data[DATA_MANAGER] = backup_manager + await backup_manager.async_setup() + async_register_websocket_handlers(hass, with_hassio) - if with_hassio: - if DOMAIN in config: - LOGGER.error( - "The backup integration is not supported on this installation method, " - "please remove it from your configuration" - ) - return True - async def async_handle_create_service(call: ServiceCall) -> None: """Service handler for creating backups.""" - await backup_manager.async_create_backup() + agent_id = list(backup_manager.local_backup_agents)[0] + await backup_manager.async_create_backup( + agent_ids=[agent_id], + include_addons=None, + include_all_addons=False, + include_database=True, + include_folders=None, + include_homeassistant=True, + name=None, + password=None, + ) - hass.services.async_register(DOMAIN, "create", async_handle_create_service) + async def async_handle_create_automatic_service(call: ServiceCall) -> None: + """Service handler for creating automatic backups.""" + config_data = backup_manager.config.data + await backup_manager.async_create_backup( + agent_ids=config_data.create_backup.agent_ids, + include_addons=config_data.create_backup.include_addons, + include_all_addons=config_data.create_backup.include_all_addons, + include_database=config_data.create_backup.include_database, + include_folders=config_data.create_backup.include_folders, + include_homeassistant=True, # always include HA + name=config_data.create_backup.name, + password=config_data.create_backup.password, + with_automatic_settings=True, + ) + + if not with_hassio: + hass.services.async_register(DOMAIN, "create", async_handle_create_service) + hass.services.async_register( + DOMAIN, "create_automatic", async_handle_create_automatic_service + ) async_register_http_views(hass) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py new file mode 100644 index 00000000000..cb03327e941 --- /dev/null +++ b/homeassistant/components/backup/agent.py @@ -0,0 +1,121 @@ +"""Backup agents for the Backup integration.""" + +from __future__ import annotations + +import abc +from collections.abc import AsyncIterator, Callable, Coroutine +from pathlib import Path +from typing import Any, Protocol + +from propcache.api import cached_property + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError + +from .models import AgentBackup + + +class BackupAgentError(HomeAssistantError): + """Base class for backup agent errors.""" + + +class BackupAgentUnreachableError(BackupAgentError): + """Raised when the agent can't reach its API.""" + + _message = "The backup agent is unreachable." + + +class BackupAgent(abc.ABC): + """Backup agent interface.""" + + domain: str + name: str + + @cached_property + def agent_id(self) -> str: + """Return the agent_id.""" + return f"{self.domain}.{self.name}" + + @abc.abstractmethod + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + + @abc.abstractmethod + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + + @abc.abstractmethod + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + + @abc.abstractmethod + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + + @abc.abstractmethod + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + + +class LocalBackupAgent(BackupAgent): + """Local backup agent.""" + + @abc.abstractmethod + def get_backup_path(self, backup_id: str) -> Path: + """Return the local path to a backup. + + The method should return the path to the backup file with the specified id. + """ + + +class BackupAgentPlatformProtocol(Protocol): + """Define the format of backup platforms which implement backup agents.""" + + async def async_get_backup_agents( + self, + hass: HomeAssistant, + **kwargs: Any, + ) -> list[BackupAgent]: + """Return a list of backup agents.""" + + @callback + def async_register_backup_agents_listener( + self, + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, + ) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py new file mode 100644 index 00000000000..ef4924161c2 --- /dev/null +++ b/homeassistant/components/backup/backup.py @@ -0,0 +1,125 @@ +"""Local backup support for Core and Container installations.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +import json +from pathlib import Path +from tarfile import TarError +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.hassio import is_hassio + +from .agent import BackupAgent, LocalBackupAgent +from .const import DOMAIN, LOGGER +from .models import AgentBackup +from .util import read_backup + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return the local backup agent.""" + if is_hassio(hass): + return [] + return [CoreLocalBackupAgent(hass)] + + +class CoreLocalBackupAgent(LocalBackupAgent): + """Local backup agent for Core and Container installations.""" + + domain = DOMAIN + name = "local" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the backup agent.""" + super().__init__() + self._hass = hass + self._backup_dir = Path(hass.config.path("backups")) + self._backups: dict[str, AgentBackup] = {} + self._loaded_backups = False + + async def _load_backups(self) -> None: + """Load data of stored backup files.""" + backups = await self._hass.async_add_executor_job(self._read_backups) + LOGGER.debug("Loaded %s local backups", len(backups)) + self._backups = backups + self._loaded_backups = True + + def _read_backups(self) -> dict[str, AgentBackup]: + """Read backups from disk.""" + backups: dict[str, AgentBackup] = {} + for backup_path in self._backup_dir.glob("*.tar"): + try: + backup = read_backup(backup_path) + backups[backup.backup_id] = backup + except (OSError, TarError, json.JSONDecodeError, KeyError) as err: + LOGGER.warning("Unable to read backup %s: %s", backup_path, err) + return backups + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + raise NotImplementedError + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + self._backups[backup.backup_id] = backup + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + if not self._loaded_backups: + await self._load_backups() + return list(self._backups.values()) + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + if not self._loaded_backups: + await self._load_backups() + + if not (backup := self._backups.get(backup_id)): + return None + + backup_path = self.get_backup_path(backup_id) + if not await self._hass.async_add_executor_job(backup_path.exists): + LOGGER.debug( + ( + "Removing tracked backup (%s) that does not exists on the expected" + " path %s" + ), + backup.backup_id, + backup_path, + ) + self._backups.pop(backup_id) + return None + + return backup + + def get_backup_path(self, backup_id: str) -> Path: + """Return the local path to a backup.""" + return self._backup_dir / f"{backup_id}.tar" + + async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: + """Delete a backup file.""" + if await self.async_get_backup(backup_id) is None: + return + + backup_path = self.get_backup_path(backup_id) + await self._hass.async_add_executor_job(backup_path.unlink, True) + LOGGER.debug("Deleted backup located at %s", backup_path) + self._backups.pop(backup_id) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py new file mode 100644 index 00000000000..8edd6cf0f2b --- /dev/null +++ b/homeassistant/components/backup/config.py @@ -0,0 +1,573 @@ +"""Provide persistent configuration for the backup integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field, replace +import datetime as dt +from datetime import datetime, timedelta +from enum import StrEnum +import random +from typing import TYPE_CHECKING, Self, TypedDict + +from cronsim import CronSim + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_call_later, async_track_point_in_time +from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.util import dt as dt_util + +from .const import LOGGER +from .models import BackupManagerError, Folder + +if TYPE_CHECKING: + from .manager import BackupManager, ManagerBackup + +CRON_PATTERN_DAILY = "{m} {h} * * *" +CRON_PATTERN_WEEKLY = "{m} {h} * * {d}" + +# The default time for automatic backups to run is at 04:45. +# This time is chosen to be compatible with the time of the recorder's +# nightly job which runs at 04:12. +DEFAULT_BACKUP_TIME = dt.time(4, 45) + +# Randomize the start time of the backup by up to 60 minutes to avoid +# all backups running at the same time. +BACKUP_START_TIME_JITTER = 60 * 60 + + +class StoredBackupConfig(TypedDict): + """Represent the stored backup config.""" + + create_backup: StoredCreateBackupConfig + last_attempted_automatic_backup: str | None + last_completed_automatic_backup: str | None + retention: StoredRetentionConfig + schedule: StoredBackupSchedule + + +@dataclass(kw_only=True) +class BackupConfigData: + """Represent loaded backup config data.""" + + create_backup: CreateBackupConfig + last_attempted_automatic_backup: datetime | None = None + last_completed_automatic_backup: datetime | None = None + retention: RetentionConfig + schedule: BackupSchedule + + @classmethod + def from_dict(cls, data: StoredBackupConfig) -> Self: + """Initialize backup config data from a dict.""" + include_folders_data = data["create_backup"]["include_folders"] + if include_folders_data: + include_folders = [Folder(folder) for folder in include_folders_data] + else: + include_folders = None + retention = data["retention"] + + if last_attempted_str := data["last_attempted_automatic_backup"]: + last_attempted = dt_util.parse_datetime(last_attempted_str) + else: + last_attempted = None + + if last_attempted_str := data["last_completed_automatic_backup"]: + last_completed = dt_util.parse_datetime(last_attempted_str) + else: + last_completed = None + + if time_str := data["schedule"]["time"]: + time = dt_util.parse_time(time_str) + else: + time = None + days = [Day(day) for day in data["schedule"]["days"]] + + return cls( + create_backup=CreateBackupConfig( + agent_ids=data["create_backup"]["agent_ids"], + include_addons=data["create_backup"]["include_addons"], + include_all_addons=data["create_backup"]["include_all_addons"], + include_database=data["create_backup"]["include_database"], + include_folders=include_folders, + name=data["create_backup"]["name"], + password=data["create_backup"]["password"], + ), + last_attempted_automatic_backup=last_attempted, + last_completed_automatic_backup=last_completed, + retention=RetentionConfig( + copies=retention["copies"], + days=retention["days"], + ), + schedule=BackupSchedule( + days=days, + recurrence=ScheduleRecurrence(data["schedule"]["recurrence"]), + state=ScheduleState(data["schedule"].get("state", ScheduleState.NEVER)), + time=time, + ), + ) + + def to_dict(self) -> StoredBackupConfig: + """Convert backup config data to a dict.""" + if self.last_attempted_automatic_backup: + last_attempted = self.last_attempted_automatic_backup.isoformat() + else: + last_attempted = None + + if self.last_completed_automatic_backup: + last_completed = self.last_completed_automatic_backup.isoformat() + else: + last_completed = None + + return StoredBackupConfig( + create_backup=self.create_backup.to_dict(), + last_attempted_automatic_backup=last_attempted, + last_completed_automatic_backup=last_completed, + retention=self.retention.to_dict(), + schedule=self.schedule.to_dict(), + ) + + +class BackupConfig: + """Handle backup config.""" + + def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None: + """Initialize backup config.""" + self.data = BackupConfigData( + create_backup=CreateBackupConfig(), + retention=RetentionConfig(), + schedule=BackupSchedule(), + ) + self._manager = manager + + def load(self, stored_config: StoredBackupConfig) -> None: + """Load config.""" + self.data = BackupConfigData.from_dict(stored_config) + self.data.retention.apply(self._manager) + self.data.schedule.apply(self._manager) + + async def update( + self, + *, + create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, + retention: RetentionParametersDict | UndefinedType = UNDEFINED, + schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, + ) -> None: + """Update config.""" + if create_backup is not UNDEFINED: + self.data.create_backup = replace(self.data.create_backup, **create_backup) + if retention is not UNDEFINED: + new_retention = RetentionConfig(**retention) + if new_retention != self.data.retention: + self.data.retention = new_retention + self.data.retention.apply(self._manager) + if schedule is not UNDEFINED: + new_schedule = BackupSchedule(**schedule) + if new_schedule.to_dict() != self.data.schedule.to_dict(): + self.data.schedule = new_schedule + self.data.schedule.apply(self._manager) + + self._manager.store.save() + + +@dataclass(kw_only=True) +class RetentionConfig: + """Represent the backup retention configuration.""" + + copies: int | None = None + days: int | None = None + + def apply(self, manager: BackupManager) -> None: + """Apply backup retention configuration.""" + if self.days is not None: + LOGGER.debug( + "Scheduling next automatic delete of backups older than %s in 1 day", + self.days, + ) + self._schedule_next(manager) + else: + LOGGER.debug("Unscheduling next automatic delete") + self._unschedule_next(manager) + + def to_dict(self) -> StoredRetentionConfig: + """Convert backup retention configuration to a dict.""" + return StoredRetentionConfig( + copies=self.copies, + days=self.days, + ) + + @callback + def _schedule_next( + self, + manager: BackupManager, + ) -> None: + """Schedule the next delete after days.""" + self._unschedule_next(manager) + + async def _delete_backups(now: datetime) -> None: + """Delete backups older than days.""" + self._schedule_next(manager) + + def _backups_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return backups older than days to delete.""" + # we need to check here since we await before + # this filter is applied + if self.days is None: + return {} + now = dt_util.utcnow() + return { + backup_id: backup + for backup_id, backup in backups.items() + if dt_util.parse_datetime(backup.date, raise_on_error=True) + + timedelta(days=self.days) + < now + } + + await _delete_filtered_backups(manager, _backups_filter) + + manager.remove_next_delete_event = async_call_later( + manager.hass, timedelta(days=1), _delete_backups + ) + + @callback + def _unschedule_next(self, manager: BackupManager) -> None: + """Unschedule the next delete after days.""" + if (remove_next_event := manager.remove_next_delete_event) is not None: + remove_next_event() + manager.remove_next_delete_event = None + + +class StoredRetentionConfig(TypedDict): + """Represent the stored backup retention configuration.""" + + copies: int | None + days: int | None + + +class RetentionParametersDict(TypedDict, total=False): + """Represent the parameters for retention.""" + + copies: int | None + days: int | None + + +class StoredBackupSchedule(TypedDict): + """Represent the stored backup schedule configuration.""" + + days: list[Day] + recurrence: ScheduleRecurrence + state: ScheduleState + time: str | None + + +class ScheduleParametersDict(TypedDict, total=False): + """Represent parameters for backup schedule.""" + + days: list[Day] + recurrence: ScheduleRecurrence + state: ScheduleState + time: dt.time | None + + +class Day(StrEnum): + """Represent the day(s) in a custom schedule recurrence.""" + + MONDAY = "mon" + TUESDAY = "tue" + WEDNESDAY = "wed" + THURSDAY = "thu" + FRIDAY = "fri" + SATURDAY = "sat" + SUNDAY = "sun" + + +class ScheduleRecurrence(StrEnum): + """Represent the schedule recurrence.""" + + NEVER = "never" + DAILY = "daily" + CUSTOM_DAYS = "custom_days" + + +class ScheduleState(StrEnum): + """Represent the schedule recurrence. + + This is deprecated and can be remove in HA Core 2025.8. + """ + + NEVER = "never" + DAILY = "daily" + MONDAY = "mon" + TUESDAY = "tue" + WEDNESDAY = "wed" + THURSDAY = "thu" + FRIDAY = "fri" + SATURDAY = "sat" + SUNDAY = "sun" + + +@dataclass(kw_only=True) +class BackupSchedule: + """Represent the backup schedule.""" + + days: list[Day] = field(default_factory=list) + recurrence: ScheduleRecurrence = ScheduleRecurrence.NEVER + # Although no longer used, state is kept for backwards compatibility. + # It can be removed in HA Core 2025.8. + state: ScheduleState = ScheduleState.NEVER + time: dt.time | None = None + cron_event: CronSim | None = field(init=False, default=None) + next_automatic_backup: datetime | None = field(init=False, default=None) + next_automatic_backup_additional = False + + @callback + def apply( + self, + manager: BackupManager, + ) -> None: + """Apply a new schedule. + + There are only three possible recurrence types: never, daily, or custom_days + """ + if self.recurrence is ScheduleRecurrence.NEVER or ( + self.recurrence is ScheduleRecurrence.CUSTOM_DAYS and not self.days + ): + self._unschedule_next(manager) + return + + time = self.time if self.time is not None else DEFAULT_BACKUP_TIME + if self.recurrence is ScheduleRecurrence.DAILY: + self._schedule_next( + CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), + manager, + ) + else: # ScheduleRecurrence.CUSTOM_DAYS + self._schedule_next( + CRON_PATTERN_WEEKLY.format( + m=time.minute, + h=time.hour, + d=",".join(day.value for day in self.days), + ), + manager, + ) + + @callback + def _schedule_next( + self, + cron_pattern: str, + manager: BackupManager, + ) -> None: + """Schedule the next backup.""" + self._unschedule_next(manager) + now = dt_util.now() + if (cron_event := self.cron_event) is None: + seed_time = manager.config.data.last_completed_automatic_backup or now + cron_event = self.cron_event = CronSim(cron_pattern, seed_time) + next_time = next(cron_event) + + if next_time < now: + # schedule a backup at next daily time once + # if we missed the last scheduled backup + time = self.time if self.time is not None else DEFAULT_BACKUP_TIME + cron_event = CronSim( + CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), now + ) + next_time = next(cron_event) + # reseed the cron event attribute + # add a day to the next time to avoid scheduling at the same time again + self.cron_event = CronSim(cron_pattern, now + timedelta(days=1)) + + # Compare the computed next time with the next time from the cron pattern + # to determine if an additional backup has been scheduled + cron_event_configured = CronSim(cron_pattern, now) + next_configured_time = next(cron_event_configured) + self.next_automatic_backup_additional = next_time < next_configured_time + else: + self.next_automatic_backup_additional = False + + async def _create_backup(now: datetime) -> None: + """Create backup.""" + manager.remove_next_backup_event = None + config_data = manager.config.data + self._schedule_next(cron_pattern, manager) + + # create the backup + try: + await manager.async_create_backup( + agent_ids=config_data.create_backup.agent_ids, + include_addons=config_data.create_backup.include_addons, + include_all_addons=config_data.create_backup.include_all_addons, + include_database=config_data.create_backup.include_database, + include_folders=config_data.create_backup.include_folders, + include_homeassistant=True, # always include HA + name=config_data.create_backup.name, + password=config_data.create_backup.password, + with_automatic_settings=True, + ) + except BackupManagerError as err: + LOGGER.error("Error creating backup: %s", err) + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error creating automatic backup") + + if self.time is None: + # randomize the start time of the backup by up to 60 minutes if the time is + # not set to avoid all backups running at the same time + next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER)) + LOGGER.debug("Scheduling next automatic backup at %s", next_time) + self.next_automatic_backup = next_time + manager.remove_next_backup_event = async_track_point_in_time( + manager.hass, _create_backup, next_time + ) + + def to_dict(self) -> StoredBackupSchedule: + """Convert backup schedule to a dict.""" + return StoredBackupSchedule( + days=self.days, + recurrence=self.recurrence, + state=self.state, + time=self.time.isoformat() if self.time else None, + ) + + @callback + def _unschedule_next(self, manager: BackupManager) -> None: + """Unschedule the next backup.""" + self.next_automatic_backup = None + if (remove_next_event := manager.remove_next_backup_event) is not None: + remove_next_event() + manager.remove_next_backup_event = None + + +@dataclass(kw_only=True) +class CreateBackupConfig: + """Represent the config for async_create_backup.""" + + agent_ids: list[str] = field(default_factory=list) + include_addons: list[str] | None = None + include_all_addons: bool = False + include_database: bool = True + include_folders: list[Folder] | None = None + name: str | None = None + password: str | None = None + + def to_dict(self) -> StoredCreateBackupConfig: + """Convert create backup config to a dict.""" + return { + "agent_ids": self.agent_ids, + "include_addons": self.include_addons, + "include_all_addons": self.include_all_addons, + "include_database": self.include_database, + "include_folders": self.include_folders, + "name": self.name, + "password": self.password, + } + + +class StoredCreateBackupConfig(TypedDict): + """Represent the stored config for async_create_backup.""" + + agent_ids: list[str] + include_addons: list[str] | None + include_all_addons: bool + include_database: bool + include_folders: list[Folder] | None + name: str | None + password: str | None + + +class CreateBackupParametersDict(TypedDict, total=False): + """Represent the parameters for async_create_backup.""" + + agent_ids: list[str] + include_addons: list[str] | None + include_all_addons: bool + include_database: bool + include_folders: list[Folder] | None + name: str | None + password: str | None + + +async def _delete_filtered_backups( + manager: BackupManager, + backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], +) -> None: + """Delete backups parsed with a filter. + + :param manager: The backup manager. + :param backup_filter: A filter that should return the backups to delete. + """ + backups, get_agent_errors = await manager.async_get_backups() + if get_agent_errors: + LOGGER.debug( + "Error getting backups; continuing anyway: %s", + get_agent_errors, + ) + + # only delete backups that are created with the saved automatic settings + backups = { + backup_id: backup + for backup_id, backup in backups.items() + if backup.with_automatic_settings + } + + LOGGER.debug("Total automatic backups: %s", backups) + + filtered_backups = backup_filter(backups) + + if not filtered_backups: + return + + # always delete oldest backup first + filtered_backups = dict( + sorted( + filtered_backups.items(), + key=lambda backup_item: backup_item[1].date, + ) + ) + + if len(filtered_backups) >= len(backups): + # Never delete the last backup. + last_backup = filtered_backups.popitem() + LOGGER.debug("Keeping the last backup: %s", last_backup) + + LOGGER.debug("Backups to delete: %s", filtered_backups) + + if not filtered_backups: + return + + backup_ids = list(filtered_backups) + delete_results = await asyncio.gather( + *(manager.async_delete_backup(backup_id) for backup_id in filtered_backups) + ) + agent_errors = { + backup_id: error + for backup_id, error in zip(backup_ids, delete_results, strict=True) + if error + } + if agent_errors: + LOGGER.error( + "Error deleting old copies: %s", + agent_errors, + ) + + +async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None: + """Delete backups exceeding the configured retention count.""" + + def _backups_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return oldest backups more numerous than copies to delete.""" + # we need to check here since we await before + # this filter is applied + if manager.config.data.retention.copies is None: + return {} + return dict( + sorted( + backups.items(), + key=lambda backup_item: backup_item[1].date, + )[: max(len(backups) - manager.config.data.retention.copies, 0)] + ) + + await _delete_filtered_backups(manager, _backups_filter) diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index f613f7cc352..c2070a37b2d 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -10,6 +10,7 @@ from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: from .manager import BackupManager +BUF_SIZE = 2**20 * 4 # 4MB DOMAIN = "backup" DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN) LOGGER = getLogger(__package__) @@ -22,6 +23,12 @@ EXCLUDE_FROM_BACKUP = [ "*.log.*", "*.log", "backups/*.tar", + "tmp_backups/*.tar", "OZW_Log.txt", "tts/*", ] + +EXCLUDE_DATABASE_FROM_BACKUP = [ + "home-assistant_v2.db", + "home-assistant_v2.db-wal", +] diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 42693035bd3..b909b2728a7 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -4,17 +4,23 @@ from __future__ import annotations import asyncio from http import HTTPStatus -from typing import cast +import threading +from typing import IO, cast from aiohttp import BodyPartReader from aiohttp.hdrs import CONTENT_DISPOSITION -from aiohttp.web import FileResponse, Request, Response +from aiohttp.web import FileResponse, Request, Response, StreamResponse +from multidict import istr from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import slugify +from . import util +from .agent import BackupAgent from .const import DATA_MANAGER +from .manager import BackupManager @callback @@ -27,31 +33,115 @@ def async_register_http_views(hass: HomeAssistant) -> None: class DownloadBackupView(HomeAssistantView): """Generate backup view.""" - url = "/api/backup/download/{slug}" + url = "/api/backup/download/{backup_id}" name = "api:backup:download" async def get( self, request: Request, - slug: str, - ) -> FileResponse | Response: + backup_id: str, + ) -> StreamResponse | FileResponse | Response: """Download a backup file.""" if not request["hass_user"].is_admin: return Response(status=HTTPStatus.UNAUTHORIZED) + try: + agent_id = request.query.getone("agent_id") + except KeyError: + return Response(status=HTTPStatus.BAD_REQUEST) + try: + password = request.query.getone("password") + except KeyError: + password = None - manager = request.app[KEY_HASS].data[DATA_MANAGER] - backup = await manager.async_get_backup(slug=slug) + hass = request.app[KEY_HASS] + manager = hass.data[DATA_MANAGER] + if agent_id not in manager.backup_agents: + return Response(status=HTTPStatus.BAD_REQUEST) + agent = manager.backup_agents[agent_id] + backup = await agent.async_get_backup(backup_id) - if backup is None or not backup.path.exists(): + # We don't need to check if the path exists, aiohttp.FileResponse will handle + # that + if backup is None: return Response(status=HTTPStatus.NOT_FOUND) - return FileResponse( - path=backup.path.as_posix(), - headers={ - CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" - }, + headers = { + CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" + } + + if not password: + return await self._send_backup_no_password( + request, headers, backup_id, agent_id, agent, manager + ) + return await self._send_backup_with_password( + hass, request, headers, backup_id, agent_id, password, agent, manager ) + async def _send_backup_no_password( + self, + request: Request, + headers: dict[istr, str], + backup_id: str, + agent_id: str, + agent: BackupAgent, + manager: BackupManager, + ) -> StreamResponse | FileResponse | Response: + if agent_id in manager.local_backup_agents: + local_agent = manager.local_backup_agents[agent_id] + path = local_agent.get_backup_path(backup_id) + return FileResponse(path=path.as_posix(), headers=headers) + + stream = await agent.async_download_backup(backup_id) + response = StreamResponse(status=HTTPStatus.OK, headers=headers) + await response.prepare(request) + async for chunk in stream: + await response.write(chunk) + return response + + async def _send_backup_with_password( + self, + hass: HomeAssistant, + request: Request, + headers: dict[istr, str], + backup_id: str, + agent_id: str, + password: str, + agent: BackupAgent, + manager: BackupManager, + ) -> StreamResponse | FileResponse | Response: + reader: IO[bytes] + if agent_id in manager.local_backup_agents: + local_agent = manager.local_backup_agents[agent_id] + path = local_agent.get_backup_path(backup_id) + try: + reader = await hass.async_add_executor_job(open, path.as_posix(), "rb") + except FileNotFoundError: + return Response(status=HTTPStatus.NOT_FOUND) + else: + stream = await agent.async_download_backup(backup_id) + reader = cast(IO[bytes], util.AsyncIteratorReader(hass, stream)) + + worker_done_event = asyncio.Event() + + def on_done() -> None: + """Call by the worker thread when it's done.""" + hass.loop.call_soon_threadsafe(worker_done_event.set) + + stream = util.AsyncIteratorWriter(hass) + worker = threading.Thread( + target=util.decrypt_backup, args=[reader, stream, password, on_done] + ) + try: + worker.start() + response = StreamResponse(status=HTTPStatus.OK, headers=headers) + await response.prepare(request) + async for chunk in stream: + await response.write(chunk) + return response + finally: + reader.close() + await worker_done_event.wait() + class UploadBackupView(HomeAssistantView): """Generate backup view.""" @@ -62,15 +152,24 @@ class UploadBackupView(HomeAssistantView): @require_admin async def post(self, request: Request) -> Response: """Upload a backup file.""" + try: + agent_ids = request.query.getall("agent_id") + except KeyError: + return Response(status=HTTPStatus.BAD_REQUEST) manager = request.app[KEY_HASS].data[DATA_MANAGER] reader = await request.multipart() contents = cast(BodyPartReader, await reader.next()) try: - await manager.async_receive_backup(contents=contents) + await manager.async_receive_backup(contents=contents, agent_ids=agent_ids) except OSError as err: return Response( - body=f"Can't write backup file {err}", + body=f"Can't write backup file: {err}", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + except HomeAssistantError as err: + return Response( + body=f"Can't upload backup file: {err}", status=HTTPStatus.INTERNAL_SERVER_ERROR, ) except asyncio.CancelledError: diff --git a/homeassistant/components/backup/icons.json b/homeassistant/components/backup/icons.json index bd5ff4a81ee..8a412f66edc 100644 --- a/homeassistant/components/backup/icons.json +++ b/homeassistant/components/backup/icons.json @@ -2,6 +2,9 @@ "services": { "create": { "service": "mdi:cloud-upload" + }, + "create_automatic": { + "service": "mdi:cloud-upload" } } } diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4300f75eed0..32979194980 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -4,49 +4,193 @@ from __future__ import annotations import abc import asyncio -from dataclasses import asdict, dataclass +from collections.abc import AsyncIterator, Callable, Coroutine +from dataclasses import dataclass +from enum import StrEnum import hashlib import io import json -from pathlib import Path -from queue import SimpleQueue +from pathlib import Path, PurePath import shutil import tarfile -from tarfile import TarError -from tempfile import TemporaryDirectory import time -from typing import Any, Protocol, cast +from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast import aiohttp from securetar import SecureTarFile, atomic_contents_add -from homeassistant.backup_restore import RESTORE_BACKUP_FILE +from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import integration_platform +from homeassistant.helpers import ( + instance_id, + integration_platform, + issue_registry as ir, +) from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util -from homeassistant.util.json import json_loads_object -from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER - -BUF_SIZE = 2**20 * 4 # 4MB +from . import util as backup_util +from .agent import ( + BackupAgent, + BackupAgentError, + BackupAgentPlatformProtocol, + LocalBackupAgent, +) +from .config import BackupConfig, delete_backups_exceeding_configured_count +from .const import ( + BUF_SIZE, + DATA_MANAGER, + DOMAIN, + EXCLUDE_DATABASE_FROM_BACKUP, + EXCLUDE_FROM_BACKUP, + LOGGER, +) +from .models import AgentBackup, BackupManagerError, Folder +from .store import BackupStore +from .util import ( + AsyncIteratorReader, + make_backup_dir, + read_backup, + validate_password, + validate_password_stream, +) -@dataclass(slots=True) -class Backup: +@dataclass(frozen=True, kw_only=True, slots=True) +class NewBackup: + """New backup class.""" + + backup_job_id: str + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ManagerBackup(AgentBackup): """Backup class.""" - slug: str - name: str - date: str - path: Path - size: float + agent_ids: list[str] + failed_agent_ids: list[str] + with_automatic_settings: bool | None - def as_dict(self) -> dict: - """Return a dict representation of this backup.""" - return {**asdict(self), "path": self.path.as_posix()} + +@dataclass(frozen=True, kw_only=True, slots=True) +class WrittenBackup: + """Written backup class.""" + + backup: AgentBackup + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]] + release_stream: Callable[[], Coroutine[Any, Any, None]] + + +class BackupManagerState(StrEnum): + """Backup state type.""" + + IDLE = "idle" + CREATE_BACKUP = "create_backup" + RECEIVE_BACKUP = "receive_backup" + RESTORE_BACKUP = "restore_backup" + + +class CreateBackupStage(StrEnum): + """Create backup stage enum.""" + + ADDON_REPOSITORIES = "addon_repositories" + ADDONS = "addons" + AWAIT_ADDON_RESTARTS = "await_addon_restarts" + DOCKER_CONFIG = "docker_config" + FINISHING_FILE = "finishing_file" + FOLDERS = "folders" + HOME_ASSISTANT = "home_assistant" + UPLOAD_TO_AGENTS = "upload_to_agents" + + +class CreateBackupState(StrEnum): + """Create backup state enum.""" + + COMPLETED = "completed" + FAILED = "failed" + IN_PROGRESS = "in_progress" + + +class ReceiveBackupStage(StrEnum): + """Receive backup stage enum.""" + + RECEIVE_FILE = "receive_file" + UPLOAD_TO_AGENTS = "upload_to_agents" + + +class ReceiveBackupState(StrEnum): + """Receive backup state enum.""" + + COMPLETED = "completed" + FAILED = "failed" + IN_PROGRESS = "in_progress" + + +class RestoreBackupStage(StrEnum): + """Restore backup stage enum.""" + + ADDON_REPOSITORIES = "addon_repositories" + ADDONS = "addons" + AWAIT_ADDON_RESTARTS = "await_addon_restarts" + AWAIT_HOME_ASSISTANT_RESTART = "await_home_assistant_restart" + CHECK_HOME_ASSISTANT = "check_home_assistant" + DOCKER_CONFIG = "docker_config" + DOWNLOAD_FROM_AGENT = "download_from_agent" + FOLDERS = "folders" + HOME_ASSISTANT = "home_assistant" + REMOVE_DELTA_ADDONS = "remove_delta_addons" + + +class RestoreBackupState(StrEnum): + """Receive backup state enum.""" + + COMPLETED = "completed" + CORE_RESTART = "core_restart" + FAILED = "failed" + IN_PROGRESS = "in_progress" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ManagerStateEvent: + """Backup state class.""" + + manager_state: BackupManagerState + + +@dataclass(frozen=True, kw_only=True, slots=True) +class IdleEvent(ManagerStateEvent): + """Backup manager idle.""" + + manager_state: BackupManagerState = BackupManagerState.IDLE + + +@dataclass(frozen=True, kw_only=True, slots=True) +class CreateBackupEvent(ManagerStateEvent): + """Backup in progress.""" + + manager_state: BackupManagerState = BackupManagerState.CREATE_BACKUP + stage: CreateBackupStage | None + state: CreateBackupState + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ReceiveBackupEvent(ManagerStateEvent): + """Backup receive.""" + + manager_state: BackupManagerState = BackupManagerState.RECEIVE_BACKUP + stage: ReceiveBackupStage | None + state: ReceiveBackupState + + +@dataclass(frozen=True, kw_only=True, slots=True) +class RestoreBackupEvent(ManagerStateEvent): + """Backup restore.""" + + manager_state: BackupManagerState = BackupManagerState.RESTORE_BACKUP + stage: RestoreBackupStage | None + state: RestoreBackupState class BackupPlatformProtocol(Protocol): @@ -59,40 +203,188 @@ class BackupPlatformProtocol(Protocol): """Perform operations after a backup finishes.""" -class BaseBackupManager(abc.ABC): +class BackupReaderWriter(abc.ABC): + """Abstract class for reading and writing backups.""" + + @abc.abstractmethod + async def async_create_backup( + self, + *, + agent_ids: list[str], + backup_name: str, + extra_metadata: dict[str, bool | str], + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + on_progress: Callable[[CreateBackupEvent], None], + password: str | None, + ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]: + """Create a backup.""" + + @abc.abstractmethod + async def async_receive_backup( + self, + *, + agent_ids: list[str], + stream: AsyncIterator[bytes], + suggested_filename: str, + ) -> WrittenBackup: + """Receive a backup.""" + + @abc.abstractmethod + async def async_restore_backup( + self, + backup_id: str, + *, + agent_id: str, + on_progress: Callable[[RestoreBackupEvent], None], + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, + restore_addons: list[str] | None, + restore_database: bool, + restore_folders: list[Folder] | None, + restore_homeassistant: bool, + ) -> None: + """Restore a backup.""" + + +class BackupReaderWriterError(HomeAssistantError): + """Backup reader/writer error.""" + + +class IncorrectPasswordError(BackupReaderWriterError): + """Raised when the password is incorrect.""" + + _message = "The password provided is incorrect." + + +class DecryptOnDowloadNotSupported(BackupManagerError): + """Raised when on-the-fly decryption is not supported.""" + + _message = "On-the-fly decryption is not supported for this backup." + + +class BackupManager: """Define the format that backup managers can have.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, reader_writer: BackupReaderWriter) -> None: """Initialize the backup manager.""" self.hass = hass - self.backing_up = False - self.backups: dict[str, Backup] = {} - self.loaded_platforms = False self.platforms: dict[str, BackupPlatformProtocol] = {} + self.backup_agent_platforms: dict[str, BackupAgentPlatformProtocol] = {} + self.backup_agents: dict[str, BackupAgent] = {} + self.local_backup_agents: dict[str, LocalBackupAgent] = {} + + self.config = BackupConfig(hass, self) + self._reader_writer = reader_writer + self.known_backups = KnownBackups(self) + self.store = BackupStore(hass, self) + + # Tasks and flags tracking backup and restore progress + self._backup_task: asyncio.Task[WrittenBackup] | None = None + self._backup_finish_task: asyncio.Task[None] | None = None + + # Backup schedule and retention listeners + self.remove_next_backup_event: Callable[[], None] | None = None + self.remove_next_delete_event: Callable[[], None] | None = None + + # Latest backup event and backup event subscribers + self.last_event: ManagerStateEvent = IdleEvent() + self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = [] + + async def async_setup(self) -> None: + """Set up the backup manager.""" + stored = await self.store.load() + if stored: + self.config.load(stored["config"]) + self.known_backups.load(stored["backups"]) + + await self.load_platforms() + + @property + def state(self) -> BackupManagerState: + """Return the state of the backup manager.""" + return self.last_event.manager_state @callback - def _add_platform( + def _add_platform_pre_post_handler( self, - hass: HomeAssistant, integration_domain: str, platform: BackupPlatformProtocol, ) -> None: - """Add a platform to the backup manager.""" + """Add a backup platform.""" if not hasattr(platform, "async_pre_backup") or not hasattr( platform, "async_post_backup" ): - LOGGER.warning( - "%s does not implement required functions for the backup platform", - integration_domain, - ) return + self.platforms[integration_domain] = platform - async def async_pre_backup_actions(self, **kwargs: Any) -> None: - """Perform pre backup actions.""" - if not self.loaded_platforms: - await self.load_platforms() + @callback + def _async_add_backup_agent_platform( + self, + integration_domain: str, + platform: BackupAgentPlatformProtocol, + ) -> None: + """Add backup agent platform to the backup manager.""" + if not hasattr(platform, "async_get_backup_agents"): + return + self.backup_agent_platforms[integration_domain] = platform + + @callback + def listener() -> None: + LOGGER.debug("Loading backup agents for %s", integration_domain) + self.hass.async_create_task( + self._async_reload_backup_agents(integration_domain) + ) + + if hasattr(platform, "async_register_backup_agents_listener"): + platform.async_register_backup_agents_listener(self.hass, listener=listener) + + listener() + + async def _async_reload_backup_agents(self, domain: str) -> None: + """Add backup agent platform to the backup manager.""" + platform = self.backup_agent_platforms[domain] + + # Remove all agents for the domain + for agent_id in list(self.backup_agents): + if self.backup_agents[agent_id].domain == domain: + del self.backup_agents[agent_id] + for agent_id in list(self.local_backup_agents): + if self.local_backup_agents[agent_id].domain == domain: + del self.local_backup_agents[agent_id] + + # Add new agents + agents = await platform.async_get_backup_agents(self.hass) + self.backup_agents.update({agent.agent_id: agent for agent in agents}) + self.local_backup_agents.update( + { + agent.agent_id: agent + for agent in agents + if isinstance(agent, LocalBackupAgent) + } + ) + + async def _add_platform( + self, + hass: HomeAssistant, + integration_domain: str, + platform: Any, + ) -> None: + """Add a backup platform manager.""" + self._add_platform_pre_post_handler(integration_domain, platform) + self._async_add_backup_agent_platform(integration_domain, platform) + LOGGER.debug("Backup platform %s loaded", integration_domain) + LOGGER.debug("%s platforms loaded in total", len(self.platforms)) + LOGGER.debug("%s agents loaded in total", len(self.backup_agents)) + LOGGER.debug("%s local agents loaded in total", len(self.local_backup_agents)) + + async def async_pre_backup_actions(self) -> None: + """Perform pre backup actions.""" pre_backup_results = await asyncio.gather( *( platform.async_pre_backup(self.hass) @@ -102,13 +394,12 @@ class BaseBackupManager(abc.ABC): ) for result in pre_backup_results: if isinstance(result, Exception): - raise result + raise BackupManagerError( + f"Error during pre-backup: {result}" + ) from result - async def async_post_backup_actions(self, **kwargs: Any) -> None: + async def async_post_backup_actions(self) -> None: """Perform post backup actions.""" - if not self.loaded_platforms: - await self.load_platforms() - post_backup_results = await asyncio.gather( *( platform.async_post_backup(self.hass) @@ -118,231 +409,894 @@ class BaseBackupManager(abc.ABC): ) for result in post_backup_results: if isinstance(result, Exception): - raise result + raise BackupManagerError( + f"Error during post-backup: {result}" + ) from result async def load_platforms(self) -> None: """Load backup platforms.""" await integration_platform.async_process_integration_platforms( - self.hass, DOMAIN, self._add_platform, wait_for_platforms=True + self.hass, + DOMAIN, + self._add_platform, + wait_for_platforms=True, ) LOGGER.debug("Loaded %s platforms", len(self.platforms)) - self.loaded_platforms = True + LOGGER.debug("Loaded %s agents", len(self.backup_agents)) - @abc.abstractmethod - async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: - """Restore a backup.""" + async def _async_upload_backup( + self, + *, + backup: AgentBackup, + agent_ids: list[str], + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + ) -> dict[str, Exception]: + """Upload a backup to selected agents.""" + agent_errors: dict[str, Exception] = {} - @abc.abstractmethod - async def async_create_backup(self, **kwargs: Any) -> Backup: - """Generate a backup.""" + LOGGER.debug("Uploading backup %s to agents %s", backup.backup_id, agent_ids) - @abc.abstractmethod - async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]: + sync_backup_results = await asyncio.gather( + *( + self.backup_agents[agent_id].async_upload_backup( + open_stream=open_stream, + backup=backup, + ) + for agent_id in agent_ids + ), + return_exceptions=True, + ) + for idx, result in enumerate(sync_backup_results): + agent_id = agent_ids[idx] + if isinstance(result, BackupReaderWriterError): + # writer errors will affect all agents + # no point in continuing + raise BackupManagerError(str(result)) from result + if isinstance(result, BackupAgentError): + agent_errors[agent_id] = result + LOGGER.error("Upload failed for %s: %s", agent_id, result) + continue + if isinstance(result, Exception): + # trap bugs from agents + agent_errors[agent_id] = result + LOGGER.error( + "Unexpected error for %s: %s", agent_id, result, exc_info=result + ) + continue + if isinstance(result, BaseException): + raise result + + return agent_errors + + async def async_get_backups( + self, + ) -> tuple[dict[str, ManagerBackup], dict[str, Exception]]: """Get backups. - Return a dictionary of Backup instances keyed by their slug. + Return a dictionary of Backup instances keyed by their ID. """ + backups: dict[str, ManagerBackup] = {} + agent_errors: dict[str, Exception] = {} + agent_ids = list(self.backup_agents) - @abc.abstractmethod - async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None: + list_backups_results = await asyncio.gather( + *(agent.async_list_backups() for agent in self.backup_agents.values()), + return_exceptions=True, + ) + for idx, result in enumerate(list_backups_results): + if isinstance(result, BackupAgentError): + agent_errors[agent_ids[idx]] = result + continue + if isinstance(result, BaseException): + raise result # unexpected error + for agent_backup in result: + if (backup_id := agent_backup.backup_id) not in backups: + if known_backup := self.known_backups.get(backup_id): + failed_agent_ids = known_backup.failed_agent_ids + else: + failed_agent_ids = [] + with_automatic_settings = self.is_our_automatic_backup( + agent_backup, await instance_id.async_get(self.hass) + ) + backups[backup_id] = ManagerBackup( + agent_ids=[], + addons=agent_backup.addons, + backup_id=backup_id, + date=agent_backup.date, + database_included=agent_backup.database_included, + extra_metadata=agent_backup.extra_metadata, + failed_agent_ids=failed_agent_ids, + folders=agent_backup.folders, + homeassistant_included=agent_backup.homeassistant_included, + homeassistant_version=agent_backup.homeassistant_version, + name=agent_backup.name, + protected=agent_backup.protected, + size=agent_backup.size, + with_automatic_settings=with_automatic_settings, + ) + backups[backup_id].agent_ids.append(agent_ids[idx]) + + return (backups, agent_errors) + + async def async_get_backup( + self, backup_id: str + ) -> tuple[ManagerBackup | None, dict[str, Exception]]: """Get a backup.""" + backup: ManagerBackup | None = None + agent_errors: dict[str, Exception] = {} + agent_ids = list(self.backup_agents) - @abc.abstractmethod - async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None: - """Remove a backup.""" + get_backup_results = await asyncio.gather( + *( + agent.async_get_backup(backup_id) + for agent in self.backup_agents.values() + ), + return_exceptions=True, + ) + for idx, result in enumerate(get_backup_results): + if isinstance(result, BackupAgentError): + agent_errors[agent_ids[idx]] = result + continue + if isinstance(result, BaseException): + raise result # unexpected error + if not result: + continue + if backup is None: + if known_backup := self.known_backups.get(backup_id): + failed_agent_ids = known_backup.failed_agent_ids + else: + failed_agent_ids = [] + with_automatic_settings = self.is_our_automatic_backup( + result, await instance_id.async_get(self.hass) + ) + backup = ManagerBackup( + agent_ids=[], + addons=result.addons, + backup_id=result.backup_id, + date=result.date, + database_included=result.database_included, + extra_metadata=result.extra_metadata, + failed_agent_ids=failed_agent_ids, + folders=result.folders, + homeassistant_included=result.homeassistant_included, + homeassistant_version=result.homeassistant_version, + name=result.name, + protected=result.protected, + size=result.size, + with_automatic_settings=with_automatic_settings, + ) + backup.agent_ids.append(agent_ids[idx]) + + return (backup, agent_errors) + + @staticmethod + def is_our_automatic_backup( + backup: AgentBackup, our_instance_id: str + ) -> bool | None: + """Check if a backup was created by us and return automatic_settings flag. + + Returns `None` if the backup was not created by us, or if the + automatic_settings flag is not a boolean. + """ + if backup.extra_metadata.get("instance_id") != our_instance_id: + return None + with_automatic_settings = backup.extra_metadata.get("with_automatic_settings") + if not isinstance(with_automatic_settings, bool): + return None + return with_automatic_settings + + async def async_delete_backup(self, backup_id: str) -> dict[str, Exception]: + """Delete a backup.""" + agent_errors: dict[str, Exception] = {} + agent_ids = list(self.backup_agents) + + delete_backup_results = await asyncio.gather( + *( + agent.async_delete_backup(backup_id) + for agent in self.backup_agents.values() + ), + return_exceptions=True, + ) + for idx, result in enumerate(delete_backup_results): + if isinstance(result, BackupAgentError): + agent_errors[agent_ids[idx]] = result + continue + if isinstance(result, BaseException): + raise result # unexpected error + + if not agent_errors: + self.known_backups.remove(backup_id) + + return agent_errors - @abc.abstractmethod async def async_receive_backup( self, *, + agent_ids: list[str], contents: aiohttp.BodyPartReader, - **kwargs: Any, ) -> None: """Receive and store a backup file from upload.""" + if self.state is not BackupManagerState.IDLE: + raise BackupManagerError(f"Backup manager busy: {self.state}") + self.async_on_backup_event( + ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS) + ) + try: + await self._async_receive_backup(agent_ids=agent_ids, contents=contents) + except Exception: + self.async_on_backup_event( + ReceiveBackupEvent(stage=None, state=ReceiveBackupState.FAILED) + ) + raise + else: + self.async_on_backup_event( + ReceiveBackupEvent(stage=None, state=ReceiveBackupState.COMPLETED) + ) + finally: + self.async_on_backup_event(IdleEvent()) + + async def _async_receive_backup( + self, + *, + agent_ids: list[str], + contents: aiohttp.BodyPartReader, + ) -> None: + """Receive and store a backup file from upload.""" + contents.chunk_size = BUF_SIZE + self.async_on_backup_event( + ReceiveBackupEvent( + stage=ReceiveBackupStage.RECEIVE_FILE, + state=ReceiveBackupState.IN_PROGRESS, + ) + ) + written_backup = await self._reader_writer.async_receive_backup( + agent_ids=agent_ids, + stream=contents, + suggested_filename=contents.filename or "backup.tar", + ) + self.async_on_backup_event( + ReceiveBackupEvent( + stage=ReceiveBackupStage.UPLOAD_TO_AGENTS, + state=ReceiveBackupState.IN_PROGRESS, + ) + ) + agent_errors = await self._async_upload_backup( + backup=written_backup.backup, + agent_ids=agent_ids, + open_stream=written_backup.open_stream, + ) + await written_backup.release_stream() + self.known_backups.add(written_backup.backup, agent_errors) + + async def async_create_backup( + self, + *, + agent_ids: list[str], + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + name: str | None, + password: str | None, + with_automatic_settings: bool = False, + ) -> NewBackup: + """Create a backup.""" + new_backup = await self.async_initiate_backup( + agent_ids=agent_ids, + include_addons=include_addons, + include_all_addons=include_all_addons, + include_database=include_database, + include_folders=include_folders, + include_homeassistant=include_homeassistant, + name=name, + password=password, + raise_task_error=True, + with_automatic_settings=with_automatic_settings, + ) + assert self._backup_finish_task + await self._backup_finish_task + return new_backup + + async def async_initiate_backup( + self, + *, + agent_ids: list[str], + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + name: str | None, + password: str | None, + raise_task_error: bool = False, + with_automatic_settings: bool = False, + ) -> NewBackup: + """Initiate generating a backup.""" + if self.state is not BackupManagerState.IDLE: + raise BackupManagerError(f"Backup manager busy: {self.state}") + + if with_automatic_settings: + self.config.data.last_attempted_automatic_backup = dt_util.now() + self.store.save() + + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) + ) + try: + return await self._async_create_backup( + agent_ids=agent_ids, + include_addons=include_addons, + include_all_addons=include_all_addons, + include_database=include_database, + include_folders=include_folders, + include_homeassistant=include_homeassistant, + name=name, + password=password, + raise_task_error=raise_task_error, + with_automatic_settings=with_automatic_settings, + ) + except Exception: + self.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + ) + self.async_on_backup_event(IdleEvent()) + if with_automatic_settings: + self._update_issue_backup_failed() + raise + + async def _async_create_backup( + self, + *, + agent_ids: list[str], + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + name: str | None, + password: str | None, + raise_task_error: bool, + with_automatic_settings: bool, + ) -> NewBackup: + """Initiate generating a backup.""" + if not agent_ids: + raise BackupManagerError("At least one agent must be selected") + if invalid_agents := [ + agent_id for agent_id in agent_ids if agent_id not in self.backup_agents + ]: + raise BackupManagerError(f"Invalid agents selected: {invalid_agents}") + if include_all_addons and include_addons: + raise BackupManagerError( + "Cannot include all addons and specify specific addons" + ) + + backup_name = ( + name + or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" + ) + + try: + ( + new_backup, + self._backup_task, + ) = await self._reader_writer.async_create_backup( + agent_ids=agent_ids, + backup_name=backup_name, + extra_metadata={ + "instance_id": await instance_id.async_get(self.hass), + "with_automatic_settings": with_automatic_settings, + }, + include_addons=include_addons, + include_all_addons=include_all_addons, + include_database=include_database, + include_folders=include_folders, + include_homeassistant=include_homeassistant, + on_progress=self.async_on_backup_event, + password=password, + ) + except BackupReaderWriterError as err: + raise BackupManagerError(str(err)) from err + + backup_finish_task = self._backup_finish_task = self.hass.async_create_task( + self._async_finish_backup(agent_ids, with_automatic_settings), + name="backup_manager_finish_backup", + ) + if not raise_task_error: + + def log_finish_task_error(task: asyncio.Task[None]) -> None: + if task.done() and not task.cancelled() and (err := task.exception()): + if isinstance(err, BackupManagerError): + LOGGER.error("Error creating backup: %s", err) + else: + LOGGER.error("Unexpected error: %s", err, exc_info=err) + + backup_finish_task.add_done_callback(log_finish_task_error) + + return new_backup + + async def _async_finish_backup( + self, agent_ids: list[str], with_automatic_settings: bool + ) -> None: + """Finish a backup.""" + if TYPE_CHECKING: + assert self._backup_task is not None + backup_success = False + try: + written_backup = await self._backup_task + except Exception as err: + if with_automatic_settings: + self._update_issue_backup_failed() + + if isinstance(err, BackupReaderWriterError): + raise BackupManagerError(str(err)) from err + raise # unexpected error + else: + LOGGER.debug( + "Generated new backup with backup_id %s, uploading to agents %s", + written_backup.backup.backup_id, + agent_ids, + ) + self.async_on_backup_event( + CreateBackupEvent( + stage=CreateBackupStage.UPLOAD_TO_AGENTS, + state=CreateBackupState.IN_PROGRESS, + ) + ) + + try: + agent_errors = await self._async_upload_backup( + backup=written_backup.backup, + agent_ids=agent_ids, + open_stream=written_backup.open_stream, + ) + finally: + await written_backup.release_stream() + self.known_backups.add(written_backup.backup, agent_errors) + if not agent_errors: + if with_automatic_settings: + # create backup was successful, update last_completed_automatic_backup + self.config.data.last_completed_automatic_backup = dt_util.now() + self.store.save() + backup_success = True + + if with_automatic_settings: + self._update_issue_after_agent_upload(agent_errors) + # delete old backups more numerous than copies + # try this regardless of agent errors above + await delete_backups_exceeding_configured_count(self) + + finally: + self._backup_task = None + self._backup_finish_task = None + self.async_on_backup_event( + CreateBackupEvent( + stage=None, + state=CreateBackupState.COMPLETED + if backup_success + else CreateBackupState.FAILED, + ) + ) + self.async_on_backup_event(IdleEvent()) + + async def async_restore_backup( + self, + backup_id: str, + *, + agent_id: str, + password: str | None, + restore_addons: list[str] | None, + restore_database: bool, + restore_folders: list[Folder] | None, + restore_homeassistant: bool, + ) -> None: + """Initiate restoring a backup.""" + if self.state is not BackupManagerState.IDLE: + raise BackupManagerError(f"Backup manager busy: {self.state}") + + self.async_on_backup_event( + RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS) + ) + try: + await self._async_restore_backup( + backup_id=backup_id, + agent_id=agent_id, + password=password, + restore_addons=restore_addons, + restore_database=restore_database, + restore_folders=restore_folders, + restore_homeassistant=restore_homeassistant, + ) + self.async_on_backup_event( + RestoreBackupEvent(stage=None, state=RestoreBackupState.COMPLETED) + ) + except Exception: + self.async_on_backup_event( + RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED) + ) + raise + finally: + self.async_on_backup_event(IdleEvent()) + + async def _async_restore_backup( + self, + backup_id: str, + *, + agent_id: str, + password: str | None, + restore_addons: list[str] | None, + restore_database: bool, + restore_folders: list[Folder] | None, + restore_homeassistant: bool, + ) -> None: + """Initiate restoring a backup.""" + agent = self.backup_agents[agent_id] + if not await agent.async_get_backup(backup_id): + raise BackupManagerError( + f"Backup {backup_id} not found in agent {agent_id}" + ) + + async def open_backup() -> AsyncIterator[bytes]: + return await agent.async_download_backup(backup_id) + + await self._reader_writer.async_restore_backup( + backup_id=backup_id, + open_stream=open_backup, + agent_id=agent_id, + on_progress=self.async_on_backup_event, + password=password, + restore_addons=restore_addons, + restore_database=restore_database, + restore_folders=restore_folders, + restore_homeassistant=restore_homeassistant, + ) + + @callback + def async_on_backup_event( + self, + event: ManagerStateEvent, + ) -> None: + """Forward event to subscribers.""" + if (current_state := self.state) != (new_state := event.manager_state): + LOGGER.debug("Backup state: %s -> %s", current_state, new_state) + self.last_event = event + for subscription in self._backup_event_subscriptions: + subscription(event) + + @callback + def async_subscribe_events( + self, + on_event: Callable[[ManagerStateEvent], None], + ) -> Callable[[], None]: + """Subscribe events.""" + + def remove_subscription() -> None: + self._backup_event_subscriptions.remove(on_event) + + self._backup_event_subscriptions.append(on_event) + return remove_subscription + + def _update_issue_backup_failed(self) -> None: + """Update issue registry when a backup fails.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "automatic_backup_failed", + is_fixable=False, + is_persistent=True, + learn_more_url="homeassistant://config/backup", + severity=ir.IssueSeverity.WARNING, + translation_key="automatic_backup_failed_create", + ) + + def _update_issue_after_agent_upload( + self, agent_errors: dict[str, Exception] + ) -> None: + """Update issue registry after a backup is uploaded to agents.""" + if not agent_errors: + ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed") + return + ir.async_create_issue( + self.hass, + DOMAIN, + "automatic_backup_failed", + is_fixable=False, + is_persistent=True, + learn_more_url="homeassistant://config/backup", + severity=ir.IssueSeverity.WARNING, + translation_key="automatic_backup_failed_upload_agents", + translation_placeholders={"failed_agents": ", ".join(agent_errors)}, + ) + + async def async_can_decrypt_on_download( + self, + backup_id: str, + *, + agent_id: str, + password: str | None, + ) -> None: + """Check if we are able to decrypt the backup on download.""" + try: + agent = self.backup_agents[agent_id] + except KeyError as err: + raise BackupManagerError(f"Invalid agent selected: {agent_id}") from err + if not await agent.async_get_backup(backup_id): + raise BackupManagerError( + f"Backup {backup_id} not found in agent {agent_id}" + ) + reader: IO[bytes] + if agent_id in self.local_backup_agents: + local_agent = self.local_backup_agents[agent_id] + path = local_agent.get_backup_path(backup_id) + reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb") + else: + backup_stream = await agent.async_download_backup(backup_id) + reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream)) + try: + validate_password_stream(reader, password) + except backup_util.IncorrectPassword as err: + raise IncorrectPasswordError from err + except backup_util.UnsupportedSecureTarVersion as err: + raise DecryptOnDowloadNotSupported from err + except backup_util.DecryptError as err: + raise BackupManagerError(str(err)) from err + finally: + reader.close() -class BackupManager(BaseBackupManager): - """Backup manager for the Backup integration.""" +class KnownBackups: + """Track known backups.""" + + def __init__(self, manager: BackupManager) -> None: + """Initialize.""" + self._backups: dict[str, KnownBackup] = {} + self._manager = manager + + def load(self, stored_backups: list[StoredKnownBackup]) -> None: + """Load backups.""" + self._backups = { + backup["backup_id"]: KnownBackup( + backup_id=backup["backup_id"], + failed_agent_ids=backup["failed_agent_ids"], + ) + for backup in stored_backups + } + + def to_list(self) -> list[StoredKnownBackup]: + """Convert known backups to a dict.""" + return [backup.to_dict() for backup in self._backups.values()] + + def add( + self, + backup: AgentBackup, + agent_errors: dict[str, Exception], + ) -> None: + """Add a backup.""" + self._backups[backup.backup_id] = KnownBackup( + backup_id=backup.backup_id, + failed_agent_ids=list(agent_errors), + ) + self._manager.store.save() + + def get(self, backup_id: str) -> KnownBackup | None: + """Get a backup.""" + return self._backups.get(backup_id) + + def remove(self, backup_id: str) -> None: + """Remove a backup.""" + if backup_id not in self._backups: + return + self._backups.pop(backup_id) + self._manager.store.save() + + +@dataclass(kw_only=True) +class KnownBackup: + """Persistent backup data.""" + + backup_id: str + failed_agent_ids: list[str] + + def to_dict(self) -> StoredKnownBackup: + """Convert known backup to a dict.""" + return { + "backup_id": self.backup_id, + "failed_agent_ids": self.failed_agent_ids, + } + + +class StoredKnownBackup(TypedDict): + """Stored persistent backup data.""" + + backup_id: str + failed_agent_ids: list[str] + + +class CoreBackupReaderWriter(BackupReaderWriter): + """Class for reading and writing backups in core and container installations.""" + + _local_agent_id = f"{DOMAIN}.local" def __init__(self, hass: HomeAssistant) -> None: - """Initialize the backup manager.""" - super().__init__(hass=hass) - self.backup_dir = Path(hass.config.path("backups")) - self.loaded_backups = False + """Initialize the backup reader/writer.""" + self._hass = hass + self.temp_backup_dir = Path(hass.config.path("tmp_backups")) - async def load_backups(self) -> None: - """Load data of stored backup files.""" - backups = await self.hass.async_add_executor_job(self._read_backups) - LOGGER.debug("Loaded %s backups", len(backups)) - self.backups = backups - self.loaded_backups = True - - def _read_backups(self) -> dict[str, Backup]: - """Read backups from disk.""" - backups: dict[str, Backup] = {} - for backup_path in self.backup_dir.glob("*.tar"): - try: - with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file: - if data_file := backup_file.extractfile("./backup.json"): - data = json_loads_object(data_file.read()) - backup = Backup( - slug=cast(str, data["slug"]), - name=cast(str, data["name"]), - date=cast(str, data["date"]), - path=backup_path, - size=round(backup_path.stat().st_size / 1_048_576, 2), - ) - backups[backup.slug] = backup - except (OSError, TarError, json.JSONDecodeError, KeyError) as err: - LOGGER.warning("Unable to read backup %s: %s", backup_path, err) - return backups - - async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]: - """Return backups.""" - if not self.loaded_backups: - await self.load_backups() - - return self.backups - - async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None: - """Return a backup.""" - if not self.loaded_backups: - await self.load_backups() - - if not (backup := self.backups.get(slug)): - return None - - if not backup.path.exists(): - LOGGER.debug( - ( - "Removing tracked backup (%s) that does not exists on the expected" - " path %s" - ), - backup.slug, - backup.path, - ) - self.backups.pop(slug) - return None - - return backup - - async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None: - """Remove a backup.""" - if (backup := await self.async_get_backup(slug=slug)) is None: - return - - await self.hass.async_add_executor_job(backup.path.unlink, True) - LOGGER.debug("Removed backup located at %s", backup.path) - self.backups.pop(slug) - - async def async_receive_backup( + async def async_create_backup( self, *, - contents: aiohttp.BodyPartReader, - **kwargs: Any, - ) -> None: - """Receive and store a backup file from upload.""" - queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = ( - SimpleQueue() - ) - temp_dir_handler = await self.hass.async_add_executor_job(TemporaryDirectory) - target_temp_file = Path( - temp_dir_handler.name, contents.filename or "backup.tar" + agent_ids: list[str], + backup_name: str, + extra_metadata: dict[str, bool | str], + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + on_progress: Callable[[CreateBackupEvent], None], + password: str | None, + ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]: + """Initiate generating a backup.""" + date_str = dt_util.now().isoformat() + backup_id = _generate_backup_id(date_str, backup_name) + + if include_addons or include_all_addons or include_folders: + raise BackupReaderWriterError( + "Addons and folders are not supported by core backup" + ) + if not include_homeassistant: + raise BackupReaderWriterError("Home Assistant must be included in backup") + + backup_task = self._hass.async_create_task( + self._async_create_backup( + agent_ids=agent_ids, + backup_id=backup_id, + backup_name=backup_name, + extra_metadata=extra_metadata, + include_database=include_database, + date_str=date_str, + on_progress=on_progress, + password=password, + ), + name="backup_manager_create_backup", + eager_start=False, # To ensure the task is not started before we return ) - def _sync_queue_consumer() -> None: - with target_temp_file.open("wb") as file_handle: - while True: - if (_chunk_future := queue.get()) is None: - break - _chunk, _future = _chunk_future - if _future is not None: - self.hass.loop.call_soon_threadsafe(_future.set_result, None) - file_handle.write(_chunk) + return (NewBackup(backup_job_id=backup_id), backup_task) - fut: asyncio.Future[None] | None = None - try: - fut = self.hass.async_add_executor_job(_sync_queue_consumer) - megabytes_sending = 0 - while chunk := await contents.read_chunk(BUF_SIZE): - megabytes_sending += 1 - if megabytes_sending % 5 != 0: - queue.put_nowait((chunk, None)) - continue - - chunk_future = self.hass.loop.create_future() - queue.put_nowait((chunk, chunk_future)) - await asyncio.wait( - (fut, chunk_future), - return_when=asyncio.FIRST_COMPLETED, - ) - if fut.done(): - # The executor job failed - break - - queue.put_nowait(None) # terminate queue consumer - finally: - if fut is not None: - await fut - - def _move_and_cleanup() -> None: - shutil.move(target_temp_file, self.backup_dir / target_temp_file.name) - temp_dir_handler.cleanup() - - await self.hass.async_add_executor_job(_move_and_cleanup) - await self.load_backups() - - async def async_create_backup(self, **kwargs: Any) -> Backup: + async def _async_create_backup( + self, + *, + agent_ids: list[str], + backup_id: str, + backup_name: str, + date_str: str, + extra_metadata: dict[str, bool | str], + include_database: bool, + on_progress: Callable[[CreateBackupEvent], None], + password: str | None, + ) -> WrittenBackup: """Generate a backup.""" - if self.backing_up: - raise HomeAssistantError("Backup already in progress") + manager = self._hass.data[DATA_MANAGER] + local_agent_tar_file_path = None + if self._local_agent_id in agent_ids: + local_agent = manager.local_backup_agents[self._local_agent_id] + local_agent_tar_file_path = local_agent.get_backup_path(backup_id) + + on_progress( + CreateBackupEvent( + stage=CreateBackupStage.HOME_ASSISTANT, + state=CreateBackupState.IN_PROGRESS, + ) + ) try: - self.backing_up = True - await self.async_pre_backup_actions() - backup_name = f"Core {HAVERSION}" - date_str = dt_util.now().isoformat() - slug = _generate_slug(date_str, backup_name) + # Inform integrations a backup is about to be made + await manager.async_pre_backup_actions() backup_data = { - "slug": slug, - "name": backup_name, - "date": date_str, - "type": "partial", - "folders": ["homeassistant"], - "homeassistant": {"version": HAVERSION}, "compressed": True, + "date": date_str, + "extra": extra_metadata, + "homeassistant": { + "exclude_database": not include_database, + "version": HAVERSION, + }, + "name": backup_name, + "protected": password is not None, + "slug": backup_id, + "type": "partial", + "version": 2, } - tar_file_path = Path(self.backup_dir, f"{backup_data['slug']}.tar") - size_in_bytes = await self.hass.async_add_executor_job( + + tar_file_path, size_in_bytes = await self._hass.async_add_executor_job( self._mkdir_and_generate_backup_contents, - tar_file_path, backup_data, + include_database, + password, + local_agent_tar_file_path, ) - backup = Backup( - slug=slug, - name=backup_name, + except (BackupManagerError, OSError, tarfile.TarError, ValueError) as err: + # BackupManagerError from async_pre_backup_actions + # OSError from file operations + # TarError from tarfile + # ValueError from json_bytes + raise BackupReaderWriterError(str(err)) from err + else: + backup = AgentBackup( + addons=[], + backup_id=backup_id, + database_included=include_database, date=date_str, - path=tar_file_path, - size=round(size_in_bytes / 1_048_576, 2), + extra_metadata=extra_metadata, + folders=[], + homeassistant_included=True, + homeassistant_version=HAVERSION, + name=backup_name, + protected=password is not None, + size=size_in_bytes, + ) + + async_add_executor_job = self._hass.async_add_executor_job + + async def send_backup() -> AsyncIterator[bytes]: + try: + f = await async_add_executor_job(tar_file_path.open, "rb") + try: + while chunk := await async_add_executor_job(f.read, 2**20): + yield chunk + finally: + await async_add_executor_job(f.close) + except OSError as err: + raise BackupReaderWriterError(str(err)) from err + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + async def remove_backup() -> None: + if local_agent_tar_file_path: + return + try: + await async_add_executor_job(tar_file_path.unlink, True) + except OSError as err: + raise BackupReaderWriterError(str(err)) from err + + return WrittenBackup( + backup=backup, open_stream=open_backup, release_stream=remove_backup ) - if self.loaded_backups: - self.backups[slug] = backup - LOGGER.debug("Generated new backup with slug %s", slug) - return backup finally: - self.backing_up = False - await self.async_post_backup_actions() + # Inform integrations the backup is done + try: + await manager.async_post_backup_actions() + except BackupManagerError as err: + raise BackupReaderWriterError(str(err)) from err def _mkdir_and_generate_backup_contents( self, - tar_file_path: Path, backup_data: dict[str, Any], - ) -> int: + database_included: bool, + password: str | None, + tar_file_path: Path | None, + ) -> tuple[Path, int]: """Generate backup contents and return the size.""" - if not self.backup_dir.exists(): - LOGGER.debug("Creating backup directory") - self.backup_dir.mkdir() + if not tar_file_path: + tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar" + make_backup_dir(tar_file_path.parent) + + excludes = EXCLUDE_FROM_BACKUP + if not database_included: + excludes = excludes + EXCLUDE_DATABASE_FROM_BACKUP + + def is_excluded_by_filter(path: PurePath) -> bool: + """Filter to filter excludes.""" + + for exclude in excludes: + if not path.match(exclude): + continue + LOGGER.debug("Ignoring %s because of %s", path, exclude) + return True + + return False outer_secure_tarfile = SecureTarFile( tar_file_path, "w", gzip=False, bufsize=BUF_SIZE @@ -355,37 +1309,147 @@ class BackupManager(BaseBackupManager): tar_info.mtime = int(time.time()) outer_secure_tarfile_tarfile.addfile(tar_info, fileobj=fileobj) with outer_secure_tarfile.create_inner_tar( - "./homeassistant.tar.gz", gzip=True + "./homeassistant.tar.gz", + gzip=True, + key=password_to_key(password) if password is not None else None, ) as core_tar: atomic_contents_add( tar_file=core_tar, - origin_path=Path(self.hass.config.path()), - excludes=EXCLUDE_FROM_BACKUP, + origin_path=Path(self._hass.config.path()), + file_filter=is_excluded_by_filter, arcname="data", ) + return (tar_file_path, tar_file_path.stat().st_size) - return tar_file_path.stat().st_size + async def async_receive_backup( + self, + *, + agent_ids: list[str], + stream: AsyncIterator[bytes], + suggested_filename: str, + ) -> WrittenBackup: + """Receive a backup.""" + temp_file = Path(self.temp_backup_dir, suggested_filename) - async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: + async_add_executor_job = self._hass.async_add_executor_job + await async_add_executor_job(make_backup_dir, self.temp_backup_dir) + f = await async_add_executor_job(temp_file.open, "wb") + try: + async for chunk in stream: + await async_add_executor_job(f.write, chunk) + finally: + await async_add_executor_job(f.close) + + try: + backup = await async_add_executor_job(read_backup, temp_file) + except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError) as err: + LOGGER.warning("Unable to parse backup %s: %s", temp_file, err) + raise + + manager = self._hass.data[DATA_MANAGER] + if self._local_agent_id in agent_ids: + local_agent = manager.local_backup_agents[self._local_agent_id] + tar_file_path = local_agent.get_backup_path(backup.backup_id) + await async_add_executor_job(make_backup_dir, tar_file_path.parent) + await async_add_executor_job(shutil.move, temp_file, tar_file_path) + else: + tar_file_path = temp_file + + async def send_backup() -> AsyncIterator[bytes]: + f = await async_add_executor_job(tar_file_path.open, "rb") + try: + while chunk := await async_add_executor_job(f.read, 2**20): + yield chunk + finally: + await async_add_executor_job(f.close) + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + async def remove_backup() -> None: + if self._local_agent_id in agent_ids: + return + await async_add_executor_job(temp_file.unlink, True) + + return WrittenBackup( + backup=backup, open_stream=open_backup, release_stream=remove_backup + ) + + async def async_restore_backup( + self, + backup_id: str, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + *, + agent_id: str, + on_progress: Callable[[RestoreBackupEvent], None], + password: str | None, + restore_addons: list[str] | None, + restore_database: bool, + restore_folders: list[Folder] | None, + restore_homeassistant: bool, + ) -> None: """Restore a backup. This will write the restore information to .HA_RESTORE which will be handled during startup by the restore_backup module. """ - if (backup := await self.async_get_backup(slug=slug)) is None: - raise HomeAssistantError(f"Backup {slug} not found") + + if restore_addons or restore_folders: + raise BackupReaderWriterError( + "Addons and folders are not supported in core restore" + ) + if not restore_homeassistant and not restore_database: + raise BackupReaderWriterError( + "Home Assistant or database must be included in restore" + ) + + manager = self._hass.data[DATA_MANAGER] + if agent_id in manager.local_backup_agents: + local_agent = manager.local_backup_agents[agent_id] + path = local_agent.get_backup_path(backup_id) + remove_after_restore = False + else: + async_add_executor_job = self._hass.async_add_executor_job + path = self.temp_backup_dir / f"{backup_id}.tar" + stream = await open_stream() + await async_add_executor_job(make_backup_dir, self.temp_backup_dir) + f = await async_add_executor_job(path.open, "wb") + try: + async for chunk in stream: + await async_add_executor_job(f.write, chunk) + finally: + await async_add_executor_job(f.close) + + remove_after_restore = True + + password_valid = await self._hass.async_add_executor_job( + validate_password, path, password + ) + if not password_valid: + raise IncorrectPasswordError def _write_restore_file() -> None: """Write the restore file.""" - Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text( - json.dumps({"path": backup.path.as_posix()}), + Path(self._hass.config.path(RESTORE_BACKUP_FILE)).write_text( + json.dumps( + { + "path": path.as_posix(), + "password": password, + "remove_after_restore": remove_after_restore, + "restore_database": restore_database, + "restore_homeassistant": restore_homeassistant, + } + ), encoding="utf-8", ) - await self.hass.async_add_executor_job(_write_restore_file) - await self.hass.services.async_call("homeassistant", "restart", {}) + await self._hass.async_add_executor_job(_write_restore_file) + on_progress( + RestoreBackupEvent(stage=None, state=RestoreBackupState.CORE_RESTART) + ) + await self._hass.services.async_call("homeassistant", "restart", blocking=True) -def _generate_slug(date: str, name: str) -> str: - """Generate a backup slug.""" +def _generate_backup_id(date: str, name: str) -> str: + """Generate a backup ID.""" return hashlib.sha1(f"{date} - {name}".lower().encode()).hexdigest()[:8] diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 0a906bb6dfa..ffaed260c88 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -1,11 +1,12 @@ { "domain": "backup", "name": "Backup", + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/backup", "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2024.11.0"] + "requirements": ["cronsim==2.6", "securetar==2025.1.3"] } diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py new file mode 100644 index 00000000000..81c00d699c6 --- /dev/null +++ b/homeassistant/components/backup/models.py @@ -0,0 +1,75 @@ +"""Models for the backup integration.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from enum import StrEnum +from typing import Any, Self + +from homeassistant.exceptions import HomeAssistantError + + +@dataclass(frozen=True, kw_only=True) +class AddonInfo: + """Addon information.""" + + name: str + slug: str + version: str + + +class Folder(StrEnum): + """Folder type.""" + + SHARE = "share" + ADDONS = "addons/local" + SSL = "ssl" + MEDIA = "media" + + +@dataclass(frozen=True, kw_only=True) +class AgentBackup: + """Base backup class.""" + + addons: list[AddonInfo] + backup_id: str + date: str + database_included: bool + extra_metadata: dict[str, bool | str] + folders: list[Folder] + homeassistant_included: bool + homeassistant_version: str | None # None if homeassistant_included is False + name: str + protected: bool + size: int + + def as_dict(self) -> dict: + """Return a dict representation of this backup.""" + return asdict(self) + + def as_frontend_json(self) -> dict: + """Return a dict representation of this backup for sending to frontend.""" + return { + key: val for key, val in asdict(self).items() if key != "extra_metadata" + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """Create an instance from a JSON serialization.""" + return cls( + addons=[AddonInfo(**addon) for addon in data["addons"]], + backup_id=data["backup_id"], + date=data["date"], + database_included=data["database_included"], + extra_metadata=data["extra_metadata"], + folders=[Folder(folder) for folder in data["folders"]], + homeassistant_included=data["homeassistant_included"], + homeassistant_version=data["homeassistant_version"], + name=data["name"], + protected=data["protected"], + size=data["size"], + ) + + +class BackupManagerError(HomeAssistantError): + """Backup manager error.""" diff --git a/homeassistant/components/backup/services.yaml b/homeassistant/components/backup/services.yaml index 900aa39dd6e..70900f93bff 100644 --- a/homeassistant/components/backup/services.yaml +++ b/homeassistant/components/backup/services.yaml @@ -1 +1,2 @@ create: +create_automatic: diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py new file mode 100644 index 00000000000..0e1c49426c5 --- /dev/null +++ b/homeassistant/components/backup/store.py @@ -0,0 +1,91 @@ +"""Store backup configuration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypedDict + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.storage import Store + +from .const import DOMAIN + +if TYPE_CHECKING: + from .config import StoredBackupConfig + from .manager import BackupManager, StoredKnownBackup + +STORE_DELAY_SAVE = 30 +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 + + +class StoredBackupData(TypedDict): + """Represent the stored backup config.""" + + backups: list[StoredKnownBackup] + config: StoredBackupConfig + + +class _BackupStore(Store[StoredBackupData]): + """Class to help storing backup data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize storage class.""" + super().__init__( + hass, + STORAGE_VERSION, + STORAGE_KEY, + minor_version=STORAGE_VERSION_MINOR, + ) + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, Any], + ) -> dict[str, Any]: + """Migrate to the new version.""" + data = old_data + if old_major_version == 1: + if old_minor_version < 2: + # Version 1.2 adds configurable backup time and custom days + data["config"]["schedule"]["time"] = None + if (state := data["config"]["schedule"]["state"]) in ("daily", "never"): + data["config"]["schedule"]["days"] = [] + data["config"]["schedule"]["recurrence"] = state + else: + data["config"]["schedule"]["days"] = [state] + data["config"]["schedule"]["recurrence"] = "custom_days" + + # Note: We allow reading data with major version 2. + # Reject if major version is higher than 2. + if old_major_version > 2: + raise NotImplementedError + return data + + +class BackupStore: + """Store backup config.""" + + def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None: + """Initialize the backup store.""" + self._hass = hass + self._manager = manager + self._store = _BackupStore(hass) + + async def load(self) -> StoredBackupData | None: + """Load the store.""" + return await self._store.async_load() + + @callback + def save(self) -> None: + """Save config.""" + self._store.async_delay_save(self._data_to_save, STORE_DELAY_SAVE) + + @callback + def _data_to_save(self) -> StoredBackupData: + """Return data to save.""" + return { + "backups": self._manager.known_backups.to_list(), + "config": self._manager.config.data.to_dict(), + } diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index 6ad3416b1b9..32d76ded049 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -1,8 +1,22 @@ { + "issues": { + "automatic_backup_failed_create": { + "title": "Automatic backup could not be created", + "description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." + }, + "automatic_backup_failed_upload_agents": { + "title": "Automatic backup could not be uploaded to the configured locations", + "description": "The automatic backup could not be uploaded to the configured locations {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." + } + }, "services": { "create": { "name": "Create backup", "description": "Creates a new backup." + }, + "create_automatic": { + "name": "Create automatic backup", + "description": "Creates a new backup with automatic backup settings." } } } diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py new file mode 100644 index 00000000000..e5acf974012 --- /dev/null +++ b/homeassistant/components/backup/util.py @@ -0,0 +1,330 @@ +"""Local backup support for Core and Container installations.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable +import copy +from io import BytesIO +import json +from pathlib import Path, PurePath +from queue import SimpleQueue +import tarfile +from typing import IO, Self, cast + +import aiohttp +from securetar import SecureTarError, SecureTarFile, SecureTarReadError + +from homeassistant.backup_restore import password_to_key +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.json import JsonObjectType, json_loads_object + +from .const import BUF_SIZE, LOGGER +from .models import AddonInfo, AgentBackup, Folder + + +class DecryptError(HomeAssistantError): + """Error during decryption.""" + + _message = "Unexpected error during decryption." + + +class UnsupportedSecureTarVersion(DecryptError): + """Unsupported securetar version.""" + + _message = "Unsupported securetar version." + + +class IncorrectPassword(DecryptError): + """Invalid password or corrupted backup.""" + + _message = "Invalid password or corrupted backup." + + +class BackupEmpty(DecryptError): + """No tar files found in the backup.""" + + _message = "No tar files found in the backup." + + +def make_backup_dir(path: Path) -> None: + """Create a backup directory if it does not exist.""" + path.mkdir(exist_ok=True) + + +def read_backup(backup_path: Path) -> AgentBackup: + """Read a backup from disk.""" + + with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file: + if not (data_file := backup_file.extractfile("./backup.json")): + raise KeyError("backup.json not found in tar file") + data = json_loads_object(data_file.read()) + addons = [ + AddonInfo( + name=cast(str, addon["name"]), + slug=cast(str, addon["slug"]), + version=cast(str, addon["version"]), + ) + for addon in cast(list[JsonObjectType], data.get("addons", [])) + ] + + folders = [ + Folder(folder) + for folder in cast(list[str], data.get("folders", [])) + if folder != "homeassistant" + ] + + homeassistant_included = False + homeassistant_version: str | None = None + database_included = False + if ( + homeassistant := cast(JsonObjectType, data.get("homeassistant")) + ) and "version" in homeassistant: + homeassistant_included = True + homeassistant_version = cast(str, homeassistant["version"]) + database_included = not cast( + bool, homeassistant.get("exclude_database", False) + ) + + return AgentBackup( + addons=addons, + backup_id=cast(str, data["slug"]), + database_included=database_included, + date=cast(str, data["date"]), + extra_metadata=cast(dict[str, bool | str], data.get("extra", {})), + folders=folders, + homeassistant_included=homeassistant_included, + homeassistant_version=homeassistant_version, + name=cast(str, data["name"]), + protected=cast(bool, data.get("protected", False)), + size=backup_path.stat().st_size, + ) + + +def validate_password(path: Path, password: str | None) -> bool: + """Validate the password.""" + with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file: + compressed = False + ha_tar_name = "homeassistant.tar" + try: + ha_tar = backup_file.extractfile(ha_tar_name) + except KeyError: + compressed = True + ha_tar_name = "homeassistant.tar.gz" + try: + ha_tar = backup_file.extractfile(ha_tar_name) + except KeyError: + LOGGER.error("No homeassistant.tar or homeassistant.tar.gz found") + return False + try: + with SecureTarFile( + path, # Not used + gzip=compressed, + key=password_to_key(password) if password is not None else None, + mode="r", + fileobj=ha_tar, + ): + # If we can read the tar file, the password is correct + return True + except tarfile.ReadError: + LOGGER.debug("Invalid password") + return False + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error validating password") + return False + + +class AsyncIteratorReader: + """Wrap an AsyncIterator.""" + + def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None: + """Initialize the wrapper.""" + self._hass = hass + self._stream = stream + self._buffer: bytes | None = None + self._pos: int = 0 + + async def _next(self) -> bytes | None: + """Get the next chunk from the iterator.""" + return await anext(self._stream, None) + + def read(self, n: int = -1, /) -> bytes: + """Read data from the iterator.""" + result = bytearray() + while n < 0 or len(result) < n: + if not self._buffer: + self._buffer = asyncio.run_coroutine_threadsafe( + self._next(), self._hass.loop + ).result() + self._pos = 0 + if not self._buffer: + # The stream is exhausted + break + chunk = self._buffer[self._pos : self._pos + n] + result.extend(chunk) + n -= len(chunk) + self._pos += len(chunk) + if self._pos == len(self._buffer): + self._buffer = None + return bytes(result) + + def close(self) -> None: + """Close the iterator.""" + + +class AsyncIteratorWriter: + """Wrap an AsyncIterator.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the wrapper.""" + self._hass = hass + self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1) + + def __aiter__(self) -> Self: + """Return the iterator.""" + return self + + async def __anext__(self) -> bytes: + """Get the next chunk from the iterator.""" + if data := await self._queue.get(): + return data + raise StopAsyncIteration + + def write(self, s: bytes, /) -> int: + """Write data to the iterator.""" + asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result() + return len(s) + + +def validate_password_stream( + input_stream: IO[bytes], + password: str | None, +) -> None: + """Decrypt a backup.""" + with ( + tarfile.open(fileobj=input_stream, mode="r|", bufsize=BUF_SIZE) as input_tar, + ): + for obj in input_tar: + if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + continue + istf = SecureTarFile( + None, # Not used + gzip=False, + key=password_to_key(password) if password is not None else None, + mode="r", + fileobj=input_tar.extractfile(obj), + ) + with istf.decrypt(obj) as decrypted: + if istf.securetar_header.plaintext_size is None: + raise UnsupportedSecureTarVersion + try: + decrypted.read(1) # Read a single byte to trigger the decryption + except SecureTarReadError as err: + raise IncorrectPassword from err + return + raise BackupEmpty + + +def decrypt_backup( + input_stream: IO[bytes], + output_stream: IO[bytes], + password: str | None, + on_done: Callable[[], None], +) -> None: + """Decrypt a backup.""" + try: + with ( + tarfile.open( + fileobj=input_stream, mode="r|", bufsize=BUF_SIZE + ) as input_tar, + tarfile.open( + fileobj=output_stream, mode="w|", bufsize=BUF_SIZE + ) as output_tar, + ): + _decrypt_backup(input_tar, output_tar, password) + except (DecryptError, SecureTarError, tarfile.TarError) as err: + LOGGER.warning("Error decrypting backup: %s", err) + finally: + output_stream.write(b"") # Write an empty chunk to signal the end of the stream + on_done() + + +def _decrypt_backup( + input_tar: tarfile.TarFile, + output_tar: tarfile.TarFile, + password: str | None, +) -> None: + """Decrypt a backup.""" + for obj in input_tar: + # We compare with PurePath to avoid issues with different path separators, + # for example when backup.json is added as "./backup.json" + if PurePath(obj.name) == PurePath("backup.json"): + # Rewrite the backup.json file to indicate that the backup is decrypted + if not (reader := input_tar.extractfile(obj)): + raise DecryptError + metadata = json_loads_object(reader.read()) + metadata["protected"] = False + updated_metadata_b = json.dumps(metadata).encode() + metadata_obj = copy.deepcopy(obj) + metadata_obj.size = len(updated_metadata_b) + output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) + continue + if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + output_tar.addfile(obj, input_tar.extractfile(obj)) + continue + istf = SecureTarFile( + None, # Not used + gzip=False, + key=password_to_key(password) if password is not None else None, + mode="r", + fileobj=input_tar.extractfile(obj), + ) + with istf.decrypt(obj) as decrypted: + if (plaintext_size := istf.securetar_header.plaintext_size) is None: + raise UnsupportedSecureTarVersion + decrypted_obj = copy.deepcopy(obj) + decrypted_obj.size = plaintext_size + output_tar.addfile(decrypted_obj, decrypted) + + +async def receive_file( + hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path +) -> None: + """Receive a file from a stream and write it to a file.""" + queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = SimpleQueue() + + def _sync_queue_consumer() -> None: + with path.open("wb") as file_handle: + while True: + if (_chunk_future := queue.get()) is None: + break + _chunk, _future = _chunk_future + if _future is not None: + hass.loop.call_soon_threadsafe(_future.set_result, None) + file_handle.write(_chunk) + + fut: asyncio.Future[None] | None = None + try: + fut = hass.async_add_executor_job(_sync_queue_consumer) + megabytes_sending = 0 + while chunk := await contents.read_chunk(BUF_SIZE): + megabytes_sending += 1 + if megabytes_sending % 5 != 0: + queue.put_nowait((chunk, None)) + continue + + chunk_future = hass.loop.create_future() + queue.put_nowait((chunk, chunk_future)) + await asyncio.wait( + (fut, chunk_future), + return_when=asyncio.FIRST_COMPLETED, + ) + if fut.done(): + # The executor job failed + break + + queue.put_nowait(None) # terminate queue consumer + finally: + if fut is not None: + await fut diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 3ac8a7ace3e..70fc568c05c 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -6,23 +6,38 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from .config import Day, ScheduleRecurrence from .const import DATA_MANAGER, LOGGER +from .manager import ( + DecryptOnDowloadNotSupported, + IncorrectPasswordError, + ManagerStateEvent, +) +from .models import Folder @callback def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> None: """Register websocket commands.""" + websocket_api.async_register_command(hass, backup_agents_info) + if with_hassio: websocket_api.async_register_command(hass, handle_backup_end) websocket_api.async_register_command(hass, handle_backup_start) - return websocket_api.async_register_command(hass, handle_details) websocket_api.async_register_command(hass, handle_info) + websocket_api.async_register_command(hass, handle_can_decrypt_on_download) websocket_api.async_register_command(hass, handle_create) - websocket_api.async_register_command(hass, handle_remove) + websocket_api.async_register_command(hass, handle_create_with_automatic_settings) + websocket_api.async_register_command(hass, handle_delete) websocket_api.async_register_command(hass, handle_restore) + websocket_api.async_register_command(hass, handle_subscribe_events) + + websocket_api.async_register_command(hass, handle_config_info) + websocket_api.async_register_command(hass, handle_config_update) @websocket_api.require_admin @@ -35,12 +50,18 @@ async def handle_info( ) -> None: """List all stored backups.""" manager = hass.data[DATA_MANAGER] - backups = await manager.async_get_backups() + backups, agent_errors = await manager.async_get_backups() connection.send_result( msg["id"], { - "backups": list(backups.values()), - "backing_up": manager.backing_up, + "agent_errors": { + agent_id: str(err) for agent_id, err in agent_errors.items() + }, + "backups": [backup.as_frontend_json() for backup in backups.values()], + "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, + "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, + "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, + "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, }, ) @@ -49,7 +70,7 @@ async def handle_info( @websocket_api.websocket_command( { vol.Required("type"): "backup/details", - vol.Required("slug"): str, + vol.Required("backup_id"): str, } ) @websocket_api.async_response @@ -58,12 +79,17 @@ async def handle_details( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Get backup details for a specific slug.""" - backup = await hass.data[DATA_MANAGER].async_get_backup(slug=msg["slug"]) + """Get backup details for a specific backup.""" + backup, agent_errors = await hass.data[DATA_MANAGER].async_get_backup( + msg["backup_id"] + ) connection.send_result( msg["id"], { - "backup": backup, + "agent_errors": { + agent_id: str(err) for agent_id, err in agent_errors.items() + }, + "backup": backup.as_frontend_json() if backup else None, }, ) @@ -71,26 +97,39 @@ async def handle_details( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required("type"): "backup/remove", - vol.Required("slug"): str, + vol.Required("type"): "backup/delete", + vol.Required("backup_id"): str, } ) @websocket_api.async_response -async def handle_remove( +async def handle_delete( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Remove a backup.""" - await hass.data[DATA_MANAGER].async_remove_backup(slug=msg["slug"]) - connection.send_result(msg["id"]) + """Delete a backup.""" + agent_errors = await hass.data[DATA_MANAGER].async_delete_backup(msg["backup_id"]) + connection.send_result( + msg["id"], + { + "agent_errors": { + agent_id: str(err) for agent_id, err in agent_errors.items() + } + }, + ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "backup/restore", - vol.Required("slug"): str, + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Optional("password"): str, + vol.Optional("restore_addons"): [str], + vol.Optional("restore_database", default=True): bool, + vol.Optional("restore_folders"): [vol.Coerce(Folder)], + vol.Optional("restore_homeassistant", default=True): bool, } ) @websocket_api.async_response @@ -100,12 +139,68 @@ async def handle_restore( msg: dict[str, Any], ) -> None: """Restore a backup.""" - await hass.data[DATA_MANAGER].async_restore_backup(msg["slug"]) - connection.send_result(msg["id"]) + try: + await hass.data[DATA_MANAGER].async_restore_backup( + msg["backup_id"], + agent_id=msg["agent_id"], + password=msg.get("password"), + restore_addons=msg.get("restore_addons"), + restore_database=msg["restore_database"], + restore_folders=msg.get("restore_folders"), + restore_homeassistant=msg["restore_homeassistant"], + ) + except IncorrectPasswordError: + connection.send_error(msg["id"], "password_incorrect", "Incorrect password") + else: + connection.send_result(msg["id"]) @websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "backup/generate"}) +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/can_decrypt_on_download", + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Required("password"): str, + } +) +@websocket_api.async_response +async def handle_can_decrypt_on_download( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Check if the supplied password is correct.""" + try: + await hass.data[DATA_MANAGER].async_can_decrypt_on_download( + msg["backup_id"], + agent_id=msg["agent_id"], + password=msg.get("password"), + ) + except IncorrectPasswordError: + connection.send_error(msg["id"], "password_incorrect", "Incorrect password") + except DecryptOnDowloadNotSupported: + connection.send_error( + msg["id"], "decrypt_not_supported", "Decrypt on download not supported" + ) + else: + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/generate", + vol.Required("agent_ids"): [str], + vol.Optional("include_addons"): [str], + vol.Optional("include_all_addons", default=False): bool, + vol.Optional("include_database", default=True): bool, + vol.Optional("include_folders"): [vol.Coerce(Folder)], + vol.Optional("include_homeassistant", default=True): bool, + vol.Optional("name"): str, + vol.Optional("password"): str, + } +) @websocket_api.async_response async def handle_create( hass: HomeAssistant, @@ -113,7 +208,46 @@ async def handle_create( msg: dict[str, Any], ) -> None: """Generate a backup.""" - backup = await hass.data[DATA_MANAGER].async_create_backup() + + backup = await hass.data[DATA_MANAGER].async_initiate_backup( + agent_ids=msg["agent_ids"], + include_addons=msg.get("include_addons"), + include_all_addons=msg["include_all_addons"], + include_database=msg["include_database"], + include_folders=msg.get("include_folders"), + include_homeassistant=msg["include_homeassistant"], + name=msg.get("name"), + password=msg.get("password"), + ) + connection.send_result(msg["id"], backup) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/generate_with_automatic_settings", + } +) +@websocket_api.async_response +async def handle_create_with_automatic_settings( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate a backup with stored settings.""" + + config_data = hass.data[DATA_MANAGER].config.data + backup = await hass.data[DATA_MANAGER].async_initiate_backup( + agent_ids=config_data.create_backup.agent_ids, + include_addons=config_data.create_backup.include_addons, + include_all_addons=config_data.create_backup.include_all_addons, + include_database=config_data.create_backup.include_database, + include_folders=config_data.create_backup.include_folders, + include_homeassistant=True, # always include HA + name=config_data.create_backup.name, + password=config_data.create_backup.password, + with_automatic_settings=True, + ) connection.send_result(msg["id"], backup) @@ -127,7 +261,6 @@ async def handle_backup_start( ) -> None: """Backup start notification.""" manager = hass.data[DATA_MANAGER] - manager.backing_up = True LOGGER.debug("Backup start notification") try: @@ -149,7 +282,6 @@ async def handle_backup_end( ) -> None: """Backup end notification.""" manager = hass.data[DATA_MANAGER] - manager.backing_up = False LOGGER.debug("Backup end notification") try: @@ -159,3 +291,119 @@ async def handle_backup_end( return connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/agents/info"}) +@websocket_api.async_response +async def backup_agents_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return backup agents info.""" + manager = hass.data[DATA_MANAGER] + connection.send_result( + msg["id"], + { + "agents": [{"agent_id": agent_id} for agent_id in manager.backup_agents], + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/config/info"}) +@websocket_api.async_response +async def handle_config_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Send the stored backup config.""" + manager = hass.data[DATA_MANAGER] + config = manager.config.data.to_dict() + # Remove state from schedule, it's not needed in the frontend + # mypy doesn't like deleting from TypedDict, ignore it + del config["schedule"]["state"] # type: ignore[misc] + connection.send_result( + msg["id"], + { + "config": config + | { + "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, + "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, + } + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/config/update", + vol.Optional("create_backup"): vol.Schema( + { + vol.Optional("agent_ids"): vol.All([str], vol.Unique()), + vol.Optional("include_addons"): vol.Any( + vol.All([str], vol.Unique()), None + ), + vol.Optional("include_all_addons"): bool, + vol.Optional("include_database"): bool, + vol.Optional("include_folders"): vol.Any( + vol.All([vol.Coerce(Folder)], vol.Unique()), None + ), + vol.Optional("name"): vol.Any(str, None), + vol.Optional("password"): vol.Any(str, None), + }, + ), + vol.Optional("retention"): vol.Schema( + { + vol.Optional("copies"): vol.Any(int, None), + vol.Optional("days"): vol.Any(int, None), + }, + ), + vol.Optional("schedule"): vol.Schema( + { + vol.Optional("days"): vol.Any( + vol.All([vol.Coerce(Day)], vol.Unique()), + ), + vol.Optional("recurrence"): vol.All( + str, vol.Coerce(ScheduleRecurrence) + ), + vol.Optional("time"): vol.Any(cv.time, None), + } + ), + } +) +@websocket_api.async_response +async def handle_config_update( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Update the stored backup config.""" + manager = hass.data[DATA_MANAGER] + changes = dict(msg) + changes.pop("id") + changes.pop("type") + await manager.config.update(**changes) + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) +@websocket_api.async_response +async def handle_subscribe_events( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to backup events.""" + + def on_event(event: ManagerStateEvent) -> None: + connection.send_message(websocket_api.event_message(msg["id"], event)) + + manager = hass.data[DATA_MANAGER] + on_event(manager.last_event) + connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/baf/climate.py b/homeassistant/components/baf/climate.py index 38407813d37..c30d49e8c9d 100644 --- a/homeassistant/components/baf/climate.py +++ b/homeassistant/components/baf/climate.py @@ -40,7 +40,6 @@ class BAFAutoComfort(BAFEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY] _attr_translation_key = "auto_comfort" - _enable_turn_on_off_backwards_compatibility = False @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index 0d56699e1ce..4dbb59165fa 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -10,9 +10,9 @@ from aiobafi6 import Device, Service from aiobafi6.discovery import PORT import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, RUN_TIMEOUT from .models import BAFDiscovery @@ -44,7 +44,7 @@ class BAFFlowHandler(ConfigFlow, domain=DOMAIN): self.discovery: BAFDiscovery | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if discovery_info.ip_address.version == 6: diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index d0ba668373a..8f7aab40b79 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -46,7 +46,7 @@ class BAFFan(BAFEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False + _attr_preset_modes = [PRESET_MODE_AUTO] _attr_speed_count = SPEED_COUNT _attr_name = None diff --git a/homeassistant/components/baf/light.py b/homeassistant/components/baf/light.py index 2fb36ed874f..4c0b1e353fe 100644 --- a/homeassistant/components/baf/light.py +++ b/homeassistant/components/baf/light.py @@ -8,16 +8,12 @@ from aiobafi6 import Device, OffOnAuto from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ColorMode, LightEntity, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, -) from . import BAFConfigEntry from .entity import BAFEntity @@ -77,25 +73,17 @@ class BAFStandaloneLight(BAFLight): def __init__(self, device: Device) -> None: """Init a standalone light.""" super().__init__(device) - self._attr_min_mireds = color_temperature_kelvin_to_mired( - device.light_warmest_color_temperature - ) - self._attr_max_mireds = color_temperature_kelvin_to_mired( - device.light_coolest_color_temperature - ) + self._attr_max_color_temp_kelvin = device.light_warmest_color_temperature + self._attr_min_color_temp_kelvin = device.light_coolest_color_temperature @callback def _async_update_attrs(self) -> None: """Update attrs from device.""" super()._async_update_attrs() - self._attr_color_temp = color_temperature_kelvin_to_mired( - self._device.light_color_temperature - ) + self._attr_color_temp_kelvin = self._device.light_color_temperature async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: - self._device.light_color_temperature = color_temperature_mired_to_kelvin( - color_temp - ) + if (color_temp := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None: + self._device.light_color_temperature = color_temp await super().async_turn_on(**kwargs) diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index d27fd459676..76b02f0e165 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -65,7 +65,6 @@ class BalboaClimateEntity(BalboaEntity, ClimateEntity): ) _attr_translation_key = DOMAIN _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, client: SpaClient) -> None: """Initialize the climate entity.""" diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py index a7d75bfbdf5..a541d044a21 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -20,7 +20,7 @@ class BalboaEntity(Entity): """Initialize the control.""" mac = client.mac_address model = client.model - self._attr_unique_id = f'{model}-{key}-{mac.replace(":","")[-6:]}' + self._attr_unique_id = f"{model}-{key}-{mac.replace(':', '')[-6:]}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mac)}, name=model, diff --git a/homeassistant/components/balboa/fan.py b/homeassistant/components/balboa/fan.py index 67c1d9a9a62..3ecfec53a1e 100644 --- a/homeassistant/components/balboa/fan.py +++ b/homeassistant/components/balboa/fan.py @@ -38,7 +38,7 @@ class BalboaPumpFanEntity(BalboaEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False + _attr_translation_key = "pump" def __init__(self, control: SpaControl) -> None: diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index c8ba1f1c3dc..b80e625e8d4 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -8,6 +8,7 @@ from aiohttp.client_exceptions import ( ClientConnectorError, ClientOSError, ServerTimeoutError, + WSMessageTypeError, ) from mozart_api.exceptions import ApiException from mozart_api.mozart_client import MozartClient @@ -33,7 +34,7 @@ class BangOlufsenData: type BangOlufsenConfigEntry = ConfigEntry[BangOlufsenData] -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER] async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) -> bool: @@ -62,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) ServerTimeoutError, ApiException, TimeoutError, + WSMessageTypeError, ) as error: await client.close_api_client() raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error diff --git a/homeassistant/components/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py index e1c1c7ab538..e776b63b945 100644 --- a/homeassistant/components/bang_olufsen/config_flow.py +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -10,10 +10,10 @@ from mozart_api.exceptions import ApiException from mozart_api.mozart_client import MozartClient import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.ssl import get_default_context from .const import ( diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 209311d3e8a..c5ee5d1a26e 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -79,6 +79,7 @@ class WebsocketNotification(StrEnum): """Enum for WebSocket notification types.""" ACTIVE_LISTENING_MODE = "active_listening_mode" + BUTTON = "button" PLAYBACK_ERROR = "playback_error" PLAYBACK_METADATA = "playback_metadata" PLAYBACK_PROGRESS = "playback_progress" @@ -203,10 +204,73 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray( ), ] ) +# Map for storing compatibility of devices. +MODEL_SUPPORT_DEVICE_BUTTONS: Final[str] = "device_buttons" + +MODEL_SUPPORT_MAP = { + MODEL_SUPPORT_DEVICE_BUTTONS: ( + BangOlufsenModel.BEOLAB_8, + BangOlufsenModel.BEOLAB_28, + BangOlufsenModel.BEOSOUND_2, + BangOlufsenModel.BEOSOUND_A5, + BangOlufsenModel.BEOSOUND_A9, + BangOlufsenModel.BEOSOUND_BALANCE, + BangOlufsenModel.BEOSOUND_EMERGE, + BangOlufsenModel.BEOSOUND_LEVEL, + BangOlufsenModel.BEOSOUND_THEATRE, + ) +} # Device events BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event" +# Dict used to translate native Bang & Olufsen event names to string.json compatible ones +EVENT_TRANSLATION_MAP: dict[str, str] = { + "shortPress (Release)": "short_press_release", + "longPress (Timeout)": "long_press_timeout", + "longPress (Release)": "long_press_release", + "veryLongPress (Timeout)": "very_long_press_timeout", + "veryLongPress (Release)": "very_long_press_release", +} CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS" + +DEVICE_BUTTONS: Final[list[str]] = [ + "Bluetooth", + "Microphone", + "Next", + "PlayPause", + "Preset1", + "Preset2", + "Preset3", + "Preset4", + "Previous", + "Volume", +] + + +DEVICE_BUTTON_EVENTS: Final[list[str]] = [ + "short_press_release", + "long_press_timeout", + "long_press_release", + "very_long_press_timeout", + "very_long_press_release", +] + +# Beolink Converter NL/ML sources need to be transformed to upper case +BEOLINK_JOIN_SOURCES_TO_UPPER = ( + "aux_a", + "cd", + "ph", + "radio", + "tp1", + "tp2", +) +BEOLINK_JOIN_SOURCES = ( + *BEOLINK_JOIN_SOURCES_TO_UPPER, + "beoradio", + "deezer", + "spotify", + "tidal", +) diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py new file mode 100644 index 00000000000..cab7eae5e25 --- /dev/null +++ b/homeassistant/components/bang_olufsen/diagnostics.py @@ -0,0 +1,40 @@ +"""Support for Bang & Olufsen diagnostics.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from . import BangOlufsenConfigEntry +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: BangOlufsenConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + data: dict = { + "config_entry": config_entry.as_dict(), + "websocket_connected": config_entry.runtime_data.client.websocket_connected, + } + + if TYPE_CHECKING: + assert config_entry.unique_id + + # Add media_player entity's state + entity_registry = er.async_get(hass) + if entity_id := entity_registry.async_get_entity_id( + MEDIA_PLAYER_DOMAIN, DOMAIN, config_entry.unique_id + ): + if state := hass.states.get(entity_id): + state_dict = dict(state.as_dict()) + + # Remove context as it is not relevant + state_dict.pop("context") + data["media_player"] = state_dict + + return data diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py new file mode 100644 index 00000000000..80ad4060c5e --- /dev/null +++ b/homeassistant/components/bang_olufsen/event.py @@ -0,0 +1,76 @@ +"""Event entities for the Bang & Olufsen integration.""" + +from __future__ import annotations + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BangOlufsenConfigEntry +from .const import ( + CONNECTION_STATUS, + DEVICE_BUTTON_EVENTS, + DEVICE_BUTTONS, + MODEL_SUPPORT_DEVICE_BUTTONS, + MODEL_SUPPORT_MAP, + WebsocketNotification, +) +from .entity import BangOlufsenEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BangOlufsenConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Sensor entities from config entry.""" + + if config_entry.data[CONF_MODEL] in MODEL_SUPPORT_MAP[MODEL_SUPPORT_DEVICE_BUTTONS]: + async_add_entities( + BangOlufsenButtonEvent(config_entry, button_type) + for button_type in DEVICE_BUTTONS + ) + + +class BangOlufsenButtonEvent(BangOlufsenEntity, EventEntity): + """Event class for Button events.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_entity_registry_enabled_default = False + _attr_event_types = DEVICE_BUTTON_EVENTS + + def __init__(self, config_entry: BangOlufsenConfigEntry, button_type: str) -> None: + """Initialize Button.""" + super().__init__(config_entry, config_entry.runtime_data.client) + + self._attr_unique_id = f"{self._unique_id}_{button_type}" + + # Make the native button name Home Assistant compatible + self._attr_translation_key = button_type.lower() + + self._button_type = button_type + + async def async_added_to_hass(self) -> None: + """Listen to WebSocket button events.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{CONNECTION_STATUS}", + self._async_update_connection_state, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WebsocketNotification.BUTTON}_{self._button_type}", + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self, event: str) -> None: + """Handle event.""" + self._trigger_event(event) + self.async_write_ha_state() diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json index 1565c98e979..b29fe9731de 100644 --- a/homeassistant/components/bang_olufsen/manifest.json +++ b/homeassistant/components/bang_olufsen/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bang_olufsen", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mozart-api==4.1.1.116.3"], + "requirements": ["mozart-api==4.1.1.116.4"], "zeroconf": ["_bangolufsen._tcp.local."] } diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 96e7cca0175..282ecdd2ae5 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -74,6 +74,8 @@ from .const import ( BANG_OLUFSEN_REPEAT_FROM_HA, BANG_OLUFSEN_REPEAT_TO_HA, BANG_OLUFSEN_STATES, + BEOLINK_JOIN_SOURCES, + BEOLINK_JOIN_SOURCES_TO_UPPER, CONF_BEOLINK_JID, CONNECTION_STATUS, DOMAIN, @@ -135,7 +137,10 @@ async def async_setup_entry( platform.async_register_entity_service( name="beolink_join", - schema={vol.Optional("beolink_jid"): jid_regex}, + schema={ + vol.Optional("beolink_jid"): jid_regex, + vol.Optional("source_id"): vol.In(BEOLINK_JOIN_SOURCES), + }, func="async_beolink_join", ) @@ -985,12 +990,23 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): await self.async_beolink_leave() # Custom actions: - async def async_beolink_join(self, beolink_jid: str | None = None) -> None: + async def async_beolink_join( + self, beolink_jid: str | None = None, source_id: str | None = None + ) -> None: """Join a Beolink multi-room experience.""" + # Touch to join if beolink_jid is None: await self._client.join_latest_beolink_experience() - else: + # Join a peer + elif beolink_jid and source_id is None: await self._client.join_beolink_peer(jid=beolink_jid) + # Join a peer and select specific source + elif beolink_jid and source_id: + # Beolink Converter NL/ML sources need to be in upper case + if source_id in BEOLINK_JOIN_SOURCES_TO_UPPER: + source_id = source_id.upper() + + await self._client.join_beolink_peer(jid=beolink_jid, source=source_id) async def async_beolink_expand( self, beolink_jids: list[str] | None = None, all_discovered: bool = False diff --git a/homeassistant/components/bang_olufsen/services.yaml b/homeassistant/components/bang_olufsen/services.yaml index e5d61420dff..7c3a2d659bd 100644 --- a/homeassistant/components/bang_olufsen/services.yaml +++ b/homeassistant/components/bang_olufsen/services.yaml @@ -48,6 +48,23 @@ beolink_join: example: 1111.2222222.33333333@products.bang-olufsen.com selector: text: + source_id: + required: false + example: tidal + selector: + select: + translation_key: "source_ids" + options: + - beoradio + - deezer + - spotify + - tidal + - radio + - tp1 + - tp2 + - cd + - aux_a + - ph beolink_leave: target: diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 6e75d2f26c8..57ab828f9fb 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -1,7 +1,12 @@ { "common": { + "jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity.", "jid_options_name": "JID options", - "jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity." + "long_press_release": "Release of long press", + "long_press_timeout": "Long press", + "short_press_release": "Release of short press", + "very_long_press_release": "Release of very long press", + "very_long_press_timeout": "Very long press" }, "config": { "error": { @@ -29,6 +34,166 @@ } } }, + "entity": { + "event": { + "bluetooth": { + "name": "Bluetooth", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "microphone": { + "name": "Microphone", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "next": { + "name": "Next", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "playpause": { + "name": "Play / Pause", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "preset1": { + "name": "Favourite 1", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "preset2": { + "name": "Favourite 2", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "preset3": { + "name": "Favourite 3", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "preset4": { + "name": "Favourite 4", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "previous": { + "name": "Previous", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + }, + "volume": { + "name": "Volume", + "state_attributes": { + "event_type": { + "state": { + "short_press_release": "[%key:component::bang_olufsen::common::short_press_release%]", + "long_press_timeout": "[%key:component::bang_olufsen::common::long_press_timeout%]", + "long_press_release": "[%key:component::bang_olufsen::common::long_press_release%]", + "very_long_press_timeout": "[%key:component::bang_olufsen::common::very_long_press_timeout%]", + "very_long_press_release": "[%key:component::bang_olufsen::common::very_long_press_release%]" + } + } + } + } + } + }, + "selector": { + "source_ids": { + "options": { + "beoradio": "ASE Beoradio", + "deezer": "ASE / Mozart Deezer", + "spotify": "ASE / Mozart Spotify", + "tidal": "Mozart Tidal", + "aux_a": "Beolink Converter NL/ML AUX_A", + "cd": "Beolink Converter NL/ML CD", + "ph": "Beolink Converter NL/ML PH", + "radio": "Beolink Converter NL/ML RADIO", + "tp1": "Beolink Converter NL/ML TP1", + "tp2": "Beolink Converter NL/ML TP2" + } + } + }, "services": { "beolink_allstandby": { "name": "Beolink all standby", @@ -61,6 +226,10 @@ "beolink_jid": { "name": "Beolink JID", "description": "Manually specify Beolink JID to join." + }, + "source_id": { + "name": "Source", + "description": "Specify which source to join, behavior varies between hardware platforms. Source names prefaced by a platform name can only be used when connecting to that platform. For example \"ASE Beoradio\" can only be used when joining an ASE device, while ”ASE / Mozart Deezer” can be used with ASE or Mozart devices. A defined Beolink JID is required." } }, "sections": { diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index ff3ad849e92..a6ae0358842 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -3,8 +3,10 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING from mozart_api.models import ( + ButtonEvent, ListeningModeProps, PlaybackContentMetadata, PlaybackError, @@ -26,6 +28,7 @@ from homeassistant.util.enum import try_parse_enum from .const import ( BANG_OLUFSEN_WEBSOCKET_EVENT, CONNECTION_STATUS, + EVENT_TRANSLATION_MAP, WebsocketNotification, ) from .entity import BangOlufsenBase @@ -54,6 +57,8 @@ class BangOlufsenWebsocket(BangOlufsenBase): self._client.get_active_listening_mode_notifications( self.on_active_listening_mode ) + self._client.get_button_notifications(self.on_button_notification) + self._client.get_playback_error_notifications( self.on_playback_error_notification ) @@ -104,6 +109,19 @@ class BangOlufsenWebsocket(BangOlufsenBase): notification, ) + def on_button_notification(self, notification: ButtonEvent) -> None: + """Send button dispatch.""" + # State is expected to always be available. + if TYPE_CHECKING: + assert notification.state + + # Send to event entity + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebsocketNotification.BUTTON}_{notification.button}", + EVENT_TRANSLATION_MAP[notification.state], + ) + def on_notification_notification( self, notification: WebsocketNotificationTag ) -> None: @@ -204,13 +222,11 @@ class BangOlufsenWebsocket(BangOlufsenBase): def on_all_notifications_raw(self, notification: BaseWebSocketResponse) -> None: """Receive all notifications.""" + debug_notification = { + "device_id": self._device.id, + "serial_number": int(self._unique_id), + **notification, + } - _LOGGER.debug("%s", notification) - self.hass.bus.async_fire( - BANG_OLUFSEN_WEBSOCKET_EVENT, - { - "device_id": self._device.id, - "serial_number": int(self._unique_id), - **notification, - }, - ) + _LOGGER.debug("%s", debug_notification) + self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, debug_notification) diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 6d203c344f2..74e3db34b68 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -131,7 +131,10 @@ def _no_overlapping(configs: list[dict]) -> list[dict]: for i, tup in enumerate(intervals): if len(intervals) > i + 1 and tup.below > intervals[i + 1].above: raise vol.Invalid( - f"Ranges for bayesian numeric state entities must not overlap, but {ent_id} has overlapping ranges, above:{tup.above}, below:{tup.below} overlaps with above:{intervals[i+1].above}, below:{intervals[i+1].below}." + "Ranges for bayesian numeric state entities must not overlap, " + f"but {ent_id} has overlapping ranges, above:{tup.above}, " + f"below:{tup.below} overlaps with above:{intervals[i + 1].above}, " + f"below:{intervals[i + 1].below}." ) return configs @@ -206,7 +209,10 @@ async def async_setup_platform( broken_observations: list[dict[str, Any]] = [] for observation in observations: if CONF_P_GIVEN_F not in observation: - text: str = f"{name}/{observation.get(CONF_ENTITY_ID,'')}{observation.get(CONF_VALUE_TEMPLATE,'')}" + text = ( + f"{name}/{observation.get(CONF_ENTITY_ID, '')}" + f"{observation.get(CONF_VALUE_TEMPLATE, '')}" + ) raise_no_prob_given_false(hass, text) _LOGGER.error("Missing prob_given_false YAML entry for %s", text) broken_observations.append(observation) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index baf6bf98547..7b0c121ac6b 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -4,11 +4,10 @@ from __future__ import annotations from datetime import timedelta from enum import StrEnum -from functools import partial import logging from typing import Literal, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -16,12 +15,6 @@ from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -126,94 +119,7 @@ class BinarySensorDeviceClass(StrEnum): DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(BinarySensorDeviceClass)) - -# DEVICE_CLASS* below are deprecated as of 2021.12 -# use the BinarySensorDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in BinarySensorDeviceClass] -_DEPRECATED_DEVICE_CLASS_BATTERY = DeprecatedConstantEnum( - BinarySensorDeviceClass.BATTERY, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_BATTERY_CHARGING = DeprecatedConstantEnum( - BinarySensorDeviceClass.BATTERY_CHARGING, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_CO = DeprecatedConstantEnum( - BinarySensorDeviceClass.CO, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_COLD = DeprecatedConstantEnum( - BinarySensorDeviceClass.COLD, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_CONNECTIVITY = DeprecatedConstantEnum( - BinarySensorDeviceClass.CONNECTIVITY, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum( - BinarySensorDeviceClass.DOOR, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_GARAGE_DOOR = DeprecatedConstantEnum( - BinarySensorDeviceClass.GARAGE_DOOR, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_GAS = DeprecatedConstantEnum( - BinarySensorDeviceClass.GAS, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_HEAT = DeprecatedConstantEnum( - BinarySensorDeviceClass.HEAT, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_LIGHT = DeprecatedConstantEnum( - BinarySensorDeviceClass.LIGHT, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_LOCK = DeprecatedConstantEnum( - BinarySensorDeviceClass.LOCK, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_MOISTURE = DeprecatedConstantEnum( - BinarySensorDeviceClass.MOISTURE, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_MOTION = DeprecatedConstantEnum( - BinarySensorDeviceClass.MOTION, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_MOVING = DeprecatedConstantEnum( - BinarySensorDeviceClass.MOVING, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_OCCUPANCY = DeprecatedConstantEnum( - BinarySensorDeviceClass.OCCUPANCY, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_OPENING = DeprecatedConstantEnum( - BinarySensorDeviceClass.OPENING, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_PLUG = DeprecatedConstantEnum( - BinarySensorDeviceClass.PLUG, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_POWER = DeprecatedConstantEnum( - BinarySensorDeviceClass.POWER, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_PRESENCE = DeprecatedConstantEnum( - BinarySensorDeviceClass.PRESENCE, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_PROBLEM = DeprecatedConstantEnum( - BinarySensorDeviceClass.PROBLEM, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_RUNNING = DeprecatedConstantEnum( - BinarySensorDeviceClass.RUNNING, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_SAFETY = DeprecatedConstantEnum( - BinarySensorDeviceClass.SAFETY, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_SMOKE = DeprecatedConstantEnum( - BinarySensorDeviceClass.SMOKE, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_SOUND = DeprecatedConstantEnum( - BinarySensorDeviceClass.SOUND, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_TAMPER = DeprecatedConstantEnum( - BinarySensorDeviceClass.TAMPER, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_UPDATE = DeprecatedConstantEnum( - BinarySensorDeviceClass.UPDATE, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_VIBRATION = DeprecatedConstantEnum( - BinarySensorDeviceClass.VIBRATION, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( - BinarySensorDeviceClass.WINDOW, "2025.1" -) # mypy: disallow-any-generics @@ -294,11 +200,3 @@ class BinarySensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) if (is_on := self.is_on) is None: return None return STATE_ON if is_on else STATE_OFF - - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index e04503974b7..2c528d50e3e 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -57,7 +57,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False @property def hvac_modes(self): diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index 2221e35a81f..523b5af793f 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -15,10 +15,10 @@ from blebox_uniapi.error import ( from blebox_uniapi.session import ApiHost import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import get_maybe_authenticated_session from .const import ( @@ -84,7 +84,7 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" hass = self.hass diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index 33fff1d71da..c3c9de8be51 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -11,7 +11,7 @@ from blebox_uniapi.light import BleboxColorMode from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, @@ -22,6 +22,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from . import BleBoxConfigEntry from .entity import BleBoxEntity @@ -58,8 +59,8 @@ COLOR_MODE_MAP = { class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): """Representation of BleBox lights.""" - _attr_max_mireds = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds - _attr_min_mireds = 154 # 1,000,000 divided by 6500 Kelvin = 154 Mireds + _attr_min_color_temp_kelvin = 2700 # 370 Mireds + _attr_max_color_temp_kelvin = 6500 # 154 Mireds def __init__(self, feature: blebox_uniapi.light.Light) -> None: """Initialize a BleBox light.""" @@ -78,9 +79,9 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): return self._feature.brightness @property - def color_temp(self): - """Return color temperature.""" - return self._feature.color_temp + def color_temp_kelvin(self) -> int: + """Return the color temperature value in Kelvin.""" + return color_util.color_temperature_mired_to_kelvin(self._feature.color_temp) @property def color_mode(self): @@ -136,7 +137,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): rgbw = kwargs.get(ATTR_RGBW_COLOR) brightness = kwargs.get(ATTR_BRIGHTNESS) effect = kwargs.get(ATTR_EFFECT) - color_temp = kwargs.get(ATTR_COLOR_TEMP) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) rgbww = kwargs.get(ATTR_RGBWW_COLOR) feature = self._feature value = feature.sensible_on_value @@ -144,9 +145,10 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): if rgbw is not None: value = list(rgbw) - if color_temp is not None: + if color_temp_kelvin is not None: value = feature.return_color_temp_with_brightness( - int(color_temp), self.brightness + int(color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)), + self.brightness, ) if rgbww is not None: @@ -158,9 +160,12 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): value = list(rgb) if brightness is not None: - if self.color_mode == ATTR_COLOR_TEMP: + if self.color_mode == ColorMode.COLOR_TEMP: value = feature.return_color_temp_with_brightness( - self.color_temp, brightness + color_util.color_temperature_kelvin_to_mired( + self.color_temp_kelvin + ), + brightness, ) else: value = feature.apply_brightness(value, brightness) diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 5f51598e721..dd5d1e37627 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -5,7 +5,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN +from homeassistant.const import CONF_PIN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -13,11 +13,6 @@ from homeassistant.helpers import config_validation as cv from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_SEND_PIN from .coordinator import BlinkConfigEntry -SERVICE_UPDATE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - } -) SERVICE_SEND_PIN_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]), diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 6e2384e5d5b..74f8ae1cb28 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -84,16 +84,16 @@ } }, "send_pin": { - "name": "Send pin", - "description": "Sends a new PIN to blink for 2FA.", + "name": "Send PIN", + "description": "Sends a new PIN to Blink for 2FA.", "fields": { "pin": { - "name": "Pin", - "description": "PIN received from blink. Leave empty if you only received a verification email." + "name": "PIN", + "description": "PIN received from Blink. Leave empty if you only received a verification email." }, "config_entry_id": { "name": "Integration ID", - "description": "The Blink Integration id." + "description": "The Blink Integration ID." } } } diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 3ba6349b714..0154c794c33 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -5,7 +5,7 @@ "data": { "api_token": "[%key:common::config_flow::data::api_token%]" }, - "description": "Enter your Blue Current api token", + "description": "Enter your Blue Current API token", "title": "Authentication" } }, @@ -19,7 +19,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "wrong_account": "Wrong account: Please authenticate with the api key for {email}." + "wrong_account": "Wrong account: Please authenticate with the API token for {email}." } }, "entity": { diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index c10da532324..8582761bafb 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -13,7 +13,7 @@ import yarl from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from .models import Blueprint from .schemas import BLUEPRINT_SCHEMA, is_blueprint_config @@ -115,7 +115,7 @@ def _extract_blueprint_from_community_topic( block_content = html.unescape(block_content.strip()) try: - data = yaml.parse_yaml(block_content) + data = yaml_util.parse_yaml(block_content) except HomeAssistantError: if block_syntax == "yaml": raise @@ -136,7 +136,7 @@ def _extract_blueprint_from_community_topic( ) return ImportedBlueprint( - f'{post["username"]}/{topic["slug"]}', block_content, blueprint + f"{post['username']}/{topic['slug']}", block_content, blueprint ) @@ -167,14 +167,13 @@ async def fetch_blueprint_from_github_url( resp = await session.get(import_url, raise_for_status=True) raw_yaml = await resp.text() - data = yaml.parse_yaml(raw_yaml) + data = yaml_util.parse_yaml(raw_yaml) assert isinstance(data, dict) blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA) parsed_import_url = yarl.URL(import_url) suggested_filename = f"{parsed_import_url.parts[1]}/{parsed_import_url.parts[-1]}" - if suggested_filename.endswith(".yaml"): - suggested_filename = suggested_filename[:-5] + suggested_filename = suggested_filename.removesuffix(".yaml") return ImportedBlueprint(suggested_filename, raw_yaml, blueprint) @@ -205,7 +204,7 @@ async def fetch_blueprint_from_github_gist_url( continue content = info["content"] - data = yaml.parse_yaml(content) + data = yaml_util.parse_yaml(content) if not is_blueprint_config(data): continue @@ -236,7 +235,7 @@ async def fetch_blueprint_from_website_url( resp = await session.get(url, raise_for_status=True) raw_yaml = await resp.text() - data = yaml.parse_yaml(raw_yaml) + data = yaml_util.parse_yaml(raw_yaml) assert isinstance(data, dict) blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA) @@ -253,7 +252,7 @@ async def fetch_blueprint_from_generic_url( resp = await session.get(url, raise_for_status=True) raw_yaml = await resp.text() - data = yaml.parse_yaml(raw_yaml) + data = yaml_util.parse_yaml(raw_yaml) assert isinstance(data, dict) blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA) diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index f32c3f04989..88052100259 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from .const import ( BLUEPRINT_FOLDER, @@ -79,7 +79,7 @@ class Blueprint: self.domain = data_domain - missing = yaml.extract_inputs(data) - set(self.inputs) + missing = yaml_util.extract_inputs(data) - set(self.inputs) if missing: raise InvalidBlueprint( @@ -117,7 +117,7 @@ class Blueprint: def yaml(self) -> str: """Dump blueprint as YAML.""" - return yaml.dump(self.data) + return yaml_util.dump(self.data) @callback def validate(self) -> list[str] | None: @@ -179,7 +179,7 @@ class BlueprintInputs: @callback def async_substitute(self) -> dict: """Get the blueprint value with the inputs substituted.""" - processed = yaml.substitute(self.blueprint.data, self.inputs_with_default) + processed = yaml_util.substitute(self.blueprint.data, self.inputs_with_default) combined = {**processed, **self.config_with_inputs} # From config_with_inputs combined.pop(CONF_USE_BLUEPRINT) @@ -225,7 +225,9 @@ class DomainBlueprints: def _load_blueprint(self, blueprint_path: str) -> Blueprint: """Load a blueprint.""" try: - blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path) + blueprint_data = yaml_util.load_yaml_dict( + self.blueprint_folder / blueprint_path + ) except FileNotFoundError as err: raise FailedToLoad( self.domain, diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 3be925c7c8f..0743d027d8d 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -13,7 +13,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from . import importer, models from .const import DOMAIN @@ -174,7 +174,7 @@ async def ws_save_blueprint( domain = msg["domain"] try: - yaml_data = cast(dict[str, Any], yaml.parse_yaml(msg["yaml"])) + yaml_data = cast(dict[str, Any], yaml_util.parse_yaml(msg["yaml"])) blueprint = models.Blueprint( yaml_data, expected_domain=domain, schema=BLUEPRINT_SCHEMA ) @@ -263,7 +263,7 @@ async def ws_substitute_blueprint( try: config = blueprint_inputs.async_substitute() - except yaml.UndefinedSubstitution as err: + except yaml_util.UndefinedSubstitution as err: connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err)) return diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index 82fe9b00d57..6cf1957f799 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -14,11 +14,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .services import setup_services +from .coordinator import BluesoundCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [ + Platform.MEDIA_PLAYER, +] @dataclass @@ -27,6 +29,7 @@ class BluesoundRuntimeData: player: Player sync_status: SyncStatus + coordinator: BluesoundCoordinator type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] @@ -34,10 +37,6 @@ type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Bluesound.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = [] - setup_services(hass) - return True @@ -48,13 +47,16 @@ async def async_setup_entry( host = config_entry.data[CONF_HOST] port = config_entry.data[CONF_PORT] session = async_get_clientsession(hass) - async with Player(host, port, session=session, default_timeout=10) as player: - try: - sync_status = await player.sync_status(timeout=1) - except PlayerUnreachableError as ex: - raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex + player = Player(host, port, session=session, default_timeout=10) + try: + sync_status = await player.sync_status(timeout=1) + except PlayerUnreachableError as ex: + raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex - config_entry.runtime_data = BluesoundRuntimeData(player, sync_status) + coordinator = BluesoundCoordinator(hass, player, sync_status) + await coordinator.async_config_entry_first_refresh() + + config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py index 050b3ee4eac..2f002b70e1d 100644 --- a/homeassistant/components/bluesound/config_flow.py +++ b/homeassistant/components/bluesound/config_flow.py @@ -7,10 +7,10 @@ from pyblu import Player, SyncStatus from pyblu.errors import PlayerUnreachableError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .media_player import DEFAULT_PORT @@ -71,29 +71,8 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN): ), ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import bluesound config entry from configuration.yaml.""" - session = async_get_clientsession(self.hass) - async with Player( - import_data[CONF_HOST], import_data[CONF_PORT], session=session - ) as player: - try: - sync_status = await player.sync_status(timeout=1) - except PlayerUnreachableError: - return self.async_abort(reason="cannot_connect") - - await self.async_set_unique_id( - format_unique_id(sync_status.mac, import_data[CONF_PORT]) - ) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=sync_status.name, - data=import_data, - ) - async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" if discovery_info.port is not None: diff --git a/homeassistant/components/bluesound/coordinator.py b/homeassistant/components/bluesound/coordinator.py new file mode 100644 index 00000000000..e62f3ef96cf --- /dev/null +++ b/homeassistant/components/bluesound/coordinator.py @@ -0,0 +1,160 @@ +"""Define a base coordinator for Bluesound entities.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +import contextlib +from dataclasses import dataclass, replace +from datetime import timedelta +import logging + +from pyblu import Input, Player, Preset, Status, SyncStatus +from pyblu.errors import PlayerUnreachableError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +NODE_OFFLINE_CHECK_TIMEOUT = timedelta(minutes=3) +PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15) + + +@dataclass +class BluesoundData: + """Define a class to hold Bluesound data.""" + + sync_status: SyncStatus + status: Status + presets: list[Preset] + inputs: list[Input] + + +def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]: + """Cancel a task.""" + + async def _cancel_task() -> None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + return _cancel_task + + +class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]): + """Define an object to hold Bluesound data.""" + + def __init__( + self, hass: HomeAssistant, player: Player, sync_status: SyncStatus + ) -> None: + """Initialize.""" + self.player = player + self._inital_sync_status = sync_status + + super().__init__( + hass, + logger=_LOGGER, + name=sync_status.name, + ) + + async def _async_setup(self) -> None: + assert self.config_entry is not None + + preset = await self.player.presets() + inputs = await self.player.inputs() + status = await self.player.status() + + self.async_set_updated_data( + BluesoundData( + sync_status=self._inital_sync_status, + status=status, + presets=preset, + inputs=inputs, + ) + ) + + status_loop_task = self.hass.async_create_background_task( + self._poll_status_loop(), + name=f"bluesound.poll_status_loop_{self.data.sync_status.id}", + ) + self.config_entry.async_on_unload(cancel_task(status_loop_task)) + + sync_status_loop_task = self.hass.async_create_background_task( + self._poll_sync_status_loop(), + name=f"bluesound.poll_sync_status_loop_{self.data.sync_status.id}", + ) + self.config_entry.async_on_unload(cancel_task(sync_status_loop_task)) + + presets_and_inputs_loop_task = self.hass.async_create_background_task( + self._poll_presets_and_inputs_loop(), + name=f"bluesound.poll_presets_and_inputs_loop_{self.data.sync_status.id}", + ) + self.config_entry.async_on_unload(cancel_task(presets_and_inputs_loop_task)) + + async def _async_update_data(self) -> BluesoundData: + return self.data + + async def _poll_presets_and_inputs_loop(self) -> None: + while True: + await asyncio.sleep(PRESET_AND_INPUTS_INTERVAL.total_seconds()) + try: + preset = await self.player.presets() + inputs = await self.player.inputs() + self.async_set_updated_data( + replace( + self.data, + presets=preset, + inputs=inputs, + ) + ) + except PlayerUnreachableError as ex: + self.async_set_update_error(ex) + except asyncio.CancelledError: + return + except Exception as ex: # noqa: BLE001 - this loop should never stop + self.async_set_update_error(ex) + + async def _poll_status_loop(self) -> None: + """Loop which polls the status of the player.""" + while True: + try: + status = await self.player.status( + etag=self.data.status.etag, poll_timeout=120, timeout=125 + ) + self.async_set_updated_data( + replace( + self.data, + status=status, + ) + ) + except PlayerUnreachableError as ex: + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) + except asyncio.CancelledError: + return + except Exception as ex: # noqa: BLE001 - this loop should never stop + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) + + async def _poll_sync_status_loop(self) -> None: + """Loop which polls the sync status of the player.""" + while True: + try: + sync_status = await self.player.sync_status( + etag=self.data.sync_status.etag, poll_timeout=120, timeout=125 + ) + self.async_set_updated_data( + replace( + self.data, + sync_status=sync_status, + ) + ) + except PlayerUnreachableError as ex: + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) + except asyncio.CancelledError: + raise + except Exception as ex: # noqa: BLE001 - this loop should never stop + self.async_set_update_error(ex) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds()) diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 462112a8b78..151c1512b74 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bluesound", "iot_class": "local_polling", - "requirements": ["pyblu==1.0.4"], + "requirements": ["pyblu==2.0.0"], "zeroconf": [ { "type": "_musc._tcp.local." diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 97985a74300..12e2f537935 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -2,20 +2,16 @@ from __future__ import annotations -import asyncio -from asyncio import CancelledError, Task -from contextlib import suppress +from asyncio import Task from datetime import datetime, timedelta import logging from typing import TYPE_CHECKING, Any from pyblu import Input, Player, Preset, Status, SyncStatus -from pyblu.errors import PlayerUnreachableError import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( - PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA, BrowseMedia, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -23,23 +19,26 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, format_mac, ) +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util -from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN, INTEGRATION_TITLE -from .utils import format_unique_id +from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN +from .coordinator import BluesoundCoordinator +from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id if TYPE_CHECKING: from . import BluesoundConfigEntry @@ -51,71 +50,13 @@ SCAN_INTERVAL = timedelta(minutes=15) DATA_BLUESOUND = DOMAIN DEFAULT_PORT = 11000 -NODE_OFFLINE_CHECK_TIMEOUT = 180 -NODE_RETRY_INITIATION = timedelta(minutes=3) - -SYNC_STATUS_INTERVAL = timedelta(minutes=5) +SERVICE_CLEAR_TIMER = "clear_sleep_timer" +SERVICE_JOIN = "join" +SERVICE_SET_TIMER = "set_sleep_timer" +SERVICE_UNJOIN = "unjoin" POLL_TIMEOUT = 120 -PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOSTS): vol.All( - cv.ensure_list, - [ - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } - ], - ) - } -) - - -async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: - """Import config entry from configuration.yaml.""" - if not hass.config_entries.async_entries(DOMAIN): - # Start import flow - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - if ( - result["type"] == FlowResultType.ABORT - and result["reason"] == "cannot_connect" - ): - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - async def async_setup_entry( hass: HomeAssistant, @@ -124,33 +65,28 @@ async def async_setup_entry( ) -> None: """Set up the Bluesound entry.""" bluesound_player = BluesoundPlayer( + config_entry.runtime_data.coordinator, config_entry.data[CONF_HOST], config_entry.data[CONF_PORT], config_entry.runtime_data.player, - config_entry.runtime_data.sync_status, ) - hass.data[DATA_BLUESOUND].append(bluesound_player) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_SET_TIMER, None, "async_increase_timer" + ) + platform.async_register_entity_service( + SERVICE_CLEAR_TIMER, None, "async_clear_timer" + ) + platform.async_register_entity_service( + SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join" + ) + platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin") + async_add_entities([bluesound_player], update_before_add=True) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None, -) -> None: - """Trigger import flows.""" - hosts = config.get(CONF_HOSTS, []) - for host in hosts: - import_data = { - CONF_HOST: host[CONF_HOST], - CONF_PORT: host.get(CONF_PORT, 11000), - } - hass.async_create_task(_async_import(hass, import_data)) - - -class BluesoundPlayer(MediaPlayerEntity): +class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity): """Representation of a Bluesound Player.""" _attr_media_content_type = MediaType.MUSIC @@ -159,12 +95,15 @@ class BluesoundPlayer(MediaPlayerEntity): def __init__( self, + coordinator: BluesoundCoordinator, host: str, port: int, player: Player, - sync_status: SyncStatus, ) -> None: """Initialize the media player.""" + super().__init__(coordinator) + sync_status = coordinator.data.sync_status + self.host = host self.port = port self._poll_status_loop_task: Task[None] | None = None @@ -172,16 +111,14 @@ class BluesoundPlayer(MediaPlayerEntity): self._id = sync_status.id self._last_status_update: datetime | None = None self._sync_status = sync_status - self._status: Status | None = None - self._inputs: list[Input] = [] - self._presets: list[Preset] = [] - self._muted = False - self._master: BluesoundPlayer | None = None - self._is_master = False + self._status: Status = coordinator.data.status + self._inputs: list[Input] = coordinator.data.inputs + self._presets: list[Preset] = coordinator.data.presets self._group_name: str | None = None self._group_list: list[str] = [] self._bluesound_device_name = sync_status.name self._player = player + self._last_status_update = dt_util.utcnow() self._attr_unique_id = format_unique_id(sync_status.mac, port) # there should always be one player with the default port per mac @@ -204,164 +141,51 @@ class BluesoundPlayer(MediaPlayerEntity): via_device=(DOMAIN, format_mac(sync_status.mac)), ) - async def _poll_status_loop(self) -> None: - """Loop which polls the status of the player.""" - while True: - try: - await self.async_update_status() - except PlayerUnreachableError: - _LOGGER.error( - "Node %s:%s is offline, retrying later", self.host, self.port - ) - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - except CancelledError: - _LOGGER.debug( - "Stopping the polling of node %s:%s", self.host, self.port - ) - return - except: # noqa: E722 - this loop should never stop - _LOGGER.exception( - "Unexpected error for %s:%s, retrying later", self.host, self.port - ) - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - - async def _poll_sync_status_loop(self) -> None: - """Loop which polls the sync status of the player.""" - while True: - try: - await self.update_sync_status() - except PlayerUnreachableError: - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - except CancelledError: - raise - except: # noqa: E722 - all errors must be caught for this loop - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - async def async_added_to_hass(self) -> None: """Start the polling task.""" await super().async_added_to_hass() - self._poll_status_loop_task = self.hass.async_create_background_task( - self._poll_status_loop(), - name=f"bluesound.poll_status_loop_{self.host}:{self.port}", + assert self._sync_status.id is not None + self.async_on_remove( + async_dispatcher_connect( + self.hass, + dispatcher_join_signal(self.entity_id), + self.async_add_follower, + ) ) - self._poll_sync_status_loop_task = self.hass.async_create_background_task( - self._poll_sync_status_loop(), - name=f"bluesound.poll_sync_status_loop_{self.host}:{self.port}", + self.async_on_remove( + async_dispatcher_connect( + self.hass, + dispatcher_unjoin_signal(self._sync_status.id), + self.async_remove_follower, + ) ) async def async_will_remove_from_hass(self) -> None: """Stop the polling task.""" await super().async_will_remove_from_hass() - assert self._poll_status_loop_task is not None - if self._poll_status_loop_task.cancel(): - # the sleeps in _poll_loop will raise CancelledError - with suppress(CancelledError): - await self._poll_status_loop_task + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._sync_status = self.coordinator.data.sync_status + self._status = self.coordinator.data.status + self._inputs = self.coordinator.data.inputs + self._presets = self.coordinator.data.presets - assert self._poll_sync_status_loop_task is not None - if self._poll_sync_status_loop_task.cancel(): - # the sleeps in _poll_sync_status_loop will raise CancelledError - with suppress(CancelledError): - await self._poll_sync_status_loop_task + self._last_status_update = dt_util.utcnow() - self.hass.data[DATA_BLUESOUND].remove(self) - - async def async_update(self) -> None: - """Update internal status of the entity.""" - if not self.available: - return - - with suppress(PlayerUnreachableError): - await self.async_update_presets() - await self.async_update_captures() - - async def async_update_status(self) -> None: - """Use the poll session to always get the status of the player.""" - etag = None - if self._status is not None: - etag = self._status.etag - - try: - status = await self._player.status( - etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5 - ) - - self._attr_available = True - self._last_status_update = dt_util.utcnow() - self._status = status - - group_name = status.group_name - if group_name != self._group_name: - _LOGGER.debug("Group name change detected on device: %s", self.id) - self._group_name = group_name - - # rebuild ordered list of entity_ids that are in the group, master is first - self._group_list = self.rebuild_bluesound_group() - - self.async_write_ha_state() - except PlayerUnreachableError: - self._attr_available = False - self._last_status_update = None - self._status = None - self.async_write_ha_state() - _LOGGER.error( - "Client connection error, marking %s as offline", - self._bluesound_device_name, - ) - raise - - async def update_sync_status(self) -> None: - """Update the internal status.""" - etag = None - if self._sync_status: - etag = self._sync_status.etag - sync_status = await self._player.sync_status( - etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5 - ) - - self._sync_status = sync_status - - if sync_status.master is not None: - self._is_master = False - master_id = f"{sync_status.master.ip}:{sync_status.master.port}" - master_device = [ - device - for device in self.hass.data[DATA_BLUESOUND] - if device.id == master_id - ] - - if master_device and master_id != self.id: - self._master = master_device[0] - else: - self._master = None - _LOGGER.error("Master not found %s", master_id) - else: - if self._master is not None: - self._master = None - slaves = self._sync_status.slaves - self._is_master = slaves is not None + self._group_list = self.rebuild_bluesound_group() self.async_write_ha_state() - async def async_update_captures(self) -> None: - """Update Capture sources.""" - inputs = await self._player.inputs() - self._inputs = inputs - - async def async_update_presets(self) -> None: - """Update Presets.""" - presets = await self._player.presets() - self._presets = presets - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" - if self._status is None: + if self.available is False: return MediaPlayerState.OFF - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return MediaPlayerState.IDLE match self._status.state: @@ -375,7 +199,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_title(self) -> str | None: """Title of current playing media.""" - if self._status is None or (self.is_grouped and not self.is_master): + if self.available is False or (self.is_grouped and not self.is_leader): return None return self._status.name @@ -383,10 +207,10 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_artist(self) -> str | None: """Artist of current playing media (Music track only).""" - if self._status is None: + if self.available is False: return None - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return self._group_name return self._status.artist @@ -394,7 +218,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_album_name(self) -> str | None: """Artist of current playing media (Music track only).""" - if self._status is None or (self.is_grouped and not self.is_master): + if self.available is False or (self.is_grouped and not self.is_leader): return None return self._status.album @@ -402,7 +226,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_image_url(self) -> str | None: """Image url of current playing media.""" - if self._status is None or (self.is_grouped and not self.is_master): + if self.available is False or (self.is_grouped and not self.is_leader): return None url = self._status.image @@ -417,7 +241,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_position(self) -> int | None: """Position of current playing media in seconds.""" - if self._status is None or (self.is_grouped and not self.is_master): + if self.available is False or (self.is_grouped and not self.is_leader): return None mediastate = self.state @@ -436,7 +260,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" - if self._status is None or (self.is_grouped and not self.is_master): + if self.available is False or (self.is_grouped and not self.is_leader): return None duration = self._status.total_seconds @@ -453,16 +277,11 @@ class BluesoundPlayer(MediaPlayerEntity): @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - volume = None + volume = self._status.volume - if self._status is not None: - volume = self._status.volume if self.is_grouped: volume = self._sync_status.volume - if volume is None: - return None - return volume / 100 @property @@ -495,7 +314,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def source_list(self) -> list[str] | None: """List of available input sources.""" - if self._status is None or (self.is_grouped and not self.is_master): + if self.available is False or (self.is_grouped and not self.is_leader): return None sources = [x.text for x in self._inputs] @@ -506,7 +325,7 @@ class BluesoundPlayer(MediaPlayerEntity): @property def source(self) -> str | None: """Name of the current input source.""" - if self._status is None or (self.is_grouped and not self.is_master): + if self.available is False or (self.is_grouped and not self.is_leader): return None if self._status.input_id is not None: @@ -523,10 +342,10 @@ class BluesoundPlayer(MediaPlayerEntity): @property def supported_features(self) -> MediaPlayerEntityFeature: """Flag of media commands that are supported.""" - if self._status is None: + if self.available is False: return MediaPlayerEntityFeature(0) - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return ( MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_SET @@ -566,14 +385,17 @@ class BluesoundPlayer(MediaPlayerEntity): return supported @property - def is_master(self) -> bool: - """Return true if player is a coordinator.""" - return self._is_master + def is_leader(self) -> bool: + """Return true if player is leader of a group.""" + return self._sync_status.followers is not None @property def is_grouped(self) -> bool: - """Return true if player is a coordinator.""" - return self._master is not None or self._is_master + """Return true if player is member or leader of a group.""" + return ( + self._sync_status.followers is not None + or self._sync_status.leader is not None + ) @property def shuffle(self) -> bool: @@ -586,25 +408,25 @@ class BluesoundPlayer(MediaPlayerEntity): async def async_join(self, master: str) -> None: """Join the player to a group.""" - master_device = [ - device - for device in self.hass.data[DATA_BLUESOUND] - if device.entity_id == master - ] + if master == self.entity_id: + raise ServiceValidationError("Cannot join player to itself") - if len(master_device) > 0: - if self.id == master_device[0].id: - raise ServiceValidationError("Cannot join player to itself") + _LOGGER.debug("Trying to join player: %s", self.id) + async_dispatcher_send( + self.hass, dispatcher_join_signal(master), self.host, self.port + ) - _LOGGER.debug( - "Trying to join player: %s to master: %s", - self.id, - master_device[0].id, - ) + async def async_unjoin(self) -> None: + """Unjoin the player from a group.""" + if self._sync_status.leader is None: + return - await master_device[0].async_add_slave(self) - else: - _LOGGER.error("Master not found %s", master_device) + leader_id = f"{self._sync_status.leader.ip}:{self._sync_status.leader.port}" + + _LOGGER.debug("Trying to unjoin player: %s", self.id) + async_dispatcher_send( + self.hass, dispatcher_unjoin_signal(leader_id), self.host, self.port + ) @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -613,43 +435,51 @@ class BluesoundPlayer(MediaPlayerEntity): if self._group_list: attributes = {ATTR_BLUESOUND_GROUP: self._group_list} - attributes[ATTR_MASTER] = self._is_master + attributes[ATTR_MASTER] = self.is_leader return attributes def rebuild_bluesound_group(self) -> list[str]: """Rebuild the list of entities in speaker group.""" - if self._group_name is None: + if self.sync_status.leader is None and self.sync_status.followers is None: return [] - device_group = self._group_name.split("+") - - sorted_entities: list[BluesoundPlayer] = sorted( - self.hass.data[DATA_BLUESOUND], - key=lambda entity: entity.is_master, - reverse=True, + config_entries: list[BluesoundConfigEntry] = ( + self.hass.config_entries.async_entries(DOMAIN) ) - return [ - entity.sync_status.name - for entity in sorted_entities - if entity.bluesound_device_name in device_group + sync_status_list = [ + x.runtime_data.coordinator.data.sync_status for x in config_entries ] - async def async_unjoin(self) -> None: - """Unjoin the player from a group.""" - if self._master is None: - return + leader_sync_status: SyncStatus | None = None + if self.sync_status.leader is None: + leader_sync_status = self.sync_status + else: + required_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}" + for sync_status in sync_status_list: + if sync_status.id == required_id: + leader_sync_status = sync_status + break - _LOGGER.debug("Trying to unjoin player: %s", self.id) - await self._master.async_remove_slave(self) + if leader_sync_status is None or leader_sync_status.followers is None: + return [] - async def async_add_slave(self, slave_device: BluesoundPlayer) -> None: - """Add slave to master.""" - await self._player.add_slave(slave_device.host, slave_device.port) + follower_ids = [f"{x.ip}:{x.port}" for x in leader_sync_status.followers] + follower_names = [ + sync_status.name + for sync_status in sync_status_list + if sync_status.id in follower_ids + ] + follower_names.insert(0, leader_sync_status.name) + return follower_names - async def async_remove_slave(self, slave_device: BluesoundPlayer) -> None: - """Remove slave to master.""" - await self._player.remove_slave(slave_device.host, slave_device.port) + async def async_add_follower(self, host: str, port: int) -> None: + """Add follower to leader.""" + await self._player.add_follower(host, port) + + async def async_remove_follower(self, host: str, port: int) -> None: + """Remove follower to leader.""" + await self._player.remove_follower(host, port) async def async_increase_timer(self) -> int: """Increase sleep time on player.""" @@ -667,7 +497,7 @@ class BluesoundPlayer(MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return # presets and inputs might have the same name; presets have priority @@ -686,49 +516,49 @@ class BluesoundPlayer(MediaPlayerEntity): async def async_clear_playlist(self) -> None: """Clear players playlist.""" - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return await self._player.clear() async def async_media_next_track(self) -> None: """Send media_next command to media player.""" - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return await self._player.skip() async def async_media_previous_track(self) -> None: """Send media_previous command to media player.""" - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return await self._player.back() async def async_media_play(self) -> None: """Send media_play command to media player.""" - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return await self._player.play() async def async_media_pause(self) -> None: """Send media_pause command to media player.""" - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return await self._player.pause() async def async_media_stop(self) -> None: """Send stop command.""" - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return await self._player.stop() async def async_media_seek(self, position: float) -> None: """Send media_seek command to media player.""" - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return await self._player.play(seek=int(position)) @@ -737,7 +567,7 @@ class BluesoundPlayer(MediaPlayerEntity): self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player.""" - if self.is_grouped and not self.is_master: + if self.is_grouped and not self.is_leader: return if media_source.is_media_source_id(media_id): diff --git a/homeassistant/components/bluesound/services.py b/homeassistant/components/bluesound/services.py deleted file mode 100644 index 06a507420f8..00000000000 --- a/homeassistant/components/bluesound/services.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Support for Bluesound devices.""" - -from __future__ import annotations - -from typing import NamedTuple - -import voluptuous as vol - -from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv - -from .const import ATTR_MASTER, DOMAIN - -SERVICE_CLEAR_TIMER = "clear_sleep_timer" -SERVICE_JOIN = "join" -SERVICE_SET_TIMER = "set_sleep_timer" -SERVICE_UNJOIN = "unjoin" - -BS_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) - -BS_JOIN_SCHEMA = BS_SCHEMA.extend({vol.Required(ATTR_MASTER): cv.entity_id}) - - -class ServiceMethodDetails(NamedTuple): - """Details for SERVICE_TO_METHOD mapping.""" - - method: str - schema: vol.Schema - - -SERVICE_TO_METHOD = { - SERVICE_JOIN: ServiceMethodDetails(method="async_join", schema=BS_JOIN_SCHEMA), - SERVICE_UNJOIN: ServiceMethodDetails(method="async_unjoin", schema=BS_SCHEMA), - SERVICE_SET_TIMER: ServiceMethodDetails( - method="async_increase_timer", schema=BS_SCHEMA - ), - SERVICE_CLEAR_TIMER: ServiceMethodDetails( - method="async_clear_timer", schema=BS_SCHEMA - ), -} - - -def setup_services(hass: HomeAssistant) -> None: - """Set up services for Bluesound component.""" - - async def async_service_handler(service: ServiceCall) -> None: - """Map services to method of Bluesound devices.""" - if not (method := SERVICE_TO_METHOD.get(service.service)): - return - - params = { - key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID - } - if entity_ids := service.data.get(ATTR_ENTITY_ID): - target_players = [ - player for player in hass.data[DOMAIN] if player.entity_id in entity_ids - ] - else: - target_players = hass.data[DOMAIN] - - for player in target_players: - await getattr(player, method.method)(**params) - - for service, method in SERVICE_TO_METHOD.items(): - hass.services.async_register( - DOMAIN, service, async_service_handler, schema=method.schema - ) diff --git a/homeassistant/components/bluesound/utils.py b/homeassistant/components/bluesound/utils.py index 89a6fd1e787..5df5b32de95 100644 --- a/homeassistant/components/bluesound/utils.py +++ b/homeassistant/components/bluesound/utils.py @@ -6,3 +6,16 @@ from homeassistant.helpers.device_registry import format_mac def format_unique_id(mac: str, port: int) -> str: """Generate a unique ID based on the MAC address and port number.""" return f"{format_mac(mac)}-{port}" + + +def dispatcher_join_signal(entity_id: str) -> str: + """Join an entity ID with a signal.""" + return f"bluesound_join_{entity_id}" + + +def dispatcher_unjoin_signal(leader_id: str) -> str: + """Unjoin an entity ID with a signal. + + Id is ip_address:port. This can be obtained from sync_status.id. + """ + return f"bluesound_unjoin_{leader_id}" diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 645adfdcd2d..5edec1ccc23 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -22,6 +22,7 @@ from bluetooth_adapters import ( adapter_model, adapter_unique_name, get_adapters, + get_manufacturer_from_mac, ) from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME from habluetooth import ( @@ -51,7 +52,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.loader import async_get_bluetooth -from . import passive_update_processor +from . import passive_update_processor, websocket_api from .api import ( _get_manager, async_address_present, @@ -66,6 +67,7 @@ from .api import ( async_rediscover_address, async_register_callback, async_register_scanner, + async_remove_scanner, async_scanner_by_source, async_scanner_count, async_scanner_devices_by_address, @@ -77,6 +79,9 @@ from .const import ( CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, DOMAIN, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, @@ -92,9 +97,24 @@ if TYPE_CHECKING: from homeassistant.helpers.typing import ConfigType __all__ = [ + "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", + "MONOTONIC_TIME", + "SOURCE_LOCAL", + "BaseHaRemoteScanner", + "BaseHaScanner", + "BluetoothCallback", + "BluetoothCallbackMatcher", + "BluetoothChange", + "BluetoothScannerDevice", + "BluetoothScanningMode", + "BluetoothServiceInfo", + "BluetoothServiceInfoBleak", + "HaBluetoothConnector", + "HomeAssistantRemoteScanner", "async_address_present", "async_ble_device_from_address", "async_discovered_service_info", + "async_get_advertisement_callback", "async_get_fallback_availability_interval", "async_get_learned_advertising_interval", "async_get_scanner", @@ -103,26 +123,12 @@ __all__ = [ "async_rediscover_address", "async_register_callback", "async_register_scanner", - "async_set_fallback_availability_interval", - "async_track_unavailable", + "async_remove_scanner", "async_scanner_by_source", "async_scanner_count", "async_scanner_devices_by_address", - "async_get_advertisement_callback", - "BaseHaScanner", - "HomeAssistantRemoteScanner", - "BluetoothCallbackMatcher", - "BluetoothChange", - "BluetoothServiceInfo", - "BluetoothServiceInfoBleak", - "BluetoothScanningMode", - "BluetoothCallback", - "BluetoothScannerDevice", - "HaBluetoothConnector", - "BaseHaRemoteScanner", - "SOURCE_LOCAL", - "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", - "MONOTONIC_TIME", + "async_set_fallback_availability_interval", + "async_track_unavailable", ] _LOGGER = logging.getLogger(__name__) @@ -232,6 +238,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: set_manager(manager) await storage_setup_task await manager.async_setup() + websocket_api.async_setup(hass) hass.async_create_background_task( _async_start_adapter_discovery(hass, manager, bluetooth_adapters), @@ -312,6 +319,38 @@ async def async_update_device( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for a bluetooth scanner.""" + if source_entry_id := entry.data.get(CONF_SOURCE_CONFIG_ENTRY_ID): + if not (source_entry := hass.config_entries.async_get_entry(source_entry_id)): + # Cleanup the orphaned entry using a call_soon to ensure + # we can return before the entry is removed + hass.loop.call_soon( + hass_callback( + lambda: hass.async_create_task( + hass.config_entries.async_remove(entry.entry_id), + "remove orphaned bluetooth entry {entry.entry_id}", + ) + ) + ) + address = entry.unique_id + assert address is not None + assert source_entry is not None + source_domain = entry.data[CONF_SOURCE_DOMAIN] + if mac_manufacturer := await get_manufacturer_from_mac(address): + manufacturer = f"{mac_manufacturer} ({source_domain})" + else: + manufacturer = source_domain + details = AdapterDetails( + address=address, + product=entry.data.get(CONF_SOURCE_MODEL), + manufacturer=manufacturer, + ) + await async_update_device( + hass, + entry, + source_entry.title, + details, + ) + return True manager = _get_manager(hass) address = entry.unique_id assert address is not None diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 7c3d1bc3620..03c278d6b0d 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -132,7 +132,7 @@ class ActiveBluetoothDataUpdateCoordinator[_T](PassiveBluetoothDataUpdateCoordin ) self.last_poll_successful = False return - except Exception: # noqa: BLE001 + except Exception: if self.last_poll_successful: self.logger.exception("%s: Failure while polling", self.address) self.last_poll_successful = False diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index e7b65067070..8a23de682e6 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -127,7 +127,7 @@ class ActiveBluetoothProcessorCoordinator[_DataT]( ) self.last_poll_successful = False return - except Exception: # noqa: BLE001 + except Exception: if self.last_poll_successful: self.logger.exception("%s: Failure while polling", self.address) self.last_poll_successful = False diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 505651edafd..9db570c4cba 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -178,9 +178,20 @@ def async_register_scanner( hass: HomeAssistant, scanner: BaseHaScanner, connection_slots: int | None = None, + source_domain: str | None = None, + source_model: str | None = None, + source_config_entry_id: str | None = None, ) -> CALLBACK_TYPE: """Register a BleakScanner.""" - return _get_manager(hass).async_register_scanner(scanner, connection_slots) + return _get_manager(hass).async_register_hass_scanner( + scanner, connection_slots, source_domain, source_model, source_config_entry_id + ) + + +@hass_callback +def async_remove_scanner(hass: HomeAssistant, source: str) -> None: + """Permanently remove a BleakScanner by source address.""" + return _get_manager(hass).async_remove_scanner(source) @hass_callback diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 37eefd2f265..5bfe5e7089c 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -18,7 +18,12 @@ from habluetooth import get_manager import voluptuous as vol from homeassistant.components import onboarding -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.core import callback from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, @@ -26,7 +31,16 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from homeassistant.helpers.typing import DiscoveryInfoType -from .const import CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, DOMAIN +from .const import ( + CONF_ADAPTER, + CONF_DETAILS, + CONF_PASSIVE, + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, + DOMAIN, +) from .util import adapter_title OPTIONS_SCHEMA = vol.Schema( @@ -63,6 +77,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: DiscoveryInfoType ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" + if discovery_info and CONF_SOURCE in discovery_info: + return await self.async_step_external_scanner(discovery_info) self._adapter = cast(str, discovery_info[CONF_ADAPTER]) self._details = cast(AdapterDetails, discovery_info[CONF_DETAILS]) await self.async_set_unique_id(self._details[ADAPTER_ADDRESS]) @@ -167,6 +183,24 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): ), ) + async def async_step_external_scanner( + self, user_input: dict[str, Any] + ) -> ConfigFlowResult: + """Handle a flow initialized by an external scanner.""" + source = user_input[CONF_SOURCE] + await self.async_set_unique_id(source) + data = { + CONF_SOURCE: source, + CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL], + CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN], + CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID], + } + self._abort_if_unique_id_configured(updates=data) + manager = get_manager() + scanner = manager.async_scanner_by_source(source) + assert scanner is not None + return self.async_create_entry(title=scanner.name, data=data) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -177,8 +211,10 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> SchemaOptionsFlowHandler: + ) -> SchemaOptionsFlowHandler | RemoteAdapterOptionsFlowHandler: """Get the options flow for this handler.""" + if CONF_SOURCE in config_entry.data: + return RemoteAdapterOptionsFlowHandler() return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) @classmethod @@ -186,3 +222,13 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: """Return options flow support for this handler.""" return bool((manager := get_manager()) and manager.supports_passive_scan) + + +class RemoteAdapterOptionsFlowHandler(OptionsFlow): + """Handle a option flow for remote adapters.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + return self.async_abort(reason="remote_adapters_not_supported") diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index a3238befbb8..d4b187d4605 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -18,6 +18,12 @@ CONF_DETAILS = "details" CONF_PASSIVE = "passive" +CONF_SOURCE: Final = "source" +CONF_SOURCE_DOMAIN: Final = "source_domain" +CONF_SOURCE_MODEL: Final = "source_model" +CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id" + + SOURCE_LOCAL: Final = "local" DATA_MANAGER: Final = "bluetooth_manager" diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index e192423484c..09be8f960e9 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -22,7 +22,13 @@ from homeassistant.core import ( from homeassistant.helpers import discovery_flow from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DOMAIN +from .const import ( + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, + DOMAIN, +) from .match import ( ADDRESS, CALLBACK, @@ -44,11 +50,11 @@ class HomeAssistantBluetoothManager(BluetoothManager): """Manage Bluetooth for Home Assistant.""" __slots__ = ( - "hass", - "storage", - "_integration_matcher", "_callback_index", "_cancel_logging_listener", + "_integration_matcher", + "hass", + "storage", ) def __init__( @@ -240,6 +246,39 @@ class HomeAssistantBluetoothManager(BluetoothManager): unregister() self._async_save_scanner_history(scanner) + @hass_callback + def async_register_hass_scanner( + self, + scanner: BaseHaScanner, + connection_slots: int | None = None, + source_domain: str | None = None, + source_model: str | None = None, + source_config_entry_id: str | None = None, + ) -> CALLBACK_TYPE: + """Register a scanner.""" + cancel = self.async_register_scanner(scanner, connection_slots) + if ( + isinstance(scanner, BaseHaRemoteScanner) + and source_domain + and source_config_entry_id + and not self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, scanner.source + ) + ): + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: source_domain, + CONF_SOURCE_MODEL: source_model, + CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, + }, + ) + ) + return cancel + def async_register_scanner( self, scanner: BaseHaScanner, @@ -253,6 +292,18 @@ class HomeAssistantBluetoothManager(BluetoothManager): unregister = super().async_register_scanner(scanner, connection_slots) return partial(self._async_unregister_scanner, scanner, unregister) + @hass_callback + def async_remove_scanner(self, source: str) -> None: + """Remove a scanner.""" + self.storage.async_remove_advertisement_history(source) + if entry := self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, source + ): + self.hass.async_create_task( + self.hass.config_entries.async_remove(entry.entry_id), + f"Removing {source} Bluetooth config entry", + ) + @hass_callback def _handle_config_entry_removed( self, diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index e25c077b57f..22f8aa8fdb8 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -10,16 +10,17 @@ "btsocket", "bleak_retry_connector", "bluetooth_adapters", - "bluetooth_auto_recovery" + "bluetooth_auto_recovery", + "habluetooth" ], "quality_scale": "internal", "requirements": [ "bleak==0.22.3", - "bleak-retry-connector==3.6.0", - "bluetooth-adapters==0.20.2", + "bleak-retry-connector==3.8.0", + "bluetooth-adapters==0.21.1", "bluetooth-auto-recovery==1.4.2", - "bluetooth-data-tools==1.20.0", - "dbus-fast==2.24.3", - "habluetooth==3.6.0" + "bluetooth-data-tools==1.22.0", + "dbus-fast==2.30.2", + "habluetooth==3.12.0" ] } diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index ee62420b692..6307d3ca93b 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -92,7 +92,7 @@ def seen_all_fields( class IntegrationMatcher: """Integration matcher for the bluetooth integration.""" - __slots__ = ("_integration_matchers", "_matched", "_matched_connectable", "_index") + __slots__ = ("_index", "_integration_matchers", "_matched", "_matched_connectable") def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None: """Initialize the matcher.""" @@ -164,12 +164,12 @@ class BluetoothMatcherIndexBase[ __slots__ = ( "local_name", - "service_uuid", - "service_data_uuid", "manufacturer_id", - "service_uuid_set", - "service_data_uuid_set", "manufacturer_id_set", + "service_data_uuid", + "service_data_uuid_set", + "service_uuid", + "service_uuid_set", ) def __init__(self) -> None: diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index be232f87b24..ccff85e5027 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -4,8 +4,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from typing_extensions import TypeVar - from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.update_coordinator import ( BaseCoordinatorEntity, @@ -20,12 +18,6 @@ if TYPE_CHECKING: from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak -_PassiveBluetoothDataUpdateCoordinatorT = TypeVar( - "_PassiveBluetoothDataUpdateCoordinatorT", - bound="PassiveBluetoothDataUpdateCoordinator", - default="PassiveBluetoothDataUpdateCoordinator", -) - class PassiveBluetoothDataUpdateCoordinator( BasePassiveBluetoothCoordinator, BaseDataUpdateCoordinatorProtocol @@ -98,7 +90,9 @@ class PassiveBluetoothDataUpdateCoordinator( self.async_update_listeners() -class PassiveBluetoothCoordinatorEntity( # pylint: disable=hass-enforce-class-module +class PassiveBluetoothCoordinatorEntity[ + _PassiveBluetoothDataUpdateCoordinatorT: PassiveBluetoothDataUpdateCoordinator = PassiveBluetoothDataUpdateCoordinator +]( # pylint: disable=hass-enforce-class-module BaseCoordinatorEntity[_PassiveBluetoothDataUpdateCoordinatorT] ): """A class for entities using DataUpdateCoordinator.""" diff --git a/homeassistant/components/bluetooth/storage.py b/homeassistant/components/bluetooth/storage.py index 6b4c7695fd2..369db4a7760 100644 --- a/homeassistant/components/bluetooth/storage.py +++ b/homeassistant/components/bluetooth/storage.py @@ -38,6 +38,12 @@ class BluetoothStorage: """Get all scanners.""" return list(self._data.keys()) + @callback + def async_remove_advertisement_history(self, scanner: str) -> None: + """Remove discovered devices by scanner.""" + if self._data.pop(scanner, None): + self._store.async_delay_save(self._async_get_data, SCANNER_SAVE_DELAY) + @callback def async_get_advertisement_history( self, scanner: str diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index c28bd3cc65e..1b8231c66ca 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -33,6 +33,9 @@ "passive": "Passive scanning" } } + }, + "abort": { + "remote_adapters_not_supported": "Bluetooth configuration for remote adapters is not supported." } } } diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py new file mode 100644 index 00000000000..45445a7a00f --- /dev/null +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -0,0 +1,150 @@ +"""The bluetooth integration websocket apis.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from functools import lru_cache, partial +import time +from typing import Any + +from habluetooth import BluetoothScanningMode +from home_assistant_bluetooth import BluetoothServiceInfoBleak +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_bytes + +from .api import _get_manager, async_register_callback +from .match import BluetoothCallbackMatcher +from .models import BluetoothChange + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the bluetooth websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_advertisements) + + +@lru_cache(maxsize=1024) +def serialize_service_info( + service_info: BluetoothServiceInfoBleak, time_diff: float +) -> dict[str, Any]: + """Serialize a BluetoothServiceInfoBleak object.""" + return { + "name": service_info.name, + "address": service_info.address, + "rssi": service_info.rssi, + "manufacturer_data": { + str(manufacturer_id): manufacturer_data.hex() + for manufacturer_id, manufacturer_data in service_info.manufacturer_data.items() + }, + "service_data": { + service_uuid: service_data.hex() + for service_uuid, service_data in service_info.service_data.items() + }, + "service_uuids": service_info.service_uuids, + "source": service_info.source, + "connectable": service_info.connectable, + "time": service_info.time + time_diff, + "tx_power": service_info.tx_power, + } + + +class _AdvertisementSubscription: + """Class to hold and manage the subscription data.""" + + def __init__( + self, + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + ws_msg_id: int, + match_dict: BluetoothCallbackMatcher, + ) -> None: + """Initialize the subscription data.""" + self.hass = hass + self.match_dict = match_dict + self.pending_service_infos: list[BluetoothServiceInfoBleak] = [] + self.ws_msg_id = ws_msg_id + self.connection = connection + self.pending = True + # Keep time_diff precise to 2 decimal places + # so the cached serialization can be reused, + # however we still want to calculate it each + # subscription in case the system clock is wrong + # and gets corrected. + self.time_diff = round(time.time() - time.monotonic(), 2) + + @callback + def _async_unsubscribe( + self, cancel_callbacks: tuple[Callable[[], None], ...] + ) -> None: + """Unsubscribe the callback.""" + for cancel_callback in cancel_callbacks: + cancel_callback() + + @callback + def async_start(self) -> None: + """Start the subscription.""" + connection = self.connection + cancel_adv_callback = async_register_callback( + self.hass, + self._async_on_advertisement, + self.match_dict, + BluetoothScanningMode.PASSIVE, + ) + cancel_disappeared_callback = _get_manager( + self.hass + ).async_register_disappeared_callback(self._async_removed) + connection.subscriptions[self.ws_msg_id] = partial( + self._async_unsubscribe, (cancel_adv_callback, cancel_disappeared_callback) + ) + self.pending = False + self.connection.send_message( + json_bytes(websocket_api.result_message(self.ws_msg_id)) + ) + self._async_added(self.pending_service_infos) + self.pending_service_infos.clear() + + def _async_event_message(self, message: dict[str, Any]) -> None: + self.connection.send_message( + json_bytes(websocket_api.event_message(self.ws_msg_id, message)) + ) + + def _async_added(self, service_infos: Iterable[BluetoothServiceInfoBleak]) -> None: + self._async_event_message( + { + "add": [ + serialize_service_info(service_info, self.time_diff) + for service_info in service_infos + ] + } + ) + + def _async_removed(self, address: str) -> None: + self._async_event_message({"remove": [{"address": address}]}) + + @callback + def _async_on_advertisement( + self, service_info: BluetoothServiceInfoBleak, change: BluetoothChange + ) -> None: + """Handle the callback.""" + if self.pending: + self.pending_service_infos.append(service_info) + return + self._async_added((service_info,)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "bluetooth/subscribe_advertisements", + } +) +@websocket_api.async_response +async def ws_subscribe_advertisements( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe advertisements websocket command.""" + _AdvertisementSubscription( + hass, connection, msg["id"], BluetoothCallbackMatcher(connectable=False) + ).async_start() diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 9e43cfc4187..05fa3e3cab0 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -from dataclasses import dataclass import logging import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( @@ -18,7 +16,7 @@ from homeassistant.helpers import ( import homeassistant.helpers.config_validation as cv from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN -from .coordinator import BMWDataUpdateCoordinator +from .coordinator import BMWConfigEntry, BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -49,19 +47,9 @@ PLATFORMS = [ SERVICE_UPDATE_STATE = "update_state" -type BMWConfigEntry = ConfigEntry[BMWData] - - -@dataclass -class BMWData: - """Class to store BMW runtime data.""" - - coordinator: BMWDataUpdateCoordinator - - @callback def _async_migrate_options_from_data_if_missing( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: BMWConfigEntry ) -> None: data = dict(entry.data) options = dict(entry.options) @@ -85,23 +73,29 @@ async def _async_migrate_entries( @callback def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: replacements = { - "charging_level_hv": "fuel_and_battery.remaining_battery_percent", - "fuel_percent": "fuel_and_battery.remaining_fuel_percent", - "ac_current_limit": "charging_profile.ac_current_limit", - "charging_start_time": "fuel_and_battery.charging_start_time", - "charging_end_time": "fuel_and_battery.charging_end_time", - "charging_status": "fuel_and_battery.charging_status", - "charging_target": "fuel_and_battery.charging_target", - "remaining_battery_percent": "fuel_and_battery.remaining_battery_percent", - "remaining_range_total": "fuel_and_battery.remaining_range_total", - "remaining_range_electric": "fuel_and_battery.remaining_range_electric", - "remaining_range_fuel": "fuel_and_battery.remaining_range_fuel", - "remaining_fuel": "fuel_and_battery.remaining_fuel", - "remaining_fuel_percent": "fuel_and_battery.remaining_fuel_percent", - "activity": "climate.activity", + Platform.SENSOR.value: { + "charging_level_hv": "fuel_and_battery.remaining_battery_percent", + "fuel_percent": "fuel_and_battery.remaining_fuel_percent", + "ac_current_limit": "charging_profile.ac_current_limit", + "charging_start_time": "fuel_and_battery.charging_start_time", + "charging_end_time": "fuel_and_battery.charging_end_time", + "charging_status": "fuel_and_battery.charging_status", + "charging_target": "fuel_and_battery.charging_target", + "remaining_battery_percent": "fuel_and_battery.remaining_battery_percent", + "remaining_range_total": "fuel_and_battery.remaining_range_total", + "remaining_range_electric": "fuel_and_battery.remaining_range_electric", + "remaining_range_fuel": "fuel_and_battery.remaining_range_fuel", + "remaining_fuel": "fuel_and_battery.remaining_fuel", + "remaining_fuel_percent": "fuel_and_battery.remaining_fuel_percent", + "activity": "climate.activity", + } } - if (key := entry.unique_id.split("-")[-1]) in replacements: - new_unique_id = entry.unique_id.replace(key, replacements[key]) + if (key := entry.unique_id.split("-")[-1]) in replacements.get( + entry.domain, [] + ): + new_unique_id = entry.unique_id.replace( + key, replacements[entry.domain][key] + ) _LOGGER.debug( "Migrating entity '%s' unique_id from '%s' to '%s'", entry.entity_id, @@ -127,7 +121,7 @@ async def _async_migrate_entries( return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool: """Set up BMW Connected Drive from a config entry.""" _async_migrate_options_from_data_if_missing(hass, entry) @@ -137,11 +131,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Set up one data coordinator per account/config entry coordinator = BMWDataUpdateCoordinator( hass, - entry=entry, + config_entry=entry, ) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = BMWData(coordinator) + entry.runtime_data = coordinator # Set up all platforms except notify await hass.config_entries.async_forward_entry_setups( @@ -175,7 +169,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms( diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 65bdfca997b..5a58c707d6a 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -26,6 +26,8 @@ from .const import UNIT_MAP from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) @@ -201,7 +203,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BMW binary sensors from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities = [ BMWBinarySensor(coordinator, vehicle, description, hass.config.units) diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index e6bd92b92d7..a7c31d0ef79 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -16,12 +16,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .entity import BMWBaseEntity if TYPE_CHECKING: from .coordinator import BMWDataUpdateCoordinator +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) @@ -53,7 +55,6 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( BMWButtonEntityDescription( key="deactivate_air_conditioning", translation_key="deactivate_air_conditioning", - name="Deactivate air conditioning", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled, ), @@ -71,7 +72,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BMW buttons from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities: list[BMWButton] = [] @@ -109,6 +110,10 @@ class BMWButton(BMWBaseEntity, ButtonEntity): try: await self.entity_description.remote_function(self.vehicle) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 409bfdca6f1..5a067d23474 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -18,7 +18,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -27,9 +26,19 @@ from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_US from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.util.ssl import get_default_context from . import DOMAIN -from .const import CONF_ALLOWED_REGIONS, CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN +from .const import ( + CONF_ALLOWED_REGIONS, + CONF_CAPTCHA_REGIONS, + CONF_CAPTCHA_TOKEN, + CONF_CAPTCHA_URL, + CONF_GCID, + CONF_READ_ONLY, + CONF_REFRESH_TOKEN, +) +from .coordinator import BMWConfigEntry DATA_SCHEMA = vol.Schema( { @@ -41,7 +50,20 @@ DATA_SCHEMA = vol.Schema( translation_key="regions", ) ), - } + }, + extra=vol.REMOVE_EXTRA, +) +RECONFIGURE_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + }, + extra=vol.REMOVE_EXTRA, +) +CAPTCHA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CAPTCHA_TOKEN): str, + }, + extra=vol.REMOVE_EXTRA, ) @@ -54,6 +76,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, data[CONF_USERNAME], data[CONF_PASSWORD], get_region_from_name(data[CONF_REGION]), + hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN), + verify=get_default_context(), ) try: @@ -79,39 +103,54 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - _existing_entry_data: Mapping[str, Any] | None = None + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + self._existing_entry_data: dict[str, Any] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors: dict[str, str] = {} + errors: dict[str, str] = self.data.pop("errors", {}) - if user_input is not None: + if user_input is not None and not errors: unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" await self.async_set_unique_id(unique_id) - if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: - self._abort_if_unique_id_mismatch(reason="account_mismatch") - else: + # Unique ID cannot change for reauth/reconfigure + if self.source not in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: self._abort_if_unique_id_configured() + # Store user input for later use + self.data.update(user_input) + + # North America and Rest of World require captcha token + if ( + self.data.get(CONF_REGION) in CONF_CAPTCHA_REGIONS + and CONF_CAPTCHA_TOKEN not in self.data + ): + return await self.async_step_captcha() + info = None try: - info = await validate_input(self.hass, user_input) - entry_data = { - **user_input, - CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), - CONF_GCID: info.get(CONF_GCID), - } + info = await validate_input(self.hass, self.data) except MissingCaptcha: errors["base"] = "missing_captcha" except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + finally: + self.data.pop(CONF_CAPTCHA_TOKEN, None) if info: + entry_data = { + **self.data, + CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + CONF_GCID: info.get(CONF_GCID), + } + if self.source == SOURCE_REAUTH: return self.async_update_reload_and_abort( self._get_reauth_entry(), data=entry_data @@ -128,29 +167,61 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN): schema = self.add_suggested_values_to_schema( DATA_SCHEMA, - self._existing_entry_data, + self._existing_entry_data or self.data, ) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + async def async_step_change_password( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show the change password step.""" + if user_input is not None: + return await self.async_step_user(self._existing_entry_data | user_input) + + return self.async_show_form( + step_id="change_password", + data_schema=RECONFIGURE_SCHEMA, + description_placeholders={ + CONF_USERNAME: self._existing_entry_data[CONF_USERNAME], + CONF_REGION: self._existing_entry_data[CONF_REGION], + }, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - self._existing_entry_data = entry_data - return await self.async_step_user() + self._existing_entry_data = dict(entry_data) + return await self.async_step_change_password() async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a reconfiguration flow initialized by the user.""" - self._existing_entry_data = self._get_reconfigure_entry().data - return await self.async_step_user() + self._existing_entry_data = dict(self._get_reconfigure_entry().data) + return await self.async_step_change_password() + + async def async_step_captcha( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show captcha form.""" + if user_input and user_input.get(CONF_CAPTCHA_TOKEN): + self.data[CONF_CAPTCHA_TOKEN] = user_input[CONF_CAPTCHA_TOKEN].strip() + return await self.async_step_user(self.data) + + return self.async_show_form( + step_id="captcha", + data_schema=CAPTCHA_SCHEMA, + description_placeholders={ + "captcha_url": CONF_CAPTCHA_URL.format(region=self.data[CONF_REGION]) + }, + ) @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: BMWConfigEntry, ) -> BMWOptionsFlow: """Return a MyBMW option flow.""" return BMWOptionsFlow() diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 98d4acbfc91..750289e9d0a 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -8,10 +8,15 @@ ATTR_DIRECTION = "direction" ATTR_VIN = "vin" CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"] +CONF_CAPTCHA_REGIONS = ["north_america", "rest_of_world"] CONF_READ_ONLY = "read_only" CONF_ACCOUNT = "account" CONF_REFRESH_TOKEN = "refresh_token" CONF_GCID = "gcid" +CONF_CAPTCHA_TOKEN = "captcha_token" +CONF_CAPTCHA_URL = ( + "https://bimmer-connected.readthedocs.io/en/stable/captcha/{region}.html" +) DATA_HASS_CONFIG = "hass_config" diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index d38b7ffacc2..b54d9245bbd 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -22,39 +22,51 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.ssl import get_default_context -from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS +from .const import ( + CONF_GCID, + CONF_READ_ONLY, + CONF_REFRESH_TOKEN, + DOMAIN as BMW_DOMAIN, + SCAN_INTERVALS, +) _LOGGER = logging.getLogger(__name__) +type BMWConfigEntry = ConfigEntry[BMWDataUpdateCoordinator] + + class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching BMW data.""" account: MyBMWAccount + config_entry: BMWConfigEntry - def __init__(self, hass: HomeAssistant, *, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, *, config_entry: BMWConfigEntry) -> None: """Initialize account-wide BMW data updater.""" self.account = MyBMWAccount( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - get_region_from_name(entry.data[CONF_REGION]), + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + get_region_from_name(config_entry.data[CONF_REGION]), observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), verify=get_default_context(), ) - self.read_only = entry.options[CONF_READ_ONLY] - self._entry = entry + self.read_only: bool = config_entry.options[CONF_READ_ONLY] - if CONF_REFRESH_TOKEN in entry.data: + if CONF_REFRESH_TOKEN in config_entry.data: self.account.set_refresh_token( - refresh_token=entry.data[CONF_REFRESH_TOKEN], - gcid=entry.data.get(CONF_GCID), + refresh_token=config_entry.data[CONF_REFRESH_TOKEN], + gcid=config_entry.data.get(CONF_GCID), ) super().__init__( hass, _LOGGER, - name=f"{DOMAIN}-{entry.data['username']}", - update_interval=timedelta(seconds=SCAN_INTERVALS[entry.data[CONF_REGION]]), + config_entry=config_entry, + name=f"{BMW_DOMAIN}-{config_entry.data[CONF_USERNAME]}", + update_interval=timedelta( + seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]] + ), ) # Default to false on init so _async_update_data logic works @@ -69,33 +81,39 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): except MyBMWCaptchaMissingError as err: # If a captcha is required (user/password login flow), always trigger the reauth flow raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, + translation_domain=BMW_DOMAIN, translation_key="missing_captcha", ) from err except MyBMWAuthError as err: # Allow one retry interval before raising AuthFailed to avoid flaky API issues if self.last_update_success: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=BMW_DOMAIN, + translation_key="update_failed", + translation_placeholders={"exception": str(err)}, + ) from err # Clear refresh token and trigger reauth if previous update failed as well self._update_config_entry_refresh_token(None) - raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryAuthFailed( + translation_domain=BMW_DOMAIN, + translation_key="invalid_auth", + ) from err except (MyBMWAPIError, RequestError) as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=BMW_DOMAIN, + translation_key="update_failed", + translation_placeholders={"exception": str(err)}, + ) from err if self.account.refresh_token != old_refresh_token: self._update_config_entry_refresh_token(self.account.refresh_token) - _LOGGER.debug( - "bimmer_connected: refresh token %s > %s", - old_refresh_token, - self.account.refresh_token, - ) def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None: """Update or delete the refresh_token in the Config Entry.""" data = { - **self._entry.data, + **self.config_entry.data, CONF_REFRESH_TOKEN: refresh_token, } if not refresh_token: data.pop(CONF_REFRESH_TOKEN) - self.hass.config_entries.async_update_entry(self._entry, data=data) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index 977fd531e2c..74df8693f7a 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -16,6 +16,8 @@ from .const import ATTR_DIRECTION from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) @@ -25,7 +27,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW tracker from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities: list[BMWDeviceTracker] = [] for vehicle in coordinator.account.vehicles: @@ -47,7 +49,7 @@ class BMWDeviceTracker(BMWBaseEntity, TrackerEntity): _attr_force_update = False _attr_translation_key = "car" - _attr_icon = "mdi:car" + _attr_name = None def __init__( self, @@ -56,9 +58,7 @@ class BMWDeviceTracker(BMWBaseEntity, TrackerEntity): ) -> None: """Initialize the Tracker.""" super().__init__(coordinator, vehicle) - self._attr_unique_id = vehicle.vin - self._attr_name = None @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/bmw_connected_drive/diagnostics.py b/homeassistant/components/bmw_connected_drive/diagnostics.py index ff3c6f29559..3f357c3ae79 100644 --- a/homeassistant/components/bmw_connected_drive/diagnostics.py +++ b/homeassistant/components/bmw_connected_drive/diagnostics.py @@ -16,6 +16,8 @@ from homeassistant.helpers.device_registry import DeviceEntry from . import BMWConfigEntry from .const import CONF_REFRESH_TOKEN +PARALLEL_UPDATES = 1 + if TYPE_CHECKING: from bimmer_connected.vehicle import MyBMWVehicle @@ -49,7 +51,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: BMWConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data coordinator.account.config.log_responses = True await coordinator.account.get_vehicles(force_init=True) @@ -75,7 +77,7 @@ async def async_get_device_diagnostics( hass: HomeAssistant, config_entry: BMWConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data coordinator.account.config.log_responses = True await coordinator.account.get_vehicles(force_init=True) diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index 3dfc0b1c4d4..4bec12e796b 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -14,11 +14,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity +PARALLEL_UPDATES = 1 + DOOR_LOCK_STATE = "door_lock_state" + _LOGGER = logging.getLogger(__name__) @@ -28,7 +31,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW lock from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data if not coordinator.read_only: async_add_entities( @@ -67,7 +70,11 @@ class BMWLock(BMWBaseEntity, LockEntity): # Set the state to unknown if the command fails self._attr_is_locked = None self.async_write_ha_state() - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex finally: # Always update the listeners to get the latest state self.coordinator.async_update_listeners() @@ -87,7 +94,11 @@ class BMWLock(BMWBaseEntity, LockEntity): # Set the state to unknown if the command fails self._attr_is_locked = None self.async_write_ha_state() - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex finally: # Always update the listeners to get the latest state self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index ed0919a5dcf..81928a59a52 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.16.4"] + "requirements": ["bimmer-connected[china]==0.17.2"] } diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py index 56523351e66..dfa0939e81f 100644 --- a/homeassistant/components/bmw_connected_drive/notify.py +++ b/homeassistant/components/bmw_connected_drive/notify.py @@ -20,7 +20,9 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry + +PARALLEL_UPDATES = 1 ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"] @@ -51,7 +53,7 @@ def get_service( targets = {} if ( config_entry - and (coordinator := config_entry.runtime_data.coordinator) + and (coordinator := config_entry.runtime_data) and not coordinator.read_only ): targets.update({v.name: v for v in coordinator.account.vehicles}) @@ -90,7 +92,7 @@ class BMWNotificationService(BaseNotificationService): except (vol.Invalid, TypeError, ValueError) as ex: raise ServiceValidationError( - translation_domain=DOMAIN, + translation_domain=BMW_DOMAIN, translation_key="invalid_poi", translation_placeholders={ "poi_exception": str(ex), @@ -104,4 +106,8 @@ class BMWNotificationService(BaseNotificationService): try: await vehicle.remote_services.trigger_send_poi(poi) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index 54519ff9e6b..c6a328ecc20 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -18,10 +18,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) @@ -59,7 +61,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW number from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities: list[BMWNumber] = [] @@ -107,6 +109,10 @@ class BMWNumber(BMWBaseEntity, NumberEntity): try: await self.entity_description.remote_service(self.vehicle, value) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/quality_scale.yaml b/homeassistant/components/bmw_connected_drive/quality_scale.yaml new file mode 100644 index 00000000000..bc3bd517662 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/quality_scale.yaml @@ -0,0 +1,107 @@ +# + in comment indicates requirement for quality scale +# - in comment indicates issue to be fixed, not impacting quality scale +rules: + # Bronze + action-setup: + status: exempt + comment: | + Does not have custom services + appropriate-polling: done + brands: done + common-modules: + status: done + comment: | + - 2 states writes in async_added_to_hass() required for platforms that redefine _handle_coordinator_update() + config-flow-test-coverage: + status: todo + comment: | + - test_show_form doesn't really add anything + - Patch bimmer_connected imports with homeassistant.components.bmw_connected_drive.bimmer_connected imports + + Ensure that configs flows end in CREATE_ENTRY or ABORT + - Parameterize test_authentication_error, test_api_error and test_connection_error + + test_full_user_flow_implementation doesn't assert unique id of created entry + + test that aborts when a mocked config entry already exists + + don't test on internals (e.g. `coordinator.last_update_success`) but rather on the resulting state (change) + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + Does not have custom services + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + This integration doesn't have any events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + Does not have custom services + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: + status: done + comment: | + - Use constants in tests where possible + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: This integration doesn't use discovery. + discovery: + status: exempt + comment: This integration doesn't use discovery. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: todo + comment: > + To be discussed. + We cannot regularly get new devices/vehicles due to API quota limitations. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + Other than reauthentication, this integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: todo + comment: > + To be discussed. + We cannot regularly check for stale devices/vehicles due to API quota limitations. + # Platinum + async-dependency: done + inject-websession: + status: todo + comment: > + To be discussed. + The library requires a custom client for API authentication, with custom auth lifecycle and user agents. + strict-typing: done diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 323768ad9eb..385b45fd9fa 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -15,10 +15,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) @@ -66,7 +68,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW lock from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities: list[BMWSelect] = [] @@ -121,6 +123,10 @@ class BMWSelect(BMWBaseEntity, SelectEntity): try: await self.entity_description.remote_service(self.vehicle, option) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index e24e2dd75f6..b7be367d57d 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -34,6 +34,8 @@ from . import BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) @@ -191,7 +193,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW sensors from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities = [ BMWSensor(coordinator, vehicle, description) diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 0e7a4a32ef4..edb0d5cfb12 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -2,10 +2,35 @@ "config": { "step": { "user": { + "description": "Connect to your MyBMW/MINI Connected account to retrieve vehicle data.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "region": "ConnectedDrive Region" + }, + "data_description": { + "username": "The email address of your MyBMW/MINI Connected account.", + "password": "The password of your MyBMW/MINI Connected account.", + "region": "The region of your MyBMW/MINI Connected account." + } + }, + "captcha": { + "title": "Are you a robot?", + "description": "A captcha is required for BMW login. Visit the external website to complete the challenge and submit the form. Copy the resulting token into the field below.\n\n{captcha_url}\n\nNo data will be exposed outside of your Home Assistant instance.", + "data": { + "captcha_token": "Captcha token" + }, + "data_description": { + "captcha_token": "One-time token retrieved from the captcha challenge." + } + }, + "change_password": { + "description": "Update your MyBMW/MINI Connected password for account `{username}` in region `{region}`.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::bmw_connected_drive::config::step::user::data_description::password%]" } } }, @@ -17,15 +42,17 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "account_mismatch": "Username and region are not allowed to change" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "options": { "step": { "account_options": { "data": { - "read_only": "Read-only (only sensors and notify, no execution of services, no lock)" + "read_only": "Read-only mode" + }, + "data_description": { + "read_only": "Only retrieve values and send POI data, but don't offer any services that can change the vehicle state." } } } @@ -67,6 +94,9 @@ "activate_air_conditioning": { "name": "Activate air conditioning" }, + "deactivate_air_conditioning": { + "name": "Deactivate air conditioning" + }, "find_vehicle": { "name": "Find vehicle" } @@ -204,6 +234,15 @@ }, "missing_captcha": { "message": "Login requires captcha validation" + }, + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "remote_service_error": { + "message": "Error executing remote service on vehicle. {exception}" + }, + "update_failed": { + "message": "Error updating vehicle data. {exception}" } } } diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index e8a02efdcfc..600ad41165a 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -14,10 +14,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BMWConfigEntry +from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) @@ -67,7 +69,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW switch from config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data entities: list[BMWSwitch] = [] @@ -109,8 +111,11 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity): try: await self.entity_description.remote_service_on(self.vehicle) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex - + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex self.coordinator.async_update_listeners() async def async_turn_off(self, **kwargs: Any) -> None: @@ -118,6 +123,9 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity): try: await self.entity_description.remote_service_off(self.vehicle) except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex - + raise HomeAssistantError( + translation_domain=BMW_DOMAIN, + translation_key="remote_service_error", + translation_placeholders={"exception": str(ex)}, + ) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index a12d3057258..38abd63186a 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -11,12 +11,12 @@ from aiohttp import ClientConnectionError, ClientResponseError from bond_async import Bond import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .utils import BondHub @@ -97,7 +97,7 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered[CONF_NAME] = hub_name async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" name: str = discovery_info.name diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 81f96b1772c..2ae1df5fd68 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -115,11 +115,8 @@ class BondEntity(Entity): def _async_update_if_bpup_not_alive(self, now: datetime) -> None: """Fetch via the API if BPUP is not alive.""" self._async_schedule_bpup_alive_or_poll() - if ( - self.hass.is_stopping - or self._bpup_subs.alive - and self._initialized - and self.available + if self.hass.is_stopping or ( + self._bpup_subs.alive and self._initialized and self.available ): return if self._update_lock.locked(): diff --git a/homeassistant/components/bosch_shc/__init__.py b/homeassistant/components/bosch_shc/__init__.py index 9a00029412d..2871bc52450 100644 --- a/homeassistant/components/bosch_shc/__init__.py +++ b/homeassistant/components/bosch_shc/__init__.py @@ -12,13 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from .const import ( - CONF_SSL_CERTIFICATE, - CONF_SSL_KEY, - DATA_POLLING_HANDLER, - DATA_SESSION, - DOMAIN, -) +from .const import CONF_SSL_CERTIFICATE, CONF_SSL_KEY, DOMAIN PLATFORMS = [ Platform.BINARY_SENSOR, @@ -30,7 +24,10 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type BoschConfigEntry = ConfigEntry[SHCSession] + + +async def async_setup_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> bool: """Set up Bosch SHC from a config entry.""" data = entry.data @@ -53,10 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if shc_info.updateState.name == "UPDATE_AVAILABLE": _LOGGER.warning("Please check for software updates in the Bosch Smart Home App") - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_SESSION: session, - } + entry.runtime_data = session device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -76,23 +70,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.async_add_executor_job(session.stop_polling) await hass.async_add_executor_job(session.start_polling) - hass.data[DOMAIN][entry.entry_id][DATA_POLLING_HANDLER] = ( + entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_polling) ) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> bool: """Unload a config entry.""" - session: SHCSession = hass.data[DOMAIN][entry.entry_id][DATA_SESSION] + await hass.async_add_executor_job(entry.runtime_data.stop_polling) - hass.data[DOMAIN][entry.entry_id][DATA_POLLING_HANDLER]() - hass.data[DOMAIN][entry.entry_id].pop(DATA_POLLING_HANDLER) - await hass.async_add_executor_job(session.stop_polling) - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py index 342a3e3e417..dd0f31ea6f9 100644 --- a/homeassistant/components/bosch_shc/binary_sensor.py +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -2,28 +2,27 @@ from __future__ import annotations -from boschshcpy import SHCBatteryDevice, SHCSession, SHCShutterContact +from boschshcpy import SHCBatteryDevice, SHCShutterContact from boschshcpy.device import SHCDevice from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_SESSION, DOMAIN +from . import BoschConfigEntry from .entity import SHCEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BoschConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SHC binary sensor platform.""" - session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + session = config_entry.runtime_data entities: list[BinarySensorEntity] = [ ShutterContactSensor( diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py index 58601152da5..c234000674d 100644 --- a/homeassistant/components/bosch_shc/config_flow.py +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -20,6 +20,7 @@ from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_HOSTNAME, @@ -217,7 +218,7 @@ class BoschSHCConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if not discovery_info.name.startswith("Bosch SHC"): diff --git a/homeassistant/components/bosch_shc/const.py b/homeassistant/components/bosch_shc/const.py index ccb1f2094cb..07ec3b7da85 100644 --- a/homeassistant/components/bosch_shc/const.py +++ b/homeassistant/components/bosch_shc/const.py @@ -6,7 +6,4 @@ CONF_SHC_KEY = "bosch_shc-key.pem" CONF_SSL_CERTIFICATE = "ssl_certificate" CONF_SSL_KEY = "ssl_key" -DATA_SESSION = "session" -DATA_POLLING_HANDLER = "polling_handler" - DOMAIN = "bosch_shc" diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index 5377f0c6a8f..55d6bfc35de 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -2,7 +2,7 @@ from typing import Any -from boschshcpy import SHCSession, SHCShutterControl +from boschshcpy import SHCShutterControl from homeassistant.components.cover import ( ATTR_POSITION, @@ -10,22 +10,20 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_SESSION, DOMAIN +from . import BoschConfigEntry from .entity import SHCEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BoschConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SHC cover platform.""" - - session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + session = config_entry.runtime_data async_add_entities( ShutterControlCover( diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 28f23cd9765..6408e21654e 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -6,7 +6,6 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from boschshcpy import SHCSession from boschshcpy.device import SHCDevice from homeassistant.components.sensor import ( @@ -15,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -27,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DATA_SESSION, DOMAIN +from . import BoschConfigEntry from .entity import SHCEntity @@ -127,11 +125,11 @@ SENSOR_DESCRIPTIONS: dict[str, SHCSensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BoschConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SHC sensor platform.""" - session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + session = config_entry.runtime_data entities: list[SensorEntity] = [ SHCSensor( diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 58370a120f2..76b1da3e534 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -9,7 +9,6 @@ from boschshcpy import ( SHCCamera360, SHCCameraEyes, SHCLightSwitch, - SHCSession, SHCSmartPlug, SHCSmartPlugCompact, ) @@ -20,13 +19,12 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DATA_SESSION, DOMAIN +from . import BoschConfigEntry from .entity import SHCEntity @@ -80,11 +78,11 @@ SWITCH_TYPES: dict[str, SHCSwitchEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: BoschConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SHC switch platform.""" - session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] + session = config_entry.runtime_data entities: list[SwitchEntity] = [ SHCSwitch( diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index db5c72d7932..5d775b98180 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -10,11 +10,16 @@ from aiohttp import CookieJar from pybravia import BraviaAuthError, BraviaClient, BraviaError, BraviaNotSupported import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.util.network import is_host_valid from .const import ( @@ -202,14 +207,14 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered device.""" # We can cast the hostname to str because the ssdp_location is not bytes and # not a relative url host = cast(str, urlparse(discovery_info.ssdp_location).hostname) - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) @@ -221,8 +226,8 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN): if "videoScreen" not in service_types: return self.async_abort(reason="not_bravia_device") - model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] self.context["title_placeholders"] = { CONF_NAME: f"{model_name} ({friendly_name})", diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 80b7a843cc0..0ee8e3b3155 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -4,20 +4,13 @@ from __future__ import annotations import logging -from bring_api import ( - Bring, - BringAuthException, - BringParseException, - BringRequestException, -) +from bring_api import Bring from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN from .coordinator import BringDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TODO] @@ -30,30 +23,8 @@ type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: """Set up Bring! from a config entry.""" - email = entry.data[CONF_EMAIL] - password = entry.data[CONF_PASSWORD] - session = async_get_clientsession(hass) - bring = Bring(session, email, password) - - try: - await bring.login() - except BringRequestException as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_request_exception", - ) from e - except BringParseException as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_parse_exception", - ) from e - except BringAuthException as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="setup_authentication_exception", - translation_placeholders={CONF_EMAIL: email}, - ) from e + bring = Bring(session, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) coordinator = BringDataUpdateCoordinator(hass, bring) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index 606c280cf8d..b8ee9d1e6ae 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -85,6 +85,7 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: if not (errors := await self.validate_input(user_input)): + self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( self.reauth_entry, data=user_input ) diff --git a/homeassistant/components/bring/const.py b/homeassistant/components/bring/const.py index d44b7eb9423..911c08a835d 100644 --- a/homeassistant/components/bring/const.py +++ b/homeassistant/components/bring/const.py @@ -9,4 +9,3 @@ ATTR_ITEM_NAME: Final = "item" ATTR_NOTIFICATION_TYPE: Final = "message" SERVICE_PUSH_NOTIFICATION = "send_message" -UNIT_ITEMS = "items" diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 7678213f117..d02237e84eb 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -16,7 +16,7 @@ from bring_api.types import BringItemsResponse, BringList, BringUserSettingsResp from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -51,7 +51,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: raise UpdateFailed("Unable to parse response from bring") from e - except BringAuthException as e: + except BringAuthException: # try to recover by refreshing access token, otherwise # initiate reauth flow try: @@ -64,12 +64,12 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): translation_key="setup_authentication_exception", translation_placeholders={CONF_EMAIL: self.bring.mail}, ) from exc - raise UpdateFailed( - "Authentication failed but re-authentication was successful, trying again later" - ) from e + return self.data list_dict: dict[str, BringData] = {} for lst in lists_response["lists"]: + if (ctx := set(self.async_contexts())) and lst["listUuid"] not in ctx: + continue try: items = await self.bring.get_list(lst["listUuid"]) except BringRequestException as e: @@ -86,13 +86,22 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): async def _async_setup(self) -> None: """Set up coordinator.""" - await self.async_refresh_user_settings() - - async def async_refresh_user_settings(self) -> None: - """Refresh user settings.""" try: + await self.bring.login() self.user_settings = await self.bring.get_all_user_settings() - except (BringAuthException, BringRequestException, BringParseException) as e: - raise UpdateFailed( - "Unable to connect and retrieve user settings from bring" + except BringRequestException as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e + except BringParseException as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e + except BringAuthException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={CONF_EMAIL: self.bring.mail}, ) from e diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index 5b6bf975764..a1e0cb2edc0 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -20,7 +20,7 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): bring_list: BringData, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) + super().__init__(coordinator, bring_list["listUuid"]) self._list_uuid = bring_list["listUuid"] diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index ff24a991350..71fe733ccf5 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", + "loggers": ["bring_api"], "requirements": ["bring-api==0.9.1"] } diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml new file mode 100644 index 00000000000..1fdb3f13f1b --- /dev/null +++ b/homeassistant/components/bring/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Only entity services + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: todo + dependency-transparency: done + docs-actions: done + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: The integration registers no events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: handled by coordinator + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Integration is a service and has no devices. + discovery: + status: exempt + comment: Integration is a service and has no devices. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + no repairs + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 746ed397e1b..bd33ce9bf88 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -20,11 +20,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import BringConfigEntry -from .const import UNIT_ITEMS from .coordinator import BringData, BringDataUpdateCoordinator from .entity import BringBaseEntity from .util import list_language, sum_attributes +PARALLEL_UPDATES = 0 + @dataclass(kw_only=True, frozen=True) class BringSensorEntityDescription(SensorEntityDescription): @@ -48,19 +49,16 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = ( key=BringSensor.URGENT, translation_key=BringSensor.URGENT, value_fn=lambda lst, _: sum_attributes(lst, "urgent"), - native_unit_of_measurement=UNIT_ITEMS, ), BringSensorEntityDescription( key=BringSensor.CONVENIENT, translation_key=BringSensor.CONVENIENT, value_fn=lambda lst, _: sum_attributes(lst, "convenient"), - native_unit_of_measurement=UNIT_ITEMS, ), BringSensorEntityDescription( key=BringSensor.DISCOUNTED, translation_key=BringSensor.DISCOUNTED, value_fn=lambda lst, _: sum_attributes(lst, "discounted"), - native_unit_of_measurement=UNIT_ITEMS, ), BringSensorEntityDescription( key=BringSensor.LIST_LANGUAGE, diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 9a93881b5d2..ea9af03484e 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -1,4 +1,7 @@ { + "common": { + "shopping_list_items": "items" + }, "config": { "step": { "user": { @@ -23,19 +26,23 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account." } }, "entity": { "sensor": { "urgent": { - "name": "Urgent" + "name": "Urgent", + "unit_of_measurement": "[%key:component::bring::common::shopping_list_items%]" }, "convenient": { - "name": "On occasion" + "name": "On occasion", + "unit_of_measurement": "[%key:component::bring::common::shopping_list_items%]" }, "discounted": { - "name": "Discount only" + "name": "Discount only", + "unit_of_measurement": "[%key:component::bring::common::shopping_list_items%]" }, "list_language": { "name": "Region & language", @@ -94,17 +101,17 @@ "setup_authentication_exception": { "message": "Authentication failed for {email}, check your email and password" }, - "notify_missing_argument_item": { - "message": "Failed to perform action {service}. 'URGENT_MESSAGE' requires a value @ data['item']. Got None" + "notify_missing_argument": { + "message": "This action requires field {field}, please enter a valid value for {field}" }, "notify_request_failed": { - "message": "Failed to send push notification for bring due to a connection error, try again later" + "message": "Failed to send push notification for Bring! due to a connection error, try again later" } }, "services": { "send_message": { "name": "[%key:component::notify::services::notify::name%]", - "description": "Send a mobile push notification to members of a shared Bring! list.", + "description": "Sends a mobile push notification to members of a shared Bring! list.", "fields": { "entity_id": { "name": "List", @@ -115,8 +122,8 @@ "description": "Type of push notification to send to list members." }, "item": { - "name": "Article (Required if message type `Urgent Message` selected)", - "description": "Article name to include in an urgent message e.g. `Urgent Message - Please buy Cilantro urgently`" + "name": "Item (Required if notification type 'Urgent message' is selected)", + "description": "Item name(s) to include in an urgent message e.g. 'Attention! Attention! - We still urgently need: [Items]'" } } } @@ -124,10 +131,10 @@ "selector": { "notification_type_selector": { "options": { - "going_shopping": "I'm going shopping! - Last chance to make changes", - "changed_list": "List updated - Take a look at the articles", - "shopping_done": "Shopping done - The fridge is well stocked", - "urgent_message": "Urgent Message - Please buy `Article name` urgently" + "going_shopping": "I'm going shopping! - Last chance for adjustments", + "changed_list": "I changed the list! - Take a look at the items", + "shopping_done": "The shopping is done - Our fridge is well stocked", + "urgent_message": "Attention! Attention! - We still urgently need: [Items]" } } } diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 319aedc6b80..75657e2fd64 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -34,6 +34,8 @@ from .const import ( from .coordinator import BringData, BringDataUpdateCoordinator from .entity import BringBaseEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -260,8 +262,6 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): except ValueError as e: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="notify_missing_argument_item", - translation_placeholders={ - "service": f"{DOMAIN}.{SERVICE_PUSH_NOTIFICATION}", - }, + translation_key="notify_missing_argument", + translation_placeholders={"field": "item"}, ) from e diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py index dbfd982795c..25a6bbd60a5 100644 --- a/homeassistant/components/broadlink/climate.py +++ b/homeassistant/components/broadlink/climate.py @@ -52,7 +52,6 @@ class BroadlinkThermostat(BroadlinkEntity, ClimateEntity): ) _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: BroadlinkDevice) -> None: """Initialize the climate entity.""" diff --git a/homeassistant/components/broadlink/config_flow.py b/homeassistant/components/broadlink/config_flow.py index c9b2fb46608..617e466a1b1 100644 --- a/homeassistant/components/broadlink/config_flow.py +++ b/homeassistant/components/broadlink/config_flow.py @@ -15,7 +15,6 @@ from broadlink.exceptions import ( ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_IMPORT, SOURCE_REAUTH, @@ -25,6 +24,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DEVICE_TYPES, DOMAIN from .helpers import format_mac @@ -65,7 +65,7 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN): } async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" host = discovery_info.ip diff --git a/homeassistant/components/broadlink/device.py b/homeassistant/components/broadlink/device.py index 75b6236a473..082af07ebbd 100644 --- a/homeassistant/components/broadlink/device.py +++ b/homeassistant/components/broadlink/device.py @@ -3,7 +3,6 @@ from contextlib import suppress from functools import partial import logging -from typing import Generic import broadlink as blk from broadlink.exceptions import ( @@ -13,7 +12,6 @@ from broadlink.exceptions import ( ConnectionClosedError, NetworkTimeoutError, ) -from typing_extensions import TypeVar from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -31,8 +29,6 @@ from homeassistant.helpers import device_registry as dr from .const import DEFAULT_PORT, DOMAIN, DOMAINS_AND_TYPES from .updater import BroadlinkUpdateManager, get_update_manager -_ApiT = TypeVar("_ApiT", bound=blk.Device, default=blk.Device) - _LOGGER = logging.getLogger(__name__) @@ -41,7 +37,7 @@ def get_domains(device_type: str) -> set[Platform]: return {d for d, t in DOMAINS_AND_TYPES.items() if device_type in t} -class BroadlinkDevice(Generic[_ApiT]): +class BroadlinkDevice[_ApiT: blk.Device = blk.Device]: """Manages a Broadlink device.""" api: _ApiT diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index 17c98f0182f..492023afd66 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -17,7 +17,7 @@ }, "reset": { "title": "Unlock the device", - "description": "{name} ({model} at {host}) is locked. You need to unlock the device in order to authenticate and complete the configuration. Instructions:\n1. Open the Broadlink app.\n2. Click on the device.\n3. Click `...` in the upper right.\n4. Scroll to the bottom of the page.\n5. Disable the lock." + "description": "{name} ({model} at {host}) is locked. You need to unlock the device in order to authenticate and complete the configuration. Instructions:\n1. Open the Broadlink app.\n2. Select the device.\n3. Select `...` in the upper right.\n4. Scroll to the bottom of the page.\n5. Disable the lock." }, "unlock": { "title": "Unlock the device (optional)", diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index f1455f5a541..8e0a521e182 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -5,11 +5,10 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING, Any, Generic +from typing import TYPE_CHECKING, Any, Generic, TypeVar import broadlink as blk from broadlink.exceptions import AuthorizationError, BroadlinkException -from typing_extensions import TypeVar from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index d9130b96300..f6b3f456056 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -7,12 +7,12 @@ from typing import Any from brother import Brother, SnmpError, UnsupportedModelError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.components.snmp import async_get_snmp_engine from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.network import is_host_valid from .const import DOMAIN, PRINTER_TYPES @@ -83,7 +83,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.host = discovery_info.host diff --git a/homeassistant/components/bryant_evolution/climate.py b/homeassistant/components/bryant_evolution/climate.py index dd31097a1ee..2d54ced8217 100644 --- a/homeassistant/components/bryant_evolution/climate.py +++ b/homeassistant/components/bryant_evolution/climate.py @@ -77,7 +77,6 @@ class BryantEvolutionClimate(ClimateEntity): HVACMode.OFF, ] _attr_fan_modes = ["auto", "low", "med", "high"] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 6d992da395a..2833d6549b4 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -65,7 +65,6 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity): _attr_preset_modes = PRESET_MODES _attr_hvac_modes = HVAC_MODES - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index d60089a9bf5..6d194714c64 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -42,6 +42,7 @@ EVENT_TYPES_BY_EVENT_CLASS = { "long_press", "long_double_press", "long_triple_press", + "hold_press", }, EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"}, } diff --git a/homeassistant/components/bthome/event.py b/homeassistant/components/bthome/event.py index 128d1e8388f..a6ee79f4e05 100644 --- a/homeassistant/components/bthome/event.py +++ b/homeassistant/components/bthome/event.py @@ -36,6 +36,7 @@ DESCRIPTIONS_BY_EVENT_CLASS = { "long_press", "long_double_press", "long_triple_press", + "hold_press", ], device_class=EventDeviceClass.BUTTON, ), diff --git a/homeassistant/components/bthome/logbook.py b/homeassistant/components/bthome/logbook.py index be5e156e99c..1c41d5553da 100644 --- a/homeassistant/components/bthome/logbook.py +++ b/homeassistant/components/bthome/logbook.py @@ -26,7 +26,7 @@ def async_describe_events( """Describe bthome logbook event.""" data = event.data device = dev_reg.async_get(data["device_id"]) - name = device and device.name or f'BTHome {data["address"]}' + name = (device and device.name) or f"BTHome {data['address']}" if properties := data["event_properties"]: message = f"{data['event_class']} {data['event_type']}: {properties}" else: diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json index c64028229b3..daf969ba80f 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -37,6 +37,7 @@ "long_press": "Long Press", "long_double_press": "Long Double Press", "long_triple_press": "Long Triple Press", + "hold_press": "Hold Press", "rotate_right": "Rotate Right", "rotate_left": "Rotate Left" }, @@ -56,7 +57,8 @@ "triple_press": "Triple press", "long_press": "Long press", "long_double_press": "Long double press", - "long_triple_press": "Long triple press" + "long_triple_press": "Long triple press", + "hold_press": "Hold press" } } } diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index afce293402e..712f765237e 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -742,6 +742,7 @@ class BrSensor(SensorEntity): ) -> None: """Initialize the sensor.""" self.entity_description = description + self._data: BrData | None = None self._measured = None self._attr_unique_id = ( f"{coordinates[CONF_LATITUDE]:2.6f}{coordinates[CONF_LONGITUDE]:2.6f}" @@ -756,17 +757,29 @@ class BrSensor(SensorEntity): if description.key.startswith(PRECIPITATION_FORECAST): self._timeframe = None + async def async_added_to_hass(self) -> None: + """Handle entity being added to hass.""" + if self._data is None: + return + self._update() + @callback def data_updated(self, data: BrData): - """Update data.""" - if self._load_data(data.data) and self.hass: + """Handle data update.""" + self._data = data + if not self.hass: + return + self._update() + + def _update(self): + """Update sensor data.""" + _LOGGER.debug("Updating sensor %s", self.entity_id) + if self._load_data(self._data.data): self.async_write_ha_state() @callback def _load_data(self, data): # noqa: C901 """Load the sensor with relevant data.""" - # Find sensor - # Check if we have a new measurement, # otherwise we do not have to update the sensor if self._measured == data.get(MEASURED): diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 14dc09ca33e..c6b90945329 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -7,7 +7,7 @@ from enum import StrEnum import logging from typing import final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index eb09e3f5452..c6bbd15bdff 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -186,12 +186,12 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): pattern = re.compile(search) return ( - hasattr(vevent, "summary") - and pattern.match(vevent.summary.value) - or hasattr(vevent, "location") - and pattern.match(vevent.location.value) - or hasattr(vevent, "description") - and pattern.match(vevent.description.value) + (hasattr(vevent, "summary") and pattern.match(vevent.summary.value)) + or (hasattr(vevent, "location") and pattern.match(vevent.location.value)) + or ( + hasattr(vevent, "description") + and pattern.match(vevent.description.value) + ) ) @staticmethod diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index e0d598e6493..5c1334c8029 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.3.9"] + "requirements": ["caldav==1.3.9", "icalendar==6.1.0"] } diff --git a/homeassistant/components/cambridge_audio/config_flow.py b/homeassistant/components/cambridge_audio/config_flow.py index ca587ee9a48..fb0ad66c652 100644 --- a/homeassistant/components/cambridge_audio/config_flow.py +++ b/homeassistant/components/cambridge_audio/config_flow.py @@ -6,13 +6,19 @@ from typing import Any from aiostreammagic import StreamMagicClient import voluptuous as vol -from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN): """Cambridge Audio configuration flow.""" @@ -24,7 +30,7 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN): self.data: dict[str, Any] = {} async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.data[CONF_HOST] = host = discovery_info.host @@ -64,6 +70,17 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + if not user_input: + return self.async_show_form( + step_id="reconfigure", + data_schema=DATA_SCHEMA, + ) + return await self.async_step_user(user_input) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -82,6 +99,12 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( client.info.unit_id, raise_on_progress=False ) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_HOST: user_input[CONF_HOST]}, + ) self._abort_if_unique_id_configured() return self.async_create_entry( title=client.info.name, @@ -91,6 +114,6 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN): await client.disconnect() return self.async_show_form( step_id="user", - data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + data_schema=DATA_SCHEMA, errors=errors, ) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index 7b7e341e3c6..14a389587d2 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -7,6 +7,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aiostreammagic"], + "quality_scale": "platinum", "requirements": ["aiostreammagic==2.10.0"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/homeassistant/components/cambridge_audio/media_browser.py b/homeassistant/components/cambridge_audio/media_browser.py new file mode 100644 index 00000000000..efe55ee792e --- /dev/null +++ b/homeassistant/components/cambridge_audio/media_browser.py @@ -0,0 +1,85 @@ +"""Support for media browsing.""" + +from aiostreammagic import StreamMagicClient +from aiostreammagic.models import Preset + +from homeassistant.components.media_player import BrowseMedia, MediaClass +from homeassistant.core import HomeAssistant + + +async def async_browse_media( + hass: HomeAssistant, + client: StreamMagicClient, + media_content_id: str | None, + media_content_type: str | None, +) -> BrowseMedia: + """Browse media.""" + + if media_content_type == "presets": + return await _presets_payload(client.preset_list.presets) + + return await _root_payload( + hass, + client, + ) + + +async def _root_payload( + hass: HomeAssistant, + client: StreamMagicClient, +) -> BrowseMedia: + """Return root payload for Cambridge Audio.""" + children: list[BrowseMedia] = [] + + if client.preset_list.presets: + children.append( + BrowseMedia( + title="Presets", + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type="presets", + thumbnail="https://brands.home-assistant.io/_/cambridge_audio/logo.png", + can_play=False, + can_expand=True, + ) + ) + + return BrowseMedia( + title="Cambridge Audio", + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type="root", + can_play=False, + can_expand=True, + children=children, + ) + + +async def _presets_payload(presets: list[Preset]) -> BrowseMedia: + """Create payload to list presets.""" + + children: list[BrowseMedia] = [] + for preset in presets: + if preset.state != "OK": + continue + children.append( + BrowseMedia( + title=preset.name, + media_class=MediaClass.MUSIC, + media_content_id=str(preset.preset_id), + media_content_type="preset", + can_play=True, + can_expand=False, + thumbnail=preset.art_url, + ) + ) + + return BrowseMedia( + title="Presets", + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type="presets", + can_play=False, + can_expand=True, + children=children, + ) diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index 805cf8ec7f6..042178d5781 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -13,6 +13,7 @@ from aiostreammagic import ( ) from homeassistant.components.media_player import ( + BrowseMedia, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -20,11 +21,11 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import CambridgeAudioConfigEntry, media_browser from .const import ( CAMBRIDGE_MEDIA_TYPE_AIRABLE, CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO, @@ -34,7 +35,8 @@ from .const import ( from .entity import CambridgeAudioEntity, command BASE_FEATURES = ( - MediaPlayerEntityFeature.SELECT_SOURCE + MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.PLAY_MEDIA @@ -62,7 +64,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CambridgeAudioConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Cambridge Audio device based on a config entry.""" @@ -338,3 +340,13 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): if media_type == CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO: await self.client.play_radio_url("Radio", media_id) + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Implement the media browsing helper.""" + return await media_browser.async_browse_media( + self.hass, self.client, media_content_id, media_content_type + ) diff --git a/homeassistant/components/cambridge_audio/quality_scale.yaml b/homeassistant/components/cambridge_audio/quality_scale.yaml new file mode 100644 index 00000000000..e5cafdd6368 --- /dev/null +++ b/homeassistant/components/cambridge_audio/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions beyond play media which is setup by the media player entity. + appropriate-polling: + status: exempt + comment: | + This integration uses a push API. No polling required. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: + status: exempt + comment: | + This integration is not a hub and as such only represents a single device. + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: + status: exempt + comment: | + This integration is not a hub and only represents a single device. + discovery-update-info: done + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py index b1bc0f9e4df..6bfe83c2539 100644 --- a/homeassistant/components/cambridge_audio/select.py +++ b/homeassistant/components/cambridge_audio/select.py @@ -7,11 +7,11 @@ from aiostreammagic import StreamMagicClient from aiostreammagic.models import DisplayBrightness from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import CambridgeAudioConfigEntry from .entity import CambridgeAudioEntity, command PARALLEL_UPDATES = 0 @@ -81,7 +81,7 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CambridgeAudioConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Cambridge Audio select entities based on a config entry.""" diff --git a/homeassistant/components/cambridge_audio/strings.json b/homeassistant/components/cambridge_audio/strings.json index c368ba060a7..6041232fe65 100644 --- a/homeassistant/components/cambridge_audio/strings.json +++ b/homeassistant/components/cambridge_audio/strings.json @@ -12,13 +12,24 @@ } }, "discovery_confirm": { - "description": "Do you want to setup {name}?" + "description": "Do you want to set up {name}?" + }, + "reconfigure": { + "description": "Reconfigure your Cambridge Audio Streamer.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::cambridge_audio::config::step::user::data_description::host%]" + } } }, "error": { "cannot_connect": "Failed to connect to Cambridge Audio device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect." }, "abort": { + "wrong_device": "This Cambridge Audio device does not match the existing device ID. Please make sure you entered the correct IP address.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } diff --git a/homeassistant/components/cambridge_audio/switch.py b/homeassistant/components/cambridge_audio/switch.py index 72aa0d3cbea..065a1da4f94 100644 --- a/homeassistant/components/cambridge_audio/switch.py +++ b/homeassistant/components/cambridge_audio/switch.py @@ -7,11 +7,11 @@ from typing import Any from aiostreammagic import StreamMagicClient from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import CambridgeAudioConfigEntry from .entity import CambridgeAudioEntity, command PARALLEL_UPDATES = 0 @@ -45,7 +45,7 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CambridgeAudioConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Cambridge Audio switch entities based on a config entry.""" diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 781388f12d6..556f8d75fc4 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -18,7 +18,7 @@ from typing import Any, Final, final from aiohttp import hdrs, web import attr -from propcache import cached_property, under_cached_property +from propcache.api import cached_property, under_cached_property import voluptuous as vol from webrtc_models import RTCIceCandidateInit, RTCIceServer @@ -67,9 +67,7 @@ from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import bind_hass -from .const import ( # noqa: F401 - _DEPRECATED_STREAM_TYPE_HLS, - _DEPRECATED_STREAM_TYPE_WEB_RTC, +from .const import ( CAMERA_IMAGE_TIMEOUT, CAMERA_STREAM_SOURCE_TIMEOUT, CONF_DURATION, @@ -135,16 +133,6 @@ class CameraEntityFeature(IntFlag): STREAM = 2 -# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. -# Pleease use the CameraEntityFeature enum instead. -_DEPRECATED_SUPPORT_ON_OFF: Final = DeprecatedConstantEnum( - CameraEntityFeature.ON_OFF, "2025.1" -) -_DEPRECATED_SUPPORT_STREAM: Final = DeprecatedConstantEnum( - CameraEntityFeature.STREAM, "2025.1" -) - - DEFAULT_CONTENT_TYPE: Final = "image/jpeg" ENTITY_IMAGE_URL: Final = "/api/camera_proxy/{0}?token={1}" @@ -535,7 +523,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): Remove this compatibility shim in 2025.1 or later. """ features = self.supported_features - if type(features) is int: # noqa: E721 + if type(features) is int: new_features = CameraEntityFeature(features) self._report_deprecated_supported_features_values(new_features) return new_features diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index 7e4633d410a..65862e66dab 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -3,15 +3,8 @@ from __future__ import annotations from enum import StrEnum -from functools import partial from typing import TYPE_CHECKING, Final -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: @@ -58,17 +51,3 @@ class StreamType(StrEnum): HLS = "hls" WEB_RTC = "web_rtc" - - -# These constants are deprecated as of Home Assistant 2022.5 -# Please use the StreamType enum instead. -_DEPRECATED_STREAM_TYPE_HLS = DeprecatedConstantEnum(StreamType.HLS, "2025.1") -_DEPRECATED_STREAM_TYPE_WEB_RTC = DeprecatedConstantEnum(StreamType.WEB_RTC, "2025.1") - - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index f879c308a88..a28c37580ce 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -11,7 +11,7 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -20,13 +20,11 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_FFMPEG_ARGUMENTS, - DATA_COORDINATOR, - DATA_UNDO_UPDATE_LISTENER, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, ) -from .coordinator import CanaryDataUpdateCoordinator +from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator _LOGGER: Final = logging.getLogger(__name__) @@ -59,8 +57,6 @@ PLATFORMS: Final[list[Platform]] = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Canary integration.""" - hass.data.setdefault(DOMAIN, {}) - if hass.config_entries.async_entries(DOMAIN): return True @@ -90,7 +86,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool: """Set up Canary from a config entry.""" if not entry.options: options = { @@ -107,38 +103,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Unable to connect to Canary service: %s", str(error)) raise ConfigEntryNotReady from error - coordinator = CanaryDataUpdateCoordinator(hass, api=canary_api) + coordinator = CanaryDataUpdateCoordinator(hass, entry, api=canary_api) await coordinator.async_config_entry_first_refresh() - undo_listener = entry.add_update_listener(_async_update_listener) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: CanaryConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -def _get_canary_api_instance(entry: ConfigEntry) -> Api: +def _get_canary_api_instance(entry: CanaryConfigEntry) -> Api: """Initialize a new instance of CanaryApi.""" return Api( entry.data[CONF_USERNAME], diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index 69600e4bbc7..443944da8c3 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -12,24 +12,20 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import CanaryDataUpdateCoordinator +from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CanaryConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Canary alarm control panels based on a config entry.""" - coordinator: CanaryDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data alarms = [ CanaryAlarm(coordinator, location) for location_id, location in coordinator.data["locations"].items() diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index a56d1ebc3de..8f4a01c9968 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -18,7 +18,6 @@ from homeassistant.components.camera import ( Camera, ) from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream @@ -27,14 +26,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ( - CONF_FFMPEG_ARGUMENTS, - DATA_COORDINATOR, - DEFAULT_FFMPEG_ARGUMENTS, - DOMAIN, - MANUFACTURER, -) -from .coordinator import CanaryDataUpdateCoordinator +from .const import CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DOMAIN, MANUFACTURER +from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator FORCE_CAMERA_REFRESH_INTERVAL: Final = timedelta(minutes=15) @@ -54,13 +47,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CanaryConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Canary sensors based on a config entry.""" - coordinator: CanaryDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data ffmpeg_arguments: str = entry.options.get( CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS ) diff --git a/homeassistant/components/canary/const.py b/homeassistant/components/canary/const.py index 210da35c7c1..9b9229c3ac3 100644 --- a/homeassistant/components/canary/const.py +++ b/homeassistant/components/canary/const.py @@ -9,10 +9,6 @@ MANUFACTURER: Final = "Canary Connect, Inc" # Configuration CONF_FFMPEG_ARGUMENTS: Final = "ffmpeg_arguments" -# Data -DATA_COORDINATOR: Final = "coordinator" -DATA_UNDO_UPDATE_LISTENER: Final = "undo_update_listener" - # Defaults DEFAULT_FFMPEG_ARGUMENTS: Final = "-pred 1" DEFAULT_TIMEOUT: Final = 10 diff --git a/homeassistant/components/canary/coordinator.py b/homeassistant/components/canary/coordinator.py index d58d1da0f79..7c90074f81a 100644 --- a/homeassistant/components/canary/coordinator.py +++ b/homeassistant/components/canary/coordinator.py @@ -11,6 +11,7 @@ from canary.api import Api from canary.model import Location, Reading from requests.exceptions import ConnectTimeout, HTTPError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -20,10 +21,15 @@ from .model import CanaryData _LOGGER = logging.getLogger(__name__) +type CanaryConfigEntry = ConfigEntry[CanaryDataUpdateCoordinator] + + class CanaryDataUpdateCoordinator(DataUpdateCoordinator[CanaryData]): """Class to manage fetching Canary data.""" - def __init__(self, hass: HomeAssistant, *, api: Api) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: CanaryConfigEntry, *, api: Api + ) -> None: """Initialize global Canary data updater.""" self.canary = api update_interval = timedelta(seconds=30) @@ -31,6 +37,7 @@ class CanaryDataUpdateCoordinator(DataUpdateCoordinator[CanaryData]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=update_interval, ) diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 9aab4698bf3..22f3eada2cb 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -7,7 +7,6 @@ from typing import Final from canary.model import Device, Location, SensorType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -18,8 +17,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER -from .coordinator import CanaryDataUpdateCoordinator +from .const import DOMAIN, MANUFACTURER +from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator type SensorTypeItem = tuple[ str, str | None, str | None, SensorDeviceClass | None, list[str] @@ -64,13 +63,11 @@ STATE_AIR_QUALITY_VERY_ABNORMAL: Final = "very_abnormal" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: CanaryConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Canary sensors based on a config entry.""" - coordinator: CanaryDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data sensors: list[CanarySensor] = [] for location in coordinator.data["locations"].values(): diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index 03a3f2ea1f8..034cf856023 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -16,6 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_UUID from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN @@ -50,7 +51,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_config() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" await self.async_set_unique_id(DOMAIN) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 228c69b65ec..8f4af197b8e 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -5,7 +5,7 @@ from __future__ import annotations import configparser from dataclasses import dataclass import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from urllib.parse import urlparse import aiohttp @@ -129,7 +129,7 @@ class ChromecastInfo: class ChromeCastZeroconf: """Class to hold a zeroconf instance.""" - __zconf: zeroconf.HaZeroconf | None = None + __zconf: ClassVar[zeroconf.HaZeroconf | None] = None @classmethod def set_zeroconf(cls, zconf: zeroconf.HaZeroconf) -> None: diff --git a/homeassistant/components/ccm15/__init__.py b/homeassistant/components/ccm15/__init__.py index a35568047ad..eae5d095ce7 100644 --- a/homeassistant/components/ccm15/__init__.py +++ b/homeassistant/components/ccm15/__init__.py @@ -2,34 +2,30 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import CCM15Coordinator +from .coordinator import CCM15ConfigEntry, CCM15Coordinator PLATFORMS: list[Platform] = [Platform.CLIMATE] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: CCM15ConfigEntry) -> bool: """Set up Midea ccm15 AC Controller from a config entry.""" coordinator = CCM15Coordinator( hass, + entry, entry.data[CONF_HOST], entry.data[CONF_PORT], ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: CCM15ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index a6e5d2cab61..099b91ec02c 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -17,7 +17,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -25,18 +24,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONST_CMD_FAN_MAP, CONST_CMD_STATE_MAP, DOMAIN -from .coordinator import CCM15Coordinator +from .coordinator import CCM15ConfigEntry, CCM15Coordinator _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CCM15ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up all climate.""" - coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data ac_data: CCM15DeviceState = coordinator.data entities = [ @@ -70,7 +69,6 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator diff --git a/homeassistant/components/ccm15/coordinator.py b/homeassistant/components/ccm15/coordinator.py index cd3b313f700..03a59aa3f24 100644 --- a/homeassistant/components/ccm15/coordinator.py +++ b/homeassistant/components/ccm15/coordinator.py @@ -7,6 +7,7 @@ from ccm15 import CCM15Device, CCM15DeviceState, CCM15SlaveDevice import httpx from homeassistant.components.climate import HVACMode +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,15 +20,20 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type CCM15ConfigEntry = ConfigEntry[CCM15Coordinator] + class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]): """Class to coordinate multiple CCM15Climate devices.""" - def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + def __init__( + self, hass: HomeAssistant, entry: CCM15ConfigEntry, host: str, port: int + ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=host, update_interval=datetime.timedelta(seconds=DEFAULT_INTERVAL), ) diff --git a/homeassistant/components/ccm15/diagnostics.py b/homeassistant/components/ccm15/diagnostics.py index 08cc239e972..c259e7f35c9 100644 --- a/homeassistant/components/ccm15/diagnostics.py +++ b/homeassistant/components/ccm15/diagnostics.py @@ -4,18 +4,16 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import CCM15Coordinator +from .coordinator import CCM15ConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: CCM15ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { str(device_id): { diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index 22d443c700d..c351435a73e 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -74,7 +74,7 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN): title=title, data={CONF_HOST: host, CONF_PORT: port}, ) - if self.context["source"] == SOURCE_IMPORT: + if self.source == SOURCE_IMPORT: _LOGGER.error("Config import failed for %s", user_input[CONF_HOST]) return self.async_abort(reason="import_failed") else: @@ -94,10 +94,3 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=self._errors, ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import a config entry. - - Only host was required in the yaml file all other fields are optional - """ - return await self.async_step_user(import_data) diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index a6f163b51be..4fd0846f0f3 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -2,63 +2,18 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START -from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import CertExpiryConfigEntry -from .const import DEFAULT_PORT, DOMAIN +from .const import DOMAIN from .coordinator import CertExpiryDataUpdateCoordinator from .entity import CertExpiryEntity -SCAN_INTERVAL = timedelta(hours=12) - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up certificate expiry sensor.""" - - @callback - def schedule_import(_: Event) -> None: - """Schedule delayed import after HA is fully started.""" - async_call_later(hass, 10, do_import) - - @callback - def do_import(_: datetime) -> None: - """Process YAML import.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config) - ) - ) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_import) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 1f78f95c259..b882f046a8e 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import re from pexpect import pxssh import voluptuous as vol @@ -101,11 +100,11 @@ class CiscoDeviceScanner(DeviceScanner): return False - def _get_arp_data(self): + def _get_arp_data(self) -> str | None: """Open connection to the router and get arp entries.""" try: - cisco_ssh = pxssh.pxssh() + cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="uft-8") cisco_ssh.login( self.host, self.username, @@ -115,12 +114,11 @@ class CiscoDeviceScanner(DeviceScanner): ) # Find the hostname - initial_line = cisco_ssh.before.decode("utf-8").splitlines() + initial_line = (cisco_ssh.before or "").splitlines() router_hostname = initial_line[len(initial_line) - 1] router_hostname += "#" # Set the discovered hostname as prompt - regex_expression = f"(?i)^{router_hostname}".encode() - cisco_ssh.PROMPT = re.compile(regex_expression, re.MULTILINE) + cisco_ssh.PROMPT = f"(?i)^{router_hostname}" # Allow full arp table to print at once cisco_ssh.sendline("terminal length 0") cisco_ssh.prompt(1) @@ -128,13 +126,11 @@ class CiscoDeviceScanner(DeviceScanner): cisco_ssh.sendline("show ip arp") cisco_ssh.prompt(1) - devices_result = cisco_ssh.before - - return devices_result.decode("utf-8") except pxssh.ExceptionPxssh as px_e: _LOGGER.error("Failed to login via pxssh: %s", px_e) + return None - return None + return cisco_ssh.before def _parse_cisco_mac_address(cisco_hardware_addr): diff --git a/homeassistant/components/cisco_ios/manifest.json b/homeassistant/components/cisco_ios/manifest.json index ba0678c167f..ddfb96a70ae 100644 --- a/homeassistant/components/cisco_ios/manifest.json +++ b/homeassistant/components/cisco_ios/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pexpect", "ptyprocess"], "quality_scale": "legacy", - "requirements": ["pexpect==4.6.0"] + "requirements": ["pexpect==4.9.0"] } diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 5e4da231eef..6cd401989c8 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -34,7 +34,7 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import location +from homeassistant.util import location as location_util from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -193,7 +193,7 @@ async def async_setup_platform( devices = [] for station in network.stations: - dist = location.distance( + dist = location_util.distance( latitude, longitude, station[ATTR_LATITUDE], station[ATTR_LONGITUDE] ) station_id = station[ATTR_ID] @@ -236,7 +236,7 @@ class CityBikesNetworks: for network in self.networks: network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE] network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE] - dist = location.distance( + dist = location_util.distance( latitude, longitude, network_latitude, network_longitude ) if minimum_dist is None or dist < minimum_dist: diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 08bad57b6d0..af64b06ebe6 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -2,13 +2,12 @@ from __future__ import annotations -import asyncio from datetime import timedelta import functools as ft import logging from typing import Any, Literal, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -26,14 +25,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.deprecation import ( - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_issue_tracker, async_suggest_report_issue @@ -41,20 +34,6 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( # noqa: F401 - _DEPRECATED_HVAC_MODE_AUTO, - _DEPRECATED_HVAC_MODE_COOL, - _DEPRECATED_HVAC_MODE_DRY, - _DEPRECATED_HVAC_MODE_FAN_ONLY, - _DEPRECATED_HVAC_MODE_HEAT, - _DEPRECATED_HVAC_MODE_HEAT_COOL, - _DEPRECATED_HVAC_MODE_OFF, - _DEPRECATED_SUPPORT_AUX_HEAT, - _DEPRECATED_SUPPORT_FAN_MODE, - _DEPRECATED_SUPPORT_PRESET_MODE, - _DEPRECATED_SUPPORT_SWING_MODE, - _DEPRECATED_SUPPORT_TARGET_HUMIDITY, - _DEPRECATED_SUPPORT_TARGET_TEMPERATURE, - _DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE, ATTR_AUX_HEAT, ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, @@ -70,6 +49,8 @@ from .const import ( # noqa: F401 ATTR_MIN_TEMP, ATTR_PRESET_MODE, ATTR_PRESET_MODES, + ATTR_SWING_HORIZONTAL_MODE, + ATTR_SWING_HORIZONTAL_MODES, ATTR_SWING_MODE, ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, @@ -101,6 +82,7 @@ from .const import ( # noqa: F401 SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, SWING_BOTH, @@ -219,6 +201,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_handle_set_swing_mode_service", [ClimateEntityFeature.SWING_MODE], ) + component.async_register_entity_service( + SERVICE_SET_SWING_HORIZONTAL_MODE, + {vol.Required(ATTR_SWING_HORIZONTAL_MODE): cv.string}, + "async_handle_set_swing_horizontal_mode_service", + [ClimateEntityFeature.SWING_HORIZONTAL_MODE], + ) return True @@ -256,6 +244,8 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "fan_modes", "swing_mode", "swing_modes", + "swing_horizontal_mode", + "swing_horizontal_modes", "supported_features", "min_temp", "max_temp", @@ -300,6 +290,8 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_supported_features: ClimateEntityFeature = ClimateEntityFeature(0) _attr_swing_mode: str | None _attr_swing_modes: list[str] | None + _attr_swing_horizontal_mode: str | None + _attr_swing_horizontal_modes: list[str] | None _attr_target_humidity: float | None = None _attr_target_temperature_high: float | None _attr_target_temperature_low: float | None @@ -309,115 +301,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): __climate_reported_legacy_aux = False - __mod_supported_features: ClimateEntityFeature = ClimateEntityFeature(0) - # Integrations should set `_enable_turn_on_off_backwards_compatibility` to False - # once migrated and set the feature flags TURN_ON/TURN_OFF as needed. - _enable_turn_on_off_backwards_compatibility: bool = True - - def __getattribute__(self, name: str, /) -> Any: - """Get attribute. - - Modify return of `supported_features` to - include `_mod_supported_features` if attribute is set. - """ - if name != "supported_features": - return super().__getattribute__(name) - - # Convert the supported features to ClimateEntityFeature. - # Remove this compatibility shim in 2025.1 or later. - _supported_features: ClimateEntityFeature = super().__getattribute__( - "supported_features" - ) - _mod_supported_features: ClimateEntityFeature = super().__getattribute__( - "_ClimateEntity__mod_supported_features" - ) - if type(_supported_features) is int: # noqa: E721 - _features = ClimateEntityFeature(_supported_features) - self._report_deprecated_supported_features_values(_features) - else: - _features = _supported_features - - if not _mod_supported_features: - return _features - - # Add automatically calculated ClimateEntityFeature.TURN_OFF/TURN_ON to - # supported features and return it - return _features | _mod_supported_features - - @callback - def add_to_platform_start( - self, - hass: HomeAssistant, - platform: EntityPlatform, - parallel_updates: asyncio.Semaphore | None, - ) -> None: - """Start adding an entity to a platform.""" - super().add_to_platform_start(hass, platform, parallel_updates) - - def _report_turn_on_off(feature: str, method: str) -> None: - """Log warning not implemented turn on/off feature.""" - report_issue = self._suggest_report_issue() - if feature.startswith("TURN"): - message = ( - "Entity %s (%s) does not set ClimateEntityFeature.%s" - " but implements the %s method. Please %s" - ) - else: - message = ( - "Entity %s (%s) implements HVACMode(s): %s and therefore implicitly" - " supports the %s methods without setting the proper" - " ClimateEntityFeature. Please %s" - ) - _LOGGER.warning( - message, - self.entity_id, - type(self), - feature, - method, - report_issue, - ) - - # Adds ClimateEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented - # This should be removed in 2025.1. - if self._enable_turn_on_off_backwards_compatibility is False: - # Return if integration has migrated already - return - - supported_features = self.supported_features - if supported_features & CHECK_TURN_ON_OFF_FEATURE_FLAG: - # The entity supports both turn_on and turn_off, the backwards compatibility - # checks are not needed - return - - if not supported_features & ClimateEntityFeature.TURN_OFF and ( - type(self).async_turn_off is not ClimateEntity.async_turn_off - or type(self).turn_off is not ClimateEntity.turn_off - ): - # turn_off implicitly supported by implementing turn_off method - _report_turn_on_off("TURN_OFF", "turn_off") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - ClimateEntityFeature.TURN_OFF - ) - - if not supported_features & ClimateEntityFeature.TURN_ON and ( - type(self).async_turn_on is not ClimateEntity.async_turn_on - or type(self).turn_on is not ClimateEntity.turn_on - ): - # turn_on implicitly supported by implementing turn_on method - _report_turn_on_off("TURN_ON", "turn_on") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - ClimateEntityFeature.TURN_ON - ) - - if (modes := self.hvac_modes) and len(modes) >= 2 and HVACMode.OFF in modes: - # turn_on/off implicitly supported by including more modes than 1 and one of these - # are HVACMode.OFF - _modes = [_mode for _mode in modes if _mode is not None] - _report_turn_on_off(", ".join(_modes or []), "turn_on/turn_off") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF - ) - def _report_legacy_aux(self) -> None: """Log warning and create an issue if the entity implements legacy auxiliary heater.""" @@ -513,6 +396,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ClimateEntityFeature.SWING_MODE in supported_features: data[ATTR_SWING_MODES] = self.swing_modes + if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features: + data[ATTR_SWING_HORIZONTAL_MODES] = self.swing_horizontal_modes + return data @final @@ -564,6 +450,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ClimateEntityFeature.SWING_MODE in supported_features: data[ATTR_SWING_MODE] = self.swing_mode + if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features: + data[ATTR_SWING_HORIZONTAL_MODE] = self.swing_horizontal_mode + if ClimateEntityFeature.AUX_HEAT in supported_features: data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF if ( @@ -691,11 +580,27 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ return self._attr_swing_modes + @cached_property + def swing_horizontal_mode(self) -> str | None: + """Return the horizontal swing setting. + + Requires ClimateEntityFeature.SWING_HORIZONTAL_MODE. + """ + return self._attr_swing_horizontal_mode + + @cached_property + def swing_horizontal_modes(self) -> list[str] | None: + """Return the list of available horizontal swing modes. + + Requires ClimateEntityFeature.SWING_HORIZONTAL_MODE. + """ + return self._attr_swing_horizontal_modes + @final @callback def _valid_mode_or_raise( self, - mode_type: Literal["preset", "swing", "fan", "hvac"], + mode_type: Literal["preset", "horizontal_swing", "swing", "fan", "hvac"], mode: str | HVACMode, modes: list[str] | list[HVACMode] | None, ) -> None: @@ -793,6 +698,26 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Set new target swing operation.""" await self.hass.async_add_executor_job(self.set_swing_mode, swing_mode) + @final + async def async_handle_set_swing_horizontal_mode_service( + self, swing_horizontal_mode: str + ) -> None: + """Validate and set new horizontal swing mode.""" + self._valid_mode_or_raise( + "horizontal_swing", swing_horizontal_mode, self.swing_horizontal_modes + ) + await self.async_set_swing_horizontal_mode(swing_horizontal_mode) + + def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new target horizontal swing operation.""" + raise NotImplementedError + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new target horizontal swing operation.""" + await self.hass.async_add_executor_job( + self.set_swing_horizontal_mode, swing_horizontal_mode + ) + @final async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None: """Validate and set new preset mode.""" @@ -1027,13 +952,3 @@ async def async_service_temperature_set( kwargs[value] = temp await entity.async_set_temperature(**kwargs) - - -# As we import deprecated constants from the const module, we need to add these two functions -# otherwise this module will be logged for using deprecated constants and not the custom component -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = ft.partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index a84a2f3c628..111401a2251 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -1,14 +1,6 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum -from functools import partial - -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) class HVACMode(StrEnum): @@ -37,15 +29,6 @@ class HVACMode(StrEnum): FAN_ONLY = "fan_only" -# These HVAC_MODE_* constants are deprecated as of Home Assistant 2022.5. -# Please use the HVACMode enum instead. -_DEPRECATED_HVAC_MODE_OFF = DeprecatedConstantEnum(HVACMode.OFF, "2025.1") -_DEPRECATED_HVAC_MODE_HEAT = DeprecatedConstantEnum(HVACMode.HEAT, "2025.1") -_DEPRECATED_HVAC_MODE_COOL = DeprecatedConstantEnum(HVACMode.COOL, "2025.1") -_DEPRECATED_HVAC_MODE_HEAT_COOL = DeprecatedConstantEnum(HVACMode.HEAT_COOL, "2025.1") -_DEPRECATED_HVAC_MODE_AUTO = DeprecatedConstantEnum(HVACMode.AUTO, "2025.1") -_DEPRECATED_HVAC_MODE_DRY = DeprecatedConstantEnum(HVACMode.DRY, "2025.1") -_DEPRECATED_HVAC_MODE_FAN_ONLY = DeprecatedConstantEnum(HVACMode.FAN_ONLY, "2025.1") HVAC_MODES = [cls.value for cls in HVACMode] # No preset is active @@ -92,6 +75,10 @@ SWING_BOTH = "both" SWING_VERTICAL = "vertical" SWING_HORIZONTAL = "horizontal" +# Possible horizontal swing state +SWING_HORIZONTAL_ON = "on" +SWING_HORIZONTAL_OFF = "off" + class HVACAction(StrEnum): """HVAC action for climate devices.""" @@ -106,14 +93,6 @@ class HVACAction(StrEnum): PREHEATING = "preheating" -# These CURRENT_HVAC_* constants are deprecated as of Home Assistant 2022.5. -# Please use the HVACAction enum instead. -_DEPRECATED_CURRENT_HVAC_OFF = DeprecatedConstantEnum(HVACAction.OFF, "2025.1") -_DEPRECATED_CURRENT_HVAC_HEAT = DeprecatedConstantEnum(HVACAction.HEATING, "2025.1") -_DEPRECATED_CURRENT_HVAC_COOL = DeprecatedConstantEnum(HVACAction.COOLING, "2025.1") -_DEPRECATED_CURRENT_HVAC_DRY = DeprecatedConstantEnum(HVACAction.DRYING, "2025.1") -_DEPRECATED_CURRENT_HVAC_IDLE = DeprecatedConstantEnum(HVACAction.IDLE, "2025.1") -_DEPRECATED_CURRENT_HVAC_FAN = DeprecatedConstantEnum(HVACAction.FAN, "2025.1") CURRENT_HVAC_ACTIONS = [cls.value for cls in HVACAction] @@ -134,6 +113,8 @@ ATTR_HVAC_MODES = "hvac_modes" ATTR_HVAC_MODE = "hvac_mode" ATTR_SWING_MODES = "swing_modes" ATTR_SWING_MODE = "swing_mode" +ATTR_SWING_HORIZONTAL_MODE = "swing_horizontal_mode" +ATTR_SWING_HORIZONTAL_MODES = "swing_horizontal_modes" ATTR_TARGET_TEMP_HIGH = "target_temp_high" ATTR_TARGET_TEMP_LOW = "target_temp_low" ATTR_TARGET_TEMP_STEP = "target_temp_step" @@ -153,6 +134,7 @@ SERVICE_SET_PRESET_MODE = "set_preset_mode" SERVICE_SET_HUMIDITY = "set_humidity" SERVICE_SET_HVAC_MODE = "set_hvac_mode" SERVICE_SET_SWING_MODE = "set_swing_mode" +SERVICE_SET_SWING_HORIZONTAL_MODE = "set_swing_horizontal_mode" SERVICE_SET_TEMPERATURE = "set_temperature" @@ -168,35 +150,4 @@ class ClimateEntityFeature(IntFlag): AUX_HEAT = 64 TURN_OFF = 128 TURN_ON = 256 - - -# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. -# Please use the ClimateEntityFeature enum instead. -_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum( - ClimateEntityFeature.TARGET_TEMPERATURE, "2025.1" -) -_DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE = DeprecatedConstantEnum( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, "2025.1" -) -_DEPRECATED_SUPPORT_TARGET_HUMIDITY = DeprecatedConstantEnum( - ClimateEntityFeature.TARGET_HUMIDITY, "2025.1" -) -_DEPRECATED_SUPPORT_FAN_MODE = DeprecatedConstantEnum( - ClimateEntityFeature.FAN_MODE, "2025.1" -) -_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum( - ClimateEntityFeature.PRESET_MODE, "2025.1" -) -_DEPRECATED_SUPPORT_SWING_MODE = DeprecatedConstantEnum( - ClimateEntityFeature.SWING_MODE, "2025.1" -) -_DEPRECATED_SUPPORT_AUX_HEAT = DeprecatedConstantEnum( - ClimateEntityFeature.AUX_HEAT, "2025.1" -) - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) + SWING_HORIZONTAL_MODE = 512 diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index c9a8d12d01b..8f4ffa6b19f 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -51,6 +51,13 @@ "on": "mdi:arrow-oscillating", "vertical": "mdi:arrow-up-down" } + }, + "swing_horizontal_mode": { + "default": "mdi:circle-medium", + "state": { + "off": "mdi:arrow-oscillating-off", + "on": "mdi:arrow-expand-horizontal" + } } } } @@ -65,6 +72,9 @@ "set_swing_mode": { "service": "mdi:arrow-oscillating" }, + "set_swing_horizontal_mode": { + "service": "mdi:arrow-expand-horizontal" + }, "set_temperature": { "service": "mdi:thermometer" }, diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py index 99357777fba..d38e243cb62 100644 --- a/homeassistant/components/climate/reproduce_state.py +++ b/homeassistant/components/climate/reproduce_state.py @@ -14,6 +14,7 @@ from .const import ( ATTR_HUMIDITY, ATTR_HVAC_MODE, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -23,6 +24,7 @@ from .const import ( SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, ) @@ -76,6 +78,14 @@ async def _async_reproduce_states( ): await call_service(SERVICE_SET_SWING_MODE, [ATTR_SWING_MODE]) + if ( + ATTR_SWING_HORIZONTAL_MODE in state.attributes + and state.attributes[ATTR_SWING_HORIZONTAL_MODE] is not None + ): + await call_service( + SERVICE_SET_SWING_HORIZONTAL_MODE, [ATTR_SWING_HORIZONTAL_MODE] + ) + if ( ATTR_FAN_MODE in state.attributes and state.attributes[ATTR_FAN_MODE] is not None diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 12a8e6f001f..68421bf2386 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -131,7 +131,20 @@ set_swing_mode: fields: swing_mode: required: true - example: "horizontal" + example: "on" + selector: + text: + +set_swing_horizontal_mode: + target: + entity: + domain: climate + supported_features: + - climate.ClimateEntityFeature.SWING_HORIZONTAL_MODE + fields: + swing_horizontal_mode: + required: true + example: "on" selector: text: diff --git a/homeassistant/components/climate/significant_change.py b/homeassistant/components/climate/significant_change.py index 0c4cdd4ac6a..2b7e2c5d8b1 100644 --- a/homeassistant/components/climate/significant_change.py +++ b/homeassistant/components/climate/significant_change.py @@ -19,6 +19,7 @@ from . import ( ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -34,6 +35,7 @@ SIGNIFICANT_ATTRIBUTES: set[str] = { ATTR_HVAC_ACTION, ATTR_PRESET_MODE, ATTR_SWING_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ATTR_TEMPERATURE, @@ -70,6 +72,7 @@ def async_check_significant_change( ATTR_HVAC_ACTION, ATTR_PRESET_MODE, ATTR_SWING_MODE, + ATTR_SWING_HORIZONTAL_MODE, ]: return True diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index f607419195e..6d8b2c5449d 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -123,6 +123,16 @@ "swing_modes": { "name": "Swing modes" }, + "swing_horizontal_mode": { + "name": "Horizontal swing mode", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "swing_horizontal_modes": { + "name": "Horizontal swing modes" + }, "target_temp_high": { "name": "Upper target temperature" }, @@ -221,6 +231,16 @@ } } }, + "set_swing_horizontal_mode": { + "name": "Set horizontal swing mode", + "description": "Sets horizontal swing operation mode.", + "fields": { + "swing_horizontal_mode": { + "name": "Horizontal swing mode", + "description": "Horizontal swing operation mode." + } + } + }, "turn_on": { "name": "[%key:common::action::turn_on%]", "description": "Turns climate device on." @@ -264,6 +284,9 @@ "not_valid_swing_mode": { "message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}." }, + "not_valid_horizontal_swing_mode": { + "message": "Horizontal swing mode {mode} is not valid. Valid horizontal swing modes are: {modes}." + }, "not_valid_fan_mode": { "message": "Fan mode {mode} is not valid. Valid fan modes are: {modes}." }, diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 80c02571d24..55ffedd2781 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -36,14 +36,20 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.signal_type import SignalType -from . import account_link, http_api +# Pre-import backup to avoid it being imported +# later when the import executor is busy and delaying +# startup +from . import ( + account_link, + backup, # noqa: F401 + http_api, +) from .client import CloudClient from .const import ( CONF_ACCOUNT_LINK_SERVER, CONF_ACCOUNTS_SERVER, CONF_ACME_SERVER, CONF_ALEXA, - CONF_ALEXA_SERVER, CONF_ALIASES, CONF_CLOUDHOOK_SERVER, CONF_COGNITO_CLIENT_ID, @@ -121,7 +127,6 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_ACCOUNT_LINK_SERVER): str, vol.Optional(CONF_ACCOUNTS_SERVER): str, vol.Optional(CONF_ACME_SERVER): str, - vol.Optional(CONF_ALEXA_SERVER): str, vol.Optional(CONF_CLOUDHOOK_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str, diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index b67c1afad71..851d658f8e0 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -65,7 +65,7 @@ async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]: services: list[dict[str, Any]] if DATA_SERVICES in hass.data: services = hass.data[DATA_SERVICES] - return services # noqa: RET504 + return services try: services = await account_link.async_fetch_available_services( diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py new file mode 100644 index 00000000000..153d0741770 --- /dev/null +++ b/homeassistant/components/cloud/backup.py @@ -0,0 +1,259 @@ +"""Backup platform for the cloud integration.""" + +from __future__ import annotations + +import asyncio +import base64 +from collections.abc import AsyncIterator, Callable, Coroutine, Mapping +import hashlib +import logging +import random +from typing import Any + +from aiohttp import ClientError, ClientTimeout +from hass_nabucasa import Cloud, CloudError +from hass_nabucasa.cloud_api import ( + async_files_delete_file, + async_files_download_details, + async_files_list, + async_files_upload_details, +) + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .client import CloudClient +from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT + +_LOGGER = logging.getLogger(__name__) +_STORAGE_BACKUP = "backup" +_RETRY_LIMIT = 5 +_RETRY_SECONDS_MIN = 60 +_RETRY_SECONDS_MAX = 600 + + +async def _b64md5(stream: AsyncIterator[bytes]) -> str: + """Calculate the MD5 hash of a file.""" + file_hash = hashlib.md5() + async for chunk in stream: + file_hash.update(chunk) + return base64.b64encode(file_hash.digest()).decode() + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return the cloud backup agent.""" + cloud = hass.data[DATA_CLOUD] + if not cloud.is_logged_in: + return [] + + return [CloudBackupAgent(hass=hass, cloud=cloud)] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + + @callback + def unsub() -> None: + """Unsubscribe from events.""" + unsub_signal() + + @callback + def handle_event(data: Mapping[str, Any]) -> None: + """Handle event.""" + if data["type"] not in ("login", "logout"): + return + listener() + + unsub_signal = async_dispatcher_connect(hass, EVENT_CLOUD_EVENT, handle_event) + return unsub + + +class CloudBackupAgent(BackupAgent): + """Cloud backup agent.""" + + domain = DOMAIN + name = DOMAIN + + def __init__(self, hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None: + """Initialize the cloud backup sync agent.""" + super().__init__() + self._cloud = cloud + self._hass = hass + + @callback + def _get_backup_filename(self) -> str: + """Return the backup filename.""" + return f"{self._cloud.client.prefs.instance_id}.tar" + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + if not await self.async_get_backup(backup_id): + raise BackupAgentError("Backup not found") + + try: + details = await async_files_download_details( + self._cloud, + storage_type=_STORAGE_BACKUP, + filename=self._get_backup_filename(), + ) + except (ClientError, CloudError) as err: + raise BackupAgentError("Failed to get download details") from err + + try: + resp = await self._cloud.websession.get( + details["url"], + timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h + ) + + resp.raise_for_status() + except ClientError as err: + raise BackupAgentError("Failed to download backup") from err + + return ChunkAsyncStreamIterator(resp.content) + + async def _async_do_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + filename: str, + base64md5hash: str, + metadata: dict[str, Any], + size: int, + ) -> None: + """Upload a backup.""" + try: + details = await async_files_upload_details( + self._cloud, + storage_type=_STORAGE_BACKUP, + filename=filename, + metadata=metadata, + size=size, + base64md5hash=base64md5hash, + ) + except (ClientError, CloudError) as err: + raise BackupAgentError("Failed to get upload details") from err + + try: + upload_status = await self._cloud.websession.put( + details["url"], + data=await open_stream(), + headers=details["headers"] | {"content-length": str(size)}, + timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h + ) + _LOGGER.log( + logging.DEBUG if upload_status.status < 400 else logging.WARNING, + "Backup upload status: %s", + upload_status.status, + ) + upload_status.raise_for_status() + except (TimeoutError, ClientError) as err: + raise BackupAgentError("Failed to upload backup") from err + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + if not backup.protected: + raise BackupAgentError("Cloud backups must be protected") + + base64md5hash = await _b64md5(await open_stream()) + filename = self._get_backup_filename() + metadata = backup.as_dict() + size = backup.size + + tries = 1 + while tries <= _RETRY_LIMIT: + try: + await self._async_do_upload_backup( + open_stream=open_stream, + filename=filename, + base64md5hash=base64md5hash, + metadata=metadata, + size=size, + ) + break + except BackupAgentError as err: + if tries == _RETRY_LIMIT: + raise + tries += 1 + retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) + _LOGGER.info( + "Failed to upload backup, retrying (%s/%s) in %ss: %s", + tries, + _RETRY_LIMIT, + retry_timer, + err, + ) + await asyncio.sleep(retry_timer) + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + if not await self.async_get_backup(backup_id): + return + + try: + await async_files_delete_file( + self._cloud, + storage_type=_STORAGE_BACKUP, + filename=self._get_backup_filename(), + ) + except (ClientError, CloudError) as err: + raise BackupAgentError("Failed to delete backup") from err + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + try: + backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP) + _LOGGER.debug("Cloud backups: %s", backups) + except (ClientError, CloudError) as err: + raise BackupAgentError("Failed to list backups") from err + + return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + backups = await self.async_list_backups() + + for backup in backups: + if backup.backup_id == backup_id: + return backup + + return None diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index ee46fa42125..ea3d992e8f7 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -306,6 +306,7 @@ class CloudClient(Interface): }, "version": HA_VERSION, "instance_id": self.prefs.instance_id, + "name": self._hass.config.location_name, } async def async_alexa_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 4392bf94827..3883f19d1b7 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -18,6 +18,8 @@ DATA_CLOUD: HassKey[Cloud[CloudClient]] = HassKey(DOMAIN) DATA_PLATFORMS_SETUP: HassKey[dict[str, asyncio.Event]] = HassKey( "cloud_platforms_setup" ) +EVENT_CLOUD_EVENT = "cloud_event" + REQUEST_TIMEOUT = 10 PREF_ENABLE_ALEXA = "alexa_enabled" @@ -74,7 +76,6 @@ CONF_USER_POOL_ID = "user_pool_id" CONF_ACCOUNT_LINK_SERVER = "account_link_server" CONF_ACCOUNTS_SERVER = "accounts_server" CONF_ACME_SERVER = "acme_server" -CONF_ALEXA_SERVER = "alexa_server" CONF_CLOUDHOOK_SERVER = "cloudhook_server" CONF_RELAYER_SERVER = "relayer_server" CONF_REMOTESTATE_SERVER = "remotestate_server" @@ -88,3 +89,5 @@ DISPATCHER_REMOTE_UPDATE: SignalType[Any] = SignalType("cloud_remote_update") STT_ENTITY_UNIQUE_ID = "cloud-speech-to-text" TTS_ENTITY_UNIQUE_ID = "cloud-text-to-speech" + +LOGIN_MFA_TIMEOUT = 60 diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 4f2ad0ddcf7..473f553593a 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -9,6 +9,7 @@ import dataclasses from functools import wraps from http import HTTPStatus import logging +import time from typing import Any, Concatenate import aiohttp @@ -31,7 +32,9 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.location import async_detect_location_info from .alexa_config import entity_supported as entity_supported_by_alexa @@ -39,6 +42,8 @@ from .assist_pipeline import async_create_cloud_pipeline from .client import CloudClient from .const import ( DATA_CLOUD, + EVENT_CLOUD_EVENT, + LOGIN_MFA_TIMEOUT, PREF_ALEXA_REPORT_STATE, PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, @@ -69,6 +74,10 @@ _CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = { } +class MFAExpiredOrNotStarted(auth.CloudError): + """Multi-factor authentication expired, or not started.""" + + @callback def async_setup(hass: HomeAssistant) -> None: """Initialize the HTTP API.""" @@ -101,6 +110,11 @@ def async_setup(hass: HomeAssistant) -> None: _CLOUD_ERRORS.update( { + auth.InvalidTotpCode: (HTTPStatus.BAD_REQUEST, "Invalid TOTP code."), + auth.MFARequired: ( + HTTPStatus.UNAUTHORIZED, + "Multi-factor authentication required.", + ), auth.UserNotFound: (HTTPStatus.BAD_REQUEST, "User does not exist."), auth.UserNotConfirmed: (HTTPStatus.BAD_REQUEST, "Email not confirmed."), auth.UserExists: ( @@ -112,6 +126,10 @@ def async_setup(hass: HomeAssistant) -> None: HTTPStatus.BAD_REQUEST, "Password change required.", ), + MFAExpiredOrNotStarted: ( + HTTPStatus.BAD_REQUEST, + "Multi-factor authentication expired, or not started. Please try again.", + ), } ) @@ -206,24 +224,64 @@ class GoogleActionsSyncView(HomeAssistantView): class CloudLoginView(HomeAssistantView): """Login to Home Assistant cloud.""" + _mfa_tokens: dict[str, str] = {} + _mfa_tokens_set_time: float = 0 + url = "/api/cloud/login" name = "api:cloud:login" @require_admin @_handle_cloud_errors @RequestDataValidator( - vol.Schema({vol.Required("email"): str, vol.Required("password"): str}) + vol.Schema( + vol.All( + { + vol.Required("email"): str, + vol.Exclusive("password", "login"): str, + vol.Exclusive("code", "login"): str, + }, + cv.has_at_least_one_key("password", "code"), + ) + ) ) async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle login request.""" hass = request.app[KEY_HASS] cloud = hass.data[DATA_CLOUD] - await cloud.login(data["email"], data["password"]) + + try: + email = data["email"] + password = data.get("password") + code = data.get("code") + + if email and password: + await cloud.login(email, password) + + else: + if ( + not self._mfa_tokens + or time.time() - self._mfa_tokens_set_time > LOGIN_MFA_TIMEOUT + ): + raise MFAExpiredOrNotStarted + + # Voluptuous should ensure that code is not None because password is + assert code is not None + + await cloud.login_verify_totp(email, code, self._mfa_tokens) + self._mfa_tokens = {} + self._mfa_tokens_set_time = 0 + + except auth.MFARequired as mfa_err: + self._mfa_tokens = mfa_err.mfa_tokens + self._mfa_tokens_set_time = time.time() + raise if "assist_pipeline" in hass.config.components: new_cloud_pipeline_id = await async_create_cloud_pipeline(hass) else: new_cloud_pipeline_id = None + + async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "login"}) return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id}) @@ -243,6 +301,7 @@ class CloudLogoutView(HomeAssistantView): async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.logout() + async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "logout"}) return self.json_message("ok") diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 60b105b401e..0f415b1738a 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -1,13 +1,18 @@ { "domain": "cloud", "name": "Home Assistant Cloud", - "after_dependencies": ["assist_pipeline", "google_assistant", "alexa"], + "after_dependencies": [ + "alexa", + "assist_pipeline", + "backup", + "google_assistant" + ], "codeowners": ["@home-assistant/cloud"], "dependencies": ["auth", "http", "repairs", "webhook"], "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.85.0"], + "requirements": ["hass-nabucasa==0.88.1"], "single_config_entry": true } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 9f7e0dbadcd..1da91f67813 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -68,12 +68,12 @@ }, "services": { "remote_connect": { - "name": "Remote connect", - "description": "Makes the instance UI accessible from outside of the local network by using Home Assistant Cloud." + "name": "Enable remote access", + "description": "Makes the instance UI accessible from outside of the local network by enabling your Home Assistant Cloud connection." }, "remote_disconnect": { - "name": "Remote disconnect", - "description": "Disconnects the Home Assistant UI from the Home Assistant Cloud. You will no longer be able to access your Home Assistant instance from outside your local network." + "name": "Disable remote access", + "description": "Disconnects the instance UI from Home Assistant Cloud. This disables access to it from outside your local network." } } } diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py index bd27be71d18..f8fbac396a6 100644 --- a/homeassistant/components/cloudflare/__init__.py +++ b/homeassistant/components/cloudflare/__init__.py @@ -74,9 +74,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_track_time_interval(hass, update_records, update_interval) ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - hass.services.async_register(DOMAIN, SERVICE_UPDATE_RECORDS, update_records_service) return True @@ -84,7 +81,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Cloudflare config entry.""" - hass.data[DOMAIN].pop(entry.entry_id) return True diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index f5fd8fa1dc3..a29154d9c1b 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -37,7 +37,6 @@ from .const import ( CONF_CURRENCIES, CONF_EXCHANGE_BASE, CONF_EXCHANGE_RATES, - DOMAIN, ) _LOGGER = logging.getLogger(__name__) @@ -45,33 +44,29 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) +type CoinbaseConfigEntry = ConfigEntry[CoinbaseData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) -> bool: """Set up Coinbase from a config entry.""" instance = await hass.async_add_executor_job(create_and_update_instance, entry) entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data.setdefault(DOMAIN, {}) - - hass.data[DOMAIN][entry.entry_id] = instance + entry.runtime_data = instance await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData: +def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData: """Create and update a Coinbase Data instance.""" if "organizations" not in entry.data[CONF_API_KEY]: client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN]) @@ -87,7 +82,9 @@ def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData: return instance -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: CoinbaseConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) @@ -101,7 +98,8 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non if ( "xe" in entity.unique_id and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, []) - or "wallet" in entity.unique_id + ) or ( + "wallet" in entity.unique_id and currency not in config_entry.options.get(CONF_CURRENCIES, []) ): registry.async_remove(entity.entity_id) diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 8b7b4b9e313..2b58f2b2f37 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -11,18 +11,13 @@ from coinbase.wallet.client import Client as LegacyClient from coinbase.wallet.error import AuthenticationError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from . import get_accounts +from . import CoinbaseConfigEntry, get_accounts from .const import ( ACCOUNT_IS_VAULT, API_ACCOUNT_CURRENCY, @@ -83,10 +78,12 @@ async def validate_api(hass: HomeAssistant, data): return {"title": user, "api_version": api_version} -async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, options): +async def validate_options( + hass: HomeAssistant, config_entry: CoinbaseConfigEntry, options +): """Validate the requested resources are provided by API.""" - client = hass.data[DOMAIN][config_entry.entry_id].client + client = config_entry.runtime_data.client accounts = await hass.async_add_executor_job( get_accounts, client, config_entry.data.get("api_version", "v2") @@ -155,7 +152,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: CoinbaseConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/coinbase/diagnostics.py b/homeassistant/components/coinbase/diagnostics.py index 674ce9dca28..f391b1a14f5 100644 --- a/homeassistant/components/coinbase/diagnostics.py +++ b/homeassistant/components/coinbase/diagnostics.py @@ -3,12 +3,11 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_ID from homeassistant.core import HomeAssistant -from . import CoinbaseData -from .const import API_ACCOUNT_AMOUNT, API_RESOURCE_PATH, CONF_TITLE, DOMAIN +from . import CoinbaseConfigEntry +from .const import API_ACCOUNT_AMOUNT, API_RESOURCE_PATH, CONF_TITLE TO_REDACT = { API_ACCOUNT_AMOUNT, @@ -21,15 +20,13 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: CoinbaseConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - instance: CoinbaseData = hass.data[DOMAIN][entry.entry_id] - return async_redact_data( { "entry": entry.as_dict(), - "accounts": instance.accounts, + "accounts": entry.runtime_data.accounts, }, TO_REDACT, ) diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index d3f3c81fb0c..37509160247 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -5,12 +5,11 @@ from __future__ import annotations import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import CoinbaseData +from . import CoinbaseConfigEntry, CoinbaseData from .const import ( ACCOUNT_IS_VAULT, API_ACCOUNT_AMOUNT, @@ -45,11 +44,11 @@ ATTRIBUTION = "Data provided by coinbase.com" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CoinbaseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Coinbase sensor platform.""" - instance: CoinbaseData = hass.data[DOMAIN][config_entry.entry_id] + instance = config_entry.runtime_data entities: list[SensorEntity] = [] diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 12f28ef206d..60a4e40140d 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -2,12 +2,16 @@ from aiocomelit.const import BRIDGE -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform from homeassistant.core import HomeAssistant -from .const import DEFAULT_PORT, DOMAIN -from .coordinator import ComelitBaseCoordinator, ComelitSerialBridge, ComelitVedoSystem +from .const import DEFAULT_PORT +from .coordinator import ( + ComelitBaseCoordinator, + ComelitConfigEntry, + ComelitSerialBridge, + ComelitVedoSystem, +) BRIDGE_PLATFORMS = [ Platform.CLIMATE, @@ -24,13 +28,14 @@ VEDO_PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> bool: """Set up Comelit platform.""" coordinator: ComelitBaseCoordinator if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: coordinator = ComelitSerialBridge( hass, + entry, entry.data[CONF_HOST], entry.data.get(CONF_PORT, DEFAULT_PORT), entry.data[CONF_PIN], @@ -39,6 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else: coordinator = ComelitVedoSystem( hass, + entry, entry.data[CONF_HOST], entry.data.get(CONF_PORT, DEFAULT_PORT), entry.data[CONF_PIN], @@ -47,14 +53,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, platforms) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> bool: """Unload a config entry.""" if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: @@ -62,10 +68,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else: platforms = VEDO_PLATFORMS - coordinator: ComelitBaseCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): await coordinator.api.logout() await coordinator.api.close() - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index b3bd6664bf8..f694c2b392b 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import cast from aiocomelit.api import ComelitVedoAreaObject from aiocomelit.const import ALARM_AREAS, AlarmAreaState @@ -13,13 +14,11 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitVedoSystem +from .coordinator import ComelitConfigEntry, ComelitVedoSystem _LOGGER = logging.getLogger(__name__) @@ -48,12 +47,12 @@ ALARM_AREA_ARMED_STATUS: dict[str, int] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Comelit VEDO system alarm control panel devices.""" - coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) async_add_entities( ComelitAlarmEntity(coordinator, device, config_entry.entry_id) diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index 30b642584f8..fa51e0b1fda 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import cast + from aiocomelit import ComelitVedoZoneObject from aiocomelit.const import ALARM_ZONES @@ -9,23 +11,21 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitVedoSystem +from .coordinator import ComelitConfigEntry, ComelitVedoSystem async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit VEDO presence sensors.""" - coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) async_add_entities( ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 0b88367c0fa..1baa777bf99 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Any +from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import CLIMATE @@ -15,14 +15,12 @@ from homeassistant.components.climate import ( HVACMode, UnitOfTemperature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitConfigEntry, ComelitSerialBridge class ClimaComelitMode(StrEnum): @@ -72,12 +70,12 @@ MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit climates.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) async_add_entities( ComelitClimateEntity(coordinator, device, config_entry.entry_id) @@ -100,7 +98,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 807f389a6d3..fcb149b21d6 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -23,15 +23,19 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import _LOGGER, DOMAIN +type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator] + class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Base coordinator for Comelit Devices.""" _hw_version: str - config_entry: ConfigEntry + config_entry: ComelitConfigEntry api: ComelitCommonApi - def __init__(self, hass: HomeAssistant, device: str, host: str) -> None: + def __init__( + self, hass: HomeAssistant, entry: ComelitConfigEntry, device: str, host: str + ) -> None: """Initialize the scanner.""" self._device = device @@ -40,13 +44,14 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass=hass, logger=_LOGGER, + config_entry=entry, name=f"{DOMAIN}-{host}-coordinator", update_interval=timedelta(seconds=5), ) device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( - config_entry_id=self.config_entry.entry_id, - identifiers={(DOMAIN, self.config_entry.entry_id)}, + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, model=device, name=f"{device} ({self._host})", manufacturer="Comelit", @@ -98,10 +103,17 @@ class ComelitSerialBridge(ComelitBaseCoordinator): _hw_version = "20003101" api: ComeliteSerialBridgeApi - def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: ComelitConfigEntry, + host: str, + port: int, + pin: int, + ) -> None: """Initialize the scanner.""" self.api = ComeliteSerialBridgeApi(host, port, pin) - super().__init__(hass, BRIDGE, host) + super().__init__(hass, entry, BRIDGE, host) async def _async_update_system_data(self) -> dict[str, Any]: """Specific method for updating data.""" @@ -114,10 +126,17 @@ class ComelitVedoSystem(ComelitBaseCoordinator): _hw_version = "VEDO IP" api: ComelitVedoApi - def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: ComelitConfigEntry, + host: str, + port: int, + pin: int, + ) -> None: """Initialize the scanner.""" self.api = ComelitVedoApi(host, port, pin) - super().__init__(hass, VEDO, host) + super().__init__(hass, entry, VEDO, host) async def _async_update_system_data(self) -> dict[str, Any]: """Specific method for updating data.""" diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 5169217ebc5..abb84824621 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -2,30 +2,28 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitConfigEntry, ComelitSerialBridge async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit covers.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) async_add_entities( ComelitCoverEntity(coordinator, device, config_entry.entry_id) diff --git a/homeassistant/components/comelit/diagnostics.py b/homeassistant/components/comelit/diagnostics.py index afa57831eae..547735f3879 100644 --- a/homeassistant/components/comelit/diagnostics.py +++ b/homeassistant/components/comelit/diagnostics.py @@ -12,22 +12,20 @@ from aiocomelit import ( from aiocomelit.const import BRIDGE from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PIN, CONF_TYPE from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import ComelitBaseCoordinator +from .coordinator import ComelitConfigEntry TO_REDACT = {CONF_PIN} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ComelitConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: ComelitBaseCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data dev_list: list[dict[str, Any]] = [] dev_type_list: list[dict[int, Any]] = [] diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index e7857535c78..d8058074c16 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Any +from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import CLIMATE @@ -16,14 +16,13 @@ from homeassistant.components.humidifier import ( HumidifierEntity, HumidifierEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitConfigEntry, ComelitSerialBridge class HumidifierComelitMode(StrEnum): @@ -55,12 +54,12 @@ MODE_TO_ACTION: dict[str, HumidifierComelitCommand] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit humidifiers.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) entities: list[ComelitHumidifierEntity] = [] for device in coordinator.data[CLIMATE].values(): diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index bb5eb5fa160..9736c9ac2a0 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -2,29 +2,27 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitConfigEntry, ComelitSerialBridge async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit lights.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) async_add_entities( ComelitLightEntity(coordinator, device, config_entry.entry_id) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index d7417ad4aad..238dede8546 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.9.1"] + "requirements": ["aiocomelit==0.10.1"] } diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index a86d49d73e9..efb2418244e 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Final +from typing import Final, cast from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject from aiocomelit.const import ALARM_ZONES, BRIDGE, OTHER, AlarmZoneState @@ -12,15 +12,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitSerialBridge, ComelitVedoSystem +from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem SENSOR_BRIDGE_TYPES: Final = ( SensorEntityDescription( @@ -43,7 +41,7 @@ SENSOR_VEDO_TYPES: Final = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit sensors.""" @@ -56,12 +54,12 @@ async def async_setup_entry( async def async_setup_bridge_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit Bridge sensors.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) entities: list[ComelitBridgeSensorEntity] = [] for device in coordinator.data[OTHER].values(): @@ -76,12 +74,12 @@ async def async_setup_bridge_entry( async def async_setup_vedo_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit VEDO sensors.""" - coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) entities: list[ComelitVedoSensorEntity] = [] for device in coordinator.data[ALARM_ZONES].values(): diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 68ba934adb6..26d3b81ebde 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -2,29 +2,27 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitConfigEntry, ComelitSerialBridge async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ComelitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Comelit switches.""" - coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) entities: list[ComelitSwitchEntity] = [] entities.extend( diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 4e30b3ee3dc..2295fdb4e8e 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -68,7 +68,7 @@ class ComfoConnectFan(FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False + _attr_preset_modes = PRESET_MODES current_speed: float | None = None diff --git a/homeassistant/components/command_line/manifest.json b/homeassistant/components/command_line/manifest.json index 3e76cf4a6a6..2a54f500504 100644 --- a/homeassistant/components/command_line/manifest.json +++ b/homeassistant/components/command_line/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@gjohansson-ST"], "documentation": "https://www.home-assistant.io/integrations/command_line", "iot_class": "local_polling", + "quality_scale": "legacy", "requirements": ["jsonpath==0.82.2"] } diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py index fae416e7fc2..e83339d2c18 100644 --- a/homeassistant/components/compensation/__init__.py +++ b/homeassistant/components/compensation/__init__.py @@ -42,7 +42,7 @@ def datapoints_greater_than_degree(value: dict) -> dict: if len(value[CONF_DATAPOINTS]) <= value[CONF_DEGREE]: raise vol.Invalid( f"{CONF_DATAPOINTS} must have at least" - f" {value[CONF_DEGREE]+1} {CONF_DATAPOINTS}" + f" {value[CONF_DEGREE] + 1} {CONF_DATAPOINTS}" ) return value diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 5b3cc5ac2ac..cdf4dd1aaa4 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", "quality_scale": "legacy", - "requirements": ["numpy==2.1.3"] + "requirements": ["numpy==2.2.2"] } diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index c8cc9242ea4..b2a590928c1 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -41,10 +41,12 @@ def websocket_list_areas( vol.Required("type"): "config/area_registry/create", vol.Optional("aliases"): list, vol.Optional("floor_id"): str, + vol.Optional("humidity_entity_id"): vol.Any(str, None), vol.Optional("icon"): str, vol.Optional("labels"): [str], vol.Required("name"): str, vol.Optional("picture"): vol.Any(str, None), + vol.Optional("temperature_entity_id"): vol.Any(str, None), } ) @websocket_api.require_admin @@ -107,10 +109,12 @@ def websocket_delete_area( vol.Optional("aliases"): list, vol.Required("area_id"): str, vol.Optional("floor_id"): vol.Any(str, None), + vol.Optional("humidity_entity_id"): vol.Any(str, None), vol.Optional("icon"): vol.Any(str, None), vol.Optional("labels"): [str], vol.Optional("name"): str, vol.Optional("picture"): vol.Any(str, None), + vol.Optional("temperature_entity_id"): vol.Any(str, None), } ) @websocket_api.require_admin diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 6f788b1c9f2..b40f533d1f8 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import async_update_suggested_units from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import check_config, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import location, unit_system +from homeassistant.util import location as location_util, unit_system @callback @@ -99,7 +99,7 @@ async def websocket_detect_config( ) -> None: """Detect core config.""" session = async_get_clientsession(hass) - location_info = await location.async_detect_location_info(session) + location_info = await location_util.async_detect_location_info(session) info: dict[str, Any] = {} diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index aed04943975..b987f249a33 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -279,9 +279,8 @@ def websocket_update_entity( result: dict[str, Any] = {"entity_entry": entity_entry.extended_dict} if "disabled_by" in changes and changes["disabled_by"] is None: # Enabling an entity requires a config entry reload, or HA restart - if ( - not (config_entry_id := entity_entry.config_entry_id) - or (config_entry := hass.config_entries.async_get_entry(config_entry_id)) + if not (config_entry_id := entity_entry.config_entry_id) or ( + (config_entry := hass.config_entries.async_get_entry(config_entry_id)) and not config_entry.supports_unload ): result["require_restart"] = True diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 898b7b2cf4f..9c1db128f15 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -48,20 +48,25 @@ from .default_agent import DefaultAgent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult +from .session import ChatMessage, ChatSession, ConverseError, async_get_chat_session from .trace import ConversationTraceEventType, async_conversation_trace_append __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", "OLD_HOME_ASSISTANT_AGENT", + "ChatMessage", + "ChatSession", "ConversationEntity", "ConversationEntityFeature", "ConversationInput", "ConversationResult", "ConversationTraceEventType", + "ConverseError", "async_conversation_trace_append", "async_converse", "async_get_agent_info", + "async_get_chat_session", "async_set_agent", "async_setup", "async_unset_agent", diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 7516d9d22ef..97dc5e1292e 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -75,6 +75,7 @@ async def async_converse( language: str | None = None, agent_id: str | None = None, device_id: str | None = None, + extra_system_prompt: str | None = None, ) -> ConversationResult: """Process text and get intent.""" agent = async_get_agent(hass, agent_id) @@ -99,6 +100,7 @@ async def async_converse( device_id=device_id, language=language, agent_id=agent_id, + extra_system_prompt=extra_system_prompt, ) with async_conversation_trace() as trace: trace.add_event( diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index c1256a1507b..bb815698941 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -62,6 +62,7 @@ from .const import ( ) from .entity import ConversationEntity from .models import ConversationInput, ConversationResult +from .session import ChatMessage, async_get_chat_session from .trace import ConversationTraceEventType, async_conversation_trace_append _LOGGER = logging.getLogger(__name__) @@ -70,7 +71,7 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] REGEX_TYPE = type(re.compile("")) TRIGGER_CALLBACK_TYPE = Callable[ - [str, RecognizeResult, str | None], Awaitable[str | None] + [ConversationInput, RecognizeResult], Awaitable[str | None] ] METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" METADATA_CUSTOM_FILE = "hass_custom_file" @@ -246,7 +247,7 @@ class DefaultAgent(ConversationEntity): self._unexposed_names_trie: Trie | None = None # Sentences that will trigger a callback (skipping intent recognition) - self._trigger_sentences: list[TriggerData] = [] + self.trigger_sentences: list[TriggerData] = [] self._trigger_intents: Intents | None = None self._unsub_clear_slot_list: list[Callable[[], None]] | None = None self._load_intents_lock = asyncio.Lock() @@ -346,35 +347,52 @@ class DefaultAgent(ConversationEntity): async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" + response: intent.IntentResponse | None = None + async with async_get_chat_session(self.hass, user_input) as chat_session: + # Check if a trigger matched + if trigger_result := await self.async_recognize_sentence_trigger( + user_input + ): + # Process callbacks and get response + response_text = await self._handle_trigger_result( + trigger_result, user_input + ) - # Check if a trigger matched - if trigger_result := await self.async_recognize_sentence_trigger(user_input): - # Process callbacks and get response - response_text = await self._handle_trigger_result( - trigger_result, user_input + # Convert to conversation result + response = intent.IntentResponse( + language=user_input.language or self.hass.config.language + ) + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_speech(response_text) + + if response is None: + # Match intents + intent_result = await self.async_recognize_intent(user_input) + response = await self._async_process_intent_result( + intent_result, user_input + ) + + speech: str = response.speech.get("plain", {}).get("speech", "") + chat_session.async_add_message( + ChatMessage( + role="assistant", + agent_id=user_input.agent_id, + content=speech, + native=response, + ) ) - # Convert to conversation result - response = intent.IntentResponse( - language=user_input.language or self.hass.config.language + return ConversationResult( + response=response, conversation_id=chat_session.conversation_id ) - response.response_type = intent.IntentResponseType.ACTION_DONE - response.async_set_speech(response_text) - - return ConversationResult(response=response) - - # Match intents - intent_result = await self.async_recognize_intent(user_input) - return await self._async_process_intent_result(intent_result, user_input) async def _async_process_intent_result( self, result: RecognizeResult | None, user_input: ConversationInput, - ) -> ConversationResult: + ) -> intent.IntentResponse: """Process user input with intents.""" language = user_input.language or self.hass.config.language - conversation_id = None # Not supported # Intent match or failure lang_intents = await self.async_get_or_load_intents(language) @@ -386,7 +404,6 @@ class DefaultAgent(ConversationEntity): language, intent.IntentResponseErrorCode.NO_INTENT_MATCH, self._get_error_text(ErrorKey.NO_INTENT, lang_intents), - conversation_id, ) if result.unmatched_entities: @@ -408,7 +425,6 @@ class DefaultAgent(ConversationEntity): self._get_error_text( error_response_type, lang_intents, **error_response_args ), - conversation_id, ) # Will never happen because result will be None when no intents are @@ -461,7 +477,6 @@ class DefaultAgent(ConversationEntity): self._get_error_text( error_response_type, lang_intents, **error_response_args ), - conversation_id, ) except intent.IntentHandleError as err: # Intent was valid and entities matched constraints, but an error @@ -473,7 +488,6 @@ class DefaultAgent(ConversationEntity): self._get_error_text( err.response_key or ErrorKey.HANDLE_ERROR, lang_intents ), - conversation_id, ) except intent.IntentUnexpectedError: _LOGGER.exception("Unexpected intent error") @@ -481,7 +495,6 @@ class DefaultAgent(ConversationEntity): language, intent.IntentResponseErrorCode.UNKNOWN, self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents), - conversation_id, ) if ( @@ -500,9 +513,7 @@ class DefaultAgent(ConversationEntity): ) intent_response.async_set_speech(speech) - return ConversationResult( - response=intent_response, conversation_id=conversation_id - ) + return intent_response def _recognize( self, @@ -711,7 +722,7 @@ class DefaultAgent(ConversationEntity): for name_tuple in self._get_entity_name_tuples(exposed=False): self._unexposed_names_trie.insert( name_tuple[0].lower(), - TextSlotValue.from_tuple(name_tuple), + TextSlotValue.from_tuple(name_tuple, allow_template=False), ) # Build filtered slot list @@ -787,36 +798,13 @@ class DefaultAgent(ConversationEntity): intent_response: intent.IntentResponse, recognize_result: RecognizeResult, ) -> str: - # Make copies of the states here so we can add translated names for responses. - matched = [ - state_copy - for state in intent_response.matched_states - if (state_copy := core.State.from_dict(state.as_dict())) - ] - unmatched = [ - state_copy - for state in intent_response.unmatched_states - if (state_copy := core.State.from_dict(state.as_dict())) - ] - all_states = matched + unmatched - domains = {state.domain for state in all_states} - translations = await translation.async_get_translations( - self.hass, language, "entity_component", domains - ) - - # Use translated state names - for state in all_states: - device_class = state.attributes.get("device_class", "_") - key = f"component.{state.domain}.entity_component.{device_class}.state.{state.state}" - state.state = translations.get(key, state.state) - # Get first matched or unmatched state. # This is available in the response template as "state". state1: core.State | None = None if intent_response.matched_states: - state1 = matched[0] + state1 = intent_response.matched_states[0] elif intent_response.unmatched_states: - state1 = unmatched[0] + state1 = intent_response.unmatched_states[0] # Render response template speech_slots = { @@ -838,11 +826,13 @@ class DefaultAgent(ConversationEntity): "query": { # Entity states that matched the query (e.g, "on") "matched": [ - template.TemplateState(self.hass, state) for state in matched + template.TemplateState(self.hass, state) + for state in intent_response.matched_states ], # Entity states that did not match the query "unmatched": [ - template.TemplateState(self.hass, state) for state in unmatched + template.TemplateState(self.hass, state) + for state in intent_response.unmatched_states ], }, } @@ -1188,7 +1178,7 @@ class DefaultAgent(ConversationEntity): ) -> core.CALLBACK_TYPE: """Register a list of sentences that will trigger a callback when recognized.""" trigger_data = TriggerData(sentences=sentences, callback=callback) - self._trigger_sentences.append(trigger_data) + self.trigger_sentences.append(trigger_data) # Force rebuild on next use self._trigger_intents = None @@ -1205,7 +1195,7 @@ class DefaultAgent(ConversationEntity): # This works because the intents are rebuilt on every # register/unregister. str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]} - for trigger_id, trigger_data in enumerate(self._trigger_sentences) + for trigger_id, trigger_data in enumerate(self.trigger_sentences) }, } @@ -1228,7 +1218,7 @@ class DefaultAgent(ConversationEntity): @core.callback def _unregister_trigger(self, trigger_data: TriggerData) -> None: """Unregister a set of trigger sentences.""" - self._trigger_sentences.remove(trigger_data) + self.trigger_sentences.remove(trigger_data) # Force rebuild on next use self._trigger_intents = None @@ -1241,7 +1231,7 @@ class DefaultAgent(ConversationEntity): Calls the registered callbacks if there's a match and returns a sentence trigger result. """ - if not self._trigger_sentences: + if not self.trigger_sentences: # No triggers registered return None @@ -1286,9 +1276,7 @@ class DefaultAgent(ConversationEntity): # Gather callback responses in parallel trigger_callbacks = [ - self._trigger_sentences[trigger_id].callback( - user_input.text, trigger_result, user_input.device_id - ) + self.trigger_sentences[trigger_id].callback(user_input, trigger_result) for trigger_id, trigger_result in result.matched_triggers.items() ] @@ -1341,29 +1329,36 @@ class DefaultAgent(ConversationEntity): """Try to match sentence against registered intents and return response. Only performs strict matching with exposed entities and exact wording. - Returns None if no match occurred. + Returns None if no match or a matching error occurred. """ result = await self.async_recognize_intent(user_input, strict_intents_only=True) if not isinstance(result, RecognizeResult): # No error message on failed match return None - conversation_result = await self._async_process_intent_result( - result, user_input - ) - return conversation_result.response + response = await self._async_process_intent_result(result, user_input) + if ( + response.response_type == intent.IntentResponseType.ERROR + and response.error_code + not in ( + intent.IntentResponseErrorCode.FAILED_TO_HANDLE, + intent.IntentResponseErrorCode.UNKNOWN, + ) + ): + # We ignore no matching errors + return None + return response def _make_error_result( language: str, error_code: intent.IntentResponseErrorCode, response_text: str, - conversation_id: str | None = None, -) -> ConversationResult: +) -> intent.IntentResponse: """Create conversation result with error code and text.""" response = intent.IntentResponse(language=language) response.async_set_error(error_code, response_text) - return ConversationResult(response, conversation_id) + return response def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str, Any]]: @@ -1490,12 +1485,6 @@ def _get_match_error_response( # Entity is not in correct state assert constraints.states state = next(iter(constraints.states)) - if constraints.domains: - # Translate if domain is available - domain = next(iter(constraints.domains)) - state = translation.async_translate_state( - hass, state, domain, None, None, None - ) return ErrorKey.ENTITY_WRONG_STATE, {"state": state} diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index ebc5d70f1ef..8134ecb0eee 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -24,7 +24,7 @@ from .agent_manager import ( get_agent_manager, ) from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY -from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE, DefaultAgent +from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE from .entity import ConversationEntity from .models import ConversationInput @@ -36,6 +36,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_process) websocket_api.async_register_command(hass, websocket_prepare) websocket_api.async_register_command(hass, websocket_list_agents) + websocket_api.async_register_command(hass, websocket_list_sentences) websocket_api.async_register_command(hass, websocket_hass_agent_debug) @@ -150,6 +151,26 @@ async def websocket_list_agents( connection.send_message(websocket_api.result_message(msg["id"], {"agents": agents})) +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/sentences/list", + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_list_sentences( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """List custom registered sentences.""" + agent = hass.data[DATA_DEFAULT_ENTITY] + + sentences = [] + for trigger_data in agent.trigger_sentences: + sentences.extend(trigger_data.sentences) + + connection.send_result(msg["id"], {"trigger_sentences": sentences}) + + @websocket_api.websocket_command( { vol.Required("type"): "conversation/agent/homeassistant/debug", @@ -163,8 +184,7 @@ async def websocket_hass_agent_debug( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Return intents that would be matched by the default agent for a list of sentences.""" - agent = hass.data.get(DATA_DEFAULT_ENTITY) - assert isinstance(agent, DefaultAgent) + agent = hass.data[DATA_DEFAULT_ENTITY] # Return results for each sentence in the same order as the input. result_dicts: list[dict[str, Any] | None] = [] diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index b45a5456825..979ea7538c4 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.0.4", "home-assistant-intents==2024.11.13"] + "requirements": ["hassil==2.1.0", "home-assistant-intents==2025.1.1"] } diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 724e520e6df..9462c597f23 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -40,6 +40,21 @@ class ConversationInput: agent_id: str | None = None """Agent to use for processing.""" + extra_system_prompt: str | None = None + """Extra prompt to provide extra info to LLMs how to understand the command.""" + + def as_dict(self) -> dict[str, Any]: + """Return input as a dict.""" + return { + "text": self.text, + "context": self.context.as_dict(), + "conversation_id": self.conversation_id, + "device_id": self.device_id, + "language": self.language, + "agent_id": self.agent_id, + "extra_system_prompt": self.extra_system_prompt, + } + @dataclass(slots=True) class ConversationResult: diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py new file mode 100644 index 00000000000..48040e8ac9c --- /dev/null +++ b/homeassistant/components/conversation/session.py @@ -0,0 +1,328 @@ +"""Conversation history.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field, replace +from datetime import datetime, timedelta +import logging +from typing import Literal + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.helpers import intent, llm, template +from homeassistant.helpers.event import async_call_later +from homeassistant.util import dt as dt_util, ulid as ulid_util +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN +from .models import ConversationInput, ConversationResult + +DATA_CHAT_HISTORY: HassKey[dict[str, ChatSession]] = HassKey( + "conversation_chat_session" +) +DATA_CHAT_HISTORY_CLEANUP: HassKey[SessionCleanup] = HassKey( + "conversation_chat_session_cleanup" +) + +LOGGER = logging.getLogger(__name__) +CONVERSATION_TIMEOUT = timedelta(minutes=5) + + +class SessionCleanup: + """Helper to clean up the history.""" + + unsub: CALLBACK_TYPE | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the history cleanup.""" + self.hass = hass + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop) + self.cleanup_job = HassJob( + self._cleanup, "conversation_history_cleanup", job_type=HassJobType.Callback + ) + + @callback + def schedule(self) -> None: + """Schedule the cleanup.""" + if self.unsub: + return + self.unsub = async_call_later( + self.hass, + CONVERSATION_TIMEOUT.total_seconds() + 1, + self.cleanup_job, + ) + + @callback + def _on_hass_stop(self, event: Event) -> None: + """Cancel the cleanup on shutdown.""" + if self.unsub: + self.unsub() + self.unsub = None + + @callback + def _cleanup(self, now: datetime) -> None: + """Clean up the history and schedule follow-up if necessary.""" + self.unsub = None + all_history = self.hass.data[DATA_CHAT_HISTORY] + + # We mutate original object because current commands could be + # yielding history based on it. + for conversation_id, history in list(all_history.items()): + if history.last_updated + CONVERSATION_TIMEOUT < now: + del all_history[conversation_id] + + # Still conversations left, check again in timeout time. + if all_history: + self.schedule() + + +@asynccontextmanager +async def async_get_chat_session( + hass: HomeAssistant, + user_input: ConversationInput, +) -> AsyncGenerator[ChatSession]: + """Return chat session.""" + all_history = hass.data.get(DATA_CHAT_HISTORY) + if all_history is None: + all_history = {} + hass.data[DATA_CHAT_HISTORY] = all_history + hass.data[DATA_CHAT_HISTORY_CLEANUP] = SessionCleanup(hass) + + history: ChatSession | None = None + + if user_input.conversation_id is None: + conversation_id = ulid_util.ulid_now() + + elif history := all_history.get(user_input.conversation_id): + conversation_id = user_input.conversation_id + + else: + # Conversation IDs are ULIDs. We generate a new one if not provided. + # If an old OLID is passed in, we will generate a new one to indicate + # a new conversation was started. If the user picks their own, they + # want to track a conversation and we respect it. + try: + ulid_util.ulid_to_bytes(user_input.conversation_id) + conversation_id = ulid_util.ulid_now() + except ValueError: + conversation_id = user_input.conversation_id + + if history: + history = replace(history, messages=history.messages.copy()) + else: + history = ChatSession(hass, conversation_id) + + message: ChatMessage = ChatMessage( + role="user", + agent_id=user_input.agent_id, + content=user_input.text, + ) + history.async_add_message(message) + + yield history + + if history.messages[-1] is message: + LOGGER.debug( + "History opened but no assistant message was added, ignoring update" + ) + return + + history.last_updated = dt_util.utcnow() + all_history[conversation_id] = history + hass.data[DATA_CHAT_HISTORY_CLEANUP].schedule() + + +class ConverseError(HomeAssistantError): + """Error during initialization of conversation. + + Will not be stored in the history. + """ + + def __init__( + self, message: str, conversation_id: str, response: intent.IntentResponse + ) -> None: + """Initialize the error.""" + super().__init__(message) + self.conversation_id = conversation_id + self.response = response + + def as_conversation_result(self) -> ConversationResult: + """Return the error as a conversation result.""" + return ConversationResult( + response=self.response, + conversation_id=self.conversation_id, + ) + + +@dataclass +class ChatMessage[_NativeT]: + """Base class for chat messages. + + When role is native, the content is to be ignored and message + is only meant for storing the native object. + """ + + role: Literal["system", "assistant", "user", "native"] + agent_id: str | None + content: str + native: _NativeT | None = field(default=None) + + # Validate in post-init that if role is native, there is no content and a native object exists + def __post_init__(self) -> None: + """Validate native message.""" + if self.role == "native" and self.native is None: + raise ValueError("Native message must have a native object") + + +@dataclass +class ChatSession[_NativeT]: + """Class holding all information for a specific conversation.""" + + hass: HomeAssistant + conversation_id: str + user_name: str | None = None + messages: list[ChatMessage[_NativeT]] = field( + default_factory=lambda: [ChatMessage(role="system", agent_id=None, content="")] + ) + extra_system_prompt: str | None = None + llm_api: llm.APIInstance | None = None + last_updated: datetime = field(default_factory=dt_util.utcnow) + + @callback + def async_add_message(self, message: ChatMessage[_NativeT]) -> None: + """Process intent.""" + if message.role == "system": + raise ValueError("Cannot add system messages to history") + if message.role != "native" and self.messages[-1].role == message.role: + raise ValueError("Cannot add two assistant or user messages in a row") + + self.messages.append(message) + + @callback + def async_get_messages(self, agent_id: str | None) -> list[ChatMessage[_NativeT]]: + """Get messages for a specific agent ID. + + This will filter out any native message tied to other agent IDs. + It can still include assistant/user messages generated by other agents. + """ + return [ + message + for message in self.messages + if message.role != "native" or message.agent_id == agent_id + ] + + async def async_update_llm_data( + self, + conversing_domain: str, + user_input: ConversationInput, + user_llm_hass_api: str | None = None, + user_llm_prompt: str | None = None, + ) -> None: + """Set the LLM system prompt.""" + llm_context = llm.LLMContext( + platform=conversing_domain, + context=user_input.context, + user_prompt=user_input.text, + language=user_input.language, + assistant=DOMAIN, + device_id=user_input.device_id, + ) + + llm_api: llm.APIInstance | None = None + + if user_llm_hass_api: + try: + llm_api = await llm.async_get_api( + self.hass, + user_llm_hass_api, + llm_context, + ) + except HomeAssistantError as err: + LOGGER.error( + "Error getting LLM API %s for %s: %s", + user_llm_hass_api, + conversing_domain, + err, + ) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Error preparing LLM API", + ) + raise ConverseError( + f"Error getting LLM API {user_llm_hass_api}", + conversation_id=self.conversation_id, + response=intent_response, + ) from err + + user_name: str | None = None + + if ( + user_input.context + and user_input.context.user_id + and ( + user := await self.hass.auth.async_get_user(user_input.context.user_id) + ) + ): + user_name = user.name + + try: + prompt_parts = [ + template.Template( + llm.BASE_PROMPT + + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), + self.hass, + ).async_render( + { + "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, + }, + parse_result=False, + ) + ] + + except TemplateError as err: + LOGGER.error("Error rendering prompt: %s", err) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Sorry, I had a problem with my template", + ) + raise ConverseError( + "Error rendering prompt", + conversation_id=self.conversation_id, + response=intent_response, + ) from err + + if llm_api: + prompt_parts.append(llm_api.api_prompt) + + extra_system_prompt = ( + # Take new system prompt if one was given + user_input.extra_system_prompt or self.extra_system_prompt + ) + + if extra_system_prompt: + prompt_parts.append(extra_system_prompt) + + prompt = "\n".join(prompt_parts) + + self.llm_api = llm_api + self.user_name = user_name + self.extra_system_prompt = extra_system_prompt + self.messages[0] = ChatMessage( + role="system", + agent_id=user_input.agent_id, + content=prompt, + ) diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index a4f64ffbad9..24eb54c5694 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -16,6 +16,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import UNDEFINED, ConfigType from .const import DATA_DEFAULT_ENTITY, DOMAIN +from .models import ConversationInput def has_no_punctuation(value: list[str]) -> list[str]: @@ -62,7 +63,7 @@ async def async_attach_trigger( job = HassJob(action) async def call_action( - sentence: str, result: RecognizeResult, device_id: str | None + user_input: ConversationInput, result: RecognizeResult ) -> str | None: """Call action with right context.""" @@ -83,12 +84,13 @@ async def async_attach_trigger( trigger_input: dict[str, Any] = { # Satisfy type checker **trigger_data, "platform": DOMAIN, - "sentence": sentence, + "sentence": user_input.text, "details": details, "slots": { # direct access to values entity_name: entity["value"] for entity_name, entity in details.items() }, - "device_id": device_id, + "device_id": user_input.device_id, + "user_input": user_input.as_dict(), } # Wait for the automation to complete diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py new file mode 100644 index 00000000000..67095422e65 --- /dev/null +++ b/homeassistant/components/cookidoo/__init__.py @@ -0,0 +1,92 @@ +"""The Cookidoo integration.""" + +from __future__ import annotations + +import logging + +from cookidoo_api import CookidooAuthException, CookidooRequestException + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import DOMAIN +from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator +from .helpers import cookidoo_from_config_entry + +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool: + """Set up Cookidoo from a config entry.""" + + coordinator = CookidooDataUpdateCoordinator( + hass, await cookidoo_from_config_entry(hass, entry), entry + ) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: CookidooConfigEntry +) -> bool: + """Migrate config entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1 and config_entry.minor_version == 1: + # Add the unique uuid + cookidoo = await cookidoo_from_config_entry(hass, config_entry) + + try: + auth_data = await cookidoo.login() + except (CookidooRequestException, CookidooAuthException) as e: + _LOGGER.error( + "Could not migrate config config_entry: %s", + str(e), + ) + return False + + unique_id = auth_data.sub + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=config_entry.entry_id + ) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + for dev in device_entries: + device_registry.async_update_device( + dev.id, new_identifiers={(DOMAIN, unique_id)} + ) + for ent in entity_entries: + assert ent.config_entry_id + entity_registry.async_update_entity( + ent.entity_id, + new_unique_id=ent.unique_id.replace(ent.config_entry_id, unique_id), + ) + + hass.config_entries.async_update_entry( + config_entry, unique_id=auth_data.sub, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/cookidoo/button.py b/homeassistant/components/cookidoo/button.py new file mode 100644 index 00000000000..b292a7309ba --- /dev/null +++ b/homeassistant/components/cookidoo/button.py @@ -0,0 +1,71 @@ +"""Support for Cookidoo buttons.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from cookidoo_api import Cookidoo, CookidooException + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator +from .entity import CookidooBaseEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class CookidooButtonEntityDescription(ButtonEntityDescription): + """Describes cookidoo button entity.""" + + press_fn: Callable[[Cookidoo], Awaitable[None]] + + +TODO_CLEAR = CookidooButtonEntityDescription( + key="todo_clear", + translation_key="todo_clear", + press_fn=lambda client: client.clear_shopping_list(), + entity_registry_enabled_default=False, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CookidooConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Cookidoo button entities based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities([CookidooButton(coordinator, TODO_CLEAR)]) + + +class CookidooButton(CookidooBaseEntity, ButtonEntity): + """Defines an Cookidoo button.""" + + entity_description: CookidooButtonEntityDescription + + def __init__( + self, + coordinator: CookidooDataUpdateCoordinator, + description: CookidooButtonEntityDescription, + ) -> None: + """Initialize cookidoo button.""" + super().__init__(coordinator) + self.entity_description = description + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.entity_description.press_fn(self.coordinator.cookidoo) + except CookidooException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="button_clear_todo_failed", + ) from e + await self.coordinator.async_refresh() diff --git a/homeassistant/components/cookidoo/config_flow.py b/homeassistant/components/cookidoo/config_flow.py new file mode 100644 index 00000000000..71ad3015730 --- /dev/null +++ b/homeassistant/components/cookidoo/config_flow.py @@ -0,0 +1,239 @@ +"""Config flow for Cookidoo integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from cookidoo_api import ( + CookidooAuthException, + CookidooRequestException, + get_country_options, + get_localization_options, +) +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.helpers.selector import ( + CountrySelector, + CountrySelectorConfig, + LanguageSelector, + LanguageSelectorConfig, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN +from .helpers import cookidoo_from_config_data + +_LOGGER = logging.getLogger(__name__) + +AUTH_DATA_SCHEMA = { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), +} + + +class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Cookidoo.""" + + VERSION = 1 + MINOR_VERSION = 2 + + COUNTRY_DATA_SCHEMA: dict + LANGUAGE_DATA_SCHEMA: dict + + user_input: dict[str, Any] + user_uuid: str + + async def async_step_reconfigure( + self, user_input: dict[str, Any] + ) -> ConfigFlowResult: + """Perform reconfigure upon an user action.""" + return await self.async_step_user(user_input) + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the user step as well as serve for reconfiguration.""" + errors: dict[str, str] = {} + + if user_input is not None and not ( + errors := await self.validate_input(user_input) + ): + await self.async_set_unique_id(self.user_uuid) + if self.source == SOURCE_USER: + self._abort_if_unique_id_configured() + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + self.user_input = user_input + return await self.async_step_language() + await self.generate_country_schema() + suggested_values: dict = {} + if self.source == SOURCE_RECONFIGURE: + reconfigure_entry = self._get_reconfigure_entry() + suggested_values = { + **suggested_values, + **reconfigure_entry.data, + } + if user_input is not None: + suggested_values = {**suggested_values, **user_input} + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema( + {**AUTH_DATA_SCHEMA, **self.COUNTRY_DATA_SCHEMA} + ), + suggested_values=suggested_values, + ), + description_placeholders={"cookidoo": "Cookidoo"}, + errors=errors, + ) + + async def async_step_language( + self, + language_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Async language step to set up the connection.""" + errors: dict[str, str] = {} + if language_input is not None and not ( + errors := await self.validate_input(self.user_input, language_input) + ): + if self.source == SOURCE_USER: + return self.async_create_entry( + title="Cookidoo", data={**self.user_input, **language_input} + ) + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, + data={ + **reconfigure_entry.data, + **self.user_input, + **language_input, + }, + ) + + await self.generate_language_schema() + return self.async_show_form( + step_id="language", + data_schema=vol.Schema(self.LANGUAGE_DATA_SCHEMA), + description_placeholders={"cookidoo": "Cookidoo"}, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + if not ( + errors := await self.validate_input({**reauth_entry.data, **user_input}) + ): + await self.async_set_unique_id(self.user_uuid) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema(AUTH_DATA_SCHEMA), + suggested_values={CONF_EMAIL: reauth_entry.data[CONF_EMAIL]}, + ), + description_placeholders={"cookidoo": "Cookidoo"}, + errors=errors, + ) + + async def generate_country_schema(self) -> None: + """Generate country schema.""" + self.COUNTRY_DATA_SCHEMA = { + vol.Required(CONF_COUNTRY): CountrySelector( + CountrySelectorConfig( + countries=[ + country.upper() for country in await get_country_options() + ], + ) + ) + } + + async def generate_language_schema(self) -> None: + """Generate language schema.""" + self.LANGUAGE_DATA_SCHEMA = { + vol.Required(CONF_LANGUAGE): LanguageSelector( + LanguageSelectorConfig( + languages=[ + option.language + for option in await get_localization_options( + country=self.user_input[CONF_COUNTRY].lower() + ) + ], + native_name=True, + ), + ), + } + + async def validate_input( + self, + user_input: dict[str, Any], + language_input: dict[str, Any] | None = None, + ) -> dict[str, str]: + """Input Helper.""" + + errors: dict[str, str] = {} + + data_input: dict[str, Any] = {} + + if self.source == SOURCE_RECONFIGURE: + reconfigure_entry = self._get_reconfigure_entry() + data_input = {**data_input, **reconfigure_entry.data} + data_input = {**data_input, **user_input} + if language_input: + data_input = {**data_input, **language_input} + else: + data_input[CONF_LANGUAGE] = ( + await get_localization_options(country=data_input[CONF_COUNTRY].lower()) + )[0].language # Pick any language to test login + + cookidoo = await cookidoo_from_config_data(self.hass, data_input) + try: + auth_data = await cookidoo.login() + self.user_uuid = auth_data.sub + if language_input: + await cookidoo.get_additional_items() + except CookidooRequestException: + errors["base"] = "cannot_connect" + except CookidooAuthException: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors diff --git a/homeassistant/components/cookidoo/const.py b/homeassistant/components/cookidoo/const.py new file mode 100644 index 00000000000..37c584404a0 --- /dev/null +++ b/homeassistant/components/cookidoo/const.py @@ -0,0 +1,3 @@ +"""Constants for the Cookidoo integration.""" + +DOMAIN = "cookidoo" diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py new file mode 100644 index 00000000000..ad86d1fb9f1 --- /dev/null +++ b/homeassistant/components/cookidoo/coordinator.py @@ -0,0 +1,101 @@ +"""DataUpdateCoordinator for the Cookidoo integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from cookidoo_api import ( + Cookidoo, + CookidooAdditionalItem, + CookidooAuthException, + CookidooException, + CookidooIngredientItem, + CookidooRequestException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type CookidooConfigEntry = ConfigEntry[CookidooDataUpdateCoordinator] + + +@dataclass +class CookidooData: + """Cookidoo data type.""" + + ingredient_items: list[CookidooIngredientItem] + additional_items: list[CookidooAdditionalItem] + + +class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): + """A Cookidoo Data Update Coordinator.""" + + config_entry: CookidooConfigEntry + + def __init__( + self, hass: HomeAssistant, cookidoo: Cookidoo, entry: CookidooConfigEntry + ) -> None: + """Initialize the Cookidoo data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=90), + config_entry=entry, + ) + self.cookidoo = cookidoo + + async def _async_setup(self) -> None: + try: + await self.cookidoo.login() + except CookidooRequestException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e + except CookidooAuthException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={ + CONF_EMAIL: self.config_entry.data[CONF_EMAIL] + }, + ) from e + + async def _async_update_data(self) -> CookidooData: + try: + ingredient_items = await self.cookidoo.get_ingredient_items() + additional_items = await self.cookidoo.get_additional_items() + except CookidooAuthException: + try: + await self.cookidoo.refresh_token() + except CookidooAuthException as exc: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={ + CONF_EMAIL: self.config_entry.data[CONF_EMAIL] + }, + ) from exc + _LOGGER.debug( + "Authentication failed but re-authentication was successful, trying again later" + ) + return self.data + except CookidooException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_exception", + ) from e + + return CookidooData( + ingredient_items=ingredient_items, additional_items=additional_items + ) diff --git a/homeassistant/components/cookidoo/entity.py b/homeassistant/components/cookidoo/entity.py new file mode 100644 index 00000000000..97ebb384ecb --- /dev/null +++ b/homeassistant/components/cookidoo/entity.py @@ -0,0 +1,32 @@ +"""Base entity for the Cookidoo integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CookidooDataUpdateCoordinator + + +class CookidooBaseEntity(CoordinatorEntity[CookidooDataUpdateCoordinator]): + """Cookidoo base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: CookidooDataUpdateCoordinator, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + assert coordinator.config_entry.unique_id + + self.device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name="Cookidoo", + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, + manufacturer="Vorwerk International & Co. KmG", + model="Cookidoo - Thermomix® recipe portal", + ) diff --git a/homeassistant/components/cookidoo/helpers.py b/homeassistant/components/cookidoo/helpers.py new file mode 100644 index 00000000000..199abb2e05d --- /dev/null +++ b/homeassistant/components/cookidoo/helpers.py @@ -0,0 +1,37 @@ +"""Helpers for cookidoo.""" + +from typing import Any + +from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options + +from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import CookidooConfigEntry + + +async def cookidoo_from_config_data( + hass: HomeAssistant, data: dict[str, Any] +) -> Cookidoo: + """Build cookidoo from config data.""" + localizations = await get_localization_options( + country=data[CONF_COUNTRY].lower(), + language=data[CONF_LANGUAGE], + ) + + return Cookidoo( + async_get_clientsession(hass), + CookidooConfig( + email=data[CONF_EMAIL], + password=data[CONF_PASSWORD], + localization=localizations[0], + ), + ) + + +async def cookidoo_from_config_entry( + hass: HomeAssistant, entry: CookidooConfigEntry +) -> Cookidoo: + """Build cookidoo from config entry.""" + return await cookidoo_from_config_data(hass, dict(entry.data)) diff --git a/homeassistant/components/cookidoo/icons.json b/homeassistant/components/cookidoo/icons.json new file mode 100644 index 00000000000..0e411a70fc2 --- /dev/null +++ b/homeassistant/components/cookidoo/icons.json @@ -0,0 +1,17 @@ +{ + "entity": { + "button": { + "todo_clear": { + "default": "mdi:cart-off" + } + }, + "todo": { + "ingredient_list": { + "default": "mdi:cart-plus" + }, + "additional_item_list": { + "default": "mdi:cart-plus" + } + } + } +} diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json new file mode 100644 index 00000000000..5264e47a709 --- /dev/null +++ b/homeassistant/components/cookidoo/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "cookidoo", + "name": "Cookidoo", + "codeowners": ["@miaucl"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/cookidoo", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["cookidoo_api"], + "quality_scale": "silver", + "requirements": ["cookidoo-api==0.12.2"] +} diff --git a/homeassistant/components/cookidoo/quality_scale.yaml b/homeassistant/components/cookidoo/quality_scale.yaml new file mode 100644 index 00000000000..95a35829079 --- /dev/null +++ b/homeassistant/components/cookidoo/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions implemented + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions implemented + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: + status: exempt + comment: No special external action required + entity-event-setup: + status: exempt + comment: No callbacks are implemented + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: + status: done + comment: Offloaded to coordinator + entity-unavailable: + status: done + comment: Offloaded to coordinator + action-exceptions: + status: done + comment: Only providing todo actions + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: No options flow + + # Gold + entity-translations: done + entity-device-class: + status: exempt + comment: currently no platform with device classes + devices: done + entity-category: done + entity-disabled-by-default: + status: exempt + comment: No disabled entities implemented + discovery: + status: exempt + comment: Nothing to discover + stale-devices: + status: exempt + comment: No stale entities possible + diagnostics: todo + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: + status: exempt + comment: No dynamic entities available + discovery-update-info: + status: exempt + comment: No discoverable entities implemented + repair-issues: + status: exempt + comment: No issues/repairs + docs-use-cases: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json new file mode 100644 index 00000000000..8a2a288d11b --- /dev/null +++ b/homeassistant/components/cookidoo/strings.json @@ -0,0 +1,89 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up {cookidoo}", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]", + "country": "Country" + }, + "data_description": { + "email": "Email used to access your {cookidoo} account.", + "password": "Password used to access your {cookidoo} account.", + "country": "Pick your country for the {cookidoo} content." + } + }, + "language": { + "title": "[%key:component::cookidoo::config::step::user::title%]", + "data": { + "language": "[%key:common::config_flow::data::language%]" + }, + "data_description": { + "language": "Pick your language for the {cookidoo} content." + } + }, + "reauth_confirm": { + "title": "Log in again to {cookidoo}", + "description": "Please log in to {cookidoo} again to continue using this integration.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::cookidoo::config::step::user::data_description::email%]", + "password": "[%key:component::cookidoo::config::step::user::data_description::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The user identifier does not match the previous identifier" + } + }, + "entity": { + "button": { + "todo_clear": { + "name": "Clear shopping list and additional purchases" + } + }, + "todo": { + "ingredient_list": { + "name": "Shopping list" + }, + "additional_item_list": { + "name": "Additional purchases" + } + } + }, + "exceptions": { + "button_clear_todo_failed": { + "message": "Failed to clear all items from the Cookidoo shopping list" + }, + "todo_save_item_failed": { + "message": "Failed to save {name} to Cookidoo shopping list" + }, + "todo_update_item_failed": { + "message": "Failed to update {name} in Cookidoo shopping list" + }, + "todo_delete_item_failed": { + "message": "Failed to delete {count} item(s) from Cookidoo shopping list" + }, + "setup_request_exception": { + "message": "Failed to connect to server, try again later" + }, + "setup_authentication_exception": { + "message": "Authentication failed for {email}, check your email and password" + }, + "update_exception": { + "message": "Unable to connect and retrieve data from cookidoo" + } + } +} diff --git a/homeassistant/components/cookidoo/todo.py b/homeassistant/components/cookidoo/todo.py new file mode 100644 index 00000000000..3d5264f4e01 --- /dev/null +++ b/homeassistant/components/cookidoo/todo.py @@ -0,0 +1,187 @@ +"""Todo platform for the Cookidoo integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from cookidoo_api import ( + CookidooAdditionalItem, + CookidooException, + CookidooIngredientItem, +) + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator +from .entity import CookidooBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: CookidooConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the todo list from a config entry created in the integrations UI.""" + coordinator = config_entry.runtime_data + + async_add_entities( + [ + CookidooIngredientsTodoListEntity(coordinator), + CookidooAdditionalItemTodoListEntity(coordinator), + ] + ) + + +class CookidooIngredientsTodoListEntity(CookidooBaseEntity, TodoListEntity): + """A To-do List representation of the ingredients in the Cookidoo Shopping List.""" + + _attr_translation_key = "ingredient_list" + _attr_supported_features = TodoListEntityFeature.UPDATE_TODO_ITEM + + def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_ingredients" + + @property + def todo_items(self) -> list[TodoItem]: + """Return the todo ingredients.""" + return [ + TodoItem( + uid=item.id, + summary=item.name, + description=item.description or "", + status=( + TodoItemStatus.COMPLETED + if item.is_owned + else TodoItemStatus.NEEDS_ACTION + ), + ) + for item in self.coordinator.data.ingredient_items + ] + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an ingredient to the To-do list. + + Cookidoo ingredients can be changed in state, but not in summary or description. This is currently not possible to distinguish in home assistant and just fails silently. + """ + try: + if TYPE_CHECKING: + assert item.uid + await self.coordinator.cookidoo.edit_ingredient_items_ownership( + [ + CookidooIngredientItem( + id=item.uid, + name="", + description="", + is_owned=item.status == TodoItemStatus.COMPLETED, + ) + ] + ) + except CookidooException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="todo_update_item_failed", + translation_placeholders={"name": item.summary or ""}, + ) from e + + await self.coordinator.async_refresh() + + +class CookidooAdditionalItemTodoListEntity(CookidooBaseEntity, TodoListEntity): + """A To-do List representation of the additional items in the Cookidoo Shopping List.""" + + _attr_translation_key = "additional_item_list" + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) + + def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_additional_items" + + @property + def todo_items(self) -> list[TodoItem]: + """Return the todo items.""" + + return [ + TodoItem( + uid=item.id, + summary=item.name, + status=( + TodoItemStatus.COMPLETED + if item.is_owned + else TodoItemStatus.NEEDS_ACTION + ), + ) + for item in self.coordinator.data.additional_items + ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + + try: + if TYPE_CHECKING: + assert item.summary + await self.coordinator.cookidoo.add_additional_items([item.summary]) + except CookidooException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="todo_save_item_failed", + translation_placeholders={"name": item.summary or ""}, + ) from e + + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item to the To-do list.""" + + try: + if TYPE_CHECKING: + assert item.uid + assert item.summary + new_item = CookidooAdditionalItem( + id=item.uid, + name=item.summary, + is_owned=item.status == TodoItemStatus.COMPLETED, + ) + await self.coordinator.cookidoo.edit_additional_items_ownership([new_item]) + await self.coordinator.cookidoo.edit_additional_items([new_item]) + except CookidooException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="todo_update_item_failed", + translation_placeholders={"name": item.summary or ""}, + ) from e + + await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete an item from the To-do list.""" + + try: + await self.coordinator.cookidoo.remove_additional_items(uids) + except CookidooException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="todo_delete_item_failed", + translation_placeholders={"count": str(len(uids))}, + ) from e + + await self.coordinator.async_refresh() diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 1f3f5a66380..5892ef091d9 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -2,18 +2,17 @@ from pycoolmasternet_async import CoolMasterNet -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_SWING_SUPPORT, DATA_COORDINATOR, DATA_INFO, DOMAIN -from .coordinator import CoolmasterDataUpdateCoordinator +from .const import CONF_SWING_SUPPORT +from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool: """Set up Coolmaster from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] @@ -38,21 +37,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady except OSError as error: raise ConfigEntryNotReady from error - coordinator = CoolmasterDataUpdateCoordinator(hass, coolmaster) - hass.data.setdefault(DOMAIN, {}) + coordinator = CoolmasterDataUpdateCoordinator(hass, entry, coolmaster, info) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { - DATA_INFO: info, - DATA_COORDINATOR: coordinator, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool: """Unload a Coolmaster config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/coolmaster/binary_sensor.py b/homeassistant/components/coolmaster/binary_sensor.py index ba54a073f0a..ab2718b9352 100644 --- a/homeassistant/components/coolmaster/binary_sensor.py +++ b/homeassistant/components/coolmaster/binary_sensor.py @@ -7,26 +7,23 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DATA_INFO, DOMAIN +from .coordinator import CoolmasterConfigEntry from .entity import CoolmasterEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CoolmasterConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the CoolMasterNet binary_sensor platform.""" - info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + coordinator = config_entry.runtime_data async_add_entities( - CoolmasterCleanFilter(coordinator, unit_id, info) - for unit_id in coordinator.data + CoolmasterCleanFilter(coordinator, unit_id) for unit_id in coordinator.data ) diff --git a/homeassistant/components/coolmaster/button.py b/homeassistant/components/coolmaster/button.py index d958346614c..5463566d1ef 100644 --- a/homeassistant/components/coolmaster/button.py +++ b/homeassistant/components/coolmaster/button.py @@ -3,26 +3,23 @@ from __future__ import annotations from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DATA_INFO, DOMAIN +from .coordinator import CoolmasterConfigEntry from .entity import CoolmasterEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CoolmasterConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the CoolMasterNet button platform.""" - info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + coordinator = config_entry.runtime_data async_add_entities( - CoolmasterResetFilter(coordinator, unit_id, info) - for unit_id in coordinator.data + CoolmasterResetFilter(coordinator, unit_id) for unit_id in coordinator.data ) diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index d3cb7122109..cd1659e1666 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -12,13 +12,13 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_SUPPORTED_MODES, DATA_COORDINATOR, DATA_INFO, DOMAIN +from .const import CONF_SUPPORTED_MODES +from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator from .entity import CoolmasterEntity CM_TO_HA_STATE = { @@ -38,15 +38,16 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CoolmasterConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the CoolMasterNet climate platform.""" - info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - supported_modes = config_entry.data.get(CONF_SUPPORTED_MODES) + coordinator = config_entry.runtime_data + supported_modes: list[str] = config_entry.data[CONF_SUPPORTED_MODES] async_add_entities( - CoolmasterClimate(coordinator, unit_id, info, supported_modes) + CoolmasterClimate( + coordinator, unit_id, [HVACMode(mode) for mode in supported_modes] + ) for unit_id in coordinator.data ) @@ -55,11 +56,15 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): """Representation of a coolmaster climate device.""" _attr_name = None - _enable_turn_on_off_backwards_compatibility = False - def __init__(self, coordinator, unit_id, info, supported_modes): + def __init__( + self, + coordinator: CoolmasterDataUpdateCoordinator, + unit_id: str, + supported_modes: list[HVACMode], + ) -> None: """Initialize the climate device.""" - super().__init__(coordinator, unit_id, info) + super().__init__(coordinator, unit_id) self._attr_hvac_modes = supported_modes self._attr_unique_id = unit_id diff --git a/homeassistant/components/coolmaster/const.py b/homeassistant/components/coolmaster/const.py index 1fa46e20ee9..9dd7ed3a444 100644 --- a/homeassistant/components/coolmaster/const.py +++ b/homeassistant/components/coolmaster/const.py @@ -1,8 +1,5 @@ """Constants for the Coolmaster integration.""" -DATA_INFO = "info" -DATA_COORDINATOR = "coordinator" - DOMAIN = "coolmaster" DEFAULT_PORT = 10102 diff --git a/homeassistant/components/coolmaster/coordinator.py b/homeassistant/components/coolmaster/coordinator.py index 54d69b1c540..b2c96ca12a4 100644 --- a/homeassistant/components/coolmaster/coordinator.py +++ b/homeassistant/components/coolmaster/coordinator.py @@ -1,8 +1,15 @@ """DataUpdateCoordinator for coolmaster integration.""" +from __future__ import annotations + import logging +from pycoolmasternet_async import CoolMasterNet +from pycoolmasternet_async.coolmasternet import CoolMasterNetUnit + from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -10,21 +17,34 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class CoolmasterDataUpdateCoordinator(DataUpdateCoordinator): +type CoolmasterConfigEntry = ConfigEntry[CoolmasterDataUpdateCoordinator] + + +class CoolmasterDataUpdateCoordinator( + DataUpdateCoordinator[dict[str, CoolMasterNetUnit]] +): """Class to manage fetching Coolmaster data.""" - def __init__(self, hass, coolmaster): + def __init__( + self, + hass: HomeAssistant, + entry: CoolmasterConfigEntry, + coolmaster: CoolMasterNet, + info: dict[str, str], + ) -> None: """Initialize global Coolmaster data updater.""" self._coolmaster = coolmaster + self.info = info super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, CoolMasterNetUnit]: """Fetch data from Coolmaster.""" try: return await self._coolmaster.status() diff --git a/homeassistant/components/coolmaster/entity.py b/homeassistant/components/coolmaster/entity.py index 73bd1e13a26..7d7bd8e62ba 100644 --- a/homeassistant/components/coolmaster/entity.py +++ b/homeassistant/components/coolmaster/entity.py @@ -1,7 +1,5 @@ """Base entity for Coolmaster integration.""" -from pycoolmasternet_async.coolmasternet import CoolMasterNetUnit - from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -19,18 +17,17 @@ class CoolmasterEntity(CoordinatorEntity[CoolmasterDataUpdateCoordinator]): self, coordinator: CoolmasterDataUpdateCoordinator, unit_id: str, - info: dict[str, str], ) -> None: """Initiate CoolmasterEntity.""" super().__init__(coordinator) self._unit_id: str = unit_id - self._unit: CoolMasterNetUnit = coordinator.data[self._unit_id] + self._unit = coordinator.data[self._unit_id] self._attr_device_info: DeviceInfo = DeviceInfo( identifiers={(DOMAIN, unit_id)}, manufacturer="CoolAutomation", model="CoolMasterNet", name=unit_id, - sw_version=info["version"], + sw_version=coordinator.info["version"], ) if hasattr(self, "entity_description"): self._attr_unique_id: str = f"{unit_id}-{self.entity_description.key}" diff --git a/homeassistant/components/coolmaster/sensor.py b/homeassistant/components/coolmaster/sensor.py index 4c2a09b1ce5..2b835565bae 100644 --- a/homeassistant/components/coolmaster/sensor.py +++ b/homeassistant/components/coolmaster/sensor.py @@ -3,26 +3,23 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DATA_INFO, DOMAIN +from .coordinator import CoolmasterConfigEntry from .entity import CoolmasterEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CoolmasterConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the CoolMasterNet sensor platform.""" - info = hass.data[DOMAIN][config_entry.entry_id][DATA_INFO] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + coordinator = config_entry.runtime_data async_add_entities( - CoolmasterCleanFilter(coordinator, unit_id, info) - for unit_id in coordinator.data + CoolmasterCleanFilter(coordinator, unit_id) for unit_id in coordinator.data ) diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index fb1f6467f4a..2c52fb43b9f 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -29,19 +29,19 @@ "services": { "decrement": { "name": "Decrement", - "description": "Decrements a counter." + "description": "Decrements a counter by its step size." }, "increment": { "name": "Increment", - "description": "Increments a counter." + "description": "Increments a counter by its step size." }, "reset": { "name": "Reset", - "description": "Resets a counter." + "description": "Resets a counter to its initial value." }, "set_value": { "name": "Set", - "description": "Sets the counter value.", + "description": "Sets the counter to a specific value.", "fields": { "value": { "name": "Value", diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index ea11761a753..85069b425e3 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -9,7 +9,7 @@ import functools as ft import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -89,36 +89,8 @@ class CoverDeviceClass(StrEnum): DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass)) - -# DEVICE_CLASS* below are deprecated as of 2021.12 -# use the CoverDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass] -_DEPRECATED_DEVICE_CLASS_AWNING = DeprecatedConstantEnum( - CoverDeviceClass.AWNING, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_BLIND = DeprecatedConstantEnum( - CoverDeviceClass.BLIND, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_CURTAIN = DeprecatedConstantEnum( - CoverDeviceClass.CURTAIN, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_DAMPER = DeprecatedConstantEnum( - CoverDeviceClass.DAMPER, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum(CoverDeviceClass.DOOR, "2025.1") -_DEPRECATED_DEVICE_CLASS_GARAGE = DeprecatedConstantEnum( - CoverDeviceClass.GARAGE, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_GATE = DeprecatedConstantEnum(CoverDeviceClass.GATE, "2025.1") -_DEPRECATED_DEVICE_CLASS_SHADE = DeprecatedConstantEnum( - CoverDeviceClass.SHADE, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_SHUTTER = DeprecatedConstantEnum( - CoverDeviceClass.SHUTTER, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( - CoverDeviceClass.WINDOW, "2025.1" -) + # mypy: disallow-any-generics @@ -136,27 +108,6 @@ class CoverEntityFeature(IntFlag): SET_TILT_POSITION = 128 -# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. -# Please use the CoverEntityFeature enum instead. -_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(CoverEntityFeature.OPEN, "2025.1") -_DEPRECATED_SUPPORT_CLOSE = DeprecatedConstantEnum(CoverEntityFeature.CLOSE, "2025.1") -_DEPRECATED_SUPPORT_SET_POSITION = DeprecatedConstantEnum( - CoverEntityFeature.SET_POSITION, "2025.1" -) -_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(CoverEntityFeature.STOP, "2025.1") -_DEPRECATED_SUPPORT_OPEN_TILT = DeprecatedConstantEnum( - CoverEntityFeature.OPEN_TILT, "2025.1" -) -_DEPRECATED_SUPPORT_CLOSE_TILT = DeprecatedConstantEnum( - CoverEntityFeature.CLOSE_TILT, "2025.1" -) -_DEPRECATED_SUPPORT_STOP_TILT = DeprecatedConstantEnum( - CoverEntityFeature.STOP_TILT, "2025.1" -) -_DEPRECATED_SUPPORT_SET_TILT_POSITION = DeprecatedConstantEnum( - CoverEntityFeature.SET_TILT_POSITION, "2025.1" -) - ATTR_CURRENT_POSITION = "current_position" ATTR_CURRENT_TILT_POSITION = "current_tilt_position" ATTR_POSITION = "position" @@ -349,7 +300,7 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" if (features := self._attr_supported_features) is not None: - if type(features) is int: # noqa: E721 + if type(features) is int: new_features = CoverEntityFeature(features) self._report_deprecated_supported_features_values(new_features) return new_features diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index bf6e9204714..2a96098421a 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -49,7 +49,7 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow): cloud: CrownstoneCloud def __init__( - self, flow_type: str, create_entry_cb: Callable[..., ConfigFlowResult] + self, flow_type: str, create_entry_cb: Callable[[], ConfigFlowResult] ) -> None: """Set up flow instance.""" self.flow_type = flow_type diff --git a/homeassistant/components/crownstone/helpers.py b/homeassistant/components/crownstone/helpers.py index 0dc86ea5f36..4da8bc8dbe7 100644 --- a/homeassistant/components/crownstone/helpers.py +++ b/homeassistant/components/crownstone/helpers.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence import os from serial.tools.list_ports_common import ListPortInfo @@ -12,7 +13,7 @@ from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST def list_ports_as_str( - serial_ports: list[ListPortInfo], no_usb_option: bool = True + serial_ports: Sequence[ListPortInfo], no_usb_option: bool = True ) -> list[str]: """Represent currently available serial ports as string. diff --git a/homeassistant/components/cups/manifest.json b/homeassistant/components/cups/manifest.json index c4aa596f01e..c8f19236ce7 100644 --- a/homeassistant/components/cups/manifest.json +++ b/homeassistant/components/cups/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/cups", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["pycups==1.9.73"] + "requirements": ["pycups==2.0.4"] } diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index c58578071ee..0eaffa39ee9 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -9,7 +9,6 @@ from aiohttp import ClientConnectionError from pydaikin.daikin_base import Appliance from pydaikin.factory import DaikinFactory -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -23,8 +22,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from .const import DOMAIN, KEY_MAC, TIMEOUT -from .coordinator import DaikinCoordinator +from .const import KEY_MAC, TIMEOUT +from .coordinator import DaikinConfigEntry, DaikinCoordinator _LOGGER = logging.getLogger(__name__) @@ -32,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bool: """Establish connection with Daikin.""" conf = entry.data # For backwards compat, set unique ID @@ -58,29 +57,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("ClientConnectionError to %s", host) raise ConfigEntryNotReady from err - coordinator = DaikinCoordinator(hass, device) + coordinator = DaikinCoordinator(hass, entry, device) await coordinator.async_config_entry_first_refresh() await async_migrate_unique_id(hass, entry, device) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_unique_id( - hass: HomeAssistant, config_entry: ConfigEntry, device: Appliance + hass: HomeAssistant, config_entry: DaikinConfigEntry, device: Appliance ) -> None: """Migrate old entry.""" dev_reg = dr.async_get(hass) diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 39e92ab1921..06ee0a03860 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -19,12 +19,10 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as DAIKIN_DOMAIN from .const import ( ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, @@ -32,7 +30,7 @@ from .const import ( ATTR_STATE_ON, ATTR_TARGET_TEMPERATURE, ) -from .coordinator import DaikinCoordinator +from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity _LOGGER = logging.getLogger(__name__) @@ -83,10 +81,12 @@ DAIKIN_ATTR_ADVANCED = "adv" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DaikinConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" - daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) + daikin_api = entry.runtime_data async_add_entities([DaikinClimate(daikin_api)]) @@ -104,7 +104,6 @@ class DaikinClimate(DaikinEntity, ClimateEntity): _attr_target_temperature_step = 1 _attr_fan_modes: list[str] _attr_swing_modes: list[str] - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: DaikinCoordinator) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 5956d31c5fb..cc25a88ae39 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -14,10 +14,10 @@ from pydaikin.exceptions import DaikinException from pydaikin.factory import DaikinFactory import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, KEY_MAC, TIMEOUT @@ -142,7 +142,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered Daikin device.""" _LOGGER.debug("Zeroconf user_input: %s", discovery_info) diff --git a/homeassistant/components/daikin/coordinator.py b/homeassistant/components/daikin/coordinator.py index 35d998b4ba2..8e1713af5b2 100644 --- a/homeassistant/components/daikin/coordinator.py +++ b/homeassistant/components/daikin/coordinator.py @@ -5,6 +5,7 @@ import logging from pydaikin.daikin_base import Appliance +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -12,15 +13,20 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type DaikinConfigEntry = ConfigEntry[DaikinCoordinator] + class DaikinCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Daikin data.""" - def __init__(self, hass: HomeAssistant, device: Appliance) -> None: + def __init__( + self, hass: HomeAssistant, entry: DaikinConfigEntry, device: Appliance + ) -> None: """Initialize global Daikin data updater.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=device.values.get("name", DOMAIN), update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index f6e9cb78efb..f794d97a9ba 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.13.7"], + "requirements": ["pydaikin==2.13.8"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index d2d6ef02fc3..982aac1f3f2 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfEnergy, @@ -24,7 +23,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as DAIKIN_DOMAIN from .const import ( ATTR_COMPRESSOR_FREQUENCY, ATTR_COOL_ENERGY, @@ -37,7 +35,7 @@ from .const import ( ATTR_TOTAL_ENERGY_TODAY, ATTR_TOTAL_POWER, ) -from .coordinator import DaikinCoordinator +from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity @@ -134,10 +132,12 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DaikinConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" - daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) + daikin_api = entry.runtime_data sensors = [ATTR_INSIDE_TEMPERATURE] if daikin_api.device.support_outside_temperature: sensors.append(ATTR_OUTSIDE_TEMPERATURE) diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 669048ac45e..8a3a15d367f 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -5,12 +5,10 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN -from .coordinator import DaikinCoordinator +from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity DAIKIN_ATTR_ADVANCED = "adv" @@ -19,10 +17,12 @@ DAIKIN_ATTR_MODE = "mode" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DaikinConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" - daikin_api: DaikinCoordinator = hass.data[DOMAIN][entry.entry_id] + daikin_api = entry.runtime_data switches: list[SwitchEntity] = [] if zones := daikin_api.device.zones: switches.extend( diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 622ec574542..43ce6a9b4c1 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -6,7 +6,7 @@ from datetime import date, timedelta import logging from typing import final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index 8aef34ddcbd..53f85992abc 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime, timedelta import logging from typing import final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/deako/__init__.py b/homeassistant/components/deako/__init__.py index fdcf09fad60..7a169defe01 100644 --- a/homeassistant/components/deako/__init__.py +++ b/homeassistant/components/deako/__init__.py @@ -4,8 +4,7 @@ from __future__ import annotations import logging -from pydeako.deako import Deako, DeviceListTimeout, FindDevicesTimeout -from pydeako.discover import DeakoDiscoverer +from pydeako import Deako, DeakoDiscoverer, FindDevicesError from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry @@ -30,12 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: DeakoConfigEntry) -> boo await connection.connect() try: await connection.find_devices() - except DeviceListTimeout as exc: # device list never received - _LOGGER.warning("Device not responding to device list") - await connection.disconnect() - raise ConfigEntryNotReady(exc) from exc - except FindDevicesTimeout as exc: # total devices expected not received - _LOGGER.warning("Device not responding to device requests") + except FindDevicesError as exc: + _LOGGER.warning("Error finding devices: %s", exc) await connection.disconnect() raise ConfigEntryNotReady(exc) from exc diff --git a/homeassistant/components/deako/config_flow.py b/homeassistant/components/deako/config_flow.py index d0676fa81d9..273cbf2795e 100644 --- a/homeassistant/components/deako/config_flow.py +++ b/homeassistant/components/deako/config_flow.py @@ -1,6 +1,6 @@ """Config flow for deako.""" -from pydeako.discover import DeakoDiscoverer, DevicesNotFoundException +from pydeako import DeakoDiscoverer, DevicesNotFoundException from homeassistant.components import zeroconf from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/deako/light.py b/homeassistant/components/deako/light.py index c7ff8765402..75b01935c9a 100644 --- a/homeassistant/components/deako/light.py +++ b/homeassistant/components/deako/light.py @@ -2,7 +2,7 @@ from typing import Any -from pydeako.deako import Deako +from pydeako import Deako from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/deako/manifest.json b/homeassistant/components/deako/manifest.json index e3099439b9d..f4f4782530b 100644 --- a/homeassistant/components/deako/manifest.json +++ b/homeassistant/components/deako/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/deako", "iot_class": "local_polling", "loggers": ["pydeako"], - "requirements": ["pydeako==0.5.4"], + "requirements": ["pydeako==0.6.0"], "single_config_entry": true, "zeroconf": ["_deako._tcp.local."] } diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 1e31e002a81..078af8c67a5 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.8.6"] + "requirements": ["debugpy==1.8.11"] } diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 8007f3217d5..42c81e69740 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -9,12 +9,12 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .config_flow import get_master_hub from .const import CONF_MASTER_GATEWAY, DOMAIN, PLATFORMS from .deconz_event import async_setup_events, async_unload_events from .errors import AuthenticationRequired, CannotConnect from .hub import DeconzHub, get_deconz_api from .services import async_setup_services +from .util import get_master_hub CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -46,7 +46,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hub = hass.data[DOMAIN][config_entry.entry_id] = DeconzHub(hass, config_entry, api) await hub.async_update_device_registry() - config_entry.add_update_listener(hub.async_config_entry_updated) + config_entry.async_on_unload( + config_entry.add_update_listener(hub.async_config_entry_updated) + ) await async_setup_events(hub) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 1e228dc6c48..690f943379d 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -101,7 +101,6 @@ class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity): TYPE = CLIMATE_DOMAIN _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: Thermostat, hub: DeconzHub) -> None: """Set up thermostat device.""" diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index ed54701f656..41e45d53c76 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -19,7 +19,6 @@ from pydeconz.utils import ( ) import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_HASSIO, ConfigEntry, @@ -28,9 +27,10 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from .const import ( CONF_ALLOW_CLIP_SENSOR, @@ -51,15 +51,6 @@ CONF_SERIAL = "serial" CONF_MANUAL_INPUT = "Manually define gateway" -@callback -def get_master_hub(hass: HomeAssistant) -> DeconzHub: - """Return the gateway which is marked as master.""" - for hub in hass.data[DOMAIN].values(): - if hub.master: - return cast(DeconzHub, hub) - raise ValueError - - class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a deCONZ config flow.""" @@ -220,13 +211,13 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_link() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered deCONZ bridge.""" if LOGGER.isEnabledFor(logging.DEBUG): LOGGER.debug("deCONZ SSDP discovery %s", pformat(discovery_info)) - self.bridge_id = normalize_bridge_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + self.bridge_id = normalize_bridge_id(discovery_info.upnp[ATTR_UPNP_SERIAL]) parsed_url = urlparse(discovery_info.ssdp_location) entry = await self.async_set_unique_id(self.bridge_id) diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index 48f29cf9b72..26e4d3328b8 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -65,7 +65,6 @@ class DeconzFan(DeconzDevice[Light], FanEntity): | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: Light, hub: DeconzHub) -> None: """Set up fan.""" diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 95a97959d5b..d82c05f14eb 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -12,12 +12,14 @@ from pydeconz.models.light.light import Light, LightAlert, LightColorMode, Light from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, DOMAIN as LIGHT_DOMAIN, EFFECT_COLORLOOP, FLASH_LONG, @@ -30,7 +32,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import color_hs_to_xy +from homeassistant.util.color import ( + color_hs_to_xy, + color_temperature_kelvin_to_mired, + color_temperature_mired_to_kelvin, +) from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS from .entity import DeconzDevice @@ -187,6 +193,8 @@ class DeconzBaseLight[_LightDeviceT: Group | Light]( TYPE = LIGHT_DOMAIN _attr_color_mode = ColorMode.UNKNOWN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN def __init__(self, device: _LightDeviceT, hub: DeconzHub) -> None: """Set up light.""" @@ -256,9 +264,11 @@ class DeconzBaseLight[_LightDeviceT: Group | Light]( return self._device.brightness @property - def color_temp(self) -> int | None: + def color_temp_kelvin(self) -> int | None: """Return the CT color value.""" - return self._device.color_temp + if self._device.color_temp is None or self._device.color_temp == 0: + return None + return color_temperature_mired_to_kelvin(self._device.color_temp) @property def hs_color(self) -> tuple[float, float] | None: @@ -284,8 +294,10 @@ class DeconzBaseLight[_LightDeviceT: Group | Light]( if ATTR_BRIGHTNESS in kwargs: data["brightness"] = kwargs[ATTR_BRIGHTNESS] - if ATTR_COLOR_TEMP in kwargs: - data["color_temperature"] = kwargs[ATTR_COLOR_TEMP] + if ATTR_COLOR_TEMP_KELVIN in kwargs: + data["color_temperature"] = color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) if ATTR_HS_COLOR in kwargs: if ColorMode.XY in self._attr_supported_color_modes: @@ -338,14 +350,18 @@ class DeconzLight(DeconzBaseLight[Light]): """Representation of a deCONZ light.""" @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return self._device.max_color_temp or super().max_mireds + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" + if max_color_temp_mireds := self._device.max_color_temp: + return color_temperature_mired_to_kelvin(max_color_temp_mireds) + return super().min_color_temp_kelvin @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return self._device.min_color_temp or super().min_mireds + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" + if min_color_temp_mireds := self._device.min_color_temp: + return color_temperature_mired_to_kelvin(min_color_temp_mireds) + return super().max_color_temp_kelvin @callback def async_update_callback(self) -> None: diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index e10195d86bc..6127fe44308 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -12,9 +12,9 @@ from homeassistant.helpers import ( from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.util.read_only_dict import ReadOnlyDict -from .config_flow import get_master_hub from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER from .hub import DeconzHub +from .util import get_master_hub DECONZ_SERVICES = "deconz_services" diff --git a/homeassistant/components/deconz/util.py b/homeassistant/components/deconz/util.py index 7c44280200d..bcf338b2d6d 100644 --- a/homeassistant/components/deconz/util.py +++ b/homeassistant/components/deconz/util.py @@ -2,9 +2,24 @@ from __future__ import annotations +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .hub import DeconzHub + def serial_from_unique_id(unique_id: str | None) -> str | None: """Get a device serial number from a unique ID, if possible.""" if not unique_id or unique_id.count(":") != 7: return None return unique_id.partition("-")[0] + + +@callback +def get_master_hub(hass: HomeAssistant) -> DeconzHub: + """Return the gateway which is marked as master.""" + hub: DeconzHub + for hub in hass.data[DOMAIN].values(): + if hub.master: + return hub + raise ValueError diff --git a/homeassistant/components/decorquip/__init__.py b/homeassistant/components/decorquip/__init__.py new file mode 100644 index 00000000000..2fd6dc0efce --- /dev/null +++ b/homeassistant/components/decorquip/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Decorquip.""" diff --git a/homeassistant/components/decorquip/manifest.json b/homeassistant/components/decorquip/manifest.json new file mode 100644 index 00000000000..769b0bf9441 --- /dev/null +++ b/homeassistant/components/decorquip/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "decorquip", + "name": "Decorquip Dream", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index f4608b37006..9b07ae9c875 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -41,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DelugeConfigEntry) -> bo await hass.async_add_executor_job(api.connect) except (ConnectionRefusedError, TimeoutError, SSLError) as ex: raise ConfigEntryNotReady("Connection to Deluge Daemon failed") from ex - except Exception as ex: # noqa: BLE001 + except Exception as ex: if type(ex).__name__ == "BadLoginError": raise ConfigEntryAuthFailed( "Credentials for Deluge client are not valid" diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index ff0ed5746ca..d5b763caa5a 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -43,6 +43,7 @@ async def async_setup_entry( target_humidity=None, current_humidity=None, swing_mode=None, + swing_horizontal_mode=None, hvac_mode=HVACMode.HEAT, hvac_action=HVACAction.HEATING, target_temp_high=None, @@ -60,6 +61,7 @@ async def async_setup_entry( target_humidity=67.4, current_humidity=54.2, swing_mode="off", + swing_horizontal_mode="auto", hvac_mode=HVACMode.COOL, hvac_action=HVACAction.COOLING, target_temp_high=None, @@ -78,6 +80,7 @@ async def async_setup_entry( target_humidity=None, current_humidity=None, swing_mode="auto", + swing_horizontal_mode=None, hvac_mode=HVACMode.HEAT_COOL, hvac_action=None, target_temp_high=24, @@ -95,7 +98,6 @@ class DemoClimate(ClimateEntity): _attr_name = None _attr_should_poll = False _attr_translation_key = "ubercool" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -109,6 +111,7 @@ class DemoClimate(ClimateEntity): target_humidity: float | None, current_humidity: float | None, swing_mode: str | None, + swing_horizontal_mode: str | None, hvac_mode: HVACMode, hvac_action: HVACAction | None, target_temp_high: float | None, @@ -129,6 +132,8 @@ class DemoClimate(ClimateEntity): self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY if swing_mode is not None: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + if swing_horizontal_mode is not None: + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE if HVACMode.HEAT_COOL in hvac_modes or HVACMode.AUTO in hvac_modes: self._attr_supported_features |= ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -147,9 +152,11 @@ class DemoClimate(ClimateEntity): self._hvac_action = hvac_action self._hvac_mode = hvac_mode self._current_swing_mode = swing_mode + self._current_swing_horizontal_mode = swing_horizontal_mode self._fan_modes = ["on_low", "on_high", "auto_low", "auto_high", "off"] self._hvac_modes = hvac_modes self._swing_modes = ["auto", "1", "2", "3", "off"] + self._swing_horizontal_modes = ["auto", "rangefull", "off"] self._target_temperature_high = target_temp_high self._target_temperature_low = target_temp_low self._attr_device_info = DeviceInfo( @@ -242,6 +249,16 @@ class DemoClimate(ClimateEntity): """List of available swing modes.""" return self._swing_modes + @property + def swing_horizontal_mode(self) -> str | None: + """Return the swing setting.""" + return self._current_swing_horizontal_mode + + @property + def swing_horizontal_modes(self) -> list[str]: + """List of available swing modes.""" + return self._swing_horizontal_modes + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if kwargs.get(ATTR_TEMPERATURE) is not None: @@ -266,6 +283,11 @@ class DemoClimate(ClimateEntity): self._current_swing_mode = swing_mode self.async_write_ha_state() + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new swing mode.""" + self._current_swing_horizontal_mode = swing_horizontal_mode + self.async_write_ha_state() + async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" self._current_fan_mode = fan_mode diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 064ee3bb4f7..42e7f9e2434 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -100,7 +100,6 @@ class BaseDemoFan(FanEntity): _attr_should_poll = False _attr_translation_key = "demo" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json index 17425a6d119..eafcbb9161a 100644 --- a/homeassistant/components/demo/icons.json +++ b/homeassistant/components/demo/icons.json @@ -19,6 +19,13 @@ "auto": "mdi:arrow-oscillating", "off": "mdi:arrow-oscillating-off" } + }, + "swing_horizontal_mode": { + "state": { + "rangefull": "mdi:pan-horizontal", + "auto": "mdi:compare-horizontal", + "off": "mdi:arrow-oscillating-off" + } } } } diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index c859fef3b76..ec98a056b3e 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -7,12 +7,14 @@ from typing import Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_WHITE, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ColorMode, LightEntity, LightEntityFeature, @@ -28,7 +30,7 @@ LIGHT_COLORS = [(56, 86), (345, 75)] LIGHT_EFFECT_LIST = ["rainbow", "none"] -LIGHT_TEMPS = [240, 380] +LIGHT_TEMPS = [4166, 2631] SUPPORT_DEMO = {ColorMode.HS, ColorMode.COLOR_TEMP} SUPPORT_DEMO_HS_WHITE = {ColorMode.HS, ColorMode.WHITE} @@ -100,6 +102,9 @@ class DemoLight(LightEntity): _attr_name = None _attr_should_poll = False + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN + def __init__( self, unique_id: str, @@ -185,8 +190,8 @@ class DemoLight(LightEntity): return self._rgbww_color @property - def color_temp(self) -> int: - """Return the CT color temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" return self._ct @property @@ -216,9 +221,9 @@ class DemoLight(LightEntity): if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_COLOR_TEMP in kwargs: + if ATTR_COLOR_TEMP_KELVIN in kwargs: self._color_mode = ColorMode.COLOR_TEMP - self._ct = kwargs[ATTR_COLOR_TEMP] + self._ct = kwargs[ATTR_COLOR_TEMP_KELVIN] if ATTR_EFFECT in kwargs: self._effect = kwargs[ATTR_EFFECT] diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index aa5554e9fcc..da72b33d3ca 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -42,6 +42,13 @@ "auto": "Auto", "off": "[%key:common::state::off%]" } + }, + "swing_horizontal_mode": { + "state": { + "rangefull": "Full range", + "auto": "Auto", + "off": "[%key:common::state::off%]" + } } } } diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index d4c3820d29e..3dd945ab82e 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -7,12 +7,8 @@ from typing import Any from homeassistant.components.vacuum import ( ATTR_CLEANED_AREA, - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -91,16 +87,11 @@ class StateDemoVacuum(StateVacuumEntity): """Initialize the vacuum.""" self._attr_name = name self._attr_supported_features = supported_features - self._state = STATE_DOCKED + self._attr_activity = VacuumActivity.DOCKED self._fan_speed = FAN_SPEEDS[1] self._cleaned_area: float = 0 self._battery_level = 100 - @property - def state(self) -> str: - """Return the current state of the vacuum.""" - return self._state - @property def battery_level(self) -> int: """Return the current battery level of the vacuum.""" @@ -123,33 +114,33 @@ class StateDemoVacuum(StateVacuumEntity): def start(self) -> None: """Start or resume the cleaning task.""" - if self._state != STATE_CLEANING: - self._state = STATE_CLEANING + if self._attr_activity != VacuumActivity.CLEANING: + self._attr_activity = VacuumActivity.CLEANING self._cleaned_area += 1.32 self._battery_level -= 1 self.schedule_update_ha_state() def pause(self) -> None: """Pause the cleaning task.""" - if self._state == STATE_CLEANING: - self._state = STATE_PAUSED + if self._attr_activity == VacuumActivity.CLEANING: + self._attr_activity = VacuumActivity.PAUSED self.schedule_update_ha_state() def stop(self, **kwargs: Any) -> None: """Stop the cleaning task, do not return to dock.""" - self._state = STATE_IDLE + self._attr_activity = VacuumActivity.IDLE self.schedule_update_ha_state() def return_to_base(self, **kwargs: Any) -> None: """Return dock to charging base.""" - self._state = STATE_RETURNING + self._attr_activity = VacuumActivity.RETURNING self.schedule_update_ha_state() event.call_later(self.hass, 30, self.__set_state_to_dock) def clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" - self._state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING self._cleaned_area += 1.32 self._battery_level -= 1 self.schedule_update_ha_state() @@ -167,12 +158,12 @@ class StateDemoVacuum(StateVacuumEntity): "persistent_notification", service_data={"message": "I'm here!", "title": "Locate request"}, ) - self._state = STATE_IDLE + self._attr_activity = VacuumActivity.IDLE self.async_write_ha_state() async def async_clean_spot(self, **kwargs: Any) -> None: """Locate the vacuum's position.""" - self._state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING self.async_write_ha_state() async def async_send_command( @@ -182,9 +173,9 @@ class StateDemoVacuum(StateVacuumEntity): **kwargs: Any, ) -> None: """Send a command to the vacuum.""" - self._state = STATE_IDLE + self._attr_activity = VacuumActivity.IDLE self.async_write_ha_state() def __set_state_to_dock(self, _: datetime) -> None: - self._state = STATE_DOCKED + self._attr_activity = VacuumActivity.DOCKED self.schedule_update_ha_state() diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py index 0a6fe18d986..2f46cd42294 100644 --- a/homeassistant/components/denon/media_player.py +++ b/homeassistant/components/denon/media_player.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging -import telnetlib # pylint: disable=deprecated-module +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.media_player import ( diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index 98b77a994f6..da2b601317a 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from homeassistant.helpers.httpx_client import get_async_client -from .config_flow import ( +from .const import ( CONF_SHOW_ALL_SOURCES, CONF_UPDATE_AUDYSSEY, CONF_USE_TELNET, @@ -24,21 +24,18 @@ from .config_flow import ( DEFAULT_USE_TELNET, DEFAULT_ZONE2, DEFAULT_ZONE3, - DOMAIN, ) from .receiver import ConnectDenonAVR -CONF_RECEIVER = "receiver" -UNDO_UPDATE_LISTENER = "undo_update_listener" PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) +type DenonavrConfigEntry = ConfigEntry[DenonAVR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: DenonavrConfigEntry) -> bool: """Set up the denonavr components from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - # Connect to receiver connect_denonavr = ConnectDenonAVR( entry.data[CONF_HOST], @@ -56,12 +53,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from ex receiver = connect_denonavr.receiver - undo_listener = entry.add_update_listener(update_listener) + entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data[DOMAIN][entry.entry_id] = { - CONF_RECEIVER: receiver, - UNDO_UPDATE_LISTENER: undo_listener, - } + entry.runtime_data = receiver await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) use_telnet = entry.options.get(CONF_USE_TELNET, DEFAULT_USE_TELNET) @@ -79,18 +73,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: DenonavrConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) if config_entry.options.get(CONF_USE_TELNET, DEFAULT_USE_TELNET): - receiver: DenonAVR = hass.data[DOMAIN][config_entry.entry_id][CONF_RECEIVER] + receiver = config_entry.runtime_data await receiver.async_telnet_disconnect() - hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() - # Remove zone2 and zone3 entities if needed entity_registry = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) @@ -105,12 +99,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> entity_registry.async_remove(entry.entity_id) _LOGGER.debug("Removing zone3 from DenonAvr") - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - return unload_ok -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: DenonavrConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 9ff05411588..930d0e009ac 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -10,40 +10,42 @@ import denonavr from denonavr.exceptions import AvrNetworkError, AvrTimoutError import voluptuous as vol -from homeassistant.components import ssdp -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import callback from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from . import DenonavrConfigEntry +from .const import ( + CONF_MANUFACTURER, + CONF_SERIAL_NUMBER, + CONF_SHOW_ALL_SOURCES, + CONF_UPDATE_AUDYSSEY, + CONF_USE_TELNET, + CONF_ZONE2, + CONF_ZONE3, + DEFAULT_SHOW_SOURCES, + DEFAULT_TIMEOUT, + DEFAULT_UPDATE_AUDYSSEY, + DEFAULT_USE_TELNET, + DEFAULT_ZONE2, + DEFAULT_ZONE3, + DOMAIN, +) from .receiver import ConnectDenonAVR _LOGGER = logging.getLogger(__name__) -DOMAIN = "denonavr" - SUPPORTED_MANUFACTURERS = ["Denon", "DENON", "DENON PROFESSIONAL", "Marantz"] IGNORED_MODELS = ["HEOS 1", "HEOS 3", "HEOS 5", "HEOS 7"] -CONF_SHOW_ALL_SOURCES = "show_all_sources" -CONF_ZONE2 = "zone2" -CONF_ZONE3 = "zone3" -CONF_MANUFACTURER = "manufacturer" -CONF_SERIAL_NUMBER = "serial_number" -CONF_UPDATE_AUDYSSEY = "update_audyssey" -CONF_USE_TELNET = "use_telnet" - -DEFAULT_SHOW_SOURCES = False -DEFAULT_TIMEOUT = 5 -DEFAULT_ZONE2 = False -DEFAULT_ZONE3 = False -DEFAULT_UPDATE_AUDYSSEY = False -DEFAULT_USE_TELNET = False DEFAULT_USE_TELNET_NEW_INSTALL = True CONFIG_SCHEMA = vol.Schema({vol.Optional(CONF_HOST): str}) @@ -112,7 +114,7 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: DenonavrConfigEntry, ) -> OptionsFlowHandler: """Get the options flow.""" return OptionsFlowHandler() @@ -232,7 +234,7 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Denon AVR. @@ -241,22 +243,20 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN): """ # Filter out non-Denon AVRs#1 if ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) + discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) not in SUPPORTED_MANUFACTURERS ): return self.async_abort(reason="not_denonavr_manufacturer") # Check if required information is present to set the unique_id if ( - ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp - or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp + ATTR_UPNP_MODEL_NAME not in discovery_info.upnp + or ATTR_UPNP_SERIAL not in discovery_info.upnp ): return self.async_abort(reason="not_denonavr_missing") - self.model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME].replace( - "*", "" - ) - self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + self.model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME].replace("*", "") + self.serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] assert discovery_info.ssdp_location is not None self.host = urlparse(discovery_info.ssdp_location).hostname @@ -270,9 +270,7 @@ class DenonAvrFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - "name": discovery_info.upnp.get( - ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host - ) + "name": discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, self.host) } } ) diff --git a/homeassistant/components/denonavr/const.py b/homeassistant/components/denonavr/const.py new file mode 100644 index 00000000000..d28044ec018 --- /dev/null +++ b/homeassistant/components/denonavr/const.py @@ -0,0 +1,19 @@ +"""Constants for Denon AVR.""" + +DOMAIN = "denonavr" + + +CONF_SHOW_ALL_SOURCES = "show_all_sources" +CONF_ZONE2 = "zone2" +CONF_ZONE3 = "zone3" +CONF_MANUFACTURER = "manufacturer" +CONF_SERIAL_NUMBER = "serial_number" +CONF_UPDATE_AUDYSSEY = "update_audyssey" +CONF_USE_TELNET = "use_telnet" + +DEFAULT_SHOW_SOURCES = False +DEFAULT_TIMEOUT = 5 +DEFAULT_ZONE2 = False +DEFAULT_ZONE3 = False +DEFAULT_UPDATE_AUDYSSEY = False +DEFAULT_USE_TELNET = False diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index eff70b94a18..328ab504bd1 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==1.0.0"], + "requirements": ["denonavr==1.0.1"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 03d1b00cfaf..818d530ddab 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -35,18 +35,16 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL +from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import CONF_RECEIVER -from .config_flow import ( +from . import DenonavrConfigEntry +from .const import ( CONF_MANUFACTURER, CONF_SERIAL_NUMBER, - CONF_TYPE, CONF_UPDATE_AUDYSSEY, DEFAULT_UPDATE_AUDYSSEY, DOMAIN, @@ -110,13 +108,12 @@ DENON_STATE_MAPPING = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DenonavrConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DenonAVR receiver from a config entry.""" entities = [] - data = hass.data[DOMAIN][config_entry.entry_id] - receiver = data[CONF_RECEIVER] + receiver = config_entry.runtime_data update_audyssey = config_entry.options.get( CONF_UPDATE_AUDYSSEY, DEFAULT_UPDATE_AUDYSSEY ) @@ -253,7 +250,7 @@ class DenonDevice(MediaPlayerEntity): self, receiver: DenonAVR, unique_id: str, - config_entry: ConfigEntry, + config_entry: DenonavrConfigEntry, update_audyssey: bool, ) -> None: """Initialize the device.""" diff --git a/homeassistant/components/denonavr/strings.json b/homeassistant/components/denonavr/strings.json index a4e07e33a6a..6c055c5932a 100644 --- a/homeassistant/components/denonavr/strings.json +++ b/homeassistant/components/denonavr/strings.json @@ -50,7 +50,7 @@ "services": { "get_command": { "name": "Get command", - "description": "Send sa generic HTTP get command.", + "description": "Sends a generic HTTP get command.", "fields": { "command": { "name": "Command", diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 77ce5169d8d..988da5e938b 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -272,7 +272,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): if elapsed_time > self._time_window: derivative = new_derivative else: - derivative = Decimal(0.00) + derivative = Decimal("0.00") for start, end, value in self._state_list: weight = calculate_weight(start, end, new_state.last_updated) derivative = derivative + (value * Decimal(weight)) diff --git a/homeassistant/components/devialet/config_flow.py b/homeassistant/components/devialet/config_flow.py index 41acfa4b5a7..45a00fc4073 100644 --- a/homeassistant/components/devialet/config_flow.py +++ b/homeassistant/components/devialet/config_flow.py @@ -8,10 +8,10 @@ from typing import Any from devialet.devialet_api import DevialetApi import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -70,7 +70,7 @@ class DevialetFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Devialet device found via ZEROCONF: %s", discovery_info) diff --git a/homeassistant/components/devialet/manifest.json b/homeassistant/components/devialet/manifest.json index dd30f91c835..f101a325dab 100644 --- a/homeassistant/components/devialet/manifest.json +++ b/homeassistant/components/devialet/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/devialet", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["devialet==1.4.5"], + "requirements": ["devialet==1.5.7"], "zeroconf": ["_devialet-http._tcp.local."] } diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py index d490e348b9c..8789516650a 100644 --- a/homeassistant/components/devialet/media_player.py +++ b/homeassistant/components/devialet/media_player.py @@ -122,10 +122,10 @@ class DevialetMediaPlayerEntity( if self.coordinator.client.source_state is None: return features - if not self.coordinator.client.available_options: + if not self.coordinator.client.available_operations: return features - for option in self.coordinator.client.available_options: + for option in self.coordinator.client.available_operations: features |= DEVIALET_TO_HA_FEATURE_MAP.get(option, 0) return features diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 28991483cda..313373e3181 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -2,15 +2,8 @@ from __future__ import annotations -from functools import partial - from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401 from homeassistant.core import HomeAssistant -from homeassistant.helpers.deprecation import ( - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -23,10 +16,6 @@ from .config_entry import ( # noqa: F401 async_unload_entry, ) from .const import ( # noqa: F401 - _DEPRECATED_SOURCE_TYPE_BLUETOOTH, - _DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE, - _DEPRECATED_SOURCE_TYPE_GPS, - _DEPRECATED_SOURCE_TYPE_ROUTER, ATTR_ATTRIBUTES, ATTR_BATTERY, ATTR_DEV_ID, @@ -72,13 +61,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the device tracker.""" async_setup_legacy_integration(hass, config) return True - - -# As we import deprecated constants from the const module, we need to add these two functions -# otherwise this module will be logged for using deprecated constants and not the custom component -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 50fc3d2d936..db33d5038fc 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from typing import final -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components import zone from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 964b7faab9b..c9e4d4e910a 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -4,16 +4,9 @@ from __future__ import annotations from datetime import timedelta from enum import StrEnum -from functools import partial import logging from typing import Final -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.util.signal_type import SignalType LOGGER: Final = logging.getLogger(__package__) @@ -34,19 +27,6 @@ class SourceType(StrEnum): BLUETOOTH_LE = "bluetooth_le" -# SOURCE_TYPE_* below are deprecated as of 2022.9 -# use the SourceType enum instead. -_DEPRECATED_SOURCE_TYPE_GPS: Final = DeprecatedConstantEnum(SourceType.GPS, "2025.1") -_DEPRECATED_SOURCE_TYPE_ROUTER: Final = DeprecatedConstantEnum( - SourceType.ROUTER, "2025.1" -) -_DEPRECATED_SOURCE_TYPE_BLUETOOTH: Final = DeprecatedConstantEnum( - SourceType.BLUETOOTH, "2025.1" -) -_DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE: Final = DeprecatedConstantEnum( - SourceType.BLUETOOTH_LE, "2025.1" -) - CONF_SCAN_INTERVAL: Final = "interval_seconds" SCAN_INTERVAL: Final = timedelta(seconds=12) @@ -72,10 +52,3 @@ ATTR_IP: Final = "ip" CONNECTED_DEVICE_REGISTERED = SignalType[dict[str, str | None]]( "device_tracker_connected_device_registered" ) - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 5dff5837b4b..f2f782d3d97 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -10,7 +10,7 @@ from types import ModuleType from typing import Any, Final, Protocol, final import attr -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant import util @@ -978,9 +978,9 @@ class DeviceScanner: async def async_scan_devices(self) -> list[str]: """Scan for devices.""" - assert ( - self.hass is not None - ), "hass should be set by async_setup_scanner_platform" + assert self.hass is not None, ( + "hass should be set by async_setup_scanner_platform" + ) return await self.hass.async_add_executor_job(self.scan_devices) def get_device_name(self, device: str) -> str | None: @@ -989,9 +989,9 @@ class DeviceScanner: async def async_get_device_name(self, device: str) -> str | None: """Get the name of a device.""" - assert ( - self.hass is not None - ), "hass should be set by async_setup_scanner_platform" + assert self.hass is not None, ( + "hass should be set by async_setup_scanner_platform" + ) return await self.hass.async_add_executor_job(self.get_device_name, device) def get_extra_attributes(self, device: str) -> dict: @@ -1000,9 +1000,9 @@ class DeviceScanner: async def async_get_extra_attributes(self, device: str) -> dict: """Get the extra attributes of a device.""" - assert ( - self.hass is not None - ), "hass should be set by async_setup_scanner_platform" + assert self.hass is not None, ( + "hass should be set by async_setup_scanner_platform" + ) return await self.hass.async_add_executor_job(self.get_extra_attributes, device) diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index 7755e0f22b4..e86b7b753c8 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntry -from .const import CONF_MYDEVOLO, DEFAULT_MYDEVOLO, GATEWAY_SERIAL_PATTERN, PLATFORMS +from .const import GATEWAY_SERIAL_PATTERN, PLATFORMS type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]] @@ -102,5 +102,4 @@ def configure_mydevolo(conf: dict[str, Any] | MappingProxyType[str, Any]) -> Myd mydevolo = Mydevolo() mydevolo.user = conf[CONF_USERNAME] mydevolo.password = conf[CONF_PASSWORD] - mydevolo.url = conf.get(CONF_MYDEVOLO, DEFAULT_MYDEVOLO) return mydevolo diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py index 449b1c7659f..d24033a80b9 100644 --- a/homeassistant/components/devolo_home_control/binary_sensor.py +++ b/homeassistant/components/devolo_home_control/binary_sensor.py @@ -81,14 +81,8 @@ class DevoloBinaryDeviceEntity(DevoloDeviceEntity, BinarySensorEntity): or self._binary_sensor_property.sensor_type ) - if device_instance.binary_sensor_property[element_uid].sub_type != "": - self._attr_name = device_instance.binary_sensor_property[ - element_uid - ].sub_type.capitalize() - else: - self._attr_name = device_instance.binary_sensor_property[ - element_uid - ].sensor_type.capitalize() + if device_instance.binary_sensor_property[element_uid].sub_type == "overload": + self._attr_translation_key = "overload" self._value = self._binary_sensor_property.state @@ -129,7 +123,8 @@ class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity): self._key = key self._attr_is_on = False - self._attr_name = f"Button {key}" + self._attr_translation_key = "button" + self._attr_translation_placeholders = {"key": str(key)} def _sync(self, message: tuple) -> None: """Update the binary sensor state.""" diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 29177ae2437..1f407eb6804 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -56,7 +56,6 @@ class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntit _attr_precision = PRECISION_TENTHS _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index bfb083e0c44..c4f57b2398a 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -7,7 +7,6 @@ from typing import Any import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -16,9 +15,10 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import configure_mydevolo -from .const import CONF_MYDEVOLO, DEFAULT_MYDEVOLO, DOMAIN, SUPPORTED_MODEL_TYPES +from .const import DOMAIN, SUPPORTED_MODEL_TYPES from .exceptions import CredentialsInvalid, UuidChanged @@ -35,14 +35,11 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, } - self._url = DEFAULT_MYDEVOLO async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - if self.show_advanced_options: - self.data_schema[vol.Required(CONF_MYDEVOLO, default=self._url)] = str if user_input is None: return self._show_form(step_id="user") try: @@ -51,7 +48,7 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): return self._show_form(step_id="user", errors={"base": "invalid_auth"}) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" # Check if it is a gateway @@ -78,7 +75,6 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle reauthentication.""" self._reauth_entry = self._get_reauth_entry() - self._url = entry_data[CONF_MYDEVOLO] self.data_schema = { vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str, vol.Required(CONF_PASSWORD): str, @@ -104,7 +100,6 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): async def _connect_mydevolo(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Connect to mydevolo.""" - user_input[CONF_MYDEVOLO] = user_input.get(CONF_MYDEVOLO, self._url) mydevolo = configure_mydevolo(conf=user_input) credentials_valid = await self.hass.async_add_executor_job( mydevolo.credentials_valid @@ -121,7 +116,6 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): data={ CONF_PASSWORD: mydevolo.password, CONF_USERNAME: mydevolo.user, - CONF_MYDEVOLO: mydevolo.url, }, ) diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py index eb48a6d269e..bd2282ad99f 100644 --- a/homeassistant/components/devolo_home_control/const.py +++ b/homeassistant/components/devolo_home_control/const.py @@ -5,7 +5,6 @@ import re from homeassistant.const import Platform DOMAIN = "devolo_home_control" -DEFAULT_MYDEVOLO = "https://www.mydevolo.com" PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -15,6 +14,5 @@ PLATFORMS = [ Platform.SIREN, Platform.SWITCH, ] -CONF_MYDEVOLO = "mydevolo_url" GATEWAY_SERIAL_PATTERN = re.compile(r"\d{16}") SUPPORTED_MODEL_TYPES = ["2600", "2601"] diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index 61a63419732..8d0a7f0313c 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -116,9 +116,11 @@ class DevoloGenericMultiLevelDeviceEntity(DevoloMultiLevelDeviceEntity): self._multi_level_sensor_property.sensor_type ) self._attr_native_unit_of_measurement = self._multi_level_sensor_property.unit - self._attr_name = self._multi_level_sensor_property.sensor_type.capitalize() self._value = self._multi_level_sensor_property.value + if self._multi_level_sensor_property.sensor_type == "light": + self._attr_translation_key = "brightness" + if element_uid.startswith("devolo.VoltageMultiLevelSensor:"): self._attr_entity_registry_enabled_default = False @@ -128,7 +130,6 @@ class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_native_unit_of_measurement = PERCENTAGE - _attr_name = "Battery level" _attr_device_class = SensorDeviceClass.BATTERY _attr_state_class = SensorStateClass.MEASUREMENT @@ -175,8 +176,6 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): device_instance.consumption_property[element_uid], consumption ) - self._attr_name = f"{consumption.capitalize()} consumption" - @property def unique_id(self) -> str: """Return the unique ID of the entity. diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index eeae9aa2e2f..be853e2d89d 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -12,17 +12,38 @@ "user": { "data": { "username": "Email / devolo ID", - "password": "[%key:common::config_flow::data::password%]", - "mydevolo_url": "mydevolo URL" + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Email address you used to register the central unit at mydevolo.", + "password": "Password of your mydevolo account." } }, "zeroconf_confirm": { "data": { "username": "[%key:component::devolo_home_control::config::step::user::data::username%]", - "password": "[%key:common::config_flow::data::password%]", - "mydevolo_url": "[%key:component::devolo_home_control::config::step::user::data::mydevolo_url%]" + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::devolo_home_control::config::step::user::data_description::username%]", + "password": "[%key:component::devolo_home_control::config::step::user::data_description::password%]" } } } + }, + "entity": { + "binary_sensor": { + "button": { + "name": "Button {key}" + }, + "overload": { + "name": "Overload" + } + }, + "sensor": { + "brightness": { + "name": "Brightness" + } + } } } diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py index 7c8dccd1a7b..bd2f23d602f 100644 --- a/homeassistant/components/devolo_home_network/config_flow.py +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE @@ -81,7 +82,7 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if discovery_info.properties["MT"] in ["2600", "2601"]: diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index d10e14f9081..9b1e181d7c0 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -3,6 +3,7 @@ "name": "devolo Home Network", "codeowners": ["@2Fake", "@Shutgun"], "config_flow": true, + "dependencies": ["zeroconf"], "documentation": "https://www.home-assistant.io/integrations/devolo_home_network", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 2996cea90cb..4b683b5d2fa 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -6,11 +6,17 @@ "description": "[%key:common::config_flow::description::confirm_setup%]", "data": { "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard." } }, "reauth_confirm": { "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Password you protected the device with." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/dexcom/__init__.py b/homeassistant/components/dexcom/__init__.py index e93e8e66358..54722c8dade 100644 --- a/homeassistant/components/dexcom/__init__.py +++ b/homeassistant/components/dexcom/__init__.py @@ -1,24 +1,16 @@ """The Dexcom integration.""" -from datetime import timedelta -import logging +from pydexcom import AccountError, Dexcom, SessionError -from pydexcom import AccountError, Dexcom, GlucoseReading, SessionError - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_SERVER, DOMAIN, PLATFORMS, SERVER_OUS - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(seconds=180) +from .const import CONF_SERVER, PLATFORMS, SERVER_OUS +from .coordinator import DexcomConfigEntry, DexcomCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: DexcomConfigEntry) -> bool: """Set up Dexcom from a config entry.""" try: dexcom = await hass.async_add_executor_job( @@ -32,31 +24,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SessionError as error: raise ConfigEntryNotReady from error - async def async_update_data(): - try: - return await hass.async_add_executor_job(dexcom.get_current_glucose_reading) - except SessionError as error: - raise UpdateFailed(error) from error - - coordinator = DataUpdateCoordinator[GlucoseReading]( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=async_update_data, - update_interval=SCAN_INTERVAL, - ) + coordinator = DexcomCoordinator(hass, entry=entry, dexcom=dexcom) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DexcomConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/dexcom/coordinator.py b/homeassistant/components/dexcom/coordinator.py new file mode 100644 index 00000000000..a9e14def350 --- /dev/null +++ b/homeassistant/components/dexcom/coordinator.py @@ -0,0 +1,44 @@ +"""Coordinator for the Dexcom integration.""" + +from datetime import timedelta +import logging + +from pydexcom import Dexcom, GlucoseReading + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_SCAN_INTERVAL = timedelta(seconds=180) + +type DexcomConfigEntry = ConfigEntry[DexcomCoordinator] + + +class DexcomCoordinator(DataUpdateCoordinator[GlucoseReading]): + """Dexcom Coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + entry: DexcomConfigEntry, + dexcom: Dexcom, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=_SCAN_INTERVAL, + ) + self.dexcom = dexcom + + async def _async_update_data(self) -> GlucoseReading: + """Fetch data from API endpoint.""" + return await self.hass.async_add_executor_job( + self.dexcom.get_current_glucose_reading + ) diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index 850678e7ac9..cdb1894b675 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -2,20 +2,15 @@ from __future__ import annotations -from pydexcom import GlucoseReading - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import DexcomConfigEntry, DexcomCoordinator TRENDS = { 1: "rising_quickly", @@ -30,11 +25,11 @@ TRENDS = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DexcomConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Dexcom sensors.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data username = config_entry.data[CONF_USERNAME] async_add_entities( [ @@ -44,16 +39,14 @@ async def async_setup_entry( ) -class DexcomSensorEntity( - CoordinatorEntity[DataUpdateCoordinator[GlucoseReading]], SensorEntity -): +class DexcomSensorEntity(CoordinatorEntity[DexcomCoordinator], SensorEntity): """Base Dexcom sensor entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[GlucoseReading], + coordinator: DexcomCoordinator, username: str, entry_id: str, key: str, @@ -78,7 +71,7 @@ class DexcomGlucoseValueSensor(DexcomSensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: DexcomCoordinator, username: str, entry_id: str, ) -> None: @@ -101,7 +94,7 @@ class DexcomGlucoseTrendSensor(DexcomSensorEntity): _attr_options = list(TRENDS.values()) def __init__( - self, coordinator: DataUpdateCoordinator, username: str, entry_id: str + self, coordinator: DexcomCoordinator, username: str, entry_id: str ) -> None: """Initialize the sensor.""" super().__init__(coordinator, username, entry_id, "trend") diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 2de676ef52a..a11a0b262b0 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -7,7 +7,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta from fnmatch import translate -from functools import lru_cache +from functools import lru_cache, partial import itertools import logging import re @@ -44,12 +44,17 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery_flow, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -57,6 +62,7 @@ from homeassistant.helpers.event import ( async_track_state_added_domain, async_track_time_interval, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo as _DhcpServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.loader import DHCPMatcher, async_get_dhcp @@ -74,13 +80,11 @@ SCAN_INTERVAL = timedelta(minutes=60) _LOGGER = logging.getLogger(__name__) -@dataclass(slots=True) -class DhcpServiceInfo(BaseServiceInfo): - """Prepared info from dhcp entries.""" - - ip: str - hostname: str - macaddress: str +_DEPRECATED_DhcpServiceInfo = DeprecatedConstant( + _DhcpServiceInfo, + "homeassistant.helpers.service_info.dhcp.DhcpServiceInfo", + "2026.2", +) @dataclass(slots=True) @@ -296,7 +300,7 @@ class WatcherBase: self.hass, domain, {"source": config_entries.SOURCE_DHCP}, - DhcpServiceInfo( + _DhcpServiceInfo( ip=ip_address, hostname=lowercase_hostname, macaddress=mac_address, @@ -486,3 +490,11 @@ def _memorized_fnmatch(name: str, pattern: str) -> bool: since the devices will not change frequently """ return bool(_compile_fnmatch(pattern).match(name)) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py index e59fa4e9d0d..274cc4cbf53 100644 --- a/homeassistant/components/directv/__init__.py +++ b/homeassistant/components/directv/__init__.py @@ -12,13 +12,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN - PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] SCAN_INTERVAL = timedelta(seconds=30) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type DirecTVConfigEntry = ConfigEntry[DIRECTV] + + +async def async_setup_entry(hass: HomeAssistant, entry: DirecTVConfigEntry) -> bool: """Set up DirecTV from a config entry.""" dtv = DIRECTV(entry.data[CONF_HOST], session=async_get_clientsession(hass)) @@ -27,18 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except DIRECTVError as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = dtv + entry.runtime_data = dtv await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DirecTVConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py index 1e0577b4f7c..927d2325c2d 100644 --- a/homeassistant/components/directv/config_flow.py +++ b/homeassistant/components/directv/config_flow.py @@ -9,11 +9,11 @@ from urllib.parse import urlparse from directv import DIRECTV, DIRECTVError import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from .const import CONF_RECEIVER_ID, DOMAIN @@ -67,7 +67,7 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle SSDP discovery.""" # We can cast the hostname to str because the ssdp_location is not bytes and @@ -75,10 +75,8 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): host = cast(str, urlparse(discovery_info.ssdp_location).hostname) receiver_id = None - if discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL): - receiver_id = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL][ - 4: - ] # strips off RID- + if discovery_info.upnp.get(ATTR_UPNP_SERIAL): + receiver_id = discovery_info.upnp[ATTR_UPNP_SERIAL][4:] # strips off RID- self.context.update({"title_placeholders": {"name": host}}) diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 6c4a40598de..8998e050a75 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -14,17 +14,16 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util +from . import DirecTVConfigEntry from .const import ( ATTR_MEDIA_CURRENTLY_RECORDING, ATTR_MEDIA_RATING, ATTR_MEDIA_RECORDED, ATTR_MEDIA_START_TIME, - DOMAIN, ) from .entity import DIRECTVEntity @@ -55,11 +54,11 @@ SUPPORT_DTV_CLIENT = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DirecTVConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DirecTV config entry.""" - dtv = hass.data[DOMAIN][entry.entry_id] + dtv = entry.runtime_data async_add_entities( ( diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 5a77d90bd3c..dbaab5fa4e6 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -10,11 +10,10 @@ from typing import Any from directv import DIRECTV, DIRECTVError from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DirecTVConfigEntry from .entity import DIRECTVEntity _LOGGER = logging.getLogger(__name__) @@ -24,11 +23,11 @@ SCAN_INTERVAL = timedelta(minutes=2) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DirecTVConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Load DirecTV remote based on a config entry.""" - dtv = hass.data[DOMAIN][entry.entry_id] + dtv = entry.runtime_data async_add_entities( ( diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 72aa6c19a21..81c33adc052 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -60,11 +60,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index 05ed90bf354..f24fdd1e43d 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -11,12 +11,7 @@ from pydiscovergy.authentication import BasicAuth import pydiscovergy.error as discovergyError import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlow, - ConfigFlowResult, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( @@ -57,35 +52,14 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - _existing_entry: ConfigEntry - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", - data_schema=CONFIG_SCHEMA, - ) - - return await self._validate_and_save(user_input) - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle the initial step.""" - self._existing_entry = self._get_reauth_entry() - return await self.async_step_reauth_confirm() + return await self.async_step_user() - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the reauth step.""" - return await self._validate_and_save(user_input, step_id="reauth_confirm") - - async def _validate_and_save( - self, user_input: Mapping[str, Any] | None = None, step_id: str = "user" + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None ) -> ConfigFlowResult: """Validate user input and create config entry.""" errors = {} @@ -106,17 +80,17 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected error occurred while getting meters") errors["base"] = "unknown" else: + await self.async_set_unique_id(user_input[CONF_EMAIL].lower()) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="account_mismatch") return self.async_update_reload_and_abort( - entry=self._existing_entry, - data={ - CONF_EMAIL: user_input[CONF_EMAIL], + entry=self._get_reauth_entry(), + data_updates={ CONF_PASSWORD: user_input[CONF_PASSWORD], }, ) - # set unique id to title which is the account email - await self.async_set_unique_id(user_input[CONF_EMAIL].lower()) self._abort_if_unique_id_configured() return self.async_create_entry( @@ -124,10 +98,10 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id=step_id, + step_id="user", data_schema=self.add_suggested_values_to_schema( CONFIG_SCHEMA, - self._existing_entry.data + self._get_reauth_entry().data if self.source == SOURCE_REAUTH else user_input, ), diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index b82f28a5d11..2f74928c19e 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", + "quality_scale": "silver", "requirements": ["pydiscovergy==3.0.2"] } diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml index 3caeaa6bbe0..56af1d97304 100644 --- a/homeassistant/components/discovergy/quality_scale.yaml +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -8,10 +8,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - The data_descriptions are missing. + config-flow: done dependency-transparency: done docs-actions: status: exempt @@ -19,7 +16,7 @@ rules: The integration does not provide any additional actions. docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -41,11 +38,11 @@ rules: status: exempt comment: | The integration does not provide any additional options. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 531904c8740..a3ec132db9b 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -28,6 +28,8 @@ from . import DiscovergyConfigEntry from .const import DOMAIN, MANUFACTURER from .coordinator import DiscovergyUpdateCoordinator +PARALLEL_UPDATES = 0 + def _get_and_scale(reading: Reading, key: str, scale: int) -> datetime | float | None: """Get a value from a Reading and divide with scale it.""" diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index 9a91fa92dc4..0058f874a36 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -5,12 +5,10 @@ "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" - } - }, - "reauth_confirm": { - "data": { - "email": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address used to log in to your inexogy account.", + "password": "The password used to log in to your inexogy account." } } }, @@ -21,6 +19,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "account_mismatch": "The inexogy account authenticated with does not match the account that needed re-authentication.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/homeassistant/components/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py index 4452a2958fc..02ef94dae7d 100644 --- a/homeassistant/components/dlink/config_flow.py +++ b/homeassistant/components/dlink/config_flow.py @@ -8,9 +8,9 @@ from typing import Any from pyW215.pyW215 import SmartPlug import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_USE_LEGACY_PROTOCOL, DEFAULT_NAME, DEFAULT_USERNAME, DOMAIN @@ -25,7 +25,7 @@ class DLinkFlowHandler(ConfigFlow, domain=DOMAIN): self.ip_address: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" await self.async_set_unique_id(discovery_info.macaddress) diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 75f50192500..ede9119c50d 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -27,6 +27,14 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import IntegrationError from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERVICE_LIST, + SsdpServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import ( @@ -60,7 +68,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" - self._discoveries: dict[str, ssdp.SsdpServiceInfo] = {} + self._discoveries: dict[str, SsdpServiceInfo] = {} self._location: str | None = None self._udn: str | None = None self._device_type: str | None = None @@ -98,7 +106,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_manual() self._discoveries = { - discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + discovery.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or cast(str, urlparse(discovery.ssdp_location).hostname): discovery for discovery in discoveries } @@ -131,7 +139,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by SSDP discovery.""" if LOGGER.isEnabledFor(logging.DEBUG): @@ -267,7 +275,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=data, options=self._options) async def _async_set_info_from_discovery( - self, discovery_info: ssdp.SsdpServiceInfo, abort_if_configured: bool = True + self, discovery_info: SsdpServiceInfo, abort_if_configured: bool = True ) -> None: """Set information required for a config entry from the SSDP discovery.""" LOGGER.debug( @@ -285,7 +293,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): self._device_type = discovery_info.ssdp_nt or discovery_info.ssdp_st self._name = ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or urlparse(self._location).hostname or DEFAULT_NAME ) @@ -301,12 +309,12 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): updates[CONF_MAC] = self._mac self._abort_if_unique_id_configured(updates=updates, reload_on_update=False) - async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]: + async def _async_get_discoveries(self) -> list[SsdpServiceInfo]: """Get list of unconfigured DLNA devices discovered by SSDP.""" LOGGER.debug("_get_discoveries") # Get all compatible devices from ssdp's cache - discoveries: list[ssdp.SsdpServiceInfo] = [] + discoveries: list[SsdpServiceInfo] = [] for udn_st in DmrDevice.DEVICE_TYPES: st_discoveries = await ssdp.async_get_discovery_info_by_st( self.hass, udn_st @@ -386,7 +394,7 @@ class DlnaDmrOptionsFlowHandler(OptionsFlow): ) -def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_ignored_device(discovery_info: SsdpServiceInfo) -> bool: """Return True if this device should be ignored for discovery. These devices are supported better by other integrations, so don't bug @@ -402,17 +410,14 @@ def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: return True # Is the root device not a DMR? - if ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_DEVICE_TYPE) - not in DmrDevice.DEVICE_TYPES - ): + if discovery_info.upnp.get(ATTR_UPNP_DEVICE_TYPE) not in DmrDevice.DEVICE_TYPES: return True # Special cases for devices with other discovery methods (e.g. mDNS), or # that advertise multiple unrelated (sent in separate discovery packets) # UPnP devices. - manufacturer = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) or "").lower() - model = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "").lower() + manufacturer = (discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) or "").lower() + model = (discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "").lower() if manufacturer.startswith("xbmc") or model == "kodi": # kodi @@ -432,14 +437,14 @@ def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: return False -def _is_dmr_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_dmr_device(discovery_info: SsdpServiceInfo) -> bool: """Determine if discovery is a complete DLNA DMR device. Use the discovery_info instead of DmrDevice.is_profile_device to avoid contacting the device again. """ # Abort if the device doesn't support all services required for a DmrDevice. - discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) + discovery_service_list = discovery_info.upnp.get(ATTR_UPNP_SERVICE_LIST) if not discovery_service_list: return False diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 84024d5bde1..82541476a02 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.41.0", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 443c2101302..563ed209b7d 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -33,6 +33,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_BROWSE_UNFILTERED, @@ -246,7 +247,7 @@ class DlnaDmrEntity(MediaPlayerEntity): await self._device_disconnect() async def async_ssdp_callback( - self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: """Handle notification from SSDP of device state change.""" _LOGGER.debug( diff --git a/homeassistant/components/dlna_dms/config_flow.py b/homeassistant/components/dlna_dms/config_flow.py index ad959ece3b6..a87b4a510f5 100644 --- a/homeassistant/components/dlna_dms/config_flow.py +++ b/homeassistant/components/dlna_dms/config_flow.py @@ -14,6 +14,11 @@ from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERVICE_LIST, + SsdpServiceInfo, +) from .const import CONF_SOURCE_ID, CONFIG_VERSION, DEFAULT_NAME, DOMAIN from .util import generate_source_id @@ -33,7 +38,7 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" - self._discoveries: dict[str, ssdp.SsdpServiceInfo] = {} + self._discoveries: dict[str, SsdpServiceInfo] = {} self._location: str | None = None self._usn: str | None = None self._name: str | None = None @@ -60,14 +65,14 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN): } discovery_choices = { - host: f"{discovery.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)} ({host})" + host: f"{discovery.upnp.get(ATTR_UPNP_FRIENDLY_NAME)} ({host})" for host, discovery in self._discoveries.items() } data_schema = vol.Schema({vol.Optional(CONF_HOST): vol.In(discovery_choices)}) return self.async_show_form(step_id="user", data_schema=data_schema) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by SSDP discovery.""" if LOGGER.isEnabledFor(logging.DEBUG): @@ -81,7 +86,7 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN): # Abort if the device doesn't support all services required for a DmsDevice. # Use the discovery_info instead of DmsDevice.is_profile_device to avoid # contacting the device again. - discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) + discovery_service_list = discovery_info.upnp.get(ATTR_UPNP_SERVICE_LIST) if not discovery_service_list: return self.async_abort(reason="not_dms") @@ -135,7 +140,7 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=self._name, data=data) async def _async_parse_discovery( - self, discovery_info: ssdp.SsdpServiceInfo, raise_on_progress: bool = True + self, discovery_info: SsdpServiceInfo, raise_on_progress: bool = True ) -> None: """Get required details from an SSDP discovery. @@ -162,15 +167,15 @@ class DlnaDmsFlowHandler(ConfigFlow, domain=DOMAIN): ) self._name = ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or urlparse(self._location).hostname or DEFAULT_NAME ) - async def _async_get_discoveries(self) -> list[ssdp.SsdpServiceInfo]: + async def _async_get_discoveries(self) -> list[SsdpServiceInfo]: """Get list of unconfigured DLNA devices discovered by SSDP.""" # Get all compatible devices from ssdp's cache - discoveries: list[ssdp.SsdpServiceInfo] = [] + discoveries: list[SsdpServiceInfo] = [] for udn_st in DmsDevice.DEVICE_TYPES: st_discoveries = await ssdp.async_get_discovery_info_by_st( self.hass, udn_st diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 8f475d53280..89c53bc2564 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -16,7 +16,7 @@ from async_upnp_client.const import NotificationSubType from async_upnp_client.exceptions import UpnpActionError, UpnpConnectionError, UpnpError from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice from didl_lite import didl_lite -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components import ssdp from homeassistant.components.media_player import BrowseError, MediaClass @@ -29,6 +29,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_SOURCE_ID, @@ -220,7 +221,7 @@ class DmsDeviceSource: await self.device_disconnect() async def async_ssdp_callback( - self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: """Handle notification from SSDP of device state change.""" LOGGER.debug( diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 1913bb9d5d7..17fc3dc27e8 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.41.0"], + "requirements": ["async-upnp-client==0.43.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index 51633d0e05d..7b055c6dd05 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -266,7 +266,7 @@ class Doods(ImageProcessingEntity): # Draw detected objects for instance in values: - box_label = f'{label} {instance["score"]:.1f}%' + box_label = f"{label} {instance['score']:.1f}%" # Already scaled, use 1 for width and height draw_box( draw, diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index ae307bb4962..2c672dd4abb 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pydoods"], "quality_scale": "legacy", - "requirements": ["pydoods==1.0.2", "Pillow==11.0.0"] + "requirements": ["pydoods==1.0.2", "Pillow==11.1.0"] } diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index ebb1d6fc126..6a954f5310f 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -11,7 +11,6 @@ from aiohttp import ClientResponseError from doorbirdpy import DoorBird import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -22,6 +21,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNA from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from .const import ( @@ -158,7 +158,7 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=data, errors=errors) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered doorbird device.""" macaddress = discovery_info.properties["macaddress"] diff --git a/homeassistant/components/doorbird/device.py b/homeassistant/components/doorbird/device.py index eae5bb6804f..f57e7595dbc 100644 --- a/homeassistant/components/doorbird/device.py +++ b/homeassistant/components/doorbird/device.py @@ -15,7 +15,7 @@ from doorbirdpy import ( DoorBirdScheduleEntryOutput, DoorBirdScheduleEntrySchedule, ) -from propcache import cached_property +from propcache.api import cached_property from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index b4304e75aab..0a00490f3d9 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -2,30 +2,24 @@ from __future__ import annotations -from datetime import timedelta -import logging - from py_dormakaba_dkey import DKEYLock -from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS, NotAssociated from py_dormakaba_dkey.models import AssociationData from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS -from .models import DormakabaDkeyData +from .const import CONF_ASSOCIATION_DATA +from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: DormakabaDkeyConfigEntry +) -> bool: """Set up Dormakaba dKey from a config entry.""" address: str = entry.data[CONF_ADDRESS] ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True) @@ -56,29 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - async def _async_update() -> None: - """Update the device state.""" - try: - await lock.update() - await lock.disconnect() - except NotAssociated as ex: - raise ConfigEntryAuthFailed("Not associated") from ex - except DKEY_EXCEPTIONS as ex: - raise UpdateFailed(str(ex)) from ex - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=lock.name, - update_method=_async_update, - update_interval=timedelta(seconds=UPDATE_SECONDS), - ) + coordinator = DormakabaDkeyCoordinator(hass, entry, lock) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DormakabaDkeyData( - lock, coordinator - ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -89,13 +64,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) ) + entry.async_on_unload(coordinator.lock.disconnect) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: DormakabaDkeyConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: DormakabaDkeyData = hass.data[DOMAIN].pop(entry.entry_id) - await data.lock.disconnect() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index a8574443e35..56b991bf908 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from py_dormakaba_dkey import DKEYLock from py_dormakaba_dkey.commands import DoorPosition, Notifications, UnlockStatus from homeassistant.components.binary_sensor import ( @@ -13,14 +12,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity -from .models import DormakabaDkeyData @dataclass(frozen=True, kw_only=True) @@ -48,13 +44,13 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DormakabaDkeyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the binary sensor platform for Dormakaba dKey.""" - data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( - DormakabaDkeyBinarySensor(data.coordinator, data.lock, description) + DormakabaDkeyBinarySensor(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS ) @@ -67,16 +63,15 @@ class DormakabaDkeyBinarySensor(DormakabaDkeyEntity, BinarySensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[None], - lock: DKEYLock, + coordinator: DormakabaDkeyCoordinator, description: DormakabaDkeyBinarySensorDescription, ) -> None: """Initialize a Dormakaba dKey binary sensor.""" self.entity_description = description - self._attr_unique_id = f"{lock.address}_{description.key}" - super().__init__(coordinator, lock) + self._attr_unique_id = f"{coordinator.lock.address}_{description.key}" + super().__init__(coordinator) @callback def _async_update_attrs(self) -> None: """Handle updating _attr values.""" - self._attr_is_on = self.entity_description.is_on(self._lock.state) + self._attr_is_on = self.entity_description.is_on(self.coordinator.lock.state) diff --git a/homeassistant/components/dormakaba_dkey/coordinator.py b/homeassistant/components/dormakaba_dkey/coordinator.py new file mode 100644 index 00000000000..32f71ebf59d --- /dev/null +++ b/homeassistant/components/dormakaba_dkey/coordinator.py @@ -0,0 +1,50 @@ +"""Coordinator for the Dormakaba dKey integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from py_dormakaba_dkey import DKEYLock +from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS, NotAssociated + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import UPDATE_SECONDS + +_LOGGER = logging.getLogger(__name__) + +type DormakabaDkeyConfigEntry = ConfigEntry[DormakabaDkeyCoordinator] + + +class DormakabaDkeyCoordinator(DataUpdateCoordinator[None]): + """DormakabaDkey coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + entry: DormakabaDkeyConfigEntry, + lock: DKEYLock, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=lock.name, + update_interval=timedelta(seconds=UPDATE_SECONDS), + ) + self.lock = lock + + async def _async_update_data(self) -> None: + """Update the device state.""" + try: + await self.lock.update() + await self.lock.disconnect() + except NotAssociated as ex: + raise ConfigEntryAuthFailed("Not associated") from ex + except DKEY_EXCEPTIONS as ex: + raise UpdateFailed(str(ex)) from ex diff --git a/homeassistant/components/dormakaba_dkey/entity.py b/homeassistant/components/dormakaba_dkey/entity.py index 756edccf02f..cc34a70014d 100644 --- a/homeassistant/components/dormakaba_dkey/entity.py +++ b/homeassistant/components/dormakaba_dkey/entity.py @@ -4,29 +4,25 @@ from __future__ import annotations import abc -from py_dormakaba_dkey import DKEYLock from py_dormakaba_dkey.commands import Notifications from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import DormakabaDkeyCoordinator -class DormakabaDkeyEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): +class DormakabaDkeyEntity(CoordinatorEntity[DormakabaDkeyCoordinator]): """Dormakaba dKey base entity.""" _attr_has_entity_name = True - def __init__( - self, coordinator: DataUpdateCoordinator[None], lock: DKEYLock - ) -> None: + def __init__(self, coordinator: DormakabaDkeyCoordinator) -> None: """Initialize a Dormakaba dKey entity.""" super().__init__(coordinator) - self._lock = lock + lock = coordinator.lock self._attr_device_info = DeviceInfo( name=lock.device_info.device_name or lock.device_info.device_id, model="MTL 9291", @@ -53,5 +49,7 @@ class DormakabaDkeyEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): async def async_added_to_hass(self) -> None: """Register callbacks.""" - self.async_on_remove(self._lock.register_callback(self._handle_state_update)) + self.async_on_remove( + self.coordinator.lock.register_callback(self._handle_state_update) + ) return await super().async_added_to_hass() diff --git a/homeassistant/components/dormakaba_dkey/lock.py b/homeassistant/components/dormakaba_dkey/lock.py index 5f475d37152..352e7cbe0ac 100644 --- a/homeassistant/components/dormakaba_dkey/lock.py +++ b/homeassistant/components/dormakaba_dkey/lock.py @@ -4,28 +4,23 @@ from __future__ import annotations from typing import Any -from py_dormakaba_dkey import DKEYLock from py_dormakaba_dkey.commands import UnlockStatus from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity -from .models import DormakabaDkeyData async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DormakabaDkeyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the lock platform for Dormakaba dKey.""" - data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id] - async_add_entities([DormakabaDkeyLock(data.coordinator, data.lock)]) + async_add_entities([DormakabaDkeyLock(entry.runtime_data)]) class DormakabaDkeyLock(DormakabaDkeyEntity, LockEntity): @@ -33,25 +28,23 @@ class DormakabaDkeyLock(DormakabaDkeyEntity, LockEntity): _attr_has_entity_name = True - def __init__( - self, coordinator: DataUpdateCoordinator[None], lock: DKEYLock - ) -> None: + def __init__(self, coordinator: DormakabaDkeyCoordinator) -> None: """Initialize a Dormakaba dKey lock.""" - self._attr_unique_id = lock.address - super().__init__(coordinator, lock) + self._attr_unique_id = coordinator.lock.address + super().__init__(coordinator) @callback def _async_update_attrs(self) -> None: """Handle updating _attr values.""" - self._attr_is_locked = self._lock.state.unlock_status in ( + self._attr_is_locked = self.coordinator.lock.state.unlock_status in ( UnlockStatus.LOCKED, UnlockStatus.SECURITY_LOCKED, ) async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - await self._lock.lock() + await self.coordinator.lock.lock() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - await self._lock.unlock() + await self.coordinator.lock.unlock() diff --git a/homeassistant/components/dormakaba_dkey/models.py b/homeassistant/components/dormakaba_dkey/models.py deleted file mode 100644 index 23687e82334..00000000000 --- a/homeassistant/components/dormakaba_dkey/models.py +++ /dev/null @@ -1,17 +0,0 @@ -"""The Dormakaba dKey integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from py_dormakaba_dkey import DKEYLock - -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - - -@dataclass -class DormakabaDkeyData: - """Data for the Dormakaba dKey integration.""" - - lock: DKEYLock - coordinator: DataUpdateCoordinator[None] diff --git a/homeassistant/components/dormakaba_dkey/sensor.py b/homeassistant/components/dormakaba_dkey/sensor.py index e461ba1e44f..b1e941bc7e1 100644 --- a/homeassistant/components/dormakaba_dkey/sensor.py +++ b/homeassistant/components/dormakaba_dkey/sensor.py @@ -2,23 +2,18 @@ from __future__ import annotations -from py_dormakaba_dkey import DKEYLock - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity -from .models import DormakabaDkeyData BINARY_SENSOR_DESCRIPTIONS = ( SensorEntityDescription( @@ -32,13 +27,13 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DormakabaDkeyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the lock platform for Dormakaba dKey.""" - data: DormakabaDkeyData = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( - DormakabaDkeySensor(data.coordinator, data.lock, description) + DormakabaDkeySensor(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS ) @@ -50,16 +45,17 @@ class DormakabaDkeySensor(DormakabaDkeyEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[None], - lock: DKEYLock, + coordinator: DormakabaDkeyCoordinator, description: SensorEntityDescription, ) -> None: """Initialize a Dormakaba dKey binary sensor.""" self.entity_description = description - self._attr_unique_id = f"{lock.address}_{description.key}" - super().__init__(coordinator, lock) + self._attr_unique_id = f"{coordinator.lock.address}_{description.key}" + super().__init__(coordinator) @callback def _async_update_attrs(self) -> None: """Handle updating _attr values.""" - self._attr_native_value = getattr(self._lock, self.entity_description.key) + self._attr_native_value = getattr( + self.coordinator.lock, self.entity_description.key + ) diff --git a/homeassistant/components/downloader/strings.json b/homeassistant/components/downloader/strings.json index 11a2bda8fce..7db7ea459d7 100644 --- a/homeassistant/components/downloader/strings.json +++ b/homeassistant/components/downloader/strings.json @@ -23,15 +23,15 @@ }, "subdir": { "name": "Subdirectory", - "description": "Download into subdirectory." + "description": "Relative download path." }, "filename": { "name": "Filename", - "description": "Determine the filename." + "description": "Custom name for the downloaded file." }, "overwrite": { "name": "Overwrite", - "description": "Whether to overwrite the file or not." + "description": "Overwrite file if it exists." } } } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index a069c32be04..e05785b8b26 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -20,6 +20,7 @@ from dsmr_parser.objects import DSMRObject, MbusDevice, Telegram import serial from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -456,24 +457,29 @@ def rename_old_gas_to_mbus( if entity.unique_id.endswith( "belgium_5min_gas_meter_reading" ) or entity.unique_id.endswith("hourly_gas_meter_reading"): - try: - ent_reg.async_update_entity( - entity.entity_id, - new_unique_id=mbus_device_id, - device_id=mbus_device_id, - ) - except ValueError: + if ent_reg.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, mbus_device_id + ): LOGGER.debug( "Skip migration of %s because it already exists", entity.entity_id, ) - else: - LOGGER.debug( - "Migrated entity %s from unique id %s to %s", - entity.entity_id, - entity.unique_id, - mbus_device_id, - ) + continue + new_device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, mbus_device_id)}, + ) + ent_reg.async_update_entity( + entity.entity_id, + new_unique_id=mbus_device_id, + device_id=new_device.id, + ) + LOGGER.debug( + "Migrated entity %s from unique id %s to %s", + entity.entity_id, + entity.unique_id, + mbus_device_id, + ) # Cleanup old device dev_entities = er.async_entries_for_device( ent_reg, device_id, include_disabled_entities=True @@ -549,7 +555,7 @@ async def async_setup_entry( dsmr_version = entry.data[CONF_DSMR_VERSION] entities: list[DSMREntity] = [] initialized: bool = False - add_entities_handler: Callable[..., None] | None + add_entities_handler: Callable[[], None] | None @callback def init_async_add_entities(telegram: Telegram) -> None: diff --git a/homeassistant/components/dte_energy_bridge/__init__.py b/homeassistant/components/dte_energy_bridge/__init__.py deleted file mode 100644 index 2525d047bce..00000000000 --- a/homeassistant/components/dte_energy_bridge/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The dte_energy_bridge component.""" diff --git a/homeassistant/components/dte_energy_bridge/manifest.json b/homeassistant/components/dte_energy_bridge/manifest.json deleted file mode 100644 index 8285469a745..00000000000 --- a/homeassistant/components/dte_energy_bridge/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "dte_energy_bridge", - "name": "DTE Energy Bridge", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/dte_energy_bridge", - "iot_class": "local_polling", - "quality_scale": "legacy" -} diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py deleted file mode 100644 index a0b9253034e..00000000000 --- a/homeassistant/components/dte_energy_bridge/sensor.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Support for monitoring energy usage using the DTE energy bridge.""" - -from __future__ import annotations - -from http import HTTPStatus -import logging - -import requests -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) -from homeassistant.const import CONF_NAME, UnitOfPower -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -CONF_IP_ADDRESS = "ip" -CONF_VERSION = "version" - -DEFAULT_NAME = "Current Energy Usage" -DEFAULT_VERSION = 1 -DOMAIN = "dte_energy_bridge" - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_IP_ADDRESS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.All( - vol.Coerce(int), vol.Any(1, 2) - ), - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the DTE energy bridge sensor.""" - create_issue( - hass, - DOMAIN, - "deprecated_integration", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_integration", - translation_placeholders={"domain": DOMAIN}, - ) - - name = config[CONF_NAME] - ip_address = config[CONF_IP_ADDRESS] - version = config[CONF_VERSION] - - add_entities([DteEnergyBridgeSensor(ip_address, name, version)], True) - - -class DteEnergyBridgeSensor(SensorEntity): - """Implementation of the DTE Energy Bridge sensors.""" - - _attr_device_class = SensorDeviceClass.POWER - _attr_native_unit_of_measurement = UnitOfPower.KILO_WATT - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__(self, ip_address, name, version): - """Initialize the sensor.""" - self._version = version - - if self._version == 1: - self._url = f"http://{ip_address}/instantaneousdemand" - elif self._version == 2: - self._url = f"http://{ip_address}:8888/zigbee/se/instantaneousdemand" - - self._attr_name = name - - def update(self) -> None: - """Get the energy usage data from the DTE energy bridge.""" - try: - response = requests.get(self._url, timeout=5) - except (requests.exceptions.RequestException, ValueError): - _LOGGER.warning( - "Could not update status for DTE Energy Bridge (%s)", self._attr_name - ) - return - - if response.status_code != HTTPStatus.OK: - _LOGGER.warning( - "Invalid status_code from DTE Energy Bridge: %s (%s)", - response.status_code, - self._attr_name, - ) - return - - response_split = response.text.split() - - if len(response_split) != 2: - _LOGGER.warning( - 'Invalid response from DTE Energy Bridge: "%s" (%s)', - response.text, - self._attr_name, - ) - return - - val = float(response_split[0]) - - # A workaround for a bug in the DTE energy bridge. - # The returned value can randomly be in W or kW. Checking for a - # a decimal seems to be a reliable way to determine the units. - # Limiting to version 1 because version 2 apparently always returns - # values in the format 000000.000 kW, but the scaling is Watts - # NOT kWatts - if self._version == 1 and "." in response_split[0]: - self._attr_native_value = val - else: - self._attr_native_value = val / 1000 diff --git a/homeassistant/components/dte_energy_bridge/strings.json b/homeassistant/components/dte_energy_bridge/strings.json deleted file mode 100644 index f75867b8faa..00000000000 --- a/homeassistant/components/dte_energy_bridge/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "deprecated_integration": { - "title": "The DTE Energy Bridge integration will be removed", - "description": "The DTE Energy Bridge integration will be removed as new users can't get any supported devices, and the integration will fail as soon as a current device gets internet access.\n\n Please remove all `{domain}`platform sensors from your configuration and restart Home Assistant." - } - } -} diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py index 68b7db12d45..2b0ae46b405 100644 --- a/homeassistant/components/duke_energy/coordinator.py +++ b/homeassistant/components/duke_energy/coordinator.py @@ -85,7 +85,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): ) continue - id_prefix = f"{meter["serviceType"].lower()}_{serial_number}" + id_prefix = f"{meter['serviceType'].lower()}_{serial_number}" consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" self._statistic_ids.add(consumption_statistic_id) _LOGGER.debug( @@ -136,7 +136,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): ) name_prefix = ( - f"Duke Energy " f"{meter["serviceType"].capitalize()} {serial_number}" + f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}" ) consumption_metadata = StatisticMetaData( has_mean=False, diff --git a/homeassistant/components/dunehd/__init__.py b/homeassistant/components/dunehd/__init__.py index 27e9e749472..302a7280128 100644 --- a/homeassistant/components/dunehd/__init__.py +++ b/homeassistant/components/dunehd/__init__.py @@ -10,29 +10,21 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type DuneHDConfigEntry = ConfigEntry[DuneHDPlayer] + + +async def async_setup_entry(hass: HomeAssistant, entry: DuneHDConfigEntry) -> bool: """Set up a config entry.""" - host: str = entry.data[CONF_HOST] - - player = DuneHDPlayer(host) - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = player + entry.runtime_data = DuneHDPlayer(entry.data[CONF_HOST]) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DuneHDConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py index ded23ea4669..db903cac2bf 100644 --- a/homeassistant/components/dunehd/media_player.py +++ b/homeassistant/components/dunehd/media_player.py @@ -15,11 +15,11 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DuneHDConfigEntry from .const import ATTR_MANUFACTURER, DEFAULT_NAME, DOMAIN CONF_SOURCES: Final = "sources" @@ -37,14 +37,14 @@ DUNEHD_PLAYER_SUPPORT: Final[MediaPlayerEntityFeature] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DuneHDConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Dune HD entities from a config_entry.""" - unique_id = entry.entry_id - - player: DuneHDPlayer = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([DuneHDPlayerEntity(player, DEFAULT_NAME, unique_id)], True) + async_add_entities( + [DuneHDPlayerEntity(entry.runtime_data, DEFAULT_NAME, entry.entry_id)], True + ) class DuneHDPlayerEntity(MediaPlayerEntity): diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py index 1873db45226..766fad49e81 100644 --- a/homeassistant/components/duotecno/__init__.py +++ b/homeassistant/components/duotecno/__init__.py @@ -10,8 +10,6 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN - PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -21,7 +19,10 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type DuotecnoConfigEntry = ConfigEntry[PyDuotecno] + + +async def async_setup_entry(hass: HomeAssistant, entry: DuotecnoConfigEntry) -> bool: """Set up duotecno from a config entry.""" controller = PyDuotecno() @@ -31,14 +32,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) except (OSError, InvalidPassword, LoadFailure) as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller + + entry.runtime_data = controller await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DuotecnoConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py index 10c807a8023..aadef47b998 100644 --- a/homeassistant/components/duotecno/binary_sensor.py +++ b/homeassistant/components/duotecno/binary_sensor.py @@ -2,28 +2,25 @@ from __future__ import annotations -from duotecno.controller import PyDuotecno from duotecno.unit import ControlUnit, VirtualUnit from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DuotecnoConfigEntry from .entity import DuotecnoEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DuotecnoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno binary sensor on config_entry.""" - cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( DuotecnoBinarySensor(channel) - for channel in cntrl.get_units(["ControlUnit", "VirtualUnit"]) + for channel in entry.runtime_data.get_units(["ControlUnit", "VirtualUnit"]) ) diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index 77b602c8716..83a211d97f5 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any, Final -from duotecno.controller import PyDuotecno from duotecno.unit import SensUnit from homeassistant.components.climate import ( @@ -12,12 +11,11 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call HVACMODE: Final = { @@ -33,13 +31,13 @@ PRESETMODES_REVERSE: Final = {value: key for key, value in PRESETMODES.items()} async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DuotecnoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno climate based on config_entry.""" - cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( - DuotecnoClimate(channel) for channel in cntrl.get_units(["SensUnit"]) + DuotecnoClimate(channel) + for channel in entry.runtime_data.get_units(["SensUnit"]) ) @@ -57,7 +55,6 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity): _attr_hvac_modes = list(HVACMODE_REVERSE) _attr_preset_modes = list(PRESETMODES) _attr_translation_key = "duotecno" - _enable_turn_on_off_backwards_compatibility = False @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/duotecno/cover.py b/homeassistant/components/duotecno/cover.py index 1c4f7d70fc5..7d879741555 100644 --- a/homeassistant/components/duotecno/cover.py +++ b/homeassistant/components/duotecno/cover.py @@ -4,27 +4,25 @@ from __future__ import annotations from typing import Any -from duotecno.controller import PyDuotecno from duotecno.unit import DuoswitchUnit from homeassistant.components.cover import CoverEntity, CoverEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DuotecnoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the duoswitch endities.""" - cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( - DuotecnoCover(channel) for channel in cntrl.get_units("DuoswitchUnit") + DuotecnoCover(channel) + for channel in entry.runtime_data.get_units("DuoswitchUnit") ) diff --git a/homeassistant/components/duotecno/light.py b/homeassistant/components/duotecno/light.py index 57635ac2bc2..7b41cbaef22 100644 --- a/homeassistant/components/duotecno/light.py +++ b/homeassistant/components/duotecno/light.py @@ -2,26 +2,25 @@ from typing import Any -from duotecno.controller import PyDuotecno from duotecno.unit import DimUnit from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DuotecnoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno light based on config_entry.""" - cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] - async_add_entities(DuotecnoLight(channel) for channel in cntrl.get_units("DimUnit")) + async_add_entities( + DuotecnoLight(channel) for channel in entry.runtime_data.get_units("DimUnit") + ) class DuotecnoLight(DuotecnoEntity, LightEntity): diff --git a/homeassistant/components/duotecno/switch.py b/homeassistant/components/duotecno/switch.py index b3a87786d4e..0c01a6ca4de 100644 --- a/homeassistant/components/duotecno/switch.py +++ b/homeassistant/components/duotecno/switch.py @@ -2,27 +2,25 @@ from typing import Any -from duotecno.controller import PyDuotecno from duotecno.unit import SwitchUnit from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DuotecnoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( - DuotecnoSwitch(channel) for channel in cntrl.get_units("SwitchUnit") + DuotecnoSwitch(channel) + for channel in entry.runtime_data.get_units("SwitchUnit") ) diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py index 8cf3813a85d..be61304bc06 100644 --- a/homeassistant/components/dwd_weather_warnings/coordinator.py +++ b/homeassistant/components/dwd_weather_warnings/coordinator.py @@ -7,7 +7,7 @@ from dwdwfsapi import DwdWeatherWarningsAPI from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import location +from homeassistant.util import location as location_util from .const import ( CONF_REGION_DEVICE_TRACKER, @@ -58,7 +58,7 @@ class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]): distance = None if self._previous_position is not None: - distance = location.distance( + distance = location_util.distance( self._previous_position[0], self._previous_position[1], position[0], diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 7388c43cb89..a1a6a38c8ab 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -2,27 +2,17 @@ from __future__ import annotations -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .bridge import DynaliteBridge -from .const import ( - ATTR_AREA, - ATTR_CHANNEL, - ATTR_HOST, - DOMAIN, - LOGGER, - PLATFORMS, - SERVICE_REQUEST_AREA_PRESET, - SERVICE_REQUEST_CHANNEL_LEVEL, -) +from .const import DOMAIN, LOGGER, PLATFORMS from .convert_config import convert_config from .panel import async_register_dynalite_frontend +from .services import setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -31,49 +21,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" hass.data[DOMAIN] = {} - async def dynalite_service(service_call: ServiceCall) -> None: - data = service_call.data - host = data.get(ATTR_HOST, "") - bridges = [ - bridge - for bridge in hass.data[DOMAIN].values() - if not host or bridge.host == host - ] - LOGGER.debug("Selected bridged for service call: %s", bridges) - if service_call.service == SERVICE_REQUEST_AREA_PRESET: - bridge_attr = "request_area_preset" - elif service_call.service == SERVICE_REQUEST_CHANNEL_LEVEL: - bridge_attr = "request_channel_level" - for bridge in bridges: - getattr(bridge.dynalite_devices, bridge_attr)( - data[ATTR_AREA], data.get(ATTR_CHANNEL) - ) - - hass.services.async_register( - DOMAIN, - SERVICE_REQUEST_AREA_PRESET, - dynalite_service, - vol.Schema( - { - vol.Optional(ATTR_HOST): cv.string, - vol.Required(ATTR_AREA): int, - vol.Optional(ATTR_CHANNEL): int, - } - ), - ) - - hass.services.async_register( - DOMAIN, - SERVICE_REQUEST_CHANNEL_LEVEL, - dynalite_service, - vol.Schema( - { - vol.Optional(ATTR_HOST): cv.string, - vol.Required(ATTR_AREA): int, - vol.Required(ATTR_CHANNEL): int, - } - ), - ) + setup_services(hass) await async_register_dynalite_frontend(hass) diff --git a/homeassistant/components/dynalite/services.py b/homeassistant/components/dynalite/services.py new file mode 100644 index 00000000000..14160cced9d --- /dev/null +++ b/homeassistant/components/dynalite/services.py @@ -0,0 +1,79 @@ +"""Support for the Dynalite networks.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .bridge import DynaliteBridge +from .const import ( + ATTR_AREA, + ATTR_CHANNEL, + ATTR_HOST, + DOMAIN, + LOGGER, + SERVICE_REQUEST_AREA_PRESET, + SERVICE_REQUEST_CHANNEL_LEVEL, +) + + +@callback +def _get_bridges(service_call: ServiceCall) -> list[DynaliteBridge]: + host = service_call.data.get(ATTR_HOST, "") + bridges = [ + bridge + for bridge in service_call.hass.data[DOMAIN].values() + if not host or bridge.host == host + ] + LOGGER.debug("Selected bridges for service call: %s", bridges) + return bridges + + +async def _request_area_preset(service_call: ServiceCall) -> None: + bridges = _get_bridges(service_call) + data = service_call.data + for bridge in bridges: + bridge.dynalite_devices.request_area_preset( + data[ATTR_AREA], data.get(ATTR_CHANNEL) + ) + + +async def _request_channel_level(service_call: ServiceCall) -> None: + bridges = _get_bridges(service_call) + data = service_call.data + for bridge in bridges: + bridge.dynalite_devices.request_channel_level( + data[ATTR_AREA], data[ATTR_CHANNEL] + ) + + +@callback +def setup_services(hass: HomeAssistant) -> None: + """Set up the Dynalite platform.""" + hass.services.async_register( + DOMAIN, + SERVICE_REQUEST_AREA_PRESET, + _request_area_preset, + vol.Schema( + { + vol.Optional(ATTR_HOST): cv.string, + vol.Required(ATTR_AREA): int, + vol.Optional(ATTR_CHANNEL): int, + } + ), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REQUEST_CHANNEL_LEVEL, + _request_channel_level, + vol.Schema( + { + vol.Optional(ATTR_HOST): cv.string, + vol.Required(ATTR_AREA): int, + vol.Required(ATTR_CHANNEL): int, + } + ), + ) diff --git a/homeassistant/components/easyenergy/__init__.py b/homeassistant/components/easyenergy/__init__.py index e520631158a..0548431f09d 100644 --- a/homeassistant/components/easyenergy/__init__.py +++ b/homeassistant/components/easyenergy/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -10,10 +9,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import EasyEnergyDataUpdateCoordinator +from .coordinator import EasyEnergyConfigEntry, EasyEnergyDataUpdateCoordinator from .services import async_setup_services -PLATFORMS = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -25,25 +24,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EasyEnergyConfigEntry) -> bool: """Set up easyEnergy from a config entry.""" - coordinator = EasyEnergyDataUpdateCoordinator(hass) + coordinator = EasyEnergyDataUpdateCoordinator(hass, entry) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: await coordinator.easyenergy.close() raise - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EasyEnergyConfigEntry) -> bool: """Unload easyEnergy config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/easyenergy/coordinator.py b/homeassistant/components/easyenergy/coordinator.py index 8c1c593af93..e36bdf188ee 100644 --- a/homeassistant/components/easyenergy/coordinator.py +++ b/homeassistant/components/easyenergy/coordinator.py @@ -21,6 +21,8 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR +type EasyEnergyConfigEntry = ConfigEntry[EasyEnergyDataUpdateCoordinator] + class EasyEnergyData(NamedTuple): """Class for defining data in dict.""" @@ -33,15 +35,16 @@ class EasyEnergyData(NamedTuple): class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]): """Class to manage fetching easyEnergy data from single endpoint.""" - config_entry: ConfigEntry + config_entry: EasyEnergyConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, entry: EasyEnergyConfigEntry) -> None: """Initialize global easyEnergy data updater.""" super().__init__( hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL, + config_entry=entry, ) self.easyenergy = EasyEnergy(session=async_get_clientsession(hass)) diff --git a/homeassistant/components/easyenergy/diagnostics.py b/homeassistant/components/easyenergy/diagnostics.py index d6912e1c926..64f30ba61fd 100644 --- a/homeassistant/components/easyenergy/diagnostics.py +++ b/homeassistant/components/easyenergy/diagnostics.py @@ -5,12 +5,9 @@ from __future__ import annotations from datetime import timedelta from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import EasyEnergyDataUpdateCoordinator -from .const import DOMAIN -from .coordinator import EasyEnergyData +from .coordinator import EasyEnergyConfigEntry, EasyEnergyData def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: @@ -32,41 +29,42 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: EasyEnergyConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: EasyEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator_data = entry.runtime_data.data + energy_today = coordinator_data.energy_today return { "entry": { "title": entry.title, }, "energy_usage": { - "current_hour_price": coordinator.data.energy_today.current_usage_price, - "next_hour_price": coordinator.data.energy_today.price_at_time( - coordinator.data.energy_today.utcnow() + timedelta(hours=1) + "current_hour_price": energy_today.current_usage_price, + "next_hour_price": energy_today.price_at_time( + energy_today.utcnow() + timedelta(hours=1) ), - "average_price": coordinator.data.energy_today.average_usage_price, - "max_price": coordinator.data.energy_today.extreme_usage_prices[1], - "min_price": coordinator.data.energy_today.extreme_usage_prices[0], - "highest_price_time": coordinator.data.energy_today.highest_usage_price_time, - "lowest_price_time": coordinator.data.energy_today.lowest_usage_price_time, - "percentage_of_max": coordinator.data.energy_today.pct_of_max_usage, + "average_price": energy_today.average_usage_price, + "max_price": energy_today.extreme_usage_prices[1], + "min_price": energy_today.extreme_usage_prices[0], + "highest_price_time": energy_today.highest_usage_price_time, + "lowest_price_time": energy_today.lowest_usage_price_time, + "percentage_of_max": energy_today.pct_of_max_usage, }, "energy_return": { - "current_hour_price": coordinator.data.energy_today.current_return_price, - "next_hour_price": coordinator.data.energy_today.price_at_time( - coordinator.data.energy_today.utcnow() + timedelta(hours=1), "return" + "current_hour_price": energy_today.current_return_price, + "next_hour_price": energy_today.price_at_time( + energy_today.utcnow() + timedelta(hours=1), "return" ), - "average_price": coordinator.data.energy_today.average_return_price, - "max_price": coordinator.data.energy_today.extreme_return_prices[1], - "min_price": coordinator.data.energy_today.extreme_return_prices[0], - "highest_price_time": coordinator.data.energy_today.highest_return_price_time, - "lowest_price_time": coordinator.data.energy_today.lowest_return_price_time, - "percentage_of_max": coordinator.data.energy_today.pct_of_max_return, + "average_price": energy_today.average_return_price, + "max_price": energy_today.extreme_return_prices[1], + "min_price": energy_today.extreme_return_prices[0], + "highest_price_time": energy_today.highest_return_price_time, + "lowest_price_time": energy_today.lowest_return_price_time, + "percentage_of_max": energy_today.pct_of_max_return, }, "gas": { - "current_hour_price": get_gas_price(coordinator.data, 0), - "next_hour_price": get_gas_price(coordinator.data, 1), + "current_hour_price": get_gas_price(coordinator_data, 0), + "next_hour_price": get_gas_price(coordinator_data, 1), }, } diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 25432196169..5cecb1d49f6 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -4,6 +4,8 @@ "codeowners": ["@klaasnicolaas"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/easyenergy", + "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["easyenergy==2.1.2"] + "requirements": ["easyenergy==2.1.2"], + "single_config_entry": true } diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 65fe2558d46..6976a38da49 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CURRENCY_EURO, PERCENTAGE, @@ -27,7 +26,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES -from .coordinator import EasyEnergyData, EasyEnergyDataUpdateCoordinator +from .coordinator import ( + EasyEnergyConfigEntry, + EasyEnergyData, + EasyEnergyDataUpdateCoordinator, +) @dataclass(frozen=True, kw_only=True) @@ -208,10 +211,12 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EasyEnergyConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up easyEnergy sensors based on a config entry.""" - coordinator: EasyEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( EasyEnergySensorEntity(coordinator=coordinator, description=description) for description in SENSORS diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py index 5b80cfafd08..f5ee89d5325 100644 --- a/homeassistant/components/easyenergy/services.py +++ b/homeassistant/components/easyenergy/services.py @@ -10,7 +10,7 @@ from typing import Final from easyenergy import Electricity, Gas, VatOption import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -23,7 +23,7 @@ from homeassistant.helpers import selector from homeassistant.util import dt as dt_util from .const import DOMAIN -from .coordinator import EasyEnergyDataUpdateCoordinator +from .coordinator import EasyEnergyConfigEntry, EasyEnergyDataUpdateCoordinator ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_START: Final = "start" @@ -86,12 +86,12 @@ def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResp } -def __get_coordinator( - hass: HomeAssistant, call: ServiceCall -) -> EasyEnergyDataUpdateCoordinator: +def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator: """Get the coordinator from the entry.""" entry_id: str = call.data[ATTR_CONFIG_ENTRY] - entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + entry: EasyEnergyConfigEntry | None = call.hass.config_entries.async_get_entry( + entry_id + ) if not entry: raise ServiceValidationError( @@ -110,18 +110,16 @@ def __get_coordinator( }, ) - coordinator: EasyEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry_id] - return coordinator + return entry.runtime_data async def __get_prices( call: ServiceCall, *, - hass: HomeAssistant, price_type: PriceType, ) -> ServiceResponse: """Get prices from easyEnergy.""" - coordinator = __get_coordinator(hass, call) + coordinator = __get_coordinator(call) start = __get_date(call.data.get(ATTR_START)) end = __get_date(call.data.get(ATTR_END)) @@ -157,21 +155,21 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, GAS_SERVICE_NAME, - partial(__get_prices, hass=hass, price_type=PriceType.GAS), + partial(__get_prices, price_type=PriceType.GAS), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_USAGE_SERVICE_NAME, - partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_USAGE), + partial(__get_prices, price_type=PriceType.ENERGY_USAGE), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_RETURN_SERVICE_NAME, - partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_RETURN), + partial(__get_prices, price_type=PriceType.ENERGY_RETURN), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json index c42ef9df5ac..96afffdf78f 100644 --- a/homeassistant/components/easyenergy/strings.json +++ b/homeassistant/components/easyenergy/strings.json @@ -57,11 +57,11 @@ "services": { "get_gas_prices": { "name": "Get gas prices", - "description": "Request gas prices from easyEnergy.", + "description": "Requests gas prices from easyEnergy.", "fields": { "config_entry": { "name": "Config Entry", - "description": "The config entry to use for this service." + "description": "The configuration entry to use for this action." }, "incl_vat": { "name": "VAT Included", @@ -79,7 +79,7 @@ }, "get_energy_usage_prices": { "name": "Get energy usage prices", - "description": "Request usage energy prices from easyEnergy.", + "description": "Requests usage energy prices from easyEnergy.", "fields": { "config_entry": { "name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]", @@ -101,7 +101,7 @@ }, "get_energy_return_prices": { "name": "Get energy return prices", - "description": "Request return energy prices from easyEnergy.", + "description": "Requests return energy prices from easyEnergy.", "fields": { "config_entry": { "name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]", diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 6a9ec0d5db9..bfb2635481c 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -353,7 +353,6 @@ class Thermostat(ClimateEntity): _attr_fan_modes = [FAN_AUTO, FAN_ON] _attr_name = None _attr_has_entity_name = True - _enable_turn_on_off_backwards_compatibility = False _attr_translation_key = "ecobee" def __init__( @@ -604,7 +603,7 @@ class Thermostat(ClimateEntity): """Return the remote sensor device name_by_user or name for the thermostat.""" return sorted( [ - f'{item["name_by_user"]} ({item["id"]})' + f"{item['name_by_user']} ({item['id']})" for item in self.remote_sensor_ids_names ] ) @@ -874,7 +873,7 @@ class Thermostat(ClimateEntity): translation_placeholders={ "options": ", ".join( [ - f'{item["name_by_user"]} ({item["id"]})' + f"{item['name_by_user']} ({item['id']})" for item in self.remote_sensor_ids_names ] ) diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index 28cfbebe506..70860003b2a 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -34,7 +34,7 @@ class EcobeeNotifyEntity(EcobeeBaseEntity, NotifyEntity): """Initialize the thermostat.""" super().__init__(data, thermostat_index) self._attr_unique_id = ( - f"{self.thermostat["identifier"]}_notify_{thermostat_index}" + f"{self.thermostat['identifier']}_notify_{thermostat_index}" ) def send_message(self, message: str, title: str | None = None) -> None: diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 8c636bd9b04..7713a8fb4b9 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -71,7 +71,7 @@ }, "start_date": { "name": "Start date", - "description": "Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with start_time)." + "description": "Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with 'Start time')." }, "start_time": { "name": "Start time", @@ -79,7 +79,7 @@ }, "end_date": { "name": "End date", - "description": "Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with end_time)." + "description": "Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with 'End time')." }, "end_time": { "name": "End time", @@ -149,11 +149,11 @@ }, "set_mic_mode": { "name": "Set mic mode", - "description": "Enables/disables Alexa mic (only for Ecobee 4).", + "description": "Enables/disables Alexa microphone (only for Ecobee 4).", "fields": { "mic_enabled": { "name": "Mic enabled", - "description": "Enable Alexa mic." + "description": "Enable Alexa microphone." } } }, diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index bac123bf206..cdf82f6817f 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -68,7 +68,6 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): _attr_should_poll = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - _enable_turn_on_off_backwards_compatibility = False def __init__(self, thermostat): """Initialize.""" diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py index 5d76b38bed8..2759ca972df 100644 --- a/homeassistant/components/ecovacs/button.py +++ b/homeassistant/components/ecovacs/button.py @@ -2,7 +2,12 @@ from dataclasses import dataclass -from deebot_client.capabilities import CapabilityExecute, CapabilityLifeSpan +from deebot_client.capabilities import ( + CapabilityExecute, + CapabilityExecuteTypes, + CapabilityLifeSpan, +) +from deebot_client.commands import StationAction from deebot_client.events import LifeSpan from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -11,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EcovacsConfigEntry -from .const import SUPPORTED_LIFESPANS +from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS from .entity import ( EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, @@ -35,6 +40,13 @@ class EcovacsLifespanButtonEntityDescription(ButtonEntityDescription): component: LifeSpan +@dataclass(kw_only=True, frozen=True) +class EcovacsStationActionButtonEntityDescription(ButtonEntityDescription): + """Ecovacs station action button entity description.""" + + action: StationAction + + ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = ( EcovacsButtonEntityDescription( capability_fn=lambda caps: caps.map.relocation if caps.map else None, @@ -44,6 +56,16 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = ( ), ) +STATION_ENTITY_DESCRIPTIONS = tuple( + EcovacsStationActionButtonEntityDescription( + action=action, + key=f"station_action_{action.name.lower()}", + translation_key=f"station_action_{action.name.lower()}", + ) + for action in SUPPORTED_STATION_ACTIONS +) + + LIFESPAN_ENTITY_DESCRIPTIONS = tuple( EcovacsLifespanButtonEntityDescription( component=component, @@ -74,6 +96,15 @@ async def async_setup_entry( for description in LIFESPAN_ENTITY_DESCRIPTIONS if description.component in device.capabilities.life_span.types ) + entities.extend( + EcovacsStationActionButtonEntity( + device, device.capabilities.station.action, description + ) + for device in controller.devices + if device.capabilities.station + for description in STATION_ENTITY_DESCRIPTIONS + if description.action in device.capabilities.station.action.types + ) async_add_entities(entities) @@ -103,3 +134,18 @@ class EcovacsResetLifespanButtonEntity( await self._device.execute_command( self._capability.reset(self.entity_description.component) ) + + +class EcovacsStationActionButtonEntity( + EcovacsDescriptionEntity[CapabilityExecuteTypes[StationAction]], + ButtonEntity, +): + """Ecovacs station action button entity.""" + + entity_description: EcovacsStationActionButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + await self._device.execute_command( + self._capability.execute(self.entity_description.action) + ) diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py index ac7a268f1bd..0bfe9cfd544 100644 --- a/homeassistant/components/ecovacs/const.py +++ b/homeassistant/components/ecovacs/const.py @@ -2,6 +2,7 @@ from enum import StrEnum +from deebot_client.commands import StationAction from deebot_client.events import LifeSpan DOMAIN = "ecovacs" @@ -19,8 +20,11 @@ SUPPORTED_LIFESPANS = ( LifeSpan.SIDE_BRUSH, LifeSpan.UNIT_CARE, LifeSpan.ROUND_MOP, + LifeSpan.STATION_FILTER, ) +SUPPORTED_STATION_ACTIONS = (StationAction.EMPTY_DUSTBIN,) + LEGACY_SUPPORTED_LIFESPANS = ( "main_brush", "side_brush", diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 3a70ab2af5b..69dd0f0813f 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -99,8 +99,8 @@ class EcovacsController: for device_config in devices.not_supported: _LOGGER.warning( ( - 'Device "%s" not supported. Please add support for it to ' - "https://github.com/DeebotUniverse/client.py: %s" + 'Device "%s" not supported. More information at ' + "https://github.com/DeebotUniverse/client.py/issues/612: %s" ), device_config["deviceName"], device_config, diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index 6097f43a4e4..b0e2a0595bf 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -27,11 +27,17 @@ "reset_lifespan_side_brush": { "default": "mdi:broom" }, + "reset_lifespan_station_filter": { + "default": "mdi:air-filter" + }, "reset_lifespan_unit_care": { "default": "mdi:robot-vacuum" }, "reset_lifespan_round_mop": { "default": "mdi:broom" + }, + "station_action_empty_dustbin": { + "default": "mdi:delete-restore" } }, "event": { @@ -72,6 +78,9 @@ "lifespan_side_brush": { "default": "mdi:broom" }, + "lifespan_station_filter": { + "default": "mdi:air-filter" + }, "lifespan_unit_care": { "default": "mdi:robot-vacuum" }, @@ -87,6 +96,9 @@ "network_ssid": { "default": "mdi:wifi" }, + "station_state": { + "default": "mdi:home" + }, "stats_area": { "default": "mdi:floor-plan" }, diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 4a43489ff24..157d5b4a5ea 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==9.0.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.0.0"] } diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py index 2b9bdc1a425..adf282560a9 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -95,7 +95,7 @@ async def async_setup_entry( class EcovacsNumberEntity( - EcovacsDescriptionEntity[CapabilitySet[EventT, int]], + EcovacsDescriptionEntity[CapabilitySet[EventT, [int]]], NumberEntity, ): """Ecovacs number entity.""" diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index c8b01a0f83a..3c3852f05ec 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -66,7 +66,7 @@ async def async_setup_entry( class EcovacsSelectEntity( - EcovacsDescriptionEntity[CapabilitySetTypes[EventT, str]], + EcovacsDescriptionEntity[CapabilitySetTypes[EventT, [str], str]], SelectEntity, ): """Ecovacs select entity.""" @@ -77,7 +77,7 @@ class EcovacsSelectEntity( def __init__( self, device: Device, - capability: CapabilitySetTypes[EventT, str], + capability: CapabilitySetTypes[EventT, [str], str], entity_description: EcovacsSelectEntityDescription, **kwargs: Any, ) -> None: diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 7c190d27775..0e906c6cb16 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -16,6 +16,7 @@ from deebot_client.events import ( NetworkInfoEvent, StatsEvent, TotalStatsEvent, + station, ) from sucks import VacBot @@ -46,7 +47,7 @@ from .entity import ( EcovacsLegacyEntity, EventT, ) -from .util import get_supported_entitites +from .util import get_name_key, get_options, get_supported_entitites @dataclass(kw_only=True, frozen=True) @@ -136,6 +137,15 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), + # Station + EcovacsSensorEntityDescription[station.StationEvent]( + capability_fn=lambda caps: caps.station.state if caps.station else None, + value_fn=lambda e: get_name_key(e.state), + key="station_state", + translation_key="station_state", + device_class=SensorDeviceClass.ENUM, + options=get_options(station.State), + ), ) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index c9de461ad5b..723bdef17f8 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -46,6 +46,9 @@ "relocate": { "name": "Relocate" }, + "reset_lifespan_base_station_filter": { + "name": "Reset station filter lifespan" + }, "reset_lifespan_blade": { "name": "Reset blade lifespan" }, @@ -66,6 +69,9 @@ }, "reset_lifespan_side_brush": { "name": "Reset side brush lifespan" + }, + "station_action_empty_dustbin": { + "name": "Empty dustbin" } }, "event": { @@ -107,6 +113,9 @@ } } }, + "lifespan_base_station_filter": { + "name": "Station filter lifespan" + }, "lifespan_blade": { "name": "Blade lifespan" }, @@ -140,6 +149,13 @@ "network_ssid": { "name": "Wi-Fi SSID" }, + "station_state": { + "name": "Station state", + "state": { + "idle": "[%key:common::state::idle%]", + "emptying_dustbin": "Emptying dustbin" + } + }, "stats_area": { "name": "Area cleaned" }, diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py index 872981b5c28..288d092d391 100644 --- a/homeassistant/components/ecovacs/switch.py +++ b/homeassistant/components/ecovacs/switch.py @@ -131,7 +131,7 @@ class EcovacsSwitchEntity( await super().async_added_to_hass() async def on_event(event: EnableEvent) -> None: - self._attr_is_on = event.enable + self._attr_is_on = event.enabled self.async_write_ha_state() self._subscribe(self._capability.event, on_event) diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py index a4894de8968..0cfbf1e8f91 100644 --- a/homeassistant/components/ecovacs/util.py +++ b/homeassistant/components/ecovacs/util.py @@ -7,6 +7,8 @@ import random import string from typing import TYPE_CHECKING +from deebot_client.events.station import State + from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify @@ -47,4 +49,13 @@ def get_supported_entitites( @callback def get_name_key(enum: Enum) -> str: """Return the lower case name of the enum.""" + if enum is State.EMPTYING: + # Will be fixed in the next major release of deebot-client + return "emptying_dustbin" return enum.name.lower() + + +@callback +def get_options(enum: type[Enum]) -> list[str]: + """Return the options for the enum.""" + return [get_name_key(option) for option in enum] diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 0d14267e08d..bc78981d1db 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -13,14 +13,9 @@ from deebot_client.models import CleanAction, CleanMode, Room, State import sucks from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, StateVacuumEntityDescription, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.core import HomeAssistant, SupportsResponse @@ -123,22 +118,22 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): self.schedule_update_ha_state() @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Return the state of the vacuum cleaner.""" if self.error is not None: - return STATE_ERROR + return VacuumActivity.ERROR if self.device.is_cleaning: - return STATE_CLEANING + return VacuumActivity.CLEANING if self.device.is_charging: - return STATE_DOCKED + return VacuumActivity.DOCKED if self.device.vacuum_status == sucks.CLEAN_MODE_STOP: - return STATE_IDLE + return VacuumActivity.IDLE if self.device.vacuum_status == sucks.CHARGE_MODE_RETURNING: - return STATE_RETURNING + return VacuumActivity.RETURNING return None @@ -168,11 +163,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): data: dict[str, Any] = {} data[ATTR_ERROR] = self.error - # these attributes are deprecated and can be removed in 2025.2 - for key, val in self.device.components.items(): - attr_name = ATTR_COMPONENT_PREFIX + key - data[attr_name] = int(val * 100) - return data def return_to_base(self, **kwargs: Any) -> None: @@ -202,7 +192,7 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" - if self.state == STATE_CLEANING: + if self.state == VacuumActivity.CLEANING: self.device.run(sucks.Clean(mode=self.device.clean_status, speed=fan_speed)) def send_command( @@ -225,12 +215,12 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): _STATE_TO_VACUUM_STATE = { - State.IDLE: STATE_IDLE, - State.CLEANING: STATE_CLEANING, - State.RETURNING: STATE_RETURNING, - State.DOCKED: STATE_DOCKED, - State.ERROR: STATE_ERROR, - State.PAUSED: STATE_PAUSED, + State.IDLE: VacuumActivity.IDLE, + State.CLEANING: VacuumActivity.CLEANING, + State.RETURNING: VacuumActivity.RETURNING, + State.DOCKED: VacuumActivity.DOCKED, + State.ERROR: VacuumActivity.ERROR, + State.PAUSED: VacuumActivity.PAUSED, } _ATTR_ROOMS = "rooms" @@ -284,7 +274,7 @@ class EcovacsVacuum( self.async_write_ha_state() async def on_status(event: StateEvent) -> None: - self._attr_state = _STATE_TO_VACUUM_STATE[event.state] + self._attr_activity = _STATE_TO_VACUUM_STATE[event.state] self.async_write_ha_state() self._subscribe(self._capability.battery.event, on_battery) diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py new file mode 100644 index 00000000000..cf08f45bed5 --- /dev/null +++ b/homeassistant/components/eheimdigital/__init__.py @@ -0,0 +1,51 @@ +"""The EHEIM Digital integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN +from .coordinator import EheimDigitalUpdateCoordinator + +PLATFORMS = [Platform.LIGHT] + +type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] + + +async def async_setup_entry( + hass: HomeAssistant, entry: EheimDigitalConfigEntry +) -> bool: + """Set up EHEIM Digital from a config entry.""" + + coordinator = EheimDigitalUpdateCoordinator(hass) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: EheimDigitalConfigEntry +) -> bool: + """Unload a config entry.""" + await entry.runtime_data.hub.close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: EheimDigitalConfigEntry, + device_entry: DeviceEntry, +) -> bool: + """Remove a config entry from a device.""" + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and identifier[1] in config_entry.runtime_data.hub.devices + ) diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py new file mode 100644 index 00000000000..c6535608b0c --- /dev/null +++ b/homeassistant/components/eheimdigital/config_flow.py @@ -0,0 +1,127 @@ +"""Config flow for EHEIM Digital.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from aiohttp import ClientError +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.hub import EheimDigitalHub +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DOMAIN, LOGGER + +CONFIG_SCHEMA = vol.Schema( + {vol.Required(CONF_HOST, default="eheimdigital.local"): selector.TextSelector()} +) + + +class EheimDigitalConfigFlow(ConfigFlow, domain=DOMAIN): + """The EHEIM Digital config flow.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.data: dict[str, Any] = {} + self.main_device_added_event = asyncio.Event() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self.data[CONF_HOST] = host = discovery_info.host + + self._async_abort_entries_match(self.data) + + hub = EheimDigitalHub( + host=host, + session=async_get_clientsession(self.hass), + loop=self.hass.loop, + main_device_added_event=self.main_device_added_event, + ) + try: + await hub.connect() + + async with asyncio.timeout(2): + # This event gets triggered when the first message is received from + # the device, it contains the data necessary to create the main device. + # This removes the race condition where the main device is accessed + # before the response from the device is parsed. + await self.main_device_added_event.wait() + if TYPE_CHECKING: + # At this point the main device is always set + assert isinstance(hub.main, EheimDigitalDevice) + await hub.close() + except (ClientError, TimeoutError): + return self.async_abort(reason="cannot_connect") + except Exception: # noqa: BLE001 + return self.async_abort(reason="unknown") + await self.async_set_unique_id(hub.main.mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.data[CONF_HOST], + data={CONF_HOST: self.data[CONF_HOST]}, + ) + + self._set_confirm_only() + return self.async_show_form(step_id="discovery_confirm") + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + if user_input is None: + return self.async_show_form(step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA) + + self._async_abort_entries_match(user_input) + errors: dict[str, str] = {} + hub = EheimDigitalHub( + host=user_input[CONF_HOST], + session=async_get_clientsession(self.hass), + loop=self.hass.loop, + main_device_added_event=self.main_device_added_event, + ) + + try: + await hub.connect() + + async with asyncio.timeout(2): + # This event gets triggered when the first message is received from + # the device, it contains the data necessary to create the main device. + # This removes the race condition where the main device is accessed + # before the response from the device is parsed. + await self.main_device_added_event.wait() + if TYPE_CHECKING: + # At this point the main device is always set + assert isinstance(hub.main, EheimDigitalDevice) + await self.async_set_unique_id( + hub.main.mac_address, raise_on_progress=False + ) + await hub.close() + except (ClientError, TimeoutError): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + errors["base"] = "unknown" + LOGGER.exception("Unknown exception occurred") + else: + self._abort_if_unique_id_configured() + return self.async_create_entry(data=user_input, title=user_input[CONF_HOST]) + return self.async_show_form( + step_id=SOURCE_USER, + data_schema=CONFIG_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/eheimdigital/const.py b/homeassistant/components/eheimdigital/const.py new file mode 100644 index 00000000000..5ed9303be40 --- /dev/null +++ b/homeassistant/components/eheimdigital/const.py @@ -0,0 +1,17 @@ +"""Constants for the EHEIM Digital integration.""" + +from logging import Logger, getLogger + +from eheimdigital.types import LightMode + +from homeassistant.components.light import EFFECT_OFF + +LOGGER: Logger = getLogger(__package__) +DOMAIN = "eheimdigital" + +EFFECT_DAYCL_MODE = "daycl_mode" + +EFFECT_TO_LIGHT_MODE = { + EFFECT_DAYCL_MODE: LightMode.DAYCL_MODE, + EFFECT_OFF: LightMode.MAN_MODE, +} diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py new file mode 100644 index 00000000000..f122a1227c5 --- /dev/null +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -0,0 +1,78 @@ +"""Data update coordinator for the EHEIM Digital integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any + +from aiohttp import ClientError +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.hub import EheimDigitalHub +from eheimdigital.types import EheimDeviceType + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]] + + +class EheimDigitalUpdateCoordinator( + DataUpdateCoordinator[dict[str, EheimDigitalDevice]] +): + """The EHEIM Digital data update coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the EHEIM Digital data update coordinator.""" + super().__init__( + hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + ) + self.hub = EheimDigitalHub( + host=self.config_entry.data[CONF_HOST], + session=async_get_clientsession(hass), + loop=hass.loop, + receive_callback=self._async_receive_callback, + device_found_callback=self._async_device_found, + ) + self.known_devices: set[str] = set() + self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set() + + def add_platform_callback( + self, + async_setup_device_entities: AsyncSetupDeviceEntitiesCallback, + ) -> None: + """Add the setup callbacks from a specific platform.""" + self.platform_callbacks.add(async_setup_device_entities) + + async def _async_device_found( + self, device_address: str, device_type: EheimDeviceType + ) -> None: + """Set up a new device found. + + This function is called from the library whenever a new device is added. + """ + + if device_address not in self.known_devices: + for platform_callback in self.platform_callbacks: + await platform_callback(device_address) + + async def _async_receive_callback(self) -> None: + self.async_set_updated_data(self.hub.devices) + + async def _async_setup(self) -> None: + await self.hub.connect() + await self.hub.update() + + async def _async_update_data(self) -> dict[str, EheimDigitalDevice]: + try: + await self.hub.update() + except ClientError as ex: + raise UpdateFailed from ex + return self.data diff --git a/homeassistant/components/eheimdigital/entity.py b/homeassistant/components/eheimdigital/entity.py new file mode 100644 index 00000000000..c0f91a4b798 --- /dev/null +++ b/homeassistant/components/eheimdigital/entity.py @@ -0,0 +1,53 @@ +"""Base entity for EHEIM Digital.""" + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from eheimdigital.device import EheimDigitalDevice + +from homeassistant.const import CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import EheimDigitalUpdateCoordinator + + +class EheimDigitalEntity[_DeviceT: EheimDigitalDevice]( + CoordinatorEntity[EheimDigitalUpdateCoordinator], ABC +): + """Represent a EHEIM Digital entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: EheimDigitalUpdateCoordinator, device: _DeviceT + ) -> None: + """Initialize a EHEIM Digital entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + # At this point at least one device is found and so there is always a main device set + assert isinstance(coordinator.hub.main, EheimDigitalDevice) + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", + name=device.name, + connections={(CONNECTION_NETWORK_MAC, device.mac_address)}, + manufacturer="EHEIM", + model=device.device_type.model_name, + identifiers={(DOMAIN, device.mac_address)}, + suggested_area=device.aquarium_name, + sw_version=device.sw_version, + via_device=(DOMAIN, coordinator.hub.main.mac_address), + ) + self._device = device + self._device_address = device.mac_address + + @abstractmethod + def _async_update_attrs(self) -> None: ... + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py new file mode 100644 index 00000000000..a119e0bda8d --- /dev/null +++ b/homeassistant/components/eheimdigital/light.py @@ -0,0 +1,127 @@ +"""EHEIM Digital lights.""" + +from typing import Any + +from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.types import EheimDigitalClientError, LightMode + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + EFFECT_OFF, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness + +from . import EheimDigitalConfigEntry +from .const import EFFECT_DAYCL_MODE, EFFECT_TO_LIGHT_MODE +from .coordinator import EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +BRIGHTNESS_SCALE = (1, 100) + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so lights can be added as devices are found.""" + coordinator = entry.runtime_data + + async def async_setup_device_entities(device_address: str) -> None: + """Set up the light entities for a device.""" + device = coordinator.hub.devices[device_address] + entities: list[EheimDigitalClassicLEDControlLight] = [] + + if isinstance(device, EheimDigitalClassicLEDControl): + for channel in range(2): + if len(device.tankconfig[channel]) > 0: + entities.append( + EheimDigitalClassicLEDControlLight(coordinator, device, channel) + ) + coordinator.known_devices.add(device.mac_address) + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + + for device_address in entry.runtime_data.hub.devices: + await async_setup_device_entities(device_address) + + +class EheimDigitalClassicLEDControlLight( + EheimDigitalEntity[EheimDigitalClassicLEDControl], LightEntity +): + """Represent a EHEIM Digital classicLEDcontrol light.""" + + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_effect_list = [EFFECT_DAYCL_MODE] + _attr_supported_features = LightEntityFeature.EFFECT + _attr_translation_key = "channel" + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: EheimDigitalClassicLEDControl, + channel: int, + ) -> None: + """Initialize an EHEIM Digital classicLEDcontrol light entity.""" + super().__init__(coordinator, device) + self._channel = channel + self._attr_translation_placeholders = {"channel_id": str(channel)} + self._attr_unique_id = f"{self._device_address}_{channel}" + self._async_update_attrs() + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return super().available and self._device.light_level[self._channel] is not None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + if ATTR_EFFECT in kwargs: + await self._device.set_light_mode(EFFECT_TO_LIGHT_MODE[kwargs[ATTR_EFFECT]]) + return + if ATTR_BRIGHTNESS in kwargs: + if self._device.light_mode == LightMode.DAYCL_MODE: + await self._device.set_light_mode(LightMode.MAN_MODE) + try: + await self._device.turn_on( + int(brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS])), + self._channel, + ) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + if self._device.light_mode == LightMode.DAYCL_MODE: + await self._device.set_light_mode(LightMode.MAN_MODE) + try: + await self._device.turn_off(self._channel) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + def _async_update_attrs(self) -> None: + light_level = self._device.light_level[self._channel] + + self._attr_is_on = light_level > 0 if light_level is not None else None + self._attr_brightness = ( + value_to_brightness(BRIGHTNESS_SCALE, light_level) + if light_level is not None + else None + ) + self._attr_effect = ( + EFFECT_DAYCL_MODE + if self._device.light_mode == LightMode.DAYCL_MODE + else EFFECT_OFF + ) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json new file mode 100644 index 00000000000..7747ca4f95d --- /dev/null +++ b/homeassistant/components/eheimdigital/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "eheimdigital", + "name": "EHEIM Digital", + "codeowners": ["@autinerd"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/eheimdigital", + "integration_type": "hub", + "iot_class": "local_polling", + "loggers": ["eheimdigital"], + "quality_scale": "bronze", + "requirements": ["eheimdigital==1.0.5"], + "zeroconf": [ + { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } + ] +} diff --git a/homeassistant/components/eheimdigital/quality_scale.yaml b/homeassistant/components/eheimdigital/quality_scale.yaml new file mode 100644 index 00000000000..a56551a14f6 --- /dev/null +++ b/homeassistant/components/eheimdigital/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions implemented. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions implemented. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No service actions implemented. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration doesn't have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration requires no authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json new file mode 100644 index 00000000000..0e6fa6a0814 --- /dev/null +++ b/homeassistant/components/eheimdigital/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "discovery_confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The host or IP address of your main device. Only needed to change if 'eheimdigital' doesn't work." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "light": { + "channel": { + "name": "Channel {channel_id}", + "state_attributes": { + "effect": { + "state": { + "daycl_mode": "Daycycle mode" + } + } + } + } + } + } +} diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 81a07545a30..04e4742554b 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -111,7 +111,6 @@ class ElectraClimateEntity(ClimateEntity): _attr_hvac_modes = ELECTRA_MODES _attr_has_entity_name = True _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None: """Initialize Electra climate entity.""" diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py index 7da4802e98a..e5807fec67c 100644 --- a/homeassistant/components/elevenlabs/__init__.py +++ b/homeassistant/components/elevenlabs/__init__.py @@ -4,14 +4,18 @@ from __future__ import annotations from dataclasses import dataclass -from elevenlabs import Model -from elevenlabs.client import AsyncElevenLabs +from elevenlabs import AsyncElevenLabs, Model from elevenlabs.core import ApiError +from httpx import ConnectError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.httpx_client import get_async_client from .const import CONF_MODEL @@ -36,10 +40,10 @@ class ElevenLabsData: model: Model -type EleventLabsConfigEntry = ConfigEntry[ElevenLabsData] +type ElevenLabsConfigEntry = ConfigEntry[ElevenLabsData] -async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) -> bool: """Set up ElevenLabs text-to-speech from a config entry.""" entry.add_update_listener(update_listener) httpx_client = get_async_client(hass) @@ -49,8 +53,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry) model_id = entry.options[CONF_MODEL] try: model = await get_model_by_id(client, model_id) + except ConnectError as err: + raise ConfigEntryNotReady("Failed to connect") from err except ApiError as err: - raise ConfigEntryError("Auth failed") from err + raise ConfigEntryAuthFailed("Auth failed") from err if model is None or (not model.languages): raise ConfigEntryError("Model could not be resolved") @@ -61,15 +67,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry) return True -async def async_unload_entry( - hass: HomeAssistant, entry: EleventLabsConfigEntry -) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def update_listener( - hass: HomeAssistant, config_entry: EleventLabsConfigEntry + hass: HomeAssistant, config_entry: ElevenLabsConfigEntry ) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index 227150a0f4e..227749bf82c 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -5,16 +5,11 @@ from __future__ import annotations import logging from typing import Any -from elevenlabs.client import AsyncElevenLabs +from elevenlabs import AsyncElevenLabs from elevenlabs.core import ApiError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client @@ -24,6 +19,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) +from . import ElevenLabsConfigEntry from .const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, @@ -96,7 +92,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: ElevenLabsConfigEntry, ) -> OptionsFlow: """Create the options flow.""" return ElevenLabsOptionsFlow(config_entry) @@ -105,7 +101,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): class ElevenLabsOptionsFlow(OptionsFlow): """ElevenLabs options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: ElevenLabsConfigEntry) -> None: """Initialize options flow.""" self.api_key: str = config_entry.data[CONF_API_KEY] # id -> name diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py index 040d38d272c..1de92f95e43 100644 --- a/homeassistant/components/elevenlabs/const.py +++ b/homeassistant/components/elevenlabs/const.py @@ -1,5 +1,7 @@ """Constants for the ElevenLabs text-to-speech integration.""" +ATTR_MODEL = "model" + CONF_VOICE = "voice" CONF_MODEL = "model" CONF_CONFIGURE_VOICE = "configure_voice" diff --git a/homeassistant/components/elevenlabs/manifest.json b/homeassistant/components/elevenlabs/manifest.json index 968ea7b688a..eb6df09149a 100644 --- a/homeassistant/components/elevenlabs/manifest.json +++ b/homeassistant/components/elevenlabs/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["elevenlabs"], - "requirements": ["elevenlabs==1.6.1"] + "requirements": ["elevenlabs==1.9.0"] } diff --git a/homeassistant/components/elevenlabs/quality_scale.yaml b/homeassistant/components/elevenlabs/quality_scale.yaml new file mode 100644 index 00000000000..94c395310c5 --- /dev/null +++ b/homeassistant/components/elevenlabs/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: done + comment: > + Only entity services + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: > + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: todo + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: todo + # Silver + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: + status: exempt + comment: > + There is no state in the TTS platform and we can't check poll if the TTS service is available. + action-exceptions: done + reauthentication-flow: todo + parallel-updates: done + test-coverage: todo + integration-owner: done + docs-installation-parameters: todo + docs-configuration-parameters: todo + + # Gold + entity-translations: todo + entity-device-class: + status: exempt + comment: There is no device class for Text To Speech entities. + devices: done + entity-category: done + entity-disabled-by-default: todo + discovery: + status: exempt + comment: > + This is not possible because there is no physical device. + stale-devices: + status: exempt + comment: > + This is not possible because there is no physical device. + diagnostics: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: + status: todo + comment: > + I imagine this could be useful if the default voice is deleted from voice lab. + dynamic-devices: + status: exempt + comment: | + This is not possible because there is no physical device. + discovery-update-info: + status: exempt + comment: > + This is not needed because there are no physical devices. + repair-issues: todo + docs-use-cases: done + docs-supported-devices: + status: exempt + comment: > + This integration does not support any devices. + docs-supported-functions: todo + docs-data-update: todo + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index efc2154882a..008cd106615 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -6,7 +6,7 @@ import logging from types import MappingProxyType from typing import Any -from elevenlabs.client import AsyncElevenLabs +from elevenlabs import AsyncElevenLabs from elevenlabs.core import ApiError from elevenlabs.types import Model, Voice as ElevenLabsVoice, VoiceSettings @@ -16,13 +16,15 @@ from homeassistant.components.tts import ( TtsAudioType, Voice, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EleventLabsConfigEntry +from . import ElevenLabsConfigEntry from .const import ( + ATTR_MODEL, CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, @@ -38,6 +40,7 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings: @@ -54,7 +57,7 @@ def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings: async def async_setup_entry( hass: HomeAssistant, - config_entry: EleventLabsConfigEntry, + config_entry: ElevenLabsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up ElevenLabs tts platform via config entry.""" @@ -83,7 +86,8 @@ async def async_setup_entry( class ElevenLabsTTSEntity(TextToSpeechEntity): """The ElevenLabs API entity.""" - _attr_supported_options = [ATTR_VOICE] + _attr_supported_options = [ATTR_VOICE, ATTR_MODEL] + _attr_entity_category = EntityCategory.CONFIG def __init__( self, @@ -138,13 +142,14 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): _LOGGER.debug("Getting TTS audio for %s", message) _LOGGER.debug("Options: %s", options) voice_id = options.get(ATTR_VOICE, self._default_voice_id) + model = options.get(ATTR_MODEL, self._model.model_id) try: audio = await self._client.generate( text=message, voice=voice_id, optimize_streaming_latency=self._latency, voice_settings=self._voice_settings, - model=self._model.model_id, + model=model, ) bytes_combined = b"".join([byte_seg async for byte_seg in audio]) except ApiError as exc: diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index 2d8446c3b76..1b1ff9948c9 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -1,17 +1,14 @@ """Support for Elgato Lights.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import ElgatoDataUpdateCoordinator +from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator PLATFORMS = [Platform.BUTTON, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] -type ElgatorConfigEntry = ConfigEntry[ElgatoDataUpdateCoordinator] - -async def async_setup_entry(hass: HomeAssistant, entry: ElgatorConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ElgatoConfigEntry) -> bool: """Set up Elgato Light from a config entry.""" coordinator = ElgatoDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() @@ -22,6 +19,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElgatorConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ElgatorConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ElgatoConfigEntry) -> bool: """Unload Elgato Light config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index aefff0b750b..505eff36b44 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -18,10 +18,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElgatorConfigEntry -from .coordinator import ElgatoDataUpdateCoordinator +from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class ElgatoButtonEntityDescription(ButtonEntityDescription): @@ -48,7 +49,7 @@ BUTTONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ElgatorConfigEntry, + entry: ElgatoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato button based on a config entry.""" diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index 5329fcee90a..a47f039384c 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -7,11 +7,12 @@ from typing import Any from elgato import Elgato, ElgatoError import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -34,7 +35,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): return self._async_show_setup_form() self.host = user_input[CONF_HOST] - self.port = user_input[CONF_PORT] try: await self._get_elgato_serial_number(raise_on_progress=False) @@ -44,12 +44,11 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): return self._async_create_entry() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.host = discovery_info.host self.mac = discovery_info.properties.get("id") - self.port = discovery_info.port or 9123 try: await self._get_elgato_serial_number() @@ -81,7 +80,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_HOST): str, - vol.Optional(CONF_PORT, default=9123): int, } ), errors=errors or {}, @@ -93,7 +91,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): title=self.serial_number, data={ CONF_HOST: self.host, - CONF_PORT: self.port, CONF_MAC: self.mac, }, ) @@ -103,7 +100,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): session = async_get_clientsession(self.hass) elgato = Elgato( host=self.host, - port=self.port, session=session, ) info = await elgato.info() @@ -113,7 +109,7 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): info.serial_number, raise_on_progress=raise_on_progress ) self._abort_if_unique_id_configured( - updates={CONF_HOST: self.host, CONF_PORT: self.port, CONF_MAC: self.mac} + updates={CONF_HOST: self.host, CONF_MAC: self.mac} ) self.serial_number = info.serial_number diff --git a/homeassistant/components/elgato/coordinator.py b/homeassistant/components/elgato/coordinator.py index c2bc79491a1..5e1ba0a6494 100644 --- a/homeassistant/components/elgato/coordinator.py +++ b/homeassistant/components/elgato/coordinator.py @@ -5,13 +5,15 @@ from dataclasses import dataclass from elgato import BatteryInfo, Elgato, ElgatoConnectionError, Info, Settings, State from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type ElgatoConfigEntry = ConfigEntry[ElgatoDataUpdateCoordinator] + @dataclass class ElgatoData: @@ -26,20 +28,20 @@ class ElgatoData: class ElgatoDataUpdateCoordinator(DataUpdateCoordinator[ElgatoData]): """Class to manage fetching Elgato data.""" - config_entry: ConfigEntry + config_entry: ElgatoConfigEntry has_battery: bool | None = None - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: ElgatoConfigEntry) -> None: """Initialize the coordinator.""" self.config_entry = entry self.client = Elgato( entry.data[CONF_HOST], - port=entry.data[CONF_PORT], session=async_get_clientsession(hass), ) super().__init__( hass, LOGGER, + config_entry=entry, name=f"{DOMAIN}_{entry.data[CONF_HOST]}", update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/elgato/diagnostics.py b/homeassistant/components/elgato/diagnostics.py index ac3ea0a155d..4e1b9d4cfdd 100644 --- a/homeassistant/components/elgato/diagnostics.py +++ b/homeassistant/components/elgato/diagnostics.py @@ -6,11 +6,11 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import ElgatorConfigEntry +from .coordinator import ElgatoConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ElgatorConfigEntry + hass: HomeAssistant, entry: ElgatoConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index a62a26f21d3..990a0606fce 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -8,7 +8,7 @@ from elgato import ElgatoError from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -19,10 +19,10 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, ) +from homeassistant.util import color as color_util -from . import ElgatorConfigEntry from .const import SERVICE_IDENTIFY -from .coordinator import ElgatoDataUpdateCoordinator +from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity PARALLEL_UPDATES = 1 @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ElgatorConfigEntry, + entry: ElgatoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato Light based on a config entry.""" @@ -49,8 +49,8 @@ class ElgatoLight(ElgatoEntity, LightEntity): """Defines an Elgato Light.""" _attr_name = None - _attr_min_mireds = 143 - _attr_max_mireds = 344 + _attr_min_color_temp_kelvin = 2900 # 344 Mireds + _attr_max_color_temp_kelvin = 7000 # 143 Mireds def __init__(self, coordinator: ElgatoDataUpdateCoordinator) -> None: """Initialize Elgato Light.""" @@ -69,8 +69,8 @@ class ElgatoLight(ElgatoEntity, LightEntity): or self.coordinator.data.state.hue is not None ): self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} - self._attr_min_mireds = 153 - self._attr_max_mireds = 285 + self._attr_min_color_temp_kelvin = 3500 # 285 Mireds + self._attr_max_color_temp_kelvin = 6500 # 153 Mireds @property def brightness(self) -> int | None: @@ -78,9 +78,11 @@ class ElgatoLight(ElgatoEntity, LightEntity): return round((self.coordinator.data.state.brightness * 255) / 100) @property - def color_temp(self) -> int | None: - """Return the CT color value in mireds.""" - return self.coordinator.data.state.temperature + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + if (mired_temperature := self.coordinator.data.state.temperature) is None: + return None + return color_util.color_temperature_mired_to_kelvin(mired_temperature) @property def color_mode(self) -> str | None: @@ -116,7 +118,7 @@ class ElgatoLight(ElgatoEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - temperature = kwargs.get(ATTR_COLOR_TEMP) + temperature_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) hue = None saturation = None @@ -133,12 +135,18 @@ class ElgatoLight(ElgatoEntity, LightEntity): if ( brightness and ATTR_HS_COLOR not in kwargs - and ATTR_COLOR_TEMP not in kwargs + and ATTR_COLOR_TEMP_KELVIN not in kwargs and self.supported_color_modes and ColorMode.HS in self.supported_color_modes and self.color_mode == ColorMode.COLOR_TEMP ): - temperature = self.color_temp + temperature_kelvin = self.color_temp_kelvin + + temperature = ( + None + if temperature_kelvin is None + else color_util.color_temperature_kelvin_to_mired(temperature_kelvin) + ) try: await self.coordinator.client.light( diff --git a/homeassistant/components/elgato/quality_scale.yaml b/homeassistant/components/elgato/quality_scale.yaml index 2910bdb4473..531f0447f70 100644 --- a/homeassistant/components/elgato/quality_scale.yaml +++ b/homeassistant/components/elgato/quality_scale.yaml @@ -5,10 +5,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - The data_description for port is missing. + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: done diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index f794d26cf7f..529d2f7c76e 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -21,10 +21,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElgatorConfigEntry -from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator +from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class ElgatoSensorEntityDescription(SensorEntityDescription): @@ -101,7 +103,7 @@ SENSORS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ElgatorConfigEntry, + entry: ElgatoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato sensor based on a config entry.""" diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index 6e1031c8ddf..727b8ee7024 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -5,8 +5,7 @@ "user": { "description": "Set up your Elgato Light to integrate with Home Assistant.", "data": { - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]" + "host": "[%key:common::config_flow::data::host%]" }, "data_description": { "host": "The hostname or IP address of your Elgato device." diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index fe177616034..3b2420b0ace 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -14,10 +14,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElgatorConfigEntry -from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator +from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class ElgatoSwitchEntityDescription(SwitchEntityDescription): @@ -52,7 +53,7 @@ SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, - entry: ElgatorConfigEntry, + entry: ElgatoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato switches based on a config entry.""" diff --git a/homeassistant/components/elkm1/binary_sensor.py b/homeassistant/components/elkm1/binary_sensor.py index 854f8c56fb8..73f6b925e8c 100644 --- a/homeassistant/components/elkm1/binary_sensor.py +++ b/homeassistant/components/elkm1/binary_sensor.py @@ -49,7 +49,7 @@ class ElkBinarySensor(ElkAttachedEntity, BinarySensorEntity): _element: Zone _attr_entity_registry_enabled_default = False - def _element_changed(self, _: Element, changeset: Any) -> None: + def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: # Zone in NORMAL state is OFF; any other state is ON self._attr_is_on = bool( self._element.logical_status != ZoneLogicalStatus.NORMAL diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index bf5650f237b..1448acc6079 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -90,7 +90,6 @@ class ElkThermostat(ElkEntity, ClimateEntity): _attr_target_temperature_step = 1 _attr_fan_modes = [FAN_AUTO, FAN_ON] _element: Thermostat - _enable_turn_on_off_backwards_compatibility = False @property def temperature_unit(self) -> str: diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py index a3dd1d46f8b..c486a385721 100644 --- a/homeassistant/components/elkm1/config_flow.py +++ b/homeassistant/components/elkm1/config_flow.py @@ -9,7 +9,6 @@ from elkm1_lib.discovery import ElkSystem from elkm1_lib.elk import Elk import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_ADDRESS, @@ -21,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType from homeassistant.util import slugify from homeassistant.util.network import is_ip_address @@ -140,7 +140,7 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_devices: dict[str, ElkSystem] = {} async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = ElkSystem( diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 7822307e12e..12c22e23ff0 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.10"] + "requirements": ["elkm1-lib==2.2.11"] } diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index e0231c86699..2ca932ec134 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -120,7 +120,7 @@ class ElkCounter(ElkSensor): _attr_icon = "mdi:numeric" _element: Counter - def _element_changed(self, _: Element, changeset: Any) -> None: + def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: self._attr_native_value = self._element.value @@ -153,7 +153,7 @@ class ElkKeypad(ElkSensor): attrs["last_keypress"] = self._element.last_keypress return attrs - def _element_changed(self, _: Element, changeset: Any) -> None: + def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: self._attr_native_value = temperature_to_state( self._element.temperature, UNDEFINED_TEMPERATURE ) @@ -173,7 +173,7 @@ class ElkPanel(ElkSensor): attrs["system_trouble_status"] = self._element.system_trouble_status return attrs - def _element_changed(self, _: Element, changeset: Any) -> None: + def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: if self._elk.is_connected(): self._attr_native_value = ( "Paused" if self._element.remote_programming_status else "Connected" @@ -188,7 +188,7 @@ class ElkSetting(ElkSensor): _attr_translation_key = "setting" _element: Setting - def _element_changed(self, _: Element, changeset: Any) -> None: + def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: self._attr_native_value = self._element.value @property @@ -257,7 +257,7 @@ class ElkZone(ElkSensor): return UnitOfElectricPotential.VOLT return None - def _element_changed(self, _: Element, changeset: Any) -> None: + def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: if self._element.definition == ZoneType.TEMPERATURE: self._attr_native_value = temperature_to_state( self._element.temperature, UNDEFINED_TEMPERATURE diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index bf02d727280..f184483646d 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -89,7 +89,7 @@ }, "alarm_arm_vacation": { "name": "Alarm arm vacation", - "description": "Arm the ElkM1 in vacation mode.", + "description": "Arms the ElkM1 in vacation mode.", "fields": { "code": { "name": "Code", diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index 88e61e36a68..18350e45efe 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -35,7 +35,7 @@ def check_local_version_supported(api_version: str | None) -> bool: class DirectPanel(PanelEntry): """Helper class for wrapping a directly accessed Elmax Panel.""" - def __init__(self, panel_uri): + def __init__(self, panel_uri) -> None: """Construct the object.""" super().__init__(panel_uri, True, {}) diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index bf479e997ef..b8697552626 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -12,9 +12,9 @@ from elmax_api.model.panel import PanelEntry, PanelStatus import httpx import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( build_direct_ssl_context, @@ -151,7 +151,9 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): port=self._panel_direct_port, ) ) - ssl_context = build_direct_ssl_context(cadata=self._panel_direct_ssl_cert) + ssl_context = await self.hass.async_add_executor_job( + build_direct_ssl_context, self._panel_direct_ssl_cert + ) # Attempt the connection to make sure the pin works. Also, take the chance to retrieve the panel ID via APIs. client_api_url = get_direct_api_url( @@ -203,7 +205,7 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_direct(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Handle the direct setup step.""" - self._selected_mode = CONF_ELMAX_MODE_CLOUD + self._selected_mode = CONF_ELMAX_MODE_DIRECT if user_input is None: return self.async_show_form( step_id=CONF_ELMAX_MODE_DIRECT, diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index a53c28c5f33..403bc51dbff 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -121,13 +121,13 @@ class ElmaxCover(ElmaxEntity, CoverEntity): else: _LOGGER.debug("Ignoring stop request as the cover is IDLE") - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.coordinator.http_client.execute_command( endpoint_id=self._device.endpoint_id, command=CoverCommand.UP ) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.coordinator.http_client.execute_command( endpoint_id=self._device.endpoint_id, command=CoverCommand.DOWN diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json index efa97a9f6b9..f4b184c0475 100644 --- a/homeassistant/components/elmax/manifest.json +++ b/homeassistant/components/elmax/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/elmax", "iot_class": "cloud_polling", "loggers": ["elmax_api"], - "requirements": ["elmax-api==0.0.6.1"], + "requirements": ["elmax-api==0.0.6.4rc0"], "zeroconf": [ { "type": "_elmax-ssl._tcp.local." diff --git a/homeassistant/components/elmax/strings.json b/homeassistant/components/elmax/strings.json index daa502a7dac..2ba74f5fc8f 100644 --- a/homeassistant/components/elmax/strings.json +++ b/homeassistant/components/elmax/strings.json @@ -50,7 +50,7 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", - "panel_pin": "Panel Pin" + "panel_pin": "Panel PIN" } } }, @@ -58,7 +58,7 @@ "no_panel_online": "No online Elmax control panel was found.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "network_error": "A network error occurred", - "invalid_pin": "The provided pin is invalid", + "invalid_pin": "The provided PIN is invalid", "invalid_mode": "Invalid or unsupported mode", "reauth_panel_disappeared": "The given panel is no longer associated to this user. Please log in using an account associated to this panel.", "unknown": "[%key:common::config_flow::error::unknown%]" diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 0cd686b5b56..581948bbc6f 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -26,7 +26,7 @@ def _migrate_unique_id( for entity in entry_entities: if entity.unique_id.split("-")[0] == entry.entry_id: feed_id = entity.unique_id.split("-")[-1] - LOGGER.debug(f"moving feed {feed_id} to hardware uuid") + LOGGER.debug("moving feed %s to hardware uuid", feed_id) ent_reg.async_update_entity( entity.entity_id, new_unique_id=f"{emoncms_unique_id}-{feed_id}" ) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index c696a569135..291ecad0bd3 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -10,16 +10,31 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, CONF_ID, CONF_UNIT_OF_MEASUREMENT, CONF_URL, CONF_VALUE_TEMPLATE, + PERCENTAGE, + UnitOfApparentPower, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, UnitOfPower, + UnitOfPressure, + UnitOfSoundPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType @@ -41,6 +56,146 @@ from .const import ( ) from .coordinator import EmoncmsCoordinator +SENSORS: dict[str | None, SensorEntityDescription] = { + "kWh": SensorEntityDescription( + key="energy|kWh", + translation_key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "Wh": SensorEntityDescription( + key="energy|Wh", + translation_key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "kW": SensorEntityDescription( + key="power|kW", + translation_key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + "W": SensorEntityDescription( + key="power|W", + translation_key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + "V": SensorEntityDescription( + key="voltage", + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + "A": SensorEntityDescription( + key="current", + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + "VA": SensorEntityDescription( + key="apparent_power", + translation_key="apparent_power", + device_class=SensorDeviceClass.APPARENT_POWER, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + "°C": SensorEntityDescription( + key="temperature|celsius", + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + "°F": SensorEntityDescription( + key="temperature|fahrenheit", + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), + "K": SensorEntityDescription( + key="temperature|kelvin", + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.KELVIN, + state_class=SensorStateClass.MEASUREMENT, + ), + "Hz": SensorEntityDescription( + key="frequency", + translation_key="frequency", + device_class=SensorDeviceClass.FREQUENCY, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + ), + "hPa": SensorEntityDescription( + key="pressure", + translation_key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + ), + "dB": SensorEntityDescription( + key="decibel", + translation_key="decibel", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + state_class=SensorStateClass.MEASUREMENT, + ), + "m³": SensorEntityDescription( + key="volume|cubic_meter", + translation_key="volume", + device_class=SensorDeviceClass.VOLUME_STORAGE, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.MEASUREMENT, + ), + "m³/h": SensorEntityDescription( + key="flow|cubic_meters_per_hour", + translation_key="flow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + "l/m": SensorEntityDescription( + key="flow|liters_per_minute", + translation_key="flow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + ), + "m/s": SensorEntityDescription( + key="speed|meters_per_second", + translation_key="speed", + device_class=SensorDeviceClass.SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + ), + "µg/m³": SensorEntityDescription( + key="concentration|microgram_per_cubic_meter", + translation_key="concentration", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + "ppm": SensorEntityDescription( + key="concentration|microgram_parts_per_million", + translation_key="concentration", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + "%": SensorEntityDescription( + key="percent", + translation_key="percent", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +} + ATTR_FEEDID = "FeedId" ATTR_FEEDNAME = "FeedName" ATTR_LASTUPDATETIME = "LastUpdated" @@ -162,7 +317,7 @@ async def async_setup_entry( EmonCmsSensor( coordinator, unique_id, - elem["unit"], + elem.get("unit"), name, idx, ) @@ -173,6 +328,8 @@ async def async_setup_entry( class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): """Implementation of an Emoncms sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: EmoncmsCoordinator, @@ -187,33 +344,16 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): elem = {} if self.coordinator.data: elem = self.coordinator.data[self.idx] - self._attr_name = f"{name} {elem[FEED_NAME]}" - self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_translation_placeholders = { + "emoncms_details": f"{elem[FEED_TAG]} {elem[FEED_NAME]}", + } self._attr_unique_id = f"{unique_id}-{elem[FEED_ID]}" - if unit_of_measurement in ("kWh", "Wh"): - self._attr_device_class = SensorDeviceClass.ENERGY - self._attr_state_class = SensorStateClass.TOTAL_INCREASING - elif unit_of_measurement == "W": - self._attr_device_class = SensorDeviceClass.POWER - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement == "V": - self._attr_device_class = SensorDeviceClass.VOLTAGE - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement == "A": - self._attr_device_class = SensorDeviceClass.CURRENT - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement == "VA": - self._attr_device_class = SensorDeviceClass.APPARENT_POWER - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement in ("°C", "°F", "K"): - self._attr_device_class = SensorDeviceClass.TEMPERATURE - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement == "Hz": - self._attr_device_class = SensorDeviceClass.FREQUENCY - self._attr_state_class = SensorStateClass.MEASUREMENT - elif unit_of_measurement == "hPa": - self._attr_device_class = SensorDeviceClass.PRESSURE - self._attr_state_class = SensorStateClass.MEASUREMENT + description = SENSORS.get(unit_of_measurement) + if description is not None: + self.entity_description = description + else: + self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_name = f"{name} {elem[FEED_NAME]}" self._update_attributes(elem) def _update_attributes(self, elem: dict[str, Any]) -> None: diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index 0d841f2efb4..77216a3fb2f 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -10,8 +10,8 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "url": "Server url starting with the protocol (http or https)", - "api_key": "Your 32 bits api key" + "url": "Server URL starting with the protocol (http or https)", + "api_key": "Your 32 bits API key" } }, "choose_feeds": { @@ -24,6 +24,52 @@ "already_configured": "This server is already configured" } }, + "entity": { + "sensor": { + "energy": { + "name": "Energy {emoncms_details}" + }, + "power": { + "name": "Power {emoncms_details}" + }, + "percent": { + "name": "Percentage {emoncms_details}" + }, + "voltage": { + "name": "Voltage {emoncms_details}" + }, + "current": { + "name": "Current {emoncms_details}" + }, + "apparent_power": { + "name": "Apparent power {emoncms_details}" + }, + "temperature": { + "name": "Temperature {emoncms_details}" + }, + "frequency": { + "name": "Frequency {emoncms_details}" + }, + "pressure": { + "name": "Pressure {emoncms_details}" + }, + "decibel": { + "name": "Decibel {emoncms_details}" + }, + "volume": { + "name": "Volume {emoncms_details}" + }, + "flow": { + "name": "Flow rate {emoncms_details}" + }, + "speed": { + "name": "Speed {emoncms_details}" + }, + "concentration": { + "name": "Concentration {emoncms_details}" + } + } + }, "options": { "error": { "api_error": "[%key:component::emoncms::config::error::api_error%]" @@ -47,7 +93,7 @@ }, "migrate_database": { "title": "Upgrade your emoncms version", - "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\n Please upgrade to at least version 11.5.7 and migrate your emoncms database.\n\n More info on [emoncms documentation]({doc_url})" + "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\nPlease upgrade to at least version 11.5.7 and migrate your emoncms database.\n\nMore info in the [emoncms documentation]({doc_url})" } } } diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py index 833b80f9d47..458eb5ae3c7 100644 --- a/homeassistant/components/emonitor/config_flow.py +++ b/homeassistant/components/emonitor/config_flow.py @@ -7,12 +7,12 @@ from aioemonitor import Emonitor import aiohttp import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import name_short_mac from .const import DOMAIN @@ -69,7 +69,7 @@ class EmonitorConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self.discovered_ip = discovery_info.ip diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 8194d31823d..464d2bcb7e7 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -39,7 +39,7 @@ from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.humidifier import ATTR_HUMIDITY, SERVICE_SET_HUMIDITY from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, @@ -67,6 +67,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, State from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.util import color as color_util from homeassistant.util.json import json_loads from homeassistant.util.network import is_local @@ -500,7 +501,11 @@ class HueOneLightChangeView(HomeAssistantView): light.color_temp_supported(color_modes) and parsed[STATE_COLOR_TEMP] is not None ): - data[ATTR_COLOR_TEMP] = parsed[STATE_COLOR_TEMP] + data[ATTR_COLOR_TEMP_KELVIN] = ( + color_util.color_temperature_mired_to_kelvin( + parsed[STATE_COLOR_TEMP] + ) + ) if ( entity_features & LightEntityFeature.TRANSITION @@ -702,7 +707,12 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]: else: data[STATE_HUE] = HUE_API_STATE_HUE_MIN data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN - data[STATE_COLOR_TEMP] = attributes.get(ATTR_COLOR_TEMP) or 0 + kelvin = attributes.get(ATTR_COLOR_TEMP_KELVIN) + data[STATE_COLOR_TEMP] = ( + color_util.color_temperature_kelvin_to_mired(kelvin) + if kelvin is not None + else 0 + ) else: data[STATE_BRIGHTNESS] = 0 @@ -855,7 +865,7 @@ def state_supports_hue_brightness( return False features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) enum = ENTITY_FEATURES_BY_DOMAIN[domain] - features = enum(features) if type(features) is int else features # noqa: E721 + features = enum(features) if type(features) is int else features return required_feature in features diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 147d8f3e26a..199d18d6b07 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -362,12 +362,11 @@ class EnergyCostSensor(SensorEntity): return if ( - ( - state_class != SensorStateClass.TOTAL_INCREASING - and energy_state.attributes.get(ATTR_LAST_RESET) - != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET) - ) - or state_class == SensorStateClass.TOTAL_INCREASING + state_class != SensorStateClass.TOTAL_INCREASING + and energy_state.attributes.get(ATTR_LAST_RESET) + != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET) + ) or ( + state_class == SensorStateClass.TOTAL_INCREASING and reset_detected( self.hass, cast(str, self._config[self._adapter.stat_energy_key]), diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py index 3e1bb830cce..fc2855374dd 100644 --- a/homeassistant/components/energyzero/__init__.py +++ b/homeassistant/components/energyzero/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -10,10 +9,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import EnergyZeroDataUpdateCoordinator +from .coordinator import EnergyZeroConfigEntry, EnergyZeroDataUpdateCoordinator from .services import async_setup_services -PLATFORMS = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -25,25 +24,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EnergyZeroConfigEntry) -> bool: """Set up EnergyZero from a config entry.""" - coordinator = EnergyZeroDataUpdateCoordinator(hass) + coordinator = EnergyZeroDataUpdateCoordinator(hass, entry) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: await coordinator.energyzero.close() raise - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EnergyZeroConfigEntry) -> bool: """Unload EnergyZero config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py index 65955b2ebe6..35054f7b3b7 100644 --- a/homeassistant/components/energyzero/coordinator.py +++ b/homeassistant/components/energyzero/coordinator.py @@ -21,6 +21,8 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR +type EnergyZeroConfigEntry = ConfigEntry[EnergyZeroDataUpdateCoordinator] + class EnergyZeroData(NamedTuple): """Class for defining data in dict.""" @@ -35,13 +37,14 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, entry: EnergyZeroConfigEntry) -> None: """Initialize global EnergyZero data updater.""" super().__init__( hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL, + config_entry=entry, ) self.energyzero = EnergyZero(session=async_get_clientsession(hass)) diff --git a/homeassistant/components/energyzero/diagnostics.py b/homeassistant/components/energyzero/diagnostics.py index 35d20fee929..0a45d87fee5 100644 --- a/homeassistant/components/energyzero/diagnostics.py +++ b/homeassistant/components/energyzero/diagnostics.py @@ -5,12 +5,9 @@ from __future__ import annotations from datetime import timedelta from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import EnergyZeroDataUpdateCoordinator -from .const import DOMAIN -from .coordinator import EnergyZeroData +from .coordinator import EnergyZeroConfigEntry, EnergyZeroData def get_gas_price(data: EnergyZeroData, hours: int) -> float | None: @@ -32,30 +29,31 @@ def get_gas_price(data: EnergyZeroData, hours: int) -> float | None: async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: EnergyZeroConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator_data = entry.runtime_data.data + energy_today = coordinator_data.energy_today return { "entry": { "title": entry.title, }, "energy": { - "current_hour_price": coordinator.data.energy_today.current_price, - "next_hour_price": coordinator.data.energy_today.price_at_time( - coordinator.data.energy_today.utcnow() + timedelta(hours=1) + "current_hour_price": energy_today.current_price, + "next_hour_price": energy_today.price_at_time( + energy_today.utcnow() + timedelta(hours=1) ), - "average_price": coordinator.data.energy_today.average_price, - "max_price": coordinator.data.energy_today.extreme_prices[1], - "min_price": coordinator.data.energy_today.extreme_prices[0], - "highest_price_time": coordinator.data.energy_today.highest_price_time, - "lowest_price_time": coordinator.data.energy_today.lowest_price_time, - "percentage_of_max": coordinator.data.energy_today.pct_of_max_price, - "hours_priced_equal_or_lower": coordinator.data.energy_today.hours_priced_equal_or_lower, + "average_price": energy_today.average_price, + "max_price": energy_today.extreme_prices[1], + "min_price": energy_today.extreme_prices[0], + "highest_price_time": energy_today.highest_price_time, + "lowest_price_time": energy_today.lowest_price_time, + "percentage_of_max": energy_today.pct_of_max_price, + "hours_priced_equal_or_lower": energy_today.hours_priced_equal_or_lower, }, "gas": { - "current_hour_price": get_gas_price(coordinator.data, 0), - "next_hour_price": get_gas_price(coordinator.data, 1), + "current_hour_price": get_gas_price(coordinator_data, 0), + "next_hour_price": get_gas_price(coordinator_data, 1), }, } diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index bb867e88d85..b647faebe1d 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -4,6 +4,8 @@ "codeowners": ["@klaasnicolaas"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/energyzero", + "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["energyzero==2.1.1"] + "requirements": ["energyzero==2.1.1"], + "single_config_entry": true } diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index f65f7bd559c..141ac793fba 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CURRENCY_EURO, PERCENTAGE, @@ -27,7 +26,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES -from .coordinator import EnergyZeroData, EnergyZeroDataUpdateCoordinator +from .coordinator import ( + EnergyZeroConfigEntry, + EnergyZeroData, + EnergyZeroDataUpdateCoordinator, +) @dataclass(frozen=True, kw_only=True) @@ -142,10 +145,12 @@ def get_gas_price(data: EnergyZeroData, hours: int) -> float | None: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EnergyZeroConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EnergyZero Sensors based on a config entry.""" - coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( EnergyZeroSensorEntity( coordinator=coordinator, diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py index d98699c5c08..c47958b670f 100644 --- a/homeassistant/components/energyzero/services.py +++ b/homeassistant/components/energyzero/services.py @@ -10,7 +10,7 @@ from typing import Final from energyzero import Electricity, Gas, VatOption import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -23,7 +23,7 @@ from homeassistant.helpers import selector from homeassistant.util import dt as dt_util from .const import DOMAIN -from .coordinator import EnergyZeroDataUpdateCoordinator +from .coordinator import EnergyZeroConfigEntry, EnergyZeroDataUpdateCoordinator ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_START: Final = "start" @@ -83,12 +83,12 @@ def __serialize_prices(prices: Electricity | Gas) -> ServiceResponse: } -def __get_coordinator( - hass: HomeAssistant, call: ServiceCall -) -> EnergyZeroDataUpdateCoordinator: +def __get_coordinator(call: ServiceCall) -> EnergyZeroDataUpdateCoordinator: """Get the coordinator from the entry.""" entry_id: str = call.data[ATTR_CONFIG_ENTRY] - entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + entry: EnergyZeroConfigEntry | None = call.hass.config_entries.async_get_entry( + entry_id + ) if not entry: raise ServiceValidationError( @@ -107,17 +107,15 @@ def __get_coordinator( }, ) - coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry_id] - return coordinator + return entry.runtime_data async def __get_prices( call: ServiceCall, *, - hass: HomeAssistant, price_type: PriceType, ) -> ServiceResponse: - coordinator = __get_coordinator(hass, call) + coordinator = __get_coordinator(call) start = __get_date(call.data.get(ATTR_START)) end = __get_date(call.data.get(ATTR_END)) @@ -152,14 +150,14 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, GAS_SERVICE_NAME, - partial(__get_prices, hass=hass, price_type=PriceType.GAS), + partial(__get_prices, price_type=PriceType.GAS), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_SERVICE_NAME, - partial(__get_prices, hass=hass, price_type=PriceType.ENERGY), + partial(__get_prices, price_type=PriceType.ENERGY), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/enigma2/config_flow.py b/homeassistant/components/enigma2/config_flow.py index e9502a0f7cd..b0649a8368d 100644 --- a/homeassistant/components/enigma2/config_flow.py +++ b/homeassistant/components/enigma2/config_flow.py @@ -133,7 +133,8 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): except Exception: # noqa: BLE001 errors = {"base": "unknown"} else: - await self.async_set_unique_id(about["info"]["ifaces"][0]["mac"]) + unique_id = about["info"]["ifaces"][0]["mac"] or self.unique_id + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return errors diff --git a/homeassistant/components/enigma2/coordinator.py b/homeassistant/components/enigma2/coordinator.py index a35e74f582f..d5bbf2c0ce5 100644 --- a/homeassistant/components/enigma2/coordinator.py +++ b/homeassistant/components/enigma2/coordinator.py @@ -35,6 +35,7 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]): """The Enigma2 data update coordinator.""" device: OpenWebIfDevice + unique_id: str | None def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the Enigma2 data update coordinator.""" @@ -64,6 +65,10 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]): name=config_entry.data[CONF_HOST], ) + # set the unique ID for the entities to the config entry unique ID + # for devices that don't report a MAC address + self.unique_id = config_entry.unique_id + async def _async_setup(self) -> None: """Provide needed data to the device info.""" @@ -71,16 +76,20 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]): self.device.mac_address = about["info"]["ifaces"][0]["mac"] self.device_info["model"] = about["info"]["model"] self.device_info["manufacturer"] = about["info"]["brand"] - self.device_info[ATTR_IDENTIFIERS] = { - (DOMAIN, format_mac(iface["mac"])) - for iface in about["info"]["ifaces"] - if "mac" in iface and iface["mac"] is not None - } - self.device_info[ATTR_CONNECTIONS] = { - (CONNECTION_NETWORK_MAC, format_mac(iface["mac"])) - for iface in about["info"]["ifaces"] - if "mac" in iface and iface["mac"] is not None - } + if self.device.mac_address is not None: + self.device_info[ATTR_IDENTIFIERS] = { + (DOMAIN, format_mac(iface["mac"])) + for iface in about["info"]["ifaces"] + if "mac" in iface and iface["mac"] is not None + } + self.device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, format_mac(iface["mac"])) + for iface in about["info"]["ifaces"] + if "mac" in iface and iface["mac"] is not None + } + self.unique_id = self.device.mac_address + elif self.unique_id is not None: + self.device_info[ATTR_IDENTIFIERS] = {(DOMAIN, self.unique_id)} async def _async_update_data(self) -> OpenWebIfStatus: await self.device.update() diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 1a0875b04c0..2bb299722b7 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["openwebif"], - "requirements": ["openwebifpy==4.2.7"] + "requirements": ["openwebifpy==4.3.1"] } diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 8287e055814..1012997ff7f 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -4,7 +4,6 @@ from __future__ import annotations import contextlib from logging import getLogger -from typing import cast from aiohttp.client_exceptions import ServerDisconnectedError from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption @@ -15,7 +14,6 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -58,6 +56,7 @@ class Enigma2Device(CoordinatorEntity[Enigma2UpdateCoordinator], MediaPlayerEnti | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.PLAY ) def __init__(self, coordinator: Enigma2UpdateCoordinator) -> None: @@ -65,10 +64,7 @@ class Enigma2Device(CoordinatorEntity[Enigma2UpdateCoordinator], MediaPlayerEnti super().__init__(coordinator) - self._attr_unique_id = ( - coordinator.device.mac_address - or cast(ConfigEntry, coordinator.config_entry).entry_id - ) + self._attr_unique_id = coordinator.unique_id self._attr_device_info = coordinator.device_info diff --git a/homeassistant/components/enocean/strings.json b/homeassistant/components/enocean/strings.json index 9d9699481b1..1a6f08cbf37 100644 --- a/homeassistant/components/enocean/strings.json +++ b/homeassistant/components/enocean/strings.json @@ -2,13 +2,13 @@ "config": { "step": { "detect": { - "title": "Select the path to your ENOcean dongle", + "title": "Select the path to your EnOcean dongle", "data": { "path": "USB dongle path" } }, "manual": { - "title": "Enter the path to your ENOcean dongle", + "title": "Enter the path to your EnOcean dongle", "data": { "path": "[%key:component::enocean::config::step::detect::data::path%]" } diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index db36cab1288..ba4aedf5013 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -51,8 +51,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> b # wait for the next discovery to find the device at its new address # and update the config entry so we do not mix up devices. raise ConfigEntryNotReady( - f"Unexpected device found at {host}; expected {entry.unique_id}, " - f"found {envoy.serial_number}" + translation_domain=DOMAIN, + translation_key="unexpected_device", + translation_placeholders={ + "host": host, + "expected_serial": str(entry.unique_id), + "actual_serial": str(envoy.serial_number), + }, ) entry.runtime_data = coordinator @@ -72,8 +77,9 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> bool: """Unload a config entry.""" - coordinator: EnphaseUpdateCoordinator = entry.runtime_data + coordinator = entry.runtime_data coordinator.async_cancel_token_refresh() + coordinator.async_cancel_firmware_refresh() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 6be29d19ecb..0258281661a 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -22,6 +22,8 @@ from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class EnvoyEnchargeBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -65,7 +67,6 @@ ENPOWER_SENSORS = ( EnvoyEnpowerBinarySensorEntityDescription( key="mains_oper_state", translation_key="grid_status", - icon="mdi:transmission-tower", value_fn=lambda enpower: enpower.mains_oper_state == "closed", ), ) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 23c769293c8..654e2262730 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -10,10 +10,8 @@ from awesomeversion import AwesomeVersion from pyenphase import AUTH_TOKEN_MIN_VERSION, Envoy, EnvoyError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -21,6 +19,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from .const import ( @@ -31,6 +30,7 @@ from .const import ( OPTION_DISABLE_KEEP_ALIVE, OPTION_DISABLE_KEEP_ALIVE_DEFAULT_VALUE, ) +from .coordinator import EnphaseConfigEntry _LOGGER = logging.getLogger(__name__) @@ -42,12 +42,28 @@ INSTALLER_AUTH_USERNAME = "installer" async def validate_input( - hass: HomeAssistant, host: str, username: str, password: str + hass: HomeAssistant, + host: str, + username: str, + password: str, + errors: dict[str, str], + description_placeholders: dict[str, str], ) -> Envoy: """Validate the user input allows us to connect.""" envoy = Envoy(host, get_async_client(hass, verify_ssl=False)) - await envoy.setup() - await envoy.authenticate(username=username, password=password) + try: + await envoy.setup() + await envoy.authenticate(username=username, password=password) + except INVALID_AUTH_ERRORS as e: + errors["base"] = "invalid_auth" + description_placeholders["reason"] = str(e) + except EnvoyError as e: + errors["base"] = "cannot_connect" + description_placeholders["reason"] = str(e) + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return envoy @@ -56,8 +72,6 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - _reauth_entry: ConfigEntry - def __init__(self) -> None: """Initialize an envoy flow.""" self.ip_address: str | None = None @@ -67,7 +81,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: EnphaseConfigEntry, ) -> EnvoyOptionsFlowHandler: """Options flow handler for Enphase_Envoy.""" return EnvoyOptionsFlowHandler() @@ -109,7 +123,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): } async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" if _LOGGER.isEnabledFor(logging.DEBUG): @@ -140,9 +154,13 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): and entry.data[CONF_HOST] == self.ip_address ): _LOGGER.debug( - "Zeroconf update envoy with this ip and blank serial in unique_id", + "Zeroconf update envoy with this ip and blank unique_id", ) - title = f"{ENVOY} {serial}" if entry.title == ENVOY else ENVOY + # Found an entry with blank unique_id (prior deleted) with same ip + # If the title is still default shorthand 'Envoy' then append serial + # to differentiate multiple Envoy. Don't change the title if any other + # title is still present in the old entry. + title = f"{ENVOY} {serial}" if entry.title == ENVOY else entry.title return self.async_update_reload_and_abort( entry, title=title, unique_id=serial, reason="already_configured" ) @@ -154,10 +172,43 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - self._reauth_entry = self._get_reauth_entry() - if unique_id := self._reauth_entry.unique_id: - await self.async_set_unique_id(unique_id, raise_on_progress=False) - return await self.async_step_user() + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + reauth_entry = self._get_reauth_entry() + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + + if user_input is not None: + await validate_input( + self.hass, + reauth_entry.data[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + errors, + description_placeholders, + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + serial = reauth_entry.unique_id or "-" + self.context["title_placeholders"] = { + CONF_SERIAL: serial, + CONF_HOST: reauth_entry.data[CONF_HOST], + } + description_placeholders["serial"] = serial + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self._async_generate_schema(), + description_placeholders=description_placeholders, + errors=errors, + ) def _async_envoy_name(self) -> str: """Return the name of the envoy.""" @@ -169,38 +220,20 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} - - if self.source == SOURCE_REAUTH: - host = self._reauth_entry.data[CONF_HOST] - else: - host = (user_input or {}).get(CONF_HOST) or self.ip_address or "" + host = (user_input or {}).get(CONF_HOST) or self.ip_address or "" if user_input is not None: - try: - envoy = await validate_input( - self.hass, - host, - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) - except INVALID_AUTH_ERRORS as e: - errors["base"] = "invalid_auth" - description_placeholders = {"reason": str(e)} - except EnvoyError as e: - errors["base"] = "cannot_connect" - description_placeholders = {"reason": str(e)} - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + envoy = await validate_input( + self.hass, + host, + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + errors, + description_placeholders, + ) + if not errors: name = self._async_envoy_name() - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._reauth_entry, - data=self._reauth_entry.data | user_input, - ) - if not self.unique_id: await self.async_set_unique_id(envoy.serial_number) name = self._async_envoy_name() @@ -246,23 +279,15 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): host: str = user_input[CONF_HOST] username: str = user_input[CONF_USERNAME] password: str = user_input[CONF_PASSWORD] - try: - envoy = await validate_input( - self.hass, - host, - username, - password, - ) - except INVALID_AUTH_ERRORS as e: - errors["base"] = "invalid_auth" - description_placeholders = {"reason": str(e)} - except EnvoyError as e: - errors["base"] = "cannot_connect" - description_placeholders = {"reason": str(e)} - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + envoy = await validate_input( + self.hass, + host, + username, + password, + errors, + description_placeholders, + ) + if not errors: await self.async_set_unique_id(envoy.serial_number) self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( @@ -274,10 +299,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + serial = reconfigure_entry.unique_id or "-" self.context["title_placeholders"] = { - CONF_SERIAL: reconfigure_entry.unique_id or "-", + CONF_SERIAL: serial, CONF_HOST: reconfigure_entry.data[CONF_HOST], } + description_placeholders["serial"] = serial suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data return self.async_show_form( diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 00bc7666f78..d92b998e731 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -18,13 +18,14 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed import homeassistant.util.dt as dt_util -from .const import INVALID_AUTH_ERRORS +from .const import DOMAIN, INVALID_AUTH_ERRORS SCAN_INTERVAL = timedelta(seconds=60) TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1) STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds() NOTIFICATION_ID = "enphase_envoy_notification" +FIRMWARE_REFRESH_INTERVAL = timedelta(hours=4) _LOGGER = logging.getLogger(__name__) @@ -37,6 +38,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): envoy_serial_number: str envoy_firmware: str + config_entry: EnphaseConfigEntry def __init__( self, hass: HomeAssistant, envoy: Envoy, entry: EnphaseConfigEntry @@ -44,12 +46,12 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Initialize DataUpdateCoordinator for the envoy.""" self.envoy = envoy entry_data = entry.data - self.entry = entry self.username = entry_data[CONF_USERNAME] self.password = entry_data[CONF_PASSWORD] self._setup_complete = False self.envoy_firmware = "" self._cancel_token_refresh: CALLBACK_TYPE | None = None + self._cancel_firmware_refresh: CALLBACK_TYPE | None = None super().__init__( hass, _LOGGER, @@ -87,10 +89,48 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): return self._async_update_saved_token() + @callback + def _async_refresh_firmware(self, now: datetime.datetime) -> None: + """Proactively check for firmware changes in Envoy.""" + self.hass.async_create_background_task( + self._async_try_refresh_firmware(), "{name} firmware refresh" + ) + + async def _async_try_refresh_firmware(self) -> None: + """Check firmware in Envoy and reload config entry if changed.""" + # envoy.setup just reads firmware, serial and partnumber from /info + try: + await self.envoy.setup() + except EnvoyError as err: + # just try again next time + _LOGGER.debug("%s: Error reading firmware: %s", err, self.name) + return + if (current_firmware := self.envoy_firmware) and current_firmware != ( + new_firmware := self.envoy.firmware + ): + self.envoy_firmware = new_firmware + _LOGGER.warning( + "Envoy firmware changed from: %s to: %s, reloading config entry %s", + current_firmware, + new_firmware, + self.name, + ) + # reload the integration to get all established again + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + @callback def _async_mark_setup_complete(self) -> None: - """Mark setup as complete and setup token refresh if needed.""" + """Mark setup as complete and setup firmware checks and token refresh if needed.""" self._setup_complete = True + self.async_cancel_firmware_refresh() + self._cancel_firmware_refresh = async_track_time_interval( + self.hass, + self._async_refresh_firmware, + FIRMWARE_REFRESH_INTERVAL, + cancel_on_shutdown=True, + ) self.async_cancel_token_refresh() if not isinstance(self.envoy.auth, EnvoyTokenAuth): return @@ -107,7 +147,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): await envoy.setup() assert envoy.serial_number is not None self.envoy_serial_number = envoy.serial_number - if token := self.entry.data.get(CONF_TOKEN): + if token := self.config_entry.data.get(CONF_TOKEN): with contextlib.suppress(*INVALID_AUTH_ERRORS): # Always set the username and password # so we can refresh the token if needed @@ -136,9 +176,9 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # as long as the token is valid _LOGGER.debug("%s: Updating token in config entry from auth", self.name) self.hass.config_entries.async_update_entry( - self.entry, + self.config_entry, data={ - **self.entry.data, + **self.config_entry.data, CONF_TOKEN: envoy.auth.token, }, ) @@ -158,9 +198,23 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # token likely expired or firmware changed, try to re-authenticate self._setup_complete = False continue - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + translation_placeholders={ + "host": envoy.host, + "args": err.args[0], + }, + ) from err except EnvoyError as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="envoy_error", + translation_placeholders={ + "host": envoy.host, + "args": err.args[0], + }, + ) from err # if we have a firmware version from previous setup, compare to current one # when envoy gets new firmware there will be an authentication failure @@ -175,7 +229,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) # reload the integration to get all established again self.hass.async_create_task( - self.hass.config_entries.async_reload(self.entry.entry_id) + self.hass.config_entries.async_reload(self.config_entry.entry_id) ) # remember firmware version for next time self.envoy_firmware = envoy.firmware @@ -190,3 +244,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): if self._cancel_token_refresh: self._cancel_token_refresh() self._cancel_token_refresh = None + + @callback + def async_cancel_firmware_refresh(self) -> None: + """Cancel firmware refresh.""" + if self._cancel_firmware_refresh: + self._cancel_firmware_refresh() + self._cancel_firmware_refresh = None diff --git a/homeassistant/components/enphase_envoy/icons.json b/homeassistant/components/enphase_envoy/icons.json new file mode 100644 index 00000000000..21262d1dc89 --- /dev/null +++ b/homeassistant/components/enphase_envoy/icons.json @@ -0,0 +1,58 @@ +{ + "entity": { + "binary_sensor": { + "grid_status": { + "default": "mdi:transmission-tower", + "state": { + "off": "mdi:transmission-tower-off" + } + } + }, + "sensor": { + "current_power_production": { + "default": "mdi:solar-power" + }, + "daily_production": { + "default": "mdi:solar-power" + }, + "seven_days_production": { + "default": "mdi:solar-power" + }, + "lifetime_production": { + "default": "mdi:solar-power" + }, + "current_power_production_phase": { + "default": "mdi:solar-power" + }, + "daily_production_phase": { + "default": "mdi:solar-power" + }, + "seven_days_production_phase": { + "default": "mdi:solar-power" + }, + "lifetime_production_phase": { + "default": "mdi:solar-power" + }, + "max_capacity": { + "default": "mdi:battery-charging-100" + }, + "available_energy": { + "default": "mdi:battery-50" + } + }, + "switch": { + "grid_enabled": { + "default": "mdi:transmission-tower", + "state": { + "off": "mdi:transmission-tower-off" + } + }, + "relay_status": { + "default": "mdi:electric-switch-closed", + "state": { + "off": "mdi:electric-switch" + } + } + } + } +} diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index bdc90e6c634..0b1fd8b04b9 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.23.0"], + "requirements": ["pyenphase==1.23.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index f27335b1f4c..a62913a4c0b 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -25,6 +25,8 @@ from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class EnvoyRelayNumberEntityDescription(NumberEntityDescription): diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml new file mode 100644 index 00000000000..4431a298c8c --- /dev/null +++ b/homeassistant/components/enphase_envoy/quality_scale.yaml @@ -0,0 +1,94 @@ +rules: + # Bronze + action-setup: + status: done + comment: only actions implemented are platform native ones. + appropriate-polling: + status: done + comment: fixed 1 minute cycle based on Enphase Envoy device characteristics + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: done + comment: https://www.home-assistant.io/integrations/enphase_envoy/#actions + docs-high-level-description: + status: done + comment: https://www.home-assistant.io/integrations/enphase_envoy + docs-installation-instructions: + status: done + comment: https://www.home-assistant.io/integrations/enphase_envoy#prerequisites + docs-removal-instructions: + status: done + comment: https://www.home-assistant.io/integrations/enphase_envoy#removing-the-integration + entity-event-setup: + status: done + comment: no events used. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: todo + comment: | + needs to raise appropriate error when exception occurs. + Pending https://github.com/pyenphase/pyenphase/pull/194 + config-entry-unloading: done + docs-configuration-parameters: + status: done + comment: https://www.home-assistant.io/integrations/enphase_envoy#configuration + docs-installation-parameters: + status: done + comment: https://www.home-assistant.io/integrations/enphase_envoy#required-manual-input + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: done + comment: pending https://github.com/home-assistant/core/pull/132373 + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: + status: done + comment: https://www.home-assistant.io/integrations/enphase_envoy#data-updates + docs-examples: + status: todo + comment: add blue-print examples, if any + docs-known-limitations: todo + docs-supported-devices: + status: done + comment: https://www.home-assistant.io/integrations/enphase_envoy#supported-devices + docs-supported-functions: todo + docs-troubleshooting: + status: done + comment: https://www.home-assistant.io/integrations/enphase_envoy#troubleshooting + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: no general issues or repair.py + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 903c2c1edf6..7dc275aab37 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -20,6 +20,8 @@ from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class EnvoyRelaySelectEntityDescription(SelectEntityDescription): @@ -35,7 +37,7 @@ class EnvoyRelaySelectEntityDescription(SelectEntityDescription): class EnvoyStorageSettingsSelectEntityDescription(SelectEntityDescription): """Describes an Envoy storage settings select entity.""" - value_fn: Callable[[EnvoyStorageSettings], str] + value_fn: Callable[[EnvoyStorageSettings], str | None] update_fn: Callable[[Envoy, str], Awaitable[dict[str, Any]]] @@ -116,7 +118,9 @@ STORAGE_MODE_ENTITY = EnvoyStorageSettingsSelectEntityDescription( key="storage_mode", translation_key="storage_mode", options=STORAGE_MODE_OPTIONS, - value_fn=lambda storage_settings: STORAGE_MODE_MAP[storage_settings.mode], + value_fn=lambda storage_settings: ( + None if not storage_settings.mode else STORAGE_MODE_MAP[storage_settings.mode] + ), update_fn=lambda envoy, value: envoy.set_storage_mode( REVERSE_STORAGE_MODE_MAP[value] ), @@ -233,7 +237,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): ) @property - def current_option(self) -> str: + def current_option(self) -> str | None: """Return the state of the select entity.""" assert self.data.tariff is not None assert self.data.tariff.storage_settings is not None diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 20d610e4b71..dcf062a5417 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -10,6 +10,8 @@ from operator import attrgetter from typing import TYPE_CHECKING from pyenphase import ( + EnvoyACBPower, + EnvoyBatteryAggregate, EnvoyEncharge, EnvoyEnchargeAggregate, EnvoyEnchargePower, @@ -35,6 +37,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -53,12 +56,13 @@ from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity -ICON = "mdi:flash" _LOGGER = logging.getLogger(__name__) INVERTERS_KEY = "inverters" LAST_REPORTED_KEY = "last_reported" +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class EnvoyInverterSensorEntityDescription(SensorEntityDescription): @@ -366,6 +370,7 @@ CT_NET_CONSUMPTION_SENSORS = ( key="net_consumption_ct_metering_status", translation_key="net_ct_metering_status", device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, options=list(CtMeterStatus), entity_registry_enabled_default=False, value_fn=attrgetter("metering_status"), @@ -375,6 +380,7 @@ CT_NET_CONSUMPTION_SENSORS = ( key="net_consumption_ct_status_flags", translation_key="net_ct_status_flags", state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), on_phase=None, @@ -448,6 +454,7 @@ CT_PRODUCTION_SENSORS = ( translation_key="production_ct_metering_status", device_class=SensorDeviceClass.ENUM, options=list(CtMeterStatus), + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=attrgetter("metering_status"), on_phase=None, @@ -456,6 +463,7 @@ CT_PRODUCTION_SENSORS = ( key="production_ct_status_flags", translation_key="production_ct_status_flags", state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), on_phase=None, @@ -561,6 +569,7 @@ CT_STORAGE_SENSORS = ( translation_key="storage_ct_metering_status", device_class=SensorDeviceClass.ENUM, options=list(CtMeterStatus), + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=attrgetter("metering_status"), on_phase=None, @@ -569,6 +578,7 @@ CT_STORAGE_SENSORS = ( key="storage_ct_status_flags", translation_key="storage_ct_status_flags", state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), on_phase=None, @@ -721,6 +731,78 @@ ENCHARGE_AGGREGATE_SENSORS = ( ) +@dataclass(frozen=True, kw_only=True) +class EnvoyAcbBatterySensorEntityDescription(SensorEntityDescription): + """Describes an Envoy ACB Battery sensor entity.""" + + value_fn: Callable[[EnvoyACBPower], int | str] + + +ACB_BATTERY_POWER_SENSORS = ( + EnvoyAcbBatterySensorEntityDescription( + key="acb_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + value_fn=attrgetter("power"), + ), + EnvoyAcbBatterySensorEntityDescription( + key="acb_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + value_fn=attrgetter("state_of_charge"), + ), + EnvoyAcbBatterySensorEntityDescription( + key="acb_battery_state", + translation_key="acb_battery_state", + device_class=SensorDeviceClass.ENUM, + options=["discharging", "idle", "charging", "full"], + value_fn=attrgetter("state"), + ), +) + +ACB_BATTERY_ENERGY_SENSORS = ( + EnvoyAcbBatterySensorEntityDescription( + key="acb_available_energy", + translation_key="acb_available_energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + value_fn=attrgetter("charge_wh"), + ), +) + + +@dataclass(frozen=True, kw_only=True) +class EnvoyAggregateBatterySensorEntityDescription(SensorEntityDescription): + """Describes an Envoy aggregate Ensemble and ACB Battery sensor entity.""" + + value_fn: Callable[[EnvoyBatteryAggregate], int] + + +AGGREGATE_BATTERY_SENSORS = ( + EnvoyAggregateBatterySensorEntityDescription( + key="aggregated_soc", + translation_key="aggregated_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + value_fn=attrgetter("state_of_charge"), + ), + EnvoyAggregateBatterySensorEntityDescription( + key="aggregated_available_energy", + translation_key="aggregated_available_energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + value_fn=attrgetter("available_energy"), + ), + EnvoyAggregateBatterySensorEntityDescription( + key="aggregated_max_battery_capacity", + translation_key="aggregated_max_capacity", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + value_fn=attrgetter("max_available_capacity"), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: EnphaseConfigEntry, @@ -845,6 +927,20 @@ async def async_setup_entry( EnvoyEnpowerEntity(coordinator, description) for description in ENPOWER_SENSORS ) + if envoy_data.acb_power: + entities.extend( + EnvoyAcbBatteryPowerEntity(coordinator, description) + for description in ACB_BATTERY_POWER_SENSORS + ) + entities.extend( + EnvoyAcbBatteryEnergyEntity(coordinator, description) + for description in ACB_BATTERY_ENERGY_SENSORS + ) + if envoy_data.battery_aggregate: + entities.extend( + AggregateBatteryEntity(coordinator, description) + for description in AGGREGATE_BATTERY_SENSORS + ) async_add_entities(entities) @@ -856,8 +952,6 @@ class EnvoySensorBaseEntity(EnvoyBaseEntity, SensorEntity): class EnvoySystemSensorEntity(EnvoySensorBaseEntity): """Envoy system base entity.""" - _attr_icon = ICON - def __init__( self, coordinator: EnphaseUpdateCoordinator, @@ -1084,7 +1178,6 @@ class EnvoyStorageCTPhaseEntity(EnvoySystemSensorEntity): class EnvoyInverterEntity(EnvoySensorBaseEntity): """Envoy inverter entity.""" - _attr_icon = ICON entity_description: EnvoyInverterSensorEntityDescription def __init__( @@ -1226,3 +1319,60 @@ class EnvoyEnpowerEntity(EnvoySensorBaseEntity): enpower = self.data.enpower assert enpower is not None return self.entity_description.value_fn(enpower) + + +class EnvoyAcbBatteryPowerEntity(EnvoySensorBaseEntity): + """Envoy ACB Battery power sensor entity.""" + + entity_description: EnvoyAcbBatterySensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyAcbBatterySensorEntityDescription, + ) -> None: + """Initialize ACB Battery entity.""" + super().__init__(coordinator, description) + acb_data = self.data.acb_power + assert acb_data is not None + self._attr_unique_id = f"{self.envoy_serial_num}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self.envoy_serial_num}_acb")}, + manufacturer="Enphase", + model="ACB", + name=f"ACB {self.envoy_serial_num}", + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def native_value(self) -> int | str | None: + """Return the state of the ACB Battery power sensors.""" + acb = self.data.acb_power + assert acb is not None + return self.entity_description.value_fn(acb) + + +class EnvoyAcbBatteryEnergyEntity(EnvoySystemSensorEntity): + """Envoy combined ACB and Ensemble Battery Aggregate energy sensor entity.""" + + entity_description: EnvoyAcbBatterySensorEntityDescription + + @property + def native_value(self) -> int | str: + """Return the state of the aggregate energy sensors.""" + acb = self.data.acb_power + assert acb is not None + return self.entity_description.value_fn(acb) + + +class AggregateBatteryEntity(EnvoySystemSensorEntity): + """Envoy combined ACB and Ensemble Battery Aggregate sensor entity.""" + + entity_description: EnvoyAggregateBatterySensorEntityDescription + + @property + def native_value(self) -> int: + """Return the state of the aggregate sensors.""" + battery_aggregate = self.data.battery_aggregate + assert battery_aggregate is not None + return self.entity_description.value_fn(battery_aggregate) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 2d91b3b0960..589dc52f71d 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -10,7 +10,9 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your Enphase Envoy gateway." + "host": "The hostname or IP address of your Enphase Envoy gateway.", + "username": "Installer or Enphase Cloud username", + "password": "Blank or Enphase Cloud password" } }, "reconfigure": { @@ -21,7 +23,20 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]" + "host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]", + "username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]", + "password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]" + } + }, + "reauth_confirm": { + "description": "[%key:component::enphase_envoy::config::step::user::description%]", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]", + "password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]" } } }, @@ -44,6 +59,10 @@ "data": { "diagnostics_include_fixtures": "Include test fixture data in diagnostic report. Use when requested to provide test data for troubleshooting or development activies. With this option enabled the diagnostic report may take more time to download. When report is created best disable this option again.", "disable_keep_alive": "Always use a new connection when requesting data from the Envoy. May resolve communication issues with some Envoy firmwares." + }, + "data_description": { + "diagnostics_include_fixtures": "Include fixtures in diagnostics report", + "disable_keep_alive": "May resolve communication issues with some Envoy firmwares." } } } @@ -337,6 +356,30 @@ }, "configured_reserve_soc": { "name": "Configured reserve battery level" + }, + "acb_battery_state": { + "name": "Battery state", + "state": { + "discharging": "Discharging", + "idle": "[%key:common::state::idle%]", + "charging": "Charging", + "full": "Full" + } + }, + "acb_available_energy": { + "name": "Available ACB battery energy" + }, + "acb_max_capacity": { + "name": "ACB Battery capacity" + }, + "aggregated_available_energy": { + "name": "Aggregated available battery energy" + }, + "aggregated_max_capacity": { + "name": "Aggregated Battery capacity" + }, + "aggregated_soc": { + "name": "Aggregated battery soc" } }, "switch": { @@ -347,5 +390,16 @@ "name": "Grid enabled" } } + }, + "exceptions": { + "unexpected_device": { + "message": "Unexpected Envoy serial-number found at {host}; expected {expected_serial}, found {actual_serial}" + }, + "authentication_error": { + "message": "Envoy authentication failure on {host}: {args}" + }, + "envoy_error": { + "message": "Error communicating with Envoy API on {host}: {args}" + } } } diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 14451aaf266..7074f341cc8 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -20,6 +20,8 @@ from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class EnvoyEnpowerSwitchEntityDescription(SwitchEntityDescription): @@ -58,6 +60,7 @@ ENPOWER_GRID_SWITCH = EnvoyEnpowerSwitchEntityDescription( RELAY_STATE_SWITCH = EnvoyDryContactSwitchEntityDescription( key="relay_status", + translation_key="relay_status", value_fn=lambda dry_contact: dry_contact.status == DryContactStatus.CLOSED, turn_on_fn=lambda envoy, id: envoy.close_dry_contact(id), turn_off_fn=lambda envoy, id: envoy.open_dry_contact(id), diff --git a/homeassistant/components/envisalink/strings.json b/homeassistant/components/envisalink/strings.json index a539c890169..265ce28f920 100644 --- a/homeassistant/components/envisalink/strings.json +++ b/homeassistant/components/envisalink/strings.json @@ -16,11 +16,11 @@ }, "invoke_custom_function": { "name": "Invoke custom function", - "description": "Allows users with DSC panels to trigger a PGM output (1-4). Note that you need to specify the alarm panel's \"code\" parameter for this to work.\n.", + "description": "Allows users with DSC panels to trigger a PGM output (1-4). Note that you need to specify the alarm panel's \"code\" parameter for this to work.", "fields": { "partition": { "name": "Partition", - "description": "The alarm panel partition to trigger the PGM output on. Typically this is just \"1\".\n." + "description": "The alarm panel partition to trigger the PGM output on. Typically this is just \"1\"." }, "pgm": { "name": "PGM", diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index 44e5986970d..cedad8b76e2 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -84,7 +84,6 @@ class EphEmberThermostat(ClimateEntity): _attr_hvac_modes = OPERATION_LIST _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, ember, zone): """Initialize the thermostat.""" diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index ed80ad9aabf..43f524516a8 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.1.1"] } diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py index 555da1494d7..c3fb0015e68 100644 --- a/homeassistant/components/escea/climate.py +++ b/homeassistant/components/escea/climate.py @@ -89,7 +89,6 @@ class ControllerEntity(ClimateEntity): ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 13e9496a9fd..5934c9a6f68 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations from aioesphomeapi import APIClient from homeassistant.components import ffmpeg, zeroconf +from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -86,4 +87,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None: """Remove an esphome config entry.""" + if mac_address := entry.unique_id: + async_remove_scanner(hass, mac_address.upper()) await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index dc513a03e02..f60668b0a06 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -95,11 +95,7 @@ async def async_setup_entry( if entry_data.device_info.voice_assistant_feature_flags_compat( entry_data.api_version ): - async_add_entities( - [ - EsphomeAssistSatellite(entry, entry_data), - ] - ) + async_add_entities([EsphomeAssistSatellite(entry, entry_data)]) class EsphomeAssistSatellite( @@ -198,6 +194,9 @@ class EsphomeAssistSatellite( self._satellite_config.max_active_wake_words = config.max_active_wake_words _LOGGER.debug("Received satellite configuration: %s", self._satellite_config) + # Inform listeners that config has been updated + self.entry_data.async_assist_satellite_config_updated(self._satellite_config) + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -254,6 +253,13 @@ class EsphomeAssistSatellite( # Will use media player for TTS/announcements self._update_tts_format() + # Update wake word select when config is updated + self.async_on_remove( + self.entry_data.async_register_assist_satellite_set_wake_word_callback( + self.async_set_wake_word + ) + ) + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() @@ -478,6 +484,17 @@ class EsphomeAssistSatellite( """Handle announcement finished message (also sent for TTS).""" self.tts_response_finished() + @callback + def async_set_wake_word(self, wake_word_id: str) -> None: + """Set active wake word and update config on satellite.""" + self._satellite_config.active_wake_words = [wake_word_id] + self.config_entry.async_create_background_task( + self.hass, + self.async_set_configuration(self._satellite_config), + "esphome_voice_assistant_set_config", + ) + _LOGGER.debug("Setting active wake word: %s", wake_word_id) + def _update_tts_format(self) -> None: """Update the TTS format from the first media player.""" for supported_format in chain(*self.entry_data.media_player_formats.values()): diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py index 37ae28df0ca..da342913d3d 100644 --- a/homeassistant/components/esphome/bluetooth.py +++ b/homeassistant/components/esphome/bluetooth.py @@ -7,11 +7,11 @@ from typing import TYPE_CHECKING from aioesphomeapi import APIClient, DeviceInfo from bleak_esphome import connect_scanner -from bleak_esphome.backend.cache import ESPHomeBluetoothCache from homeassistant.components.bluetooth import async_register_scanner from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback +from .const import DOMAIN from .entry_data import RuntimeEntryData @@ -28,10 +28,9 @@ def async_connect_scanner( entry_data: RuntimeEntryData, cli: APIClient, device_info: DeviceInfo, - cache: ESPHomeBluetoothCache, ) -> CALLBACK_TYPE: """Connect scanner.""" - client_data = connect_scanner(cli, device_info, cache, entry_data.available) + client_data = connect_scanner(cli, device_info, entry_data.available) entry_data.bluetooth_device = client_data.bluetooth_device client_data.disconnect_callbacks = entry_data.disconnect_callbacks scanner = client_data.scanner @@ -40,7 +39,13 @@ def async_connect_scanner( return partial( _async_unload, [ - async_register_scanner(hass, scanner), + async_register_scanner( + hass, + scanner, + source_domain=DOMAIN, + source_model=device_info.model, + source_config_entry_id=entry_data.entry_id, + ), scanner.async_setup(), ], ) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 1b9b53f24cd..478ce9bae2c 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -129,7 +129,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "climate" - _enable_turn_on_off_backwards_compatibility = False @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: @@ -231,6 +230,8 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti @esphome_float_state_property def current_temperature(self) -> float | None: """Return the current temperature.""" + if not self._static_info.supports_current_temperature: + return None return self._state.current_temperature @property diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index cb892b314cd..695131b19f7 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -20,7 +20,7 @@ from aioesphomeapi import ( import aiohttp import voluptuous as vol -from homeassistant.components import dhcp, zeroconf +from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -31,8 +31,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.json import json_loads_object from .const import ( @@ -223,7 +225,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" mac_address: str | None = discovery_info.properties.get("mac") @@ -293,7 +295,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index b0a37aefd0d..334c16e5730 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -12,6 +12,7 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN from .coordinator import ESPHomeDashboardCoordinator @@ -19,7 +20,9 @@ from .coordinator import ESPHomeDashboardCoordinator _LOGGER = logging.getLogger(__name__) -KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager" +KEY_DASHBOARD_MANAGER: HassKey[ESPHomeDashboardManager] = HassKey( + "esphome_dashboard_manager" +) STORAGE_KEY = "esphome.dashboard" STORAGE_VERSION = 1 @@ -33,7 +36,7 @@ async def async_setup(hass: HomeAssistant) -> None: await async_get_or_create_dashboard_manager(hass) -@singleton(KEY_DASHBOARD_MANAGER) +@singleton(KEY_DASHBOARD_MANAGER, async_=True) async def async_get_or_create_dashboard_manager( hass: HomeAssistant, ) -> ESPHomeDashboardManager: @@ -140,7 +143,7 @@ def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboardCoordinator | No where manager can be an asyncio.Event instead of the actual manager because the singleton decorator is not yet done. """ - manager: ESPHomeDashboardManager | None = hass.data.get(KEY_DASHBOARD_MANAGER) + manager = hass.data.get(KEY_DASHBOARD_MANAGER) return manager.async_get() if manager else None diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index aa46469c40e..ed307b46fd6 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -6,8 +6,6 @@ from dataclasses import dataclass, field from functools import cache from typing import Self -from bleak_esphome.backend.cache import ESPHomeBluetoothCache - from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder @@ -22,9 +20,6 @@ class DomainData: """Define a class that stores global esphome data in hass.data[DOMAIN].""" _stores: dict[str, ESPHomeStorage] = field(default_factory=dict) - bluetooth_cache: ESPHomeBluetoothCache = field( - default_factory=ESPHomeBluetoothCache - ) def get_entry_data(self, entry: ESPHomeConfigEntry) -> RuntimeEntryData: """Return the runtime entry data associated with this config entry. diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index f1b5218eec7..fc41ee99a00 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -48,6 +48,7 @@ from aioesphomeapi import ( from aioesphomeapi.model import ButtonInfo from bleak_esphome.backend.device import ESPHomeBluetoothDevice +from homeassistant.components.assist_satellite import AssistSatelliteConfiguration from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -152,6 +153,12 @@ class RuntimeEntryData: media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field( default_factory=lambda: defaultdict(list) ) + assist_satellite_config_update_callbacks: list[ + Callable[[AssistSatelliteConfiguration], None] + ] = field(default_factory=list) + assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field( + default_factory=list + ) @property def name(self) -> str: @@ -504,3 +511,35 @@ class RuntimeEntryData: # We use this to determine if a deep sleep device should # be marked as unavailable or not. self.expected_disconnect = True + + @callback + def async_register_assist_satellite_config_updated_callback( + self, + callback_: Callable[[AssistSatelliteConfiguration], None], + ) -> CALLBACK_TYPE: + """Register to receive callbacks when the Assist satellite's configuration is updated.""" + self.assist_satellite_config_update_callbacks.append(callback_) + return lambda: self.assist_satellite_config_update_callbacks.remove(callback_) + + @callback + def async_assist_satellite_config_updated( + self, config: AssistSatelliteConfiguration + ) -> None: + """Notify listeners that the Assist satellite configuration has been updated.""" + for callback_ in self.assist_satellite_config_update_callbacks.copy(): + callback_(config) + + @callback + def async_register_assist_satellite_set_wake_word_callback( + self, + callback_: Callable[[str], None], + ) -> CALLBACK_TYPE: + """Register to receive callbacks when the Assist satellite's wake word is set.""" + self.assist_satellite_set_wake_word_callbacks.append(callback_) + return lambda: self.assist_satellite_set_wake_word_callbacks.remove(callback_) + + @callback + def async_assist_satellite_set_wake_word(self, wake_word_id: str) -> None: + """Notify listeners that the Assist satellite wake word has been set.""" + for callback_ in self.assist_satellite_set_wake_word_callbacks.copy(): + callback_(wake_word_id) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 454c5edf030..c09145c17b5 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -45,7 +45,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """A fan implementation for ESPHome.""" _supports_speed_levels: bool = True - _enable_turn_on_off_backwards_compatibility = False async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py index 2dacae52f75..9484d1e7593 100644 --- a/homeassistant/components/esphome/ffmpeg_proxy.py +++ b/homeassistant/components/esphome/ffmpeg_proxy.py @@ -212,6 +212,10 @@ class FFmpegConvertResponse(web.StreamResponse): assert proc.stdout is not None assert proc.stderr is not None + stderr_task = self.hass.async_create_background_task( + self._dump_ffmpeg_stderr(proc), "ESPHome media proxy dump stderr" + ) + try: # Pull audio chunks from ffmpeg and pass them to the HTTP client while ( @@ -230,18 +234,14 @@ class FFmpegConvertResponse(web.StreamResponse): raise # don't log error except: _LOGGER.exception("Unexpected error during ffmpeg conversion") - - # Process did not exit successfully - stderr_text = "" - while line := await proc.stderr.readline(): - stderr_text += line.decode() - _LOGGER.error("FFmpeg output: %s", stderr_text) - raise finally: # Allow conversion info to be removed self.convert_info.is_finished = True + # stop dumping ffmpeg stderr task + stderr_task.cancel() + # Terminate hangs, so kill is used if proc.returncode is None: proc.kill() @@ -250,6 +250,16 @@ class FFmpegConvertResponse(web.StreamResponse): if request.transport and not request.transport.is_closing(): await writer.write_eof() + async def _dump_ffmpeg_stderr( + self, + proc: asyncio.subprocess.Process, + ) -> None: + assert proc.stdout is not None + assert proc.stderr is not None + + while self.hass.is_running and (chunk := await proc.stderr.readline()): + _LOGGER.debug("ffmpeg[%s] output: %s", proc.pid, chunk.decode().rstrip()) + class FFmpegProxyView(HomeAssistantView): """FFmpeg web view to convert audio and stream back to client.""" diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 52f999afe4f..8fecf34862b 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -414,11 +414,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): self._attr_supported_color_modes = supported self._attr_effect_list = static_info.effects - self._attr_min_mireds = round(static_info.min_mireds) - self._attr_max_mireds = round(static_info.max_mireds) - if ColorMode.COLOR_TEMP in supported: - self._attr_min_color_temp_kelvin = _mired_to_kelvin(static_info.max_mireds) - self._attr_max_color_temp_kelvin = _mired_to_kelvin(static_info.min_mireds) + self._attr_min_color_temp_kelvin = _mired_to_kelvin(static_info.max_mireds) + self._attr_max_color_temp_kelvin = _mired_to_kelvin(static_info.min_mireds) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 007b4e791e1..b382622281e 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -24,7 +24,7 @@ from aioesphomeapi import ( from awesomeversion import AwesomeVersion import voluptuous as vol -from homeassistant.components import tag, zeroconf +from homeassistant.components import bluetooth, tag, zeroconf from homeassistant.const import ( ATTR_DEVICE_ID, CONF_MODE, @@ -134,16 +134,16 @@ class ESPHomeManager: """Class to manage an ESPHome connection.""" __slots__ = ( - "hass", - "host", - "password", - "entry", "cli", "device_id", "domain_data", + "entry", + "entry_data", + "hass", + "host", + "password", "reconnect_logic", "zeroconf_instance", - "entry_data", ) def __init__( @@ -423,10 +423,10 @@ class ESPHomeManager: if device_info.bluetooth_proxy_feature_flags_compat(api_version): entry_data.disconnect_callbacks.add( - async_connect_scanner( - hass, entry_data, cli, device_info, self.domain_data.bluetooth_cache - ) + async_connect_scanner(hass, entry_data, cli, device_info) ) + else: + bluetooth.async_remove_scanner(hass, device_info.mac_address) if device_info.voice_assistant_feature_flags_compat(api_version) and ( Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 5524e87e2a8..4682be1c5c7 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,9 +16,9 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==27.0.2", + "aioesphomeapi==28.0.1", "esphome-dashboard-api==1.2.3", - "bleak-esphome==1.1.0" + "bleak-esphome==2.1.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 623946503eb..71a21186d3d 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -8,8 +8,11 @@ from homeassistant.components.assist_pipeline.select import ( AssistPipelineSelect, VadSensitivitySelect, ) -from homeassistant.components.select import SelectEntity +from homeassistant.components.assist_satellite import AssistSatelliteConfiguration +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import restore_state from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -47,6 +50,7 @@ async def async_setup_entry( [ EsphomeAssistPipelineSelect(hass, entry_data), EsphomeVadSensitivitySelect(hass, entry_data), + EsphomeAssistSatelliteWakeWordSelect(hass, entry_data), ] ) @@ -89,3 +93,77 @@ class EsphomeVadSensitivitySelect(EsphomeAssistEntity, VadSensitivitySelect): """Initialize a VAD sensitivity selector.""" EsphomeAssistEntity.__init__(self, entry_data) VadSensitivitySelect.__init__(self, hass, self._device_info.mac_address) + + +class EsphomeAssistSatelliteWakeWordSelect( + EsphomeAssistEntity, SelectEntity, restore_state.RestoreEntity +): + """Wake word selector for esphome devices.""" + + entity_description = SelectEntityDescription( + key="wake_word", + translation_key="wake_word", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_current_option: str | None = None + _attr_options: list[str] = [] + + def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: + """Initialize a wake word selector.""" + EsphomeAssistEntity.__init__(self, entry_data) + + unique_id_prefix = self._device_info.mac_address + self._attr_unique_id = f"{unique_id_prefix}-wake_word" + + # name -> id + self._wake_words: dict[str, str] = {} + + @property + def available(self) -> bool: + """Return if entity is available.""" + return bool(self._attr_options) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + + # Update options when config is updated + self.async_on_remove( + self._entry_data.async_register_assist_satellite_config_updated_callback( + self.async_satellite_config_updated + ) + ) + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + if wake_word_id := self._wake_words.get(option): + # _attr_current_option will be updated on + # async_satellite_config_updated after the device sets the wake + # word. + self._entry_data.async_assist_satellite_set_wake_word(wake_word_id) + + def async_satellite_config_updated( + self, config: AssistSatelliteConfiguration + ) -> None: + """Update options with available wake words.""" + if (not config.available_wake_words) or (config.max_active_wake_words < 1): + self._attr_current_option = None + self._wake_words.clear() + self.async_write_ha_state() + return + + self._wake_words = {w.wake_word: w.id for w in config.available_wake_words} + self._attr_options = sorted(self._wake_words) + + if config.active_wake_words: + # Select first active wake word + wake_word_id = config.active_wake_words[0] + for wake_word in config.available_wake_words: + if wake_word.id == wake_word_id: + self._attr_current_option = wake_word.wake_word + else: + # Select first available wake word + self._attr_current_option = config.available_wake_words[0].wake_word + + self.async_write_ha_state() diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 971a489a9e2..81b58de8df2 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -84,6 +84,12 @@ "aggressive": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::aggressive%]", "relaxed": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::relaxed%]" } + }, + "wake_word": { + "name": "Wake word", + "state": { + "okay_nabu": "Okay Nabu" + } } }, "climate": { diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py index c1506c00cdc..95ad8a15d1c 100644 --- a/homeassistant/components/eufy/light.py +++ b/homeassistant/components/eufy/light.py @@ -8,7 +8,7 @@ import lakeside from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -17,10 +17,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.color as color_util -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_mired_to_kelvin as mired_to_kelvin, -) EUFYHOME_MAX_KELVIN = 6500 EUFYHOME_MIN_KELVIN = 2700 @@ -41,6 +37,9 @@ def setup_platform( class EufyHomeLight(LightEntity): """Representation of a EufyHome light.""" + _attr_min_color_temp_kelvin = EUFYHOME_MIN_KELVIN + _attr_max_color_temp_kelvin = EUFYHOME_MAX_KELVIN + def __init__(self, device): """Initialize the light.""" @@ -96,23 +95,12 @@ class EufyHomeLight(LightEntity): return int(self._brightness * 255 / 100) @property - def min_mireds(self) -> int: - """Return minimum supported color temperature.""" - return kelvin_to_mired(EUFYHOME_MAX_KELVIN) - - @property - def max_mireds(self) -> int: - """Return maximum supported color temperature.""" - return kelvin_to_mired(EUFYHOME_MIN_KELVIN) - - @property - def color_temp(self): - """Return the color temperature of this light.""" - temp_in_k = int( + def color_temp_kelvin(self) -> int: + """Return the color temperature value in Kelvin.""" + return int( EUFYHOME_MIN_KELVIN + (self._temp * (EUFYHOME_MAX_KELVIN - EUFYHOME_MIN_KELVIN) / 100) ) - return kelvin_to_mired(temp_in_k) @property def hs_color(self): @@ -134,7 +122,7 @@ class EufyHomeLight(LightEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the specified light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - colortemp = kwargs.get(ATTR_COLOR_TEMP) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) hs = kwargs.get(ATTR_HS_COLOR) if brightness is not None: @@ -144,10 +132,9 @@ class EufyHomeLight(LightEntity): self._brightness = 100 brightness = self._brightness - if colortemp is not None: + if color_temp_kelvin is not None: self._colormode = False - temp_in_k = mired_to_kelvin(colortemp) - relative_temp = temp_in_k - EUFYHOME_MIN_KELVIN + relative_temp = color_temp_kelvin - EUFYHOME_MIN_KELVIN temp = int( relative_temp * 100 / (EUFYHOME_MAX_KELVIN - EUFYHOME_MIN_KELVIN) ) diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index c4a8fb2d0af..4ed5a0f1378 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -8,7 +8,7 @@ from enum import StrEnum import logging from typing import Any, Self, final -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -42,8 +42,8 @@ __all__ = [ "ATTR_EVENT_TYPE", "ATTR_EVENT_TYPES", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "EventDeviceClass", "EventEntity", "EventEntityDescription", diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 1388585bc17..c71831fa4bc 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -150,7 +150,6 @@ class EvoClimateEntity(EvoDevice, ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False class EvoZone(EvoChild, EvoClimateEntity): diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index da3d197f6aa..22edadad7f4 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==0.4.20"] + "requirements": ["evohome-async==0.4.21"] } diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index 9e88c9bb031..ca032643c9d 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -32,7 +32,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "The entity_id of the Evohome zone." + "description": "The entity ID of the Evohome zone." }, "setpoint": { "name": "Setpoint", @@ -49,8 +49,8 @@ "description": "Sets a zone to follow its schedule.", "fields": { "entity_id": { - "name": "Entity", - "description": "The entity_id of the zone." + "name": "[%key:component::evohome::services::set_zone_override::fields::entity_id::name%]", + "description": "[%key:component::evohome::services::set_zone_override::fields::entity_id::description%]" } } } diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 0fbc5cc6a68..73c09244222 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -8,7 +8,7 @@ from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -57,7 +57,9 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): ) camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) self.alarm_image_password = ( - camera.data[CONF_PASSWORD] if camera is not None else None + camera.data[CONF_PASSWORD] + if camera and camera.source != SOURCE_IGNORE + else None ) async def _async_load_image_from_url(self, url: str) -> Image | None: diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 58ac9dfde09..f1653661cdd 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -29,7 +29,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Enter credentials to reauthenticate to ezviz cloud account", + "description": "Enter credentials to reauthenticate to EZVIZ cloud account", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -45,7 +45,7 @@ "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", + "ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, @@ -53,7 +53,7 @@ "step": { "init": { "data": { - "timeout": "Request Timeout (seconds)", + "timeout": "Request timeout (seconds)", "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras" } } @@ -185,22 +185,22 @@ }, "services": { "set_alarm_detection_sensibility": { - "name": "Detection sensitivity", - "description": "Sets the detection sensibility level.", + "name": "Set detection sensibility", + "description": "Changes the sensibility level of the motion detection.", "fields": { "level": { - "name": "Sensitivity level", - "description": "Sensibility level (1-6) for type 0 (Normal camera) or (1-100) for type 3 (PIR sensor camera)." + "name": "Level", + "description": "Sensibility level. 1-6 for type 0 (normal camera), or 1-100 for type 3 (PIR sensor camera)." }, "type_value": { - "name": "Detection type", - "description": "Type of detection. Options : 0 - Camera or 3 - PIR Sensor Camera." + "name": "Type", + "description": "Detection type. 0 for normal camera, or 3 for PIR sensor camera." } } }, "wake_device": { "name": "Wake camera", - "description": "This can be used to wake the camera/device from hibernation." + "description": "Wakes a camera from sleep mode. Especially useful for battery cameras." } } } diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index b31a18d0eac..b9e20e8dc91 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from datetime import timedelta from enum import IntFlag import functools as ft @@ -10,7 +9,7 @@ import logging import math from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -23,15 +22,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey @@ -61,21 +53,6 @@ class FanEntityFeature(IntFlag): TURN_ON = 32 -# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. -# Please use the FanEntityFeature enum instead. -_DEPRECATED_SUPPORT_SET_SPEED = DeprecatedConstantEnum( - FanEntityFeature.SET_SPEED, "2025.1" -) -_DEPRECATED_SUPPORT_OSCILLATE = DeprecatedConstantEnum( - FanEntityFeature.OSCILLATE, "2025.1" -) -_DEPRECATED_SUPPORT_DIRECTION = DeprecatedConstantEnum( - FanEntityFeature.DIRECTION, "2025.1" -) -_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum( - FanEntityFeature.PRESET_MODE, "2025.1" -) - SERVICE_INCREASE_SPEED = "increase_speed" SERVICE_DECREASE_SPEED = "decrease_speed" SERVICE_OSCILLATE = "oscillate" @@ -240,99 +217,6 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_speed_count: int = 100 _attr_supported_features: FanEntityFeature = FanEntityFeature(0) - __mod_supported_features: FanEntityFeature = FanEntityFeature(0) - # Integrations should set `_enable_turn_on_off_backwards_compatibility` to False - # once migrated and set the feature flags TURN_ON/TURN_OFF as needed. - _enable_turn_on_off_backwards_compatibility: bool = True - - def __getattribute__(self, name: str, /) -> Any: - """Get attribute. - - Modify return of `supported_features` to - include `_mod_supported_features` if attribute is set. - """ - if name != "supported_features": - return super().__getattribute__(name) - - # Convert the supported features to ClimateEntityFeature. - # Remove this compatibility shim in 2025.1 or later. - _supported_features: FanEntityFeature = super().__getattribute__( - "supported_features" - ) - _mod_supported_features: FanEntityFeature = super().__getattribute__( - "_FanEntity__mod_supported_features" - ) - if type(_supported_features) is int: # noqa: E721 - _features = FanEntityFeature(_supported_features) - self._report_deprecated_supported_features_values(_features) - else: - _features = _supported_features - - if not _mod_supported_features: - return _features - - # Add automatically calculated FanEntityFeature.TURN_OFF/TURN_ON to - # supported features and return it - return _features | _mod_supported_features - - @callback - def add_to_platform_start( - self, - hass: HomeAssistant, - platform: EntityPlatform, - parallel_updates: asyncio.Semaphore | None, - ) -> None: - """Start adding an entity to a platform.""" - super().add_to_platform_start(hass, platform, parallel_updates) - - def _report_turn_on_off(feature: str, method: str) -> None: - """Log warning not implemented turn on/off feature.""" - report_issue = self._suggest_report_issue() - message = ( - "Entity %s (%s) does not set FanEntityFeature.%s" - " but implements the %s method. Please %s" - ) - _LOGGER.warning( - message, - self.entity_id, - type(self), - feature, - method, - report_issue, - ) - - # Adds FanEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented - # This should be removed in 2025.2. - if self._enable_turn_on_off_backwards_compatibility is False: - # Return if integration has migrated already - return - - supported_features = self.supported_features - if supported_features & (FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF): - # The entity supports both turn_on and turn_off, the backwards compatibility - # checks are not needed - return - - if not supported_features & FanEntityFeature.TURN_OFF and ( - type(self).async_turn_off is not ToggleEntity.async_turn_off - or type(self).turn_off is not ToggleEntity.turn_off - ): - # turn_off implicitly supported by implementing turn_off method - _report_turn_on_off("TURN_OFF", "turn_off") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - FanEntityFeature.TURN_OFF - ) - - if not supported_features & FanEntityFeature.TURN_ON and ( - type(self).async_turn_on is not FanEntity.async_turn_on - or type(self).turn_on is not FanEntity.turn_on - ): - # turn_on implicitly supported by implementing turn_on method - _report_turn_on_off("TURN_ON", "turn_on") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - FanEntityFeature.TURN_ON - ) - def set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" raise NotImplementedError @@ -543,11 +427,3 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): Requires FanEntityFeature.SET_SPEED. """ return self._attr_preset_modes - - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = ft.partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index aab714d3e07..c4951e88c91 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -56,17 +56,17 @@ "services": { "set_preset_mode": { "name": "Set preset mode", - "description": "Sets preset mode.", + "description": "Sets preset fan mode.", "fields": { "preset_mode": { "name": "Preset mode", - "description": "Preset mode." + "description": "Preset fan mode." } } }, "set_percentage": { "name": "Set speed", - "description": "Sets the fan speed.", + "description": "Sets the speed of a fan.", "fields": { "percentage": { "name": "Percentage", @@ -94,45 +94,45 @@ }, "oscillate": { "name": "Oscillate", - "description": "Controls oscillatation of the fan.", + "description": "Controls the oscillation of a fan.", "fields": { "oscillating": { "name": "Oscillating", - "description": "Turn on/off oscillation." + "description": "Turns oscillation on/off." } } }, "toggle": { "name": "[%key:common::action::toggle%]", - "description": "Toggles the fan on/off." + "description": "Toggles a fan on/off." }, "set_direction": { "name": "Set direction", - "description": "Sets the fan rotation direction.", + "description": "Sets a fan's rotation direction.", "fields": { "direction": { "name": "Direction", - "description": "Direction to rotate." + "description": "Direction of the fan rotation." } } }, "increase_speed": { "name": "Increase speed", - "description": "Increases the speed of the fan.", + "description": "Increases the speed of a fan.", "fields": { "percentage_step": { "name": "Increment", - "description": "Increases the speed by a percentage step." + "description": "Percentage step by which the speed should be increased." } } }, "decrease_speed": { "name": "Decrease speed", - "description": "Decreases the speed of the fan.", + "description": "Decreases the speed of a fan.", "fields": { "percentage_step": { "name": "Decrement", - "description": "Decreases the speed by a percentage step." + "description": "Percentage step by which the speed should be decreased." } } } diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index b9f0b006e2a..9faed54c041 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -2,17 +2,12 @@ from __future__ import annotations -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL, CONF_URL, Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL, Platform +from homeassistant.core import HomeAssistant from homeassistant.util.hass_dict import HassKey -from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import CONF_MAX_ENTRIES, DOMAIN from .coordinator import FeedReaderCoordinator, StoredData type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator] @@ -21,60 +16,6 @@ CONF_URLS = "urls" MY_KEY: HassKey[StoredData] = HassKey(DOMAIN) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]), - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - vol.Optional( - CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES - ): cv.positive_int, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Feedreader component.""" - if DOMAIN in config: - for url in config[DOMAIN][CONF_URLS]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_URL: url, - CONF_MAX_ENTRIES: config[DOMAIN][CONF_MAX_ENTRIES], - }, - ) - ) - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - is_persistent=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Feedreader", - }, - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) -> bool: """Set up Feedreader from a config entry.""" diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 72042de25ed..f3e56ad1778 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -11,7 +11,6 @@ import feedparser import voluptuous as vol from homeassistant.config_entries import ( - SOURCE_IMPORT, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -20,13 +19,11 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, TextSelectorType, ) -from homeassistant.util import slugify from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN @@ -42,7 +39,6 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 - _max_entries: int | None = None @staticmethod @callback @@ -75,21 +71,6 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - def abort_on_import_error(self, url: str, error: str) -> ConfigFlowResult: - """Abort import flow on error.""" - async_create_issue( - self.hass, - DOMAIN, - f"import_yaml_error_{DOMAIN}_{error}_{slugify(url)}", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"import_yaml_error_{error}", - translation_placeholders={"url": url}, - ) - return self.async_abort(reason=error) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -104,8 +85,6 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): if feed.bozo: LOGGER.debug("feed bozo_exception: %s", feed.bozo_exception) if isinstance(feed.bozo_exception, urllib.error.URLError): - if self.context["source"] == SOURCE_IMPORT: - return self.abort_on_import_error(user_input[CONF_URL], "url_error") return self.show_user_form(user_input, {"base": "url_error"}) feed_title = html.unescape(feed["feed"]["title"]) @@ -113,14 +92,9 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=feed_title, data=user_input, - options={CONF_MAX_ENTRIES: self._max_entries or DEFAULT_MAX_ENTRIES}, + options={CONF_MAX_ENTRIES: DEFAULT_MAX_ENTRIES}, ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle an import flow.""" - self._max_entries = import_data[CONF_MAX_ENTRIES] - return await self.async_step_user({CONF_URL: import_data[CONF_URL]}) - async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index f45b303946a..fc338d63268 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -14,6 +14,7 @@ import feedparser from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -101,7 +102,11 @@ class FeedReaderCoordinator( async def async_setup(self) -> None: """Set up the feed manager.""" - feed = await self._async_fetch_feed() + try: + feed = await self._async_fetch_feed() + except UpdateFailed as err: + raise ConfigEntryNotReady from err + self.logger.debug("Feed data fetched from %s : %s", self.url, feed["feed"]) if feed_author := feed["feed"].get("author"): self.feed_author = html.unescape(feed_author) diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 9a88317027e..6957702523f 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -7,7 +7,7 @@ import re from haffmpeg.core import HAFFmpeg from haffmpeg.tools import IMAGE_JPEG, FFVersion, ImageFrame -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.const import ( @@ -23,10 +23,10 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity -from homeassistant.helpers.system_info import is_official_image from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.signal_type import SignalType +from homeassistant.util.system_info import is_official_image DOMAIN = "ffmpeg" diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 18b9f46eb20..8ede0169482 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -28,8 +28,9 @@ from homeassistant.util import slugify from .const import CONF_IMPORT_PLUGINS, DOMAIN -_LOGGER = logging.getLogger(__name__) +type FibaroConfigEntry = ConfigEntry[FibaroController] +_LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -381,7 +382,7 @@ def init_controller(data: Mapping[str, Any]) -> FibaroController: return controller -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bool: """Set up the Fibaro Component. The unique id of the config entry is the serial number of the home center. @@ -395,7 +396,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except FibaroAuthFailed as auth_ex: raise ConfigEntryAuthFailed from auth_ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller + entry.runtime_data = controller # register the hub device info separately as the hub has sometimes no entities device_registry = dr.async_get(hass) @@ -417,25 +418,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bool: """Unload a config entry.""" _LOGGER.debug("Shutting down Fibaro connection") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][entry.entry_id].disable_state_handler() - hass.data[DOMAIN].pop(entry.entry_id) + entry.runtime_data.disable_state_handler() return unload_ok async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: FibaroConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove a device entry from fibaro integration. Only removing devices which are not present anymore are eligible to be removed. """ - controller: FibaroController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data for identifiers in controller.get_all_device_identifiers(): if device_entry.identifiers == identifiers: # Fibaro device is still served by the controller, diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 9f3efbfb514..16e79c0c1d0 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -12,13 +12,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity SENSOR_TYPES = { @@ -43,11 +41,11 @@ SENSOR_TYPES = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [ FibaroBinarySensor(device) diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 0bfc2223317..45f700026a0 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -17,13 +17,11 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity PRESET_RESUME = "resume" @@ -111,11 +109,11 @@ OP_MODE_ACTIONS = ("setMode", "setOperatingMode", "setThermostatMode") async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [ FibaroThermostat(device) @@ -128,8 +126,6 @@ async def async_setup_entry( class FibaroThermostat(FibaroEntity, ClimateEntity): """Representation of a Fibaro Thermostat.""" - _enable_turn_on_off_backwards_compatibility = False - def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the Fibaro device.""" super().__init__(fibaro_device) @@ -274,7 +270,9 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if isinstance(fibaro_operation_mode, str): with suppress(ValueError): return HVACMode(fibaro_operation_mode.lower()) - elif fibaro_operation_mode in OPMODES_HVAC: + # when the mode cannot be instantiated a preset_mode is selected + return HVACMode.AUTO + if fibaro_operation_mode in OPMODES_HVAC: return OPMODES_HVAC[fibaro_operation_mode] return None @@ -282,8 +280,6 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): """Set new target operation mode.""" if not self._op_mode_device: return - if self.preset_mode: - return if "setOperatingMode" in self._op_mode_device.fibaro_device.actions: self._op_mode_device.action("setOperatingMode", HA_OPMODES_HVAC[hvac_mode]) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index 0898d1c9318..bfebbf87bd2 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -13,23 +13,21 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro covers.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [FibaroCover(device) for device in controller.fibaro_devices[Platform.COVER]], True, diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index c964ab283c1..a2d5da7f877 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -10,23 +10,21 @@ from homeassistant.components.event import ( EventDeviceClass, EventEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro event entities.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data # Each scene event represents a button on a device async_add_entities( diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 18f86b6df7d..d40e26244f3 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -17,13 +17,11 @@ from homeassistant.components.light import ( brightness_supported, color_supported, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity PARALLEL_UPDATES = 2 @@ -52,11 +50,11 @@ def scaleto99(value: int | None) -> int: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [FibaroLight(device) for device in controller.fibaro_devices[Platform.LIGHT]], True, diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index 55583d2a967..62a9dfa43b1 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -7,23 +7,21 @@ from typing import Any from pyfibaro.fibaro_device import DeviceModel from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro locks.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [FibaroLock(device) for device in controller.fibaro_devices[Platform.LOCK]], True, diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index a40a1ef5b57..a4c0f1bd7f1 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -7,23 +7,22 @@ from typing import Any from pyfibaro.fibaro_scene import SceneModel from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify -from . import FibaroController +from . import FibaroConfigEntry, FibaroController from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro scenes.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [FibaroScene(scene, controller) for scene in controller.read_scenes()], True, diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index da94cde9ead..245a0d087d8 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, @@ -27,8 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import convert -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity # List of known sensors which represents a fibaro device @@ -103,12 +101,12 @@ FIBARO_TO_HASS_UNIT: dict[str, str] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro controller devices.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data entities: list[SensorEntity] = [ FibaroSensor(device, MAIN_SENSOR_TYPES.get(device.type)) for device in controller.fibaro_devices[Platform.SENSOR] diff --git a/homeassistant/components/fibaro/strings.json b/homeassistant/components/fibaro/strings.json index de875176cdb..99f718d545c 100644 --- a/homeassistant/components/fibaro/strings.json +++ b/homeassistant/components/fibaro/strings.json @@ -3,16 +3,25 @@ "step": { "user": { "data": { - "url": "URL in the format http://HOST/api/", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "import_plugins": "Import entities from fibaro plugins?" + "import_plugins": "Import entities from fibaro plugins / quickapps" + }, + "data_description": { + "url": "The URL of the Fibaro hub in the format `http(s)://IP`.", + "username": "The username of the Fibaro hub user.", + "password": "The password of the Fibaro hub user.", + "import_plugins": "Select if entities from Fibaro plugins / quickapps should be imported." } }, "reauth_confirm": { "data": { "password": "[%key:common::config_flow::data::password%]" }, + "data_description": { + "password": "[%key:component::fibaro::config::step::user::data_description::password%]" + }, "title": "[%key:common::config_flow::title::reauth%]", "description": "Please update your password for {username}" } diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index 1ad933f5d20..f67683dff6a 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -7,23 +7,21 @@ from typing import Any from pyfibaro.fibaro_device import DeviceModel from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController -from .const import DOMAIN +from . import FibaroConfigEntry from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FibaroConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro switches.""" - controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + controller = entry.runtime_data async_add_entities( [FibaroSwitch(device) for device in controller.fibaro_devices[Platform.SWITCH]], True, diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py index 992635d05fd..1c4fdbe5c84 100644 --- a/homeassistant/components/file/config_flow.py +++ b/homeassistant/components/file/config_flow.py @@ -32,7 +32,7 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) -from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN +from .const import CONF_TIMESTAMP, DOMAIN BOOLEAN_SELECTOR = BooleanSelector(BooleanSelectorConfig()) TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) @@ -105,7 +105,7 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN): if not await self.validate_file_path(user_input[CONF_FILE_PATH]): errors[CONF_FILE_PATH] = "not_allowed" else: - title = f"{DEFAULT_NAME} [{user_input[CONF_FILE_PATH]}]" + title = f"{platform.capitalize()} [{user_input[CONF_FILE_PATH]}]" data = deepcopy(user_input) options = {} for key, value in user_input.items(): diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json index 461eed9aefa..2a83a05bb96 100644 --- a/homeassistant/components/filter/strings.json +++ b/homeassistant/components/filter/strings.json @@ -1,4 +1,5 @@ { + "title": "Filter", "services": { "reload": { "name": "[%key:common::action::reload%]", diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index a1cd565153f..c85f08ba3d0 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -9,7 +9,7 @@ from typing import Any from fints.client import FinTS3PinTanClient from fints.models import SEPAAccount -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components.sensor import ( diff --git a/homeassistant/components/fitbit/quality_scale.yaml b/homeassistant/components/fitbit/quality_scale.yaml new file mode 100644 index 00000000000..abf127cdb98 --- /dev/null +++ b/homeassistant/components/fitbit/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration has no actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: todo + docs-actions: + status: exempt + comment: There are no actions in Fitbit integration. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: Fitbit is a polling integration that does use async events. + entity-unique-id: done + has-entity-name: done + runtime-data: + status: todo + comment: | + The integration uses `hass.data` for data associated with a configuration + entry and needs to be updated to use `runtime_data`. + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery: todo + discovery-update-info: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index 864160cb464..540a7dd410d 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -71,7 +71,7 @@ class Fan(CoordinatorEntity[FjaraskupanCoordinator], FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False + _attr_has_entity_name = True _attr_name = None diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py index b33904c805d..f0083591d4d 100644 --- a/homeassistant/components/fjaraskupan/light.py +++ b/homeassistant/components/fjaraskupan/light.py @@ -4,8 +4,6 @@ from __future__ import annotations from typing import Any -from fjaraskupan import COMMAND_LIGHT_ON_OFF - from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -62,7 +60,6 @@ class Light(CoordinatorEntity[FjaraskupanCoordinator], LightEntity): if self.is_on: async with self.coordinator.async_connect_and_update() as device: await device.send_dim(0) - await device.send_command(COMMAND_LIGHT_ON_OFF) @property def is_on(self) -> bool: diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json index 91c74b68e01..2fd49aac5ee 100644 --- a/homeassistant/components/fjaraskupan/manifest.json +++ b/homeassistant/components/fjaraskupan/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/fjaraskupan", "iot_class": "local_polling", "loggers": ["bleak", "fjaraskupan"], - "requirements": ["fjaraskupan==2.3.0"] + "requirements": ["fjaraskupan==2.3.2"] } diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index d456fbef6fc..8be5df4eca7 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -70,7 +70,6 @@ class Flexit(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, hub: ModbusHub, modbus_slave: int | None, name: str | None diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 0526a0d6bd3..a2291dea9d6 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -74,7 +74,6 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: FlexitCoordinator) -> None: """Initialize the Flexit unit.""" diff --git a/homeassistant/components/flexit_bacnet/entity.py b/homeassistant/components/flexit_bacnet/entity.py index bd92550db19..38efa838c93 100644 --- a/homeassistant/components/flexit_bacnet/entity.py +++ b/homeassistant/components/flexit_bacnet/entity.py @@ -26,6 +26,7 @@ class FlexitEntity(CoordinatorEntity[FlexitCoordinator]): name=coordinator.device.device_name, manufacturer="Flexit", model="Nordic", + model_id=coordinator.device.model, serial_number=coordinator.device.serial_number, ) diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py index 6e6e2eea980..6e405e8e8ac 100644 --- a/homeassistant/components/flexit_bacnet/number.py +++ b/homeassistant/components/flexit_bacnet/number.py @@ -23,135 +23,158 @@ from . import FlexitCoordinator from .const import DOMAIN from .entity import FlexitEntity +_MAX_FAN_SETPOINT = 100 +_MIN_FAN_SETPOINT = 30 + @dataclass(kw_only=True, frozen=True) class FlexitNumberEntityDescription(NumberEntityDescription): """Describes a Flexit number entity.""" native_value_fn: Callable[[FlexitBACnet], float] + native_max_value_fn: Callable[[FlexitBACnet], int] + native_min_value_fn: Callable[[FlexitBACnet], int] set_native_value_fn: Callable[[FlexitBACnet], Callable[[int], Awaitable[None]]] +# Setpoints for Away, Home and High are dependent of each other. Fireplace and Cooker Hood +# have setpoints between 0 (MIN_FAN_SETPOINT) and 100 (MAX_FAN_SETPOINT). +# See the table below for all the setpoints. +# +# | Mode | Setpoint | Min | Max | +# |:------------|----------|:----------------------|:----------------------| +# | HOME | Supply | AWAY Supply setpoint | 100 | +# | HOME | Extract | AWAY Extract setpoint | 100 | +# | AWAY | Supply | 30 | HOME Supply setpoint | +# | AWAY | Extract | 30 | HOME Extract setpoint | +# | HIGH | Supply | HOME Supply setpoint | 100 | +# | HIGH | Extract | HOME Extract setpoint | 100 | +# | COOKER_HOOD | Supply | 30 | 100 | +# | COOKER_HOOD | Extract | 30 | 100 | +# | FIREPLACE | Supply | 30 | 100 | +# | FIREPLACE | Extract | 30 | 100 | + + NUMBERS: tuple[FlexitNumberEntityDescription, ...] = ( FlexitNumberEntityDescription( key="away_extract_fan_setpoint", translation_key="away_extract_fan_setpoint", device_class=NumberDeviceClass.POWER_FACTOR, - native_min_value=0, - native_max_value=100, native_step=1, mode=NumberMode.SLIDER, native_value_fn=lambda device: device.fan_setpoint_extract_air_away, set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_away, native_unit_of_measurement=PERCENTAGE, + native_max_value_fn=lambda device: int(device.fan_setpoint_extract_air_home), + native_min_value_fn=lambda _: _MIN_FAN_SETPOINT, ), FlexitNumberEntityDescription( key="away_supply_fan_setpoint", translation_key="away_supply_fan_setpoint", device_class=NumberDeviceClass.POWER_FACTOR, - native_min_value=0, - native_max_value=100, native_step=1, mode=NumberMode.SLIDER, native_value_fn=lambda device: device.fan_setpoint_supply_air_away, set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_away, native_unit_of_measurement=PERCENTAGE, + native_max_value_fn=lambda device: int(device.fan_setpoint_supply_air_home), + native_min_value_fn=lambda _: _MIN_FAN_SETPOINT, ), FlexitNumberEntityDescription( key="cooker_hood_extract_fan_setpoint", translation_key="cooker_hood_extract_fan_setpoint", device_class=NumberDeviceClass.POWER_FACTOR, - native_min_value=0, - native_max_value=100, native_step=1, mode=NumberMode.SLIDER, native_value_fn=lambda device: device.fan_setpoint_extract_air_cooker, set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_cooker, native_unit_of_measurement=PERCENTAGE, + native_max_value_fn=lambda _: _MAX_FAN_SETPOINT, + native_min_value_fn=lambda _: _MIN_FAN_SETPOINT, ), FlexitNumberEntityDescription( key="cooker_hood_supply_fan_setpoint", translation_key="cooker_hood_supply_fan_setpoint", device_class=NumberDeviceClass.POWER_FACTOR, - native_min_value=0, - native_max_value=100, native_step=1, mode=NumberMode.SLIDER, native_value_fn=lambda device: device.fan_setpoint_supply_air_cooker, set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_cooker, native_unit_of_measurement=PERCENTAGE, + native_max_value_fn=lambda _: _MAX_FAN_SETPOINT, + native_min_value_fn=lambda _: _MIN_FAN_SETPOINT, ), FlexitNumberEntityDescription( key="fireplace_extract_fan_setpoint", translation_key="fireplace_extract_fan_setpoint", device_class=NumberDeviceClass.POWER_FACTOR, - native_min_value=0, - native_max_value=100, native_step=1, mode=NumberMode.SLIDER, native_value_fn=lambda device: device.fan_setpoint_extract_air_fire, set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_fire, native_unit_of_measurement=PERCENTAGE, + native_max_value_fn=lambda _: _MAX_FAN_SETPOINT, + native_min_value_fn=lambda _: _MIN_FAN_SETPOINT, ), FlexitNumberEntityDescription( key="fireplace_supply_fan_setpoint", translation_key="fireplace_supply_fan_setpoint", device_class=NumberDeviceClass.POWER_FACTOR, - native_min_value=0, - native_max_value=100, native_step=1, mode=NumberMode.SLIDER, native_value_fn=lambda device: device.fan_setpoint_supply_air_fire, set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_fire, native_unit_of_measurement=PERCENTAGE, + native_max_value_fn=lambda _: _MAX_FAN_SETPOINT, + native_min_value_fn=lambda _: _MIN_FAN_SETPOINT, ), FlexitNumberEntityDescription( key="high_extract_fan_setpoint", translation_key="high_extract_fan_setpoint", device_class=NumberDeviceClass.POWER_FACTOR, - native_min_value=0, - native_max_value=100, native_step=1, mode=NumberMode.SLIDER, native_value_fn=lambda device: device.fan_setpoint_extract_air_high, set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_high, native_unit_of_measurement=PERCENTAGE, + native_max_value_fn=lambda _: _MAX_FAN_SETPOINT, + native_min_value_fn=lambda device: int(device.fan_setpoint_extract_air_home), ), FlexitNumberEntityDescription( key="high_supply_fan_setpoint", translation_key="high_supply_fan_setpoint", device_class=NumberDeviceClass.POWER_FACTOR, - native_min_value=0, - native_max_value=100, native_step=1, mode=NumberMode.SLIDER, native_value_fn=lambda device: device.fan_setpoint_supply_air_high, set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_high, native_unit_of_measurement=PERCENTAGE, + native_max_value_fn=lambda _: _MAX_FAN_SETPOINT, + native_min_value_fn=lambda device: int(device.fan_setpoint_supply_air_home), ), FlexitNumberEntityDescription( key="home_extract_fan_setpoint", translation_key="home_extract_fan_setpoint", device_class=NumberDeviceClass.POWER_FACTOR, - native_min_value=0, - native_max_value=100, native_step=1, mode=NumberMode.SLIDER, native_value_fn=lambda device: device.fan_setpoint_extract_air_home, set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_home, native_unit_of_measurement=PERCENTAGE, + native_max_value_fn=lambda _: _MAX_FAN_SETPOINT, + native_min_value_fn=lambda device: int(device.fan_setpoint_extract_air_away), ), FlexitNumberEntityDescription( key="home_supply_fan_setpoint", translation_key="home_supply_fan_setpoint", device_class=NumberDeviceClass.POWER_FACTOR, - native_min_value=0, - native_max_value=100, native_step=1, mode=NumberMode.SLIDER, native_value_fn=lambda device: device.fan_setpoint_supply_air_home, set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_home, native_unit_of_measurement=PERCENTAGE, + native_max_value_fn=lambda _: _MAX_FAN_SETPOINT, + native_min_value_fn=lambda device: int(device.fan_setpoint_supply_air_away), ), ) @@ -192,6 +215,16 @@ class FlexitNumber(FlexitEntity, NumberEntity): """Return the state of the number.""" return self.entity_description.native_value_fn(self.coordinator.device) + @property + def native_max_value(self) -> float: + """Return the native max value of the number.""" + return self.entity_description.native_max_value_fn(self.coordinator.device) + + @property + def native_min_value(self) -> float: + """Return the native min value of the number.""" + return self.entity_description.native_min_value_fn(self.coordinator.device) + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" set_native_value_fn = self.entity_description.set_native_value_fn( diff --git a/homeassistant/components/flick_electric/__init__.py b/homeassistant/components/flick_electric/__init__.py index a963d199c5a..3ffddee1c7d 100644 --- a/homeassistant/components/flick_electric/__init__.py +++ b/homeassistant/components/flick_electric/__init__.py @@ -2,10 +2,11 @@ from datetime import datetime as dt import logging +from typing import Any import jwt from pyflick import FlickAPI -from pyflick.authentication import AbstractFlickAuth +from pyflick.authentication import SimpleFlickAuth from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET from homeassistant.config_entries import ConfigEntry @@ -20,7 +21,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import CONF_TOKEN_EXPIRY, DOMAIN +from .const import CONF_ACCOUNT_ID, CONF_SUPPLY_NODE_REF, CONF_TOKEN_EXPIRY +from .coordinator import FlickConfigEntry, FlickElectricDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -29,36 +31,85 @@ CONF_ID_TOKEN = "id_token" PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> bool: """Set up Flick Electric from a config entry.""" auth = HassFlickAuth(hass, entry) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = FlickAPI(auth) + coordinator = FlickElectricDataCoordinator( + hass, FlickAPI(auth), entry.data[CONF_SUPPLY_NODE_REF] + ) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -class HassFlickAuth(AbstractFlickAuth): +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 2: + return False + + if config_entry.version == 1: + api = FlickAPI(HassFlickAuth(hass, config_entry)) + + accounts = await api.getCustomerAccounts() + active_accounts = [ + account for account in accounts if account["status"] == "active" + ] + + # A single active account can be auto-migrated + if (len(active_accounts)) == 1: + account = active_accounts[0] + + new_data = {**config_entry.data} + new_data[CONF_ACCOUNT_ID] = account["id"] + new_data[CONF_SUPPLY_NODE_REF] = account["main_consumer"]["supply_node_ref"] + hass.config_entries.async_update_entry( + config_entry, + title=account["address"], + unique_id=account["id"], + data=new_data, + version=2, + ) + return True + + config_entry.async_start_reauth(hass, data={**config_entry.data}) + return False + + return True + + +class HassFlickAuth(SimpleFlickAuth): """Implementation of AbstractFlickAuth based on a Home Assistant entity config.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: FlickConfigEntry) -> None: """Flick authentication based on a Home Assistant entity config.""" - super().__init__(aiohttp_client.async_get_clientsession(hass)) + super().__init__( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + client_id=entry.data.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID), + client_secret=entry.data.get(CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET), + websession=aiohttp_client.async_get_clientsession(hass), + ) self._entry = entry self._hass = hass - async def _get_entry_token(self): + async def _get_entry_token(self) -> dict[str, Any]: # No token saved, generate one if ( CONF_TOKEN_EXPIRY not in self._entry.data @@ -75,13 +126,8 @@ class HassFlickAuth(AbstractFlickAuth): async def _update_token(self): _LOGGER.debug("Fetching new access token") - token = await self.get_new_token( - username=self._entry.data[CONF_USERNAME], - password=self._entry.data[CONF_PASSWORD], - client_id=self._entry.data.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID), - client_secret=self._entry.data.get( - CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET - ), + token = await super().get_new_token( + self._username, self._password, self._client_id, self._client_secret ) _LOGGER.debug("New token: %s", token) diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py index 8a2455b9d14..b6b7327fcb0 100644 --- a/homeassistant/components/flick_electric/config_flow.py +++ b/homeassistant/components/flick_electric/config_flow.py @@ -1,14 +1,18 @@ """Config Flow for Flick Electric integration.""" import asyncio +from collections.abc import Mapping import logging from typing import Any -from pyflick.authentication import AuthException, SimpleFlickAuth +from aiohttp import ClientResponseError +from pyflick import FlickAPI +from pyflick.authentication import AbstractFlickAuth, SimpleFlickAuth from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET +from pyflick.types import APIException, AuthException, CustomerAccount import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -17,12 +21,18 @@ from homeassistant.const import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import DOMAIN +from .const import CONF_ACCOUNT_ID, CONF_SUPPLY_NODE_REF, DOMAIN _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( +LOGIN_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -35,10 +45,13 @@ DATA_SCHEMA = vol.Schema( class FlickConfigFlow(ConfigFlow, domain=DOMAIN): """Flick config flow.""" - VERSION = 1 + VERSION = 2 + auth: AbstractFlickAuth + accounts: list[CustomerAccount] + data: dict[str, Any] - async def _validate_input(self, user_input): - auth = SimpleFlickAuth( + async def _validate_auth(self, user_input: Mapping[str, Any]) -> bool: + self.auth = SimpleFlickAuth( username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], websession=aiohttp_client.async_get_clientsession(self.hass), @@ -48,22 +61,83 @@ class FlickConfigFlow(ConfigFlow, domain=DOMAIN): try: async with asyncio.timeout(60): - token = await auth.async_get_access_token() - except TimeoutError as err: + token = await self.auth.async_get_access_token() + except (TimeoutError, ClientResponseError) as err: raise CannotConnect from err except AuthException as err: raise InvalidAuth from err return token is not None + async def async_step_select_account( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask user to select account.""" + + errors = {} + if user_input is not None and CONF_ACCOUNT_ID in user_input: + self.data[CONF_ACCOUNT_ID] = user_input[CONF_ACCOUNT_ID] + self.data[CONF_SUPPLY_NODE_REF] = self._get_supply_node_ref( + user_input[CONF_ACCOUNT_ID] + ) + try: + # Ensure supply node is active + await FlickAPI(self.auth).getPricing(self.data[CONF_SUPPLY_NODE_REF]) + except (APIException, ClientResponseError): + errors["base"] = "cannot_connect" + except AuthException: + # We should never get here as we have a valid token + return self.async_abort(reason="no_permissions") + else: + # Supply node is active + return await self._async_create_entry() + + try: + self.accounts = await FlickAPI(self.auth).getCustomerAccounts() + except (APIException, ClientResponseError): + errors["base"] = "cannot_connect" + + active_accounts = [a for a in self.accounts if a["status"] == "active"] + + if len(active_accounts) == 0: + return self.async_abort(reason="no_accounts") + + if len(active_accounts) == 1: + self.data[CONF_ACCOUNT_ID] = active_accounts[0]["id"] + self.data[CONF_SUPPLY_NODE_REF] = self._get_supply_node_ref( + active_accounts[0]["id"] + ) + + return await self._async_create_entry() + + return self.async_show_form( + step_id="select_account", + data_schema=vol.Schema( + { + vol.Required(CONF_ACCOUNT_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=account["id"], label=account["address"] + ) + for account in active_accounts + ], + mode=SelectSelectorMode.LIST, + ) + ) + } + ), + errors=errors, + ) + async def async_step_user( - self, user_input: dict[str, Any] | None = None + self, user_input: Mapping[str, Any] | None = None ) -> ConfigFlowResult: """Handle gathering login info.""" errors = {} if user_input is not None: try: - await self._validate_input(user_input) + await self._validate_auth(user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -72,20 +146,61 @@ class FlickConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id( - f"flick_electric_{user_input[CONF_USERNAME]}" - ) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=f"Flick Electric: {user_input[CONF_USERNAME]}", - data=user_input, - ) + self.data = dict(user_input) + return await self.async_step_select_account(user_input) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", data_schema=LOGIN_SCHEMA, errors=errors ) + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + + self.data = {**user_input} + + return await self.async_step_user(user_input) + + async def _async_create_entry(self) -> ConfigFlowResult: + """Create an entry for the flow.""" + + await self.async_set_unique_id(self.data[CONF_ACCOUNT_ID]) + + account = self._get_account(self.data[CONF_ACCOUNT_ID]) + + if self.source == SOURCE_REAUTH: + # Migration completed + if self._get_reauth_entry().version == 1: + self.hass.config_entries.async_update_entry( + self._get_reauth_entry(), + unique_id=self.unique_id, + data=self.data, + version=self.VERSION, + ) + + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + unique_id=self.unique_id, + title=account["address"], + data=self.data, + ) + + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=account["address"], + data=self.data, + ) + + def _get_account(self, account_id: str) -> CustomerAccount: + """Get the account for the account ID.""" + return next(a for a in self.accounts if a["id"] == account_id) + + def _get_supply_node_ref(self, account_id: str) -> str: + """Get the supply node ref for the account.""" + return self._get_account(account_id)["main_consumer"][CONF_SUPPLY_NODE_REF] + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/flick_electric/const.py b/homeassistant/components/flick_electric/const.py index de1942096b5..0f94aa909b7 100644 --- a/homeassistant/components/flick_electric/const.py +++ b/homeassistant/components/flick_electric/const.py @@ -3,6 +3,8 @@ DOMAIN = "flick_electric" CONF_TOKEN_EXPIRY = "expires" +CONF_ACCOUNT_ID = "account_id" +CONF_SUPPLY_NODE_REF = "supply_node_ref" ATTR_START_AT = "start_at" ATTR_END_AT = "end_at" diff --git a/homeassistant/components/flick_electric/coordinator.py b/homeassistant/components/flick_electric/coordinator.py new file mode 100644 index 00000000000..474efc5297d --- /dev/null +++ b/homeassistant/components/flick_electric/coordinator.py @@ -0,0 +1,47 @@ +"""Data Coordinator for Flick Electric.""" + +import asyncio +from datetime import timedelta +import logging + +import aiohttp +from pyflick import FlickAPI, FlickPrice +from pyflick.types import APIException, AuthException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=5) + +type FlickConfigEntry = ConfigEntry[FlickElectricDataCoordinator] + + +class FlickElectricDataCoordinator(DataUpdateCoordinator[FlickPrice]): + """Coordinator for flick power price.""" + + def __init__( + self, hass: HomeAssistant, api: FlickAPI, supply_node_ref: str + ) -> None: + """Initialize FlickElectricDataCoordinator.""" + super().__init__( + hass, + _LOGGER, + name="Flick Electric", + update_interval=SCAN_INTERVAL, + ) + self.supply_node_ref = supply_node_ref + self._api = api + + async def _async_update_data(self) -> FlickPrice: + """Fetch pricing data from Flick Electric.""" + try: + async with asyncio.timeout(60): + return await self._api.getPricing(self.supply_node_ref) + except AuthException as err: + raise ConfigEntryAuthFailed from err + except (APIException, aiohttp.ClientResponseError) as err: + raise UpdateFailed from err diff --git a/homeassistant/components/flick_electric/manifest.json b/homeassistant/components/flick_electric/manifest.json index 0b1f2677d6a..3096590f47a 100644 --- a/homeassistant/components/flick_electric/manifest.json +++ b/homeassistant/components/flick_electric/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyflick"], - "requirements": ["PyFlick==0.0.2"] + "requirements": ["PyFlick==1.1.3"] } diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index 347109c66c0..73b6f8793fb 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -1,74 +1,72 @@ """Support for Flick Electric Pricing data.""" -import asyncio from datetime import timedelta +from decimal import Decimal import logging from typing import Any -from pyflick import FlickAPI, FlickPrice - from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_CENT, UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utcnow +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT, DOMAIN +from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT +from .coordinator import FlickConfigEntry, FlickElectricDataCoordinator _LOGGER = logging.getLogger(__name__) - SCAN_INTERVAL = timedelta(minutes=5) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FlickConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Flick Sensor Setup.""" - api: FlickAPI = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data - async_add_entities([FlickPricingSensor(api)], True) + async_add_entities([FlickPricingSensor(coordinator)]) -class FlickPricingSensor(SensorEntity): +class FlickPricingSensor(CoordinatorEntity[FlickElectricDataCoordinator], SensorEntity): """Entity object for Flick Electric sensor.""" _attr_attribution = "Data provided by Flick Electric" _attr_native_unit_of_measurement = f"{CURRENCY_CENT}/{UnitOfEnergy.KILO_WATT_HOUR}" _attr_has_entity_name = True _attr_translation_key = "power_price" - _attributes: dict[str, Any] = {} - def __init__(self, api: FlickAPI) -> None: + def __init__(self, coordinator: FlickElectricDataCoordinator) -> None: """Entity object for Flick Electric sensor.""" - self._api: FlickAPI = api - self._price: FlickPrice = None + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.supply_node_ref}_pricing" @property - def native_value(self): + def native_value(self) -> Decimal: """Return the state of the sensor.""" - return self._price.price + # The API should return a unit price with quantity of 1.0 when no start/end time is provided + if self.coordinator.data.quantity != 1: + _LOGGER.warning( + "Unexpected quantity for unit price: %s", self.coordinator.data + ) + return self.coordinator.data.cost * 100 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - return self._attributes + components: dict[str, float] = {} - async def async_update(self) -> None: - """Get the Flick Pricing data from the web service.""" - if self._price and self._price.end_at >= utcnow(): - return # Power price data is still valid - - async with asyncio.timeout(60): - self._price = await self._api.getPricing() - - _LOGGER.debug("Pricing data: %s", self._price) - - self._attributes[ATTR_START_AT] = self._price.start_at - self._attributes[ATTR_END_AT] = self._price.end_at - for component in self._price.components: + for component in self.coordinator.data.components: if component.charge_setter not in ATTR_COMPONENTS: _LOGGER.warning("Found unknown component: %s", component.charge_setter) continue - self._attributes[component.charge_setter] = float(component.value) + components[component.charge_setter] = float(component.value * 100) + + return { + ATTR_START_AT: self.coordinator.data.start_at, + ATTR_END_AT: self.coordinator.data.end_at, + **components, + } diff --git a/homeassistant/components/flick_electric/strings.json b/homeassistant/components/flick_electric/strings.json index 8b55bef939e..4b1fd300e2b 100644 --- a/homeassistant/components/flick_electric/strings.json +++ b/homeassistant/components/flick_electric/strings.json @@ -9,6 +9,12 @@ "client_id": "Client ID (optional)", "client_secret": "Client Secret (optional)" } + }, + "select_account": { + "title": "Select account", + "data": { + "account_id": "Account" + } } }, "error": { @@ -17,7 +23,10 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "no_permissions": "Cannot get pricing for this account. Please check user permissions.", + "no_accounts": "No services are active on this Flick account" } }, "entity": { diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json index 5f3021960b5..acdb8e35fe0 100644 --- a/homeassistant/components/flume/strings.json +++ b/homeassistant/components/flume/strings.json @@ -8,7 +8,7 @@ "step": { "user": { "description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token", - "title": "Connect to your Flume Account", + "title": "Connect to your Flume account", "data": { "username": "[%key:common::config_flow::data::username%]", "client_secret": "Client Secret", @@ -18,7 +18,7 @@ }, "reauth_confirm": { "description": "The password for {username} is no longer valid.", - "title": "Reauthenticate your Flume Account", + "title": "Reauthenticate your Flume account", "data": { "password": "[%key:common::config_flow::data::password%]" } @@ -65,11 +65,11 @@ "services": { "list_notifications": { "name": "List notifications", - "description": "Return user notifications.", + "description": "Returns a list of fetched user notifications.", "fields": { "config_entry": { "name": "Flume", - "description": "The flume config entry for which to return notifications." + "description": "The Flume config entry for which to return notifications." } } } diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index 8a3d7ec7260..f7cf5b2c03a 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, @@ -43,7 +43,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify from homeassistant.util.color import ( color_RGB_to_xy_brightness, - color_temperature_kelvin_to_mired, color_temperature_to_rgb, ) from homeassistant.util.dt import as_local, utcnow as dt_utcnow @@ -109,13 +108,13 @@ async def async_set_lights_xy(hass, lights, x_val, y_val, brightness, transition await hass.services.async_call(LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) -async def async_set_lights_temp(hass, lights, mired, brightness, transition): +async def async_set_lights_temp(hass, lights, kelvin, brightness, transition): """Set color of array of lights.""" for light in lights: if is_on(hass, light): service_data = {ATTR_ENTITY_ID: light} - if mired is not None: - service_data[ATTR_COLOR_TEMP] = int(mired) + if kelvin is not None: + service_data[ATTR_COLOR_TEMP_KELVIN] = kelvin if brightness is not None: service_data[ATTR_BRIGHTNESS] = brightness if transition is not None: @@ -350,17 +349,15 @@ class FluxSwitch(SwitchEntity, RestoreEntity): now, ) else: - # Convert to mired and clamp to allowed values - mired = color_temperature_kelvin_to_mired(temp) await async_set_lights_temp( - self.hass, self._lights, mired, brightness, self._transition + self.hass, self._lights, int(temp), brightness, self._transition ) _LOGGER.debug( ( - "Lights updated to mired:%s brightness:%s, %s%% " + "Lights updated to kelvin:%s brightness:%s, %s%% " "of %s cycle complete at %s" ), - mired, + temp, brightness, round(percentage_complete * 100), time_state, diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 1472dfa4bf1..7597a7c9c9a 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -133,7 +133,7 @@ async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> and mac_matches_by_one(entity_mac, unique_id) ): # Old format {dhcp_mac}....., New format {discovery_mac}.... - new_unique_id = f"{unique_id}{entity_unique_id[len(unique_id):]}" + new_unique_id = f"{unique_id}{entity_unique_id[len(unique_id) :]}" else: return None _LOGGER.debug( diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 9a02120f33a..035be5b115c 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -16,7 +16,6 @@ from flux_led.const import ( from flux_led.scanner import FluxLEDDiscovery import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -30,6 +29,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from . import async_wifi_bulb_for_host @@ -78,7 +78,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN): return FluxLedOptionsFlow() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = FluxLEDDiscovery( diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index f4982a13c3a..ca7fb7aeea2 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, @@ -30,10 +30,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, -) from .const import ( CONF_COLORS, @@ -67,7 +63,7 @@ _LOGGER = logging.getLogger(__name__) MODE_ATTRS = { ATTR_EFFECT, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -205,8 +201,8 @@ class FluxLight( ) -> None: """Initialize the light.""" super().__init__(coordinator, base_unique_id, None) - self._attr_min_mireds = color_temperature_kelvin_to_mired(self._device.max_temp) - self._attr_max_mireds = color_temperature_kelvin_to_mired(self._device.min_temp) + self._attr_min_color_temp_kelvin = self._device.min_temp + self._attr_max_color_temp_kelvin = self._device.max_temp self._attr_supported_color_modes = _hass_color_modes(self._device) custom_effects: list[str] = [] if custom_effect_colors: @@ -222,9 +218,9 @@ class FluxLight( return self._device.brightness @property - def color_temp(self) -> int: - """Return the kelvin value of this light in mired.""" - return color_temperature_kelvin_to_mired(self._device.color_temp) + def color_temp_kelvin(self) -> int: + """Return the kelvin value of this light.""" + return self._device.color_temp @property def rgb_color(self) -> tuple[int, int, int]: @@ -304,8 +300,7 @@ class FluxLight( await self._async_set_effect(effect, brightness) return # Handle switch to CCT Color Mode - if color_temp_mired := kwargs.get(ATTR_COLOR_TEMP): - color_temp_kelvin = color_temperature_mired_to_kelvin(color_temp_mired) + if color_temp_kelvin := kwargs.get(ATTR_COLOR_TEMP_KELVIN): if ( ATTR_BRIGHTNESS not in kwargs and self.color_mode in MULTI_BRIGHTNESS_COLOR_MODES diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index a55ae028342..962098a0bf8 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -53,5 +53,5 @@ "documentation": "https://www.home-assistant.io/integrations/flux_led", "iot_class": "local_push", "loggers": ["flux_led"], - "requirements": ["flux-led==1.0.4"] + "requirements": ["flux-led==1.1.0"] } diff --git a/homeassistant/components/flux_led/strings.json b/homeassistant/components/flux_led/strings.json index aa56708c645..8f4517ff722 100644 --- a/homeassistant/components/flux_led/strings.json +++ b/homeassistant/components/flux_led/strings.json @@ -101,7 +101,7 @@ }, "speed_pct": { "name": "Speed", - "description": "Effect speed for the custom effect (0-100)." + "description": "The speed of the effect in % (0-100, default 50)." }, "transition": { "name": "Transition", diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py index 3aeaa6f7ef2..dd56b3aad72 100644 --- a/homeassistant/components/folder_watcher/__init__.py +++ b/homeassistant/components/folder_watcher/__init__.py @@ -7,6 +7,10 @@ import os from typing import cast from watchdog.events import ( + DirCreatedEvent, + DirDeletedEvent, + DirModifiedEvent, + DirMovedEvent, FileClosedEvent, FileCreatedEvent, FileDeletedEvent, @@ -68,7 +72,7 @@ class EventHandler(PatternMatchingEventHandler): def __init__(self, patterns: list[str], hass: HomeAssistant, entry_id: str) -> None: """Initialise the EventHandler.""" - super().__init__(patterns) + super().__init__(patterns=patterns) self.hass = hass self.entry_id = entry_id @@ -101,19 +105,19 @@ class EventHandler(PatternMatchingEventHandler): signal = f"folder_watcher-{self.entry_id}" dispatcher_send(self.hass, signal, event.event_type, fireable) - def on_modified(self, event: FileModifiedEvent) -> None: + def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None: """File modified.""" self.process(event) - def on_moved(self, event: FileMovedEvent) -> None: + def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None: """File moved.""" self.process(event, moved=True) - def on_created(self, event: FileCreatedEvent) -> None: + def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None: """File created.""" self.process(event) - def on_deleted(self, event: FileDeletedEvent) -> None: + def on_deleted(self, event: DirDeletedEvent | FileDeletedEvent) -> None: """File deleted.""" self.process(event) diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 7b471e08fcc..1f0d9c595ee 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["watchdog"], "quality_scale": "internal", - "requirements": ["watchdog==2.3.1"] + "requirements": ["watchdog==6.0.0"] } diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index 5fb9f08f1c0..b2b2d498f60 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -7,7 +7,6 @@ from typing import Any from pyforked_daapd import ForkedDaapdAPI import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -17,6 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_LIBRESPOT_JAVA_PORT, @@ -164,7 +164,7 @@ class ForkedDaapdFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered forked-daapd device.""" version_num = 0 diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index b4d64464972..09df989447a 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_registry import async_migrate_entries +from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from .config_flow import DEFAULT_RTSP_PORT from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET @@ -36,6 +36,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + # Migrate to correct unique IDs for switches + await async_migrate_entities(hass, entry) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -92,3 +95,24 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Migration to version %s successful", entry.version) return True + + +async def async_migrate_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Migrate old entry.""" + + @callback + def _update_unique_id( + entity_entry: RegistryEntry, + ) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if ( + entity_entry.domain == Platform.SWITCH + and entity_entry.unique_id == "sleep_switch" + ): + entity_new_unique_id = f"{entity_entry.config_entry_id}_sleep_switch" + return {"new_unique_id": entity_new_unique_id} + + return None + + # Migrate entities + await async_migrate_entries(hass, entry.entry_id, _update_unique_id) diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py index 9eae211881f..dfc51aaa064 100644 --- a/homeassistant/components/foscam/switch.py +++ b/homeassistant/components/foscam/switch.py @@ -41,7 +41,7 @@ class FoscamSleepSwitch(FoscamEntity, SwitchEntity): """Initialize a Foscam Sleep Switch.""" super().__init__(coordinator, config_entry.entry_id) - self._attr_unique_id = "sleep_switch" + self._attr_unique_id = f"{config_entry.entry_id}_sleep_switch" self._attr_translation_key = "sleep_switch" self._attr_has_entity_name = True diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 88e2165defd..62a1cd14b3d 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -6,9 +6,9 @@ from typing import Any from freebox_api.exceptions import AuthorizationError, HttpRequestError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .router import get_api, get_hosts_list_if_supported @@ -99,7 +99,7 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="link", errors=errors) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Initialize flow from zeroconf.""" zeroconf_properties = discovery_info.properties diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index ad7da1703b8..0cfe37c7a31 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/freebox", "iot_class": "local_polling", "loggers": ["freebox_api"], - "requirements": ["freebox-api==1.1.0"], + "requirements": ["freebox-api==1.2.2"], "zeroconf": ["_fbx-api._tcp.local."] } diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index d534db7e858..a5b0144ce0c 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -73,7 +73,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): _attr_current_temperature = 0 _attr_target_temperature = 0 _attr_hvac_mode = HVACMode.OFF - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index 698d57d1001..d21ede9bad3 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -40,7 +40,6 @@ class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntit _attr_name = None _attr_is_on = False _attr_percentage = 0 - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 920ecda1c52..7b6057b3ba2 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -13,7 +13,6 @@ from fritzconnection import FritzConnection from fritzconnection.core.exceptions import FritzConnectionException import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -32,6 +31,12 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import ( @@ -150,7 +155,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): return DEFAULT_HTTPS_PORT if user_input[CONF_SSL] else DEFAULT_HTTP_PORT async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "") @@ -160,14 +165,13 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): self._host = host self._name = ( - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) - or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) + or discovery_info.upnp[ATTR_UPNP_MODEL_NAME] ) uuid: str | None - if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): - if uuid.startswith("uuid:"): - uuid = uuid[5:] + if uuid := discovery_info.upnp.get(ATTR_UPNP_UDN): + uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: self._host}) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 90bd6068ecb..52bff67c229 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -214,6 +214,18 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self._options = options await self.hass.async_add_executor_job(self.setup) + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + configuration_url=f"http://{self.host}", + connections={(dr.CONNECTION_NETWORK_MAC, self.mac)}, + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="AVM", + model=self.model, + name=self.config_entry.title, + sw_version=self.current_firmware, + ) + def setup(self) -> None: """Set up FritzboxTools class.""" @@ -429,7 +441,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): hosts_info = await self.hass.async_add_executor_job( self.fritz_hosts.get_hosts_info ) - except Exception as ex: # noqa: BLE001 + except Exception as ex: if not self.hass.is_stopping: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py index 45665c786d4..33eb60d72cf 100644 --- a/homeassistant/components/fritz/entity.py +++ b/homeassistant/components/fritz/entity.py @@ -68,23 +68,14 @@ class FritzBoxBaseEntity: """Init device info class.""" self._avm_wrapper = avm_wrapper self._device_name = device_name - - @property - def mac_address(self) -> str: - """Return the mac address of the main device.""" - return self._avm_wrapper.mac + self.mac_address = self._avm_wrapper.mac @property def device_info(self) -> DeviceInfo: """Return the device information.""" return DeviceInfo( - configuration_url=f"http://{self._avm_wrapper.host}", connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, identifiers={(DOMAIN, self._avm_wrapper.unique_id)}, - manufacturer="AVM", - model=self._avm_wrapper.model, - name=self._device_name, - sw_version=self._avm_wrapper.current_firmware, ) diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index b832492cf9d..06c572f93a6 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -95,4 +95,7 @@ rules: comment: | the fritzconnection lib is not async and relies on requests changing this might need a bit more efforts to be spent - strict-typing: done + strict-typing: + status: todo + comment: | + Requirements 'fritzconnection==1.14.0' and 'xmltodict==0.13.0' appear untyped diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 924d92d6c5b..d5a81fdef1a 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -88,7 +88,6 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): _attr_precision = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "thermostat" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index ffec4a9ea29..3f66b43cc0c 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -11,9 +11,13 @@ from pyfritzhome import Fritzhome, LoginError from requests.exceptions import HTTPError import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN @@ -109,7 +113,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" host = urlparse(discovery_info.ssdp_location).hostname @@ -121,9 +125,8 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): ): return self.async_abort(reason="ignore_ip6_link_local") - if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): - if uuid.startswith("uuid:"): - uuid = uuid[5:] + if uuid := discovery_info.upnp.get(ATTR_UPNP_UDN): + uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: host}) @@ -138,7 +141,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): self.hass.config_entries.async_update_entry(entry, unique_id=uuid) return self.async_abort(reason="already_configured") - self._name = str(discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or host) + self._name = str(discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or host) self.context["title_placeholders"] = {"name": self._name} return await self.async_step_confirm() diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 52fa3ba1a12..a6a30ffdc6a 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -27,6 +27,7 @@ class FritzboxCoordinatorData: devices: dict[str, FritzhomeDevice] templates: dict[str, FritzhomeTemplate] + supported_color_properties: dict[str, tuple[dict, list]] class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorData]): @@ -49,7 +50,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.new_devices: set[str] = set() self.new_templates: set[str] = set() - self.data = FritzboxCoordinatorData({}, {}) + self.data = FritzboxCoordinatorData({}, {}, {}) async def async_setup(self) -> None: """Set up the coordinator.""" @@ -120,6 +121,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat devices = self.fritz.get_devices() device_data = {} + supported_color_properties = self.data.supported_color_properties for device in devices: # assume device as unavailable, see #55799 if ( @@ -136,6 +138,13 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat device_data[device.ain] = device + # pre-load supported colors and color temps for new devices + if device.has_color and device.ain not in supported_color_properties: + supported_color_properties[device.ain] = ( + device.get_colors(), + device.get_color_temps(), + ) + template_data = {} if self.has_templates: templates = self.fritz.get_templates() @@ -145,7 +154,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.new_devices = device_data.keys() - self.data.devices.keys() self.new_templates = template_data.keys() - self.data.templates.keys() - return FritzboxCoordinatorData(devices=device_data, templates=template_data) + return FritzboxCoordinatorData( + devices=device_data, + templates=template_data, + supported_color_properties=supported_color_properties, + ) async def _async_update_data(self) -> FritzboxCoordinatorData: """Fetch all device data.""" diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index d347f6898c0..36cb7dc8cff 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -57,7 +57,6 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): ) -> None: """Initialize the FritzboxLight entity.""" super().__init__(coordinator, ain, None) - self._supported_hs: dict[int, list[int]] = {} self._attr_supported_color_modes = {ColorMode.ONOFF} if self.data.has_color: @@ -65,6 +64,26 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): elif self.data.has_level: self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + (supported_colors, supported_color_temps) = ( + coordinator.data.supported_color_properties.get(self.data.ain, ({}, [])) + ) + + # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. + # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup + self._supported_hs: dict[int, list[int]] = {} + for values in supported_colors.values(): + hue = int(values[0][0]) + self._supported_hs[hue] = [ + int(values[0][1]), + int(values[1][1]), + int(values[2][1]), + ] + + if supported_color_temps: + # only available for color bulbs + self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) + self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) + @property def is_on(self) -> bool: """If the light is currently on or off.""" @@ -148,30 +167,3 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Turn the light off.""" await self.hass.async_add_executor_job(self.data.set_state_off) await self.coordinator.async_refresh() - - async def async_added_to_hass(self) -> None: - """Get light attributes from device after entity is added to hass.""" - await super().async_added_to_hass() - - def _get_color_data() -> tuple[dict, list]: - return (self.data.get_colors(), self.data.get_color_temps()) - - ( - supported_colors, - supported_color_temps, - ) = await self.hass.async_add_executor_job(_get_color_data) - - if supported_color_temps: - # only available for color bulbs - self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) - self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) - - # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. - # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup - for values in supported_colors.values(): - hue = int(values[0][0]) - self._supported_hs[hue] = [ - int(values[0][1]), - int(values[1][1]), - int(values[2][1]), - ] diff --git a/homeassistant/components/fritzbox_callmonitor/base.py b/homeassistant/components/fritzbox_callmonitor/base.py index 2816880a1b2..3c8714624e7 100644 --- a/homeassistant/components/fritzbox_callmonitor/base.py +++ b/homeassistant/components/fritzbox_callmonitor/base.py @@ -3,6 +3,7 @@ from __future__ import annotations from contextlib import suppress +from dataclasses import dataclass from datetime import timedelta import logging import re @@ -19,12 +20,33 @@ _LOGGER = logging.getLogger(__name__) MIN_TIME_PHONEBOOK_UPDATE = timedelta(hours=6) +@dataclass +class Contact: + """Store details for one phonebook contact.""" + + name: str + numbers: list[str] + vip: bool + + def __init__( + self, name: str, numbers: list[str] | None = None, category: str | None = None + ) -> None: + """Initialize the class.""" + self.name = name + self.numbers = [re.sub(REGEX_NUMBER, "", nr) for nr in numbers or ()] + self.vip = category == "1" + + +unknown_contact = Contact(UNKNOWN_NAME) + + class FritzBoxPhonebook: """Connects to a FritzBox router and downloads its phone book.""" fph: FritzPhonebook phonebook_dict: dict[str, list[str]] - number_dict: dict[str, str] + contacts: list[Contact] + number_dict: dict[str, Contact] def __init__( self, @@ -56,27 +78,27 @@ class FritzBoxPhonebook: if self.phonebook_id is None: return - self.phonebook_dict = self.fph.get_all_names(self.phonebook_id) - self.number_dict = { - re.sub(REGEX_NUMBER, "", nr): name - for name, nrs in self.phonebook_dict.items() - for nr in nrs - } + self.fph.get_all_name_numbers(self.phonebook_id) + self.contacts = [ + Contact(c.name, c.numbers, getattr(c, "category", None)) + for c in self.fph.phonebook.contacts + ] + self.number_dict = {nr: c for c in self.contacts for nr in c.numbers} _LOGGER.debug("Fritz!Box phone book successfully updated") def get_phonebook_ids(self) -> list[int]: """Return list of phonebook ids.""" return self.fph.phonebook_ids # type: ignore[no-any-return] - def get_name(self, number: str) -> str: - """Return a name for a given phone number.""" + def get_contact(self, number: str) -> Contact: + """Return a contact for a given phone number.""" number = re.sub(REGEX_NUMBER, "", str(number)) with suppress(KeyError): return self.number_dict[number] if not self.prefixes: - return UNKNOWN_NAME + return unknown_contact for prefix in self.prefixes: with suppress(KeyError): @@ -84,4 +106,4 @@ class FritzBoxPhonebook: with suppress(KeyError): return self.number_dict[prefix + number.lstrip("0")] - return UNKNOWN_NAME + return unknown_contact diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index 7bd0eacb66a..8435eff3e18 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -12,19 +12,12 @@ from requests.exceptions import ConnectionError as RequestsConnectionError import voluptuous as vol from homeassistant.config_entries import ( - SOURCE_IMPORT, ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback from .base import FritzBoxPhonebook @@ -170,16 +163,11 @@ class FritzBoxCallMonitorConfigFlow(ConfigFlow, domain=DOMAIN): if result != ConnectResult.SUCCESS: return self.async_abort(reason=result) - if self.context["source"] == SOURCE_IMPORT: - self._phonebook_id = user_input[CONF_PHONEBOOK] - self._phonebook_name = user_input[CONF_NAME] - - elif len(self._phonebook_ids) > 1: + if len(self._phonebook_ids) > 1: return await self.async_step_phonebook() - else: - self._phonebook_id = DEFAULT_PHONEBOOK - self._phonebook_name = await self._get_name_of_phonebook(self._phonebook_id) + self._phonebook_id = DEFAULT_PHONEBOOK + self._phonebook_name = await self._get_name_of_phonebook(self._phonebook_id) await self.async_set_unique_id(f"{self._serial_number}-{self._phonebook_id}") self._abort_if_unique_id_configured() diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 668369c35a7..df18ae5702a 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxCallMonitorConfigEntry -from .base import FritzBoxPhonebook +from .base import Contact, FritzBoxPhonebook from .const import ( ATTR_PREFIXES, CONF_PHONEBOOK, @@ -96,7 +96,7 @@ class FritzBoxCallSensor(SensorEntity): self._host = host self._port = port self._monitor: FritzBoxCallMonitor | None = None - self._attributes: dict[str, str | list[str]] = {} + self._attributes: dict[str, str | list[str] | bool] = {} self._attr_translation_placeholders = {"phonebook_name": phonebook_name} self._attr_unique_id = unique_id @@ -152,20 +152,20 @@ class FritzBoxCallSensor(SensorEntity): """Set the state.""" self._attr_native_value = state - def set_attributes(self, attributes: Mapping[str, str]) -> None: + def set_attributes(self, attributes: Mapping[str, str | bool]) -> None: """Set the state attributes.""" self._attributes = {**attributes} @property - def extra_state_attributes(self) -> dict[str, str | list[str]]: + def extra_state_attributes(self) -> dict[str, str | list[str] | bool]: """Return the state attributes.""" if self._prefixes: self._attributes[ATTR_PREFIXES] = self._prefixes return self._attributes - def number_to_name(self, number: str) -> str: - """Return a name for a given phone number.""" - return self._fritzbox_phonebook.get_name(number) + def number_to_contact(self, number: str) -> Contact: + """Return a contact for a given phone number.""" + return self._fritzbox_phonebook.get_contact(number) def update(self) -> None: """Update the phonebook if it is defined.""" @@ -225,35 +225,42 @@ class FritzBoxCallMonitor: df_in = "%d.%m.%y %H:%M:%S" df_out = "%Y-%m-%dT%H:%M:%S" isotime = datetime.strptime(line[0], df_in).strftime(df_out) + att: dict[str, str | bool] if line[1] == FritzState.RING: self._sensor.set_state(CallState.RINGING) + contact = self._sensor.number_to_contact(line[3]) att = { "type": "incoming", "from": line[3], "to": line[4], "device": line[5], "initiated": isotime, - "from_name": self._sensor.number_to_name(line[3]), + "from_name": contact.name, + "vip": contact.vip, } self._sensor.set_attributes(att) elif line[1] == FritzState.CALL: self._sensor.set_state(CallState.DIALING) + contact = self._sensor.number_to_contact(line[5]) att = { "type": "outgoing", "from": line[4], "to": line[5], "device": line[6], "initiated": isotime, - "to_name": self._sensor.number_to_name(line[5]), + "to_name": contact.name, + "vip": contact.vip, } self._sensor.set_attributes(att) elif line[1] == FritzState.CONNECT: self._sensor.set_state(CallState.TALKING) + contact = self._sensor.number_to_contact(line[4]) att = { "with": line[4], "device": line[3], "accepted": isotime, - "with_name": self._sensor.number_to_name(line[4]), + "with_name": contact.name, + "vip": contact.vip, } self._sensor.set_attributes(att) elif line[1] == FritzState.DISCONNECT: diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index e935549035c..437b218a8e2 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -78,7 +78,8 @@ "accepted": { "name": "Accepted" }, "with_name": { "name": "With name" }, "duration": { "name": "Duration" }, - "closed": { "name": "Closed" } + "closed": { "name": "Closed" }, + "vip": { "name": "Important" } } } } diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index e30f8e85fa0..4ba893df85c 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -60,7 +60,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FroniusConfigEntry) -> async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: FroniusConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" return True @@ -226,7 +226,14 @@ class FroniusSolarNet: _LOGGER.debug("Re-scan failed for %s", self.host) return inverter_infos - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="entry_cannot_connect", + translation_placeholders={ + "host": self.host, + "fronius_error": str(err), + }, + ) from err for inverter in _inverter_info["inverters"]: solar_net_id = inverter["device_id"]["value"] diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py index 2adbf2ae2f3..f35c9ce5bc1 100644 --- a/homeassistant/components/fronius/config_flow.py +++ b/homeassistant/components/fronius/config_flow.py @@ -9,12 +9,12 @@ from typing import Any, Final from pyfronius import Fronius, FroniusError import voluptuous as vol -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, FroniusConfigEntryData @@ -52,11 +52,9 @@ async def validate_host( try: inverter_info = await fronius.inverter_info() first_inverter = next(inverter for inverter in inverter_info["inverters"]) - except FroniusError as err: + except (FroniusError, StopIteration) as err: _LOGGER.debug(err) raise CannotConnect from err - except StopIteration as err: - raise CannotConnect("No supported Fronius SolarNet device found.") from err first_inverter_uid: str = first_inverter["unique_id"]["value"] return first_inverter_uid, FroniusConfigEntryData( host=host, @@ -89,7 +87,7 @@ class FroniusConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: await self.async_set_unique_id(unique_id, raise_on_progress=False) - self._abort_if_unique_id_configured(updates=dict(info)) + self._abort_if_unique_id_configured() return self.async_create_entry(title=create_title(info), data=info) diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py index 083085270e0..e8b2fa6c2e8 100644 --- a/homeassistant/components/fronius/const.py +++ b/homeassistant/components/fronius/const.py @@ -42,8 +42,6 @@ class InverterStatusCodeOption(StrEnum): IDLE = "idle" READY = "ready" SLEEPING = "sleeping" - UNKNOWN = "unknown" - INVALID = "invalid" _INVERTER_STATUS_CODES: Final[dict[int, InverterStatusCodeOption]] = { @@ -61,13 +59,174 @@ _INVERTER_STATUS_CODES: Final[dict[int, InverterStatusCodeOption]] = { 11: InverterStatusCodeOption.IDLE, 12: InverterStatusCodeOption.READY, 13: InverterStatusCodeOption.SLEEPING, - 255: InverterStatusCodeOption.UNKNOWN, + # 255: "Unknown" is handled by `None` state - same as the invalid codes. } -def get_inverter_status_message(code: StateType) -> InverterStatusCodeOption: +def get_inverter_status_message(code: StateType) -> InverterStatusCodeOption | None: """Return a status message for a given status code.""" - return _INVERTER_STATUS_CODES.get(code, InverterStatusCodeOption.INVALID) # type: ignore[arg-type] + return _INVERTER_STATUS_CODES.get(code) # type: ignore[arg-type] + + +INVERTER_ERROR_CODES: Final[dict[int, str]] = { + 0: "no_error", + 102: "ac_voltage_too_high", + 103: "ac_voltage_too_low", + 105: "ac_frequency_too_high", + 106: "ac_frequency_too_low", + 107: "ac_grid_outside_permissible_limits", + 108: "stand_alone_operation_detected", + 112: "rcmu_error", + 240: "arc_detection_triggered", + 241: "arc_detection_triggered", + 242: "arc_detection_triggered", + 243: "arc_detection_triggered", + 301: "overcurrent_ac", + 302: "overcurrent_dc", + 303: "dc_module_over_temperature", + 304: "ac_module_over_temperature", + 305: "no_power_fed_in_despite_closed_relay", + 306: "pv_output_too_low_for_feeding_energy_into_the_grid", + 307: "low_pv_voltage_dc_input_voltage_too_low", + 308: "intermediate_circuit_voltage_too_high", + 309: "dc_input_voltage_mppt_1_too_high", + 311: "polarity_of_dc_strings_reversed", + 313: "dc_input_voltage_mppt_2_too_high", + 314: "current_sensor_calibration_timeout", + 315: "ac_current_sensor_error", + 316: "interrupt_check_fail", + 325: "overtemperature_in_connection_area", + 326: "fan_1_error", + 327: "fan_2_error", + 401: "no_communication_with_power_stage_set", + 406: "ac_module_temperature_sensor_faulty_l1", + 407: "ac_module_temperature_sensor_faulty_l2", + 408: "dc_component_measured_in_grid_too_high", + 412: "fixed_voltage_mode_out_of_range", + 415: "safety_cut_out_triggered", + 416: "no_communication_between_power_stage_and_control_system", + 417: "hardware_id_problem", + 419: "unique_id_conflict", + 420: "no_communication_with_hybrid_manager", + 421: "hid_range_error", + 425: "no_communication_with_power_stage_set", + 426: "possible_hardware_fault", + 427: "possible_hardware_fault", + 428: "possible_hardware_fault", + 431: "software_problem", + 436: "functional_incompatibility_between_pc_boards", + 437: "power_stage_set_problem", + 438: "functional_incompatibility_between_pc_boards", + 443: "intermediate_circuit_voltage_too_low_or_asymmetric", + 445: "compatibility_error_invalid_power_stage_configuration", + 447: "insulation_fault", + 448: "neutral_conductor_not_connected", + 450: "guard_cannot_be_found", + 451: "memory_error_detected", + 452: "communication", + 502: "insulation_error_on_solar_panels", + 509: "no_energy_fed_into_grid_past_24_hours", + 515: "no_communication_with_filter", + 516: "no_communication_with_storage_unit", + 517: "power_derating_due_to_high_temperature", + 518: "internal_dsp_malfunction", + 519: "no_communication_with_storage_unit", + 520: "no_energy_fed_by_mppt1_past_24_hours", + 522: "dc_low_string_1", + 523: "dc_low_string_2", + 558: "functional_incompatibility_between_pc_boards", + 559: "functional_incompatibility_between_pc_boards", + 560: "derating_caused_by_over_frequency", + 564: "functional_incompatibility_between_pc_boards", + 566: "arc_detector_switched_off", + 567: "grid_voltage_dependent_power_reduction_active", + 601: "can_bus_full", + 603: "ac_module_temperature_sensor_faulty_l3", + 604: "dc_module_temperature_sensor_faulty", + 607: "rcmu_error", + 608: "functional_incompatibility_between_pc_boards", + 701: "internal_processor_status", + 702: "internal_processor_status", + 703: "internal_processor_status", + 704: "internal_processor_status", + 705: "internal_processor_status", + 706: "internal_processor_status", + 707: "internal_processor_status", + 708: "internal_processor_status", + 709: "internal_processor_status", + 710: "internal_processor_status", + 711: "internal_processor_status", + 712: "internal_processor_status", + 713: "internal_processor_status", + 714: "internal_processor_status", + 715: "internal_processor_status", + 716: "internal_processor_status", + 721: "eeprom_reinitialised", + 722: "internal_processor_status", + 723: "internal_processor_status", + 724: "internal_processor_status", + 725: "internal_processor_status", + 726: "internal_processor_status", + 727: "internal_processor_status", + 728: "internal_processor_status", + 729: "internal_processor_status", + 730: "internal_processor_status", + 731: "initialisation_error_usb_flash_drive_not_supported", + 732: "initialisation_error_usb_stick_over_current", + 733: "no_usb_flash_drive_connected", + 734: "update_file_not_recognised_or_missing", + 735: "update_file_does_not_match_device", + 736: "write_or_read_error_occurred", + 737: "file_could_not_be_opened", + 738: "log_file_cannot_be_saved", + 740: "initialisation_error_file_system_error_on_usb", + 741: "error_during_logging_data_recording", + 743: "error_during_update_process", + 745: "update_file_corrupt", + 746: "error_during_update_process", + 751: "time_lost", + 752: "real_time_clock_communication_error", + 753: "real_time_clock_in_emergency_mode", + 754: "internal_processor_status", + 755: "internal_processor_status", + 757: "real_time_clock_hardware_error", + 758: "real_time_clock_in_emergency_mode", + 760: "internal_hardware_error", + 761: "internal_processor_status", + 762: "internal_processor_status", + 763: "internal_processor_status", + 764: "internal_processor_status", + 765: "internal_processor_status", + 766: "emergency_power_derating_activated", + 767: "internal_processor_status", + 768: "different_power_limitation_in_hardware_modules", + 772: "storage_unit_not_available", + 773: "software_update_invalid_country_setup", + 775: "pmc_power_stage_set_not_available", + 776: "invalid_device_type", + 781: "internal_processor_status", + 782: "internal_processor_status", + 783: "internal_processor_status", + 784: "internal_processor_status", + 785: "internal_processor_status", + 786: "internal_processor_status", + 787: "internal_processor_status", + 788: "internal_processor_status", + 789: "internal_processor_status", + 790: "internal_processor_status", + 791: "internal_processor_status", + 792: "internal_processor_status", + 793: "internal_processor_status", + 794: "internal_processor_status", + 1001: "insulation_measurement_triggered", + 1024: "inverter_settings_changed_restart_required", + 1030: "wired_shut_down_triggered", + 1036: "grid_frequency_exceeded_limit_reconnecting", + 1112: "mains_voltage_dependent_power_reduction", + 1175: "too_little_dc_power_for_feed_in_operation", + 1196: "inverter_required_setup_values_not_received", + 65000: "dc_connection_inverter_battery_interrupted", +} class MeterLocationCodeOption(StrEnum): diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index c3dea123a77..d4f1fc6c230 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + DOMAIN, SOLAR_NET_ID_POWER_FLOW, SOLAR_NET_ID_SYSTEM, FroniusDeviceInfo, @@ -67,7 +68,11 @@ class FroniusCoordinatorBase( self._failed_update_count += 1 if self._failed_update_count == self.MAX_FAILED_UPDATES: self.update_interval = self.error_interval - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"fronius_error": str(err)}, + ) from err if self._failed_update_count != 0: self._failed_update_count = 0 diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 227234f9937..94d0f90b0bd 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -11,5 +11,6 @@ "documentation": "https://www.home-assistant.io/integrations/fronius", "iot_class": "local_polling", "loggers": ["pyfronius"], + "quality_scale": "gold", "requirements": ["PyFronius==0.7.3"] } diff --git a/homeassistant/components/fronius/quality_scale.yaml b/homeassistant/components/fronius/quality_scale.yaml new file mode 100644 index 00000000000..2c4b892475b --- /dev/null +++ b/homeassistant/components/fronius/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: + status: done + comment: | + Single platform only, so no entity.py file. + CoordinatorEntity is used. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + This integration does not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide configuration options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: done + comment: | + Coordinators are used and asyncio.Lock mutex across them ensure proper + rate limiting. Platforms are read-only. + reauthentication-flow: + status: exempt + comment: | + This integration doesn't require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + This integration doesn't have any known user-repairable issues. + stale-devices: done + # Platinum + async-dependency: done + inject-websession: done + strict-typing: + status: todo + comment: | + The pyfronius library isn't strictly typed and doesn't export type information. diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index c8a840b1c2c..c6c3ff4b602 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -33,6 +33,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DOMAIN, + INVERTER_ERROR_CODES, SOLAR_NET_DISCOVERY_NEW, InverterStatusCodeOption, MeterLocationCodeOption, @@ -54,6 +55,9 @@ if TYPE_CHECKING: FroniusStorageUpdateCoordinator, ) + +PARALLEL_UPDATES = 0 + ENERGY_VOLT_AMPERE_REACTIVE_HOUR: Final = "varh" @@ -202,6 +206,15 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ FroniusSensorEntityDescription( key="error_code", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + FroniusSensorEntityDescription( + key="error_message", + response_key="error_code", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=list(dict.fromkeys(INVERTER_ERROR_CODES.values())), + value_fn=INVERTER_ERROR_CODES.get, # type: ignore[arg-type] ), FroniusSensorEntityDescription( key="status_code", @@ -781,7 +794,7 @@ class LoggerSensor(_FroniusSensorEntity): "unit" ) self._attr_unique_id = ( - f'{logger_data["unique_identifier"]["value"]}-{description.key}' + f"{logger_data['unique_identifier']['value']}-{description.key}" ) @@ -802,7 +815,7 @@ class MeterSensor(_FroniusSensorEntity): if (meter_uid := meter_data["serial"]["value"]) == "n.a.": meter_uid = ( f"{coordinator.solar_net.solar_net_device_id}:" - f'{meter_data["model"]["value"]}' + f"{meter_data['model']['value']}" ) self._attr_device_info = DeviceInfo( @@ -836,7 +849,7 @@ class OhmpilotSensor(_FroniusSensorEntity): sw_version=device_data["software"]["value"], via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), ) - self._attr_unique_id = f'{device_data["serial"]["value"]}-{description.key}' + self._attr_unique_id = f"{device_data['serial']['value']}-{description.key}" class PowerFlowSensor(_FroniusSensorEntity): @@ -870,7 +883,7 @@ class StorageSensor(_FroniusSensorEntity): super().__init__(coordinator, description, solar_net_id) storage_data = self._device_data() - self._attr_unique_id = f'{storage_data["serial"]["value"]}-{description.key}' + self._attr_unique_id = f"{storage_data['serial']['value']}-{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, storage_data["serial"]["value"])}, manufacturer=storage_data["manufacturer"]["value"], diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index dfdcfc0ddb2..b77f6fec83c 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -3,10 +3,12 @@ "flow_title": "{device}", "step": { "user": { - "title": "Fronius SolarNet", - "description": "Configure the IP address or local hostname of your Fronius device.", + "description": "Configure your Fronius SolarAPI device.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The IP address or hostname of your Fronius device." } }, "confirm_discovery": { @@ -16,6 +18,9 @@ "description": "Update your configuration information for {device}.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::fronius::config::step::user::data_description::host%]" } } }, @@ -41,9 +46,6 @@ "energy_total": { "name": "Total energy" }, - "frequency_ac": { - "name": "[%key:component::sensor::entity_component::frequency::name%]" - }, "current_ac": { "name": "AC current" }, @@ -71,6 +73,107 @@ "error_code": { "name": "Error code" }, + "error_message": { + "name": "Error message", + "state": { + "no_error": "No error", + "ac_voltage_too_high": "AC voltage too high", + "ac_voltage_too_low": "AC voltage too low", + "ac_frequency_too_high": "AC frequency too high", + "ac_frequency_too_low": "AC frequency too low", + "ac_grid_outside_permissible_limits": "AC grid outside the permissible limits", + "stand_alone_operation_detected": "Stand alone operation detected", + "rcmu_error": "RCMU error", + "arc_detection_triggered": "Arc detection triggered", + "overcurrent_ac": "Overcurrent (AC)", + "overcurrent_dc": "Overcurrent (DC)", + "dc_module_over_temperature": "DC module over temperature", + "ac_module_over_temperature": "AC module over temperature", + "no_power_fed_in_despite_closed_relay": "No power being fed in, despite closed relay", + "pv_output_too_low_for_feeding_energy_into_the_grid": "PV output too low for feeding energy into the grid", + "low_pv_voltage_dc_input_voltage_too_low": "Low PV voltage - DC input voltage too low for feeding energy into the grid", + "intermediate_circuit_voltage_too_high": "Intermediate circuit voltage too high", + "dc_input_voltage_mppt_1_too_high": "DC input voltage MPPT 1 too high", + "polarity_of_dc_strings_reversed": "Polarity of DC strings reversed", + "dc_input_voltage_mppt_2_too_high": "DC input voltage MPPT 2 too high", + "current_sensor_calibration_timeout": "Current sensor calibration timeout", + "ac_current_sensor_error": "AC current sensor error", + "interrupt_check_fail": "Interrupt Check fail", + "overtemperature_in_connection_area": "Overtemperature in the connection area", + "fan_1_error": "Fan 1 error", + "fan_2_error": "Fan 2 error", + "no_communication_with_power_stage_set": "No communication with the power stage set possible", + "ac_module_temperature_sensor_faulty_l1": "AC module temperature sensor faulty (L1)", + "ac_module_temperature_sensor_faulty_l2": "AC module temperature sensor faulty (L2)", + "dc_component_measured_in_grid_too_high": "DC component measured in the grid too high", + "fixed_voltage_mode_out_of_range": "Fixed voltage mode has been selected instead of MPP voltage mode and the fixed voltage has been set to too low or too high a value", + "safety_cut_out_triggered": "Safety cut out via option card or RECERBO has triggered", + "no_communication_between_power_stage_and_control_system": "No communication possible between power stage set and control system", + "hardware_id_problem": "Hardware ID problem", + "unique_id_conflict": "Unique ID conflict", + "no_communication_with_hybrid_manager": "No communication possible with the Hybrid manager", + "hid_range_error": "HID range error", + "possible_hardware_fault": "Possible hardware fault", + "software_problem": "Software problem", + "functional_incompatibility_between_pc_boards": "Functional incompatibility (one or more PC boards in the inverter are not compatible with each other, e.g. after a PC board has been replaced)", + "power_stage_set_problem": "Power stage set problem", + "intermediate_circuit_voltage_too_low_or_asymmetric": "Intermediate circuit voltage too low or asymmetric", + "compatibility_error_invalid_power_stage_configuration": "Compatibility error (e.g. due to replacement of a PC board) - invalid power stage set configuration", + "insulation_fault": "Insulation fault", + "neutral_conductor_not_connected": "Neutral conductor not connected", + "guard_cannot_be_found": "Guard cannot be found", + "memory_error_detected": "Memory error detected", + "communication": "Communication error", + "insulation_error_on_solar_panels": "Insulation error on the solar panels", + "no_energy_fed_into_grid_past_24_hours": "No energy fed into the grid in the past 24 hours", + "no_communication_with_filter": "No communication with filter possible", + "no_communication_with_storage_unit": "No communication possible with the storage unit", + "power_derating_due_to_high_temperature": "Power derating caused by too high a temperature", + "internal_dsp_malfunction": "Internal DSP malfunction", + "no_energy_fed_by_mppt1_past_24_hours": "No energy fed into the grid by MPPT1 in the past 24 hours", + "dc_low_string_1": "DC low string 1", + "dc_low_string_2": "DC low string 2", + "derating_caused_by_over_frequency": "Derating caused by over-frequency", + "arc_detector_switched_off": "Arc detector switched off (e.g. during external arc monitoring)", + "grid_voltage_dependent_power_reduction_active": "Grid Voltage Dependent Power Reduction is active", + "can_bus_full": "CAN bus is full", + "ac_module_temperature_sensor_faulty_l3": "AC module temperature sensor faulty (L3)", + "dc_module_temperature_sensor_faulty": "DC module temperature sensor faulty", + "internal_processor_status": "Warning about the internal processor status. See status code for more information", + "eeprom_reinitialised": "EEPROM has been re-initialised", + "initialisation_error_usb_flash_drive_not_supported": "Initialisation error – USB flash drive is not supported", + "initialisation_error_usb_stick_over_current": "Initialisation error – Over current on USB stick", + "no_usb_flash_drive_connected": "No USB flash drive connected", + "update_file_not_recognised_or_missing": "Update file not recognised or not present", + "update_file_does_not_match_device": "Update file does not match the device, update file too old", + "write_or_read_error_occurred": "Write or read error occurred", + "file_could_not_be_opened": "File could not be opened", + "log_file_cannot_be_saved": "Log file cannot be saved (e.g. USB flash drive is write protected or full)", + "initialisation_error_file_system_error_on_usb": "Initialisation error in file system on USB flash drive", + "error_during_logging_data_recording": "Error during recording of logging data", + "error_during_update_process": "Error occurred during update process", + "update_file_corrupt": "Update file corrupt", + "time_lost": "Time lost", + "real_time_clock_communication_error": "Real Time Clock module communication error", + "real_time_clock_in_emergency_mode": "Internal error: Real Time Clock module is in emergency mode", + "real_time_clock_hardware_error": "Hardware error in the Real Time Clock module", + "internal_hardware_error": "Internal hardware error", + "emergency_power_derating_activated": "Emergency power derating activated", + "different_power_limitation_in_hardware_modules": "Different power limitation in the hardware modules", + "storage_unit_not_available": "Storage unit not available", + "software_update_invalid_country_setup": "Software update group 0 (invalid country setup)", + "pmc_power_stage_set_not_available": "PMC power stage set not available", + "invalid_device_type": "Invalid device type", + "insulation_measurement_triggered": "Insulation measurement triggered", + "inverter_settings_changed_restart_required": "Inverter settings have been changed, inverter restart required", + "wired_shut_down_triggered": "Wired shut down triggered", + "grid_frequency_exceeded_limit_reconnecting": "The grid frequency has exceeded a limit value when reconnecting", + "mains_voltage_dependent_power_reduction": "Mains voltage-dependent power reduction", + "too_little_dc_power_for_feed_in_operation": "Too little DC power for feed-in operation", + "inverter_required_setup_values_not_received": "Inverter required setup values could not be received", + "dc_connection_inverter_battery_interrupted": "DC connection between inverter and battery interrupted" + } + }, "status_code": { "name": "Status code" }, @@ -84,9 +187,7 @@ "error": "Error", "idle": "Idle", "ready": "Ready", - "sleeping": "Sleeping", - "unknown": "Unknown", - "invalid": "Invalid" + "sleeping": "Sleeping" } }, "led_state": { @@ -156,9 +257,6 @@ "power_apparent_phase_3": { "name": "Apparent power phase 3" }, - "power_apparent": { - "name": "[%key:component::sensor::entity_component::apparent_power::name%]" - }, "power_factor_phase_1": { "name": "Power factor phase 1" }, @@ -168,9 +266,6 @@ "power_factor_phase_3": { "name": "Power factor phase 3" }, - "power_factor": { - "name": "[%key:component::sensor::entity_component::power_factor::name%]" - }, "power_reactive_phase_1": { "name": "Reactive power phase 1" }, @@ -216,12 +311,6 @@ "energy_real_ac_consumed": { "name": "Energy consumed" }, - "power_real_ac": { - "name": "[%key:component::sensor::entity_component::power::name%]" - }, - "temperature_channel_1": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, "state_code": { "name": "State code" }, @@ -294,5 +383,13 @@ "name": "[%key:component::sensor::entity_component::temperature::name%]" } } + }, + "exceptions": { + "entry_cannot_connect": { + "message": "Failed to connect to Fronius device at {host}: {fronius_error}" + }, + "update_failed": { + "message": "An error occurred while attempting to fetch data: {fronius_error}" + } } } diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index c1098ac19d3..050d57fc358 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -11,7 +11,7 @@ from typing import Any, TypedDict from aiohttp import hdrs, web, web_urldispatcher import jinja2 -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from yarl import URL diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4dc5a2b0ae4..2724569d1ed 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -1,6 +1,7 @@ { "domain": "frontend", "name": "Home Assistant Frontend", + "after_dependencies": ["backup"], "codeowners": ["@home-assistant/frontend"], "dependencies": [ "api", @@ -20,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241106.2"] + "requirements": ["home-assistant-frontend==20250109.2"] } diff --git a/homeassistant/components/frontier_silicon/browse_media.py b/homeassistant/components/frontier_silicon/browse_media.py index 0b51cb767c7..9bad880a9b3 100644 --- a/homeassistant/components/frontier_silicon/browse_media.py +++ b/homeassistant/components/frontier_silicon/browse_media.py @@ -38,7 +38,9 @@ def _item_preset_payload(preset: Preset, player_mode: str) -> BrowseMedia: media_content_type=MediaType.CHANNEL, # We add 1 to the preset key to keep it in sync with the numbering shown # on the interface of the device - media_content_id=f"{player_mode}/{MEDIA_CONTENT_ID_PRESET}/{int(preset.key)+1}", + media_content_id=( + f"{player_mode}/{MEDIA_CONTENT_ID_PRESET}/{int(preset.key) + 1}" + ), can_play=True, can_expand=False, ) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 0612419fc33..f6514da28ff 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -15,9 +15,9 @@ from afsapi import ( ) import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_WEBFSAPI_URL, @@ -87,7 +87,7 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Process entity discovered via SSDP.""" diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 03d9f28c016..d3e1cd84e4a 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -12,7 +12,7 @@ }, "device_config": { "title": "Device configuration", - "description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'", + "description": "The PIN can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'", "data": { "pin": "[%key:common::config_flow::data::pin%]" } diff --git a/homeassistant/components/fujitsu_fglair/__init__.py b/homeassistant/components/fujitsu_fglair/__init__.py index f25e01bcd11..547545e4feb 100644 --- a/homeassistant/components/fujitsu_fglair/__init__.py +++ b/homeassistant/components/fujitsu_fglair/__init__.py @@ -15,7 +15,7 @@ from homeassistant.helpers import aiohttp_client from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU from .coordinator import FGLairCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] type FGLairConfigEntry = ConfigEntry[FGLairCoordinator] diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index 726096eab1a..c0f5ab7dce4 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -25,13 +25,11 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import FGLairConfigEntry -from .const import DOMAIN from .coordinator import FGLairCoordinator +from .entity import FGLairEntity HA_TO_FUJI_FAN = { FAN_LOW: FanSpeed.LOW, @@ -72,30 +70,19 @@ async def async_setup_entry( ) -class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity): +class FGLairDevice(FGLairEntity, ClimateEntity): """Represent a Fujitsu HVAC device.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_precision = PRECISION_HALVES _attr_target_temperature_step = 0.5 - _attr_has_entity_name = True _attr_name = None - _enable_turn_on_off_backwards_compatibility: bool = False - def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: """Store the representation of the device and set the static attributes.""" - super().__init__(coordinator, context=device.device_serial_number) + super().__init__(coordinator, device) self._attr_unique_id = device.device_serial_number - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.device_serial_number)}, - name=device.device_name, - manufacturer="Fujitsu", - model=device.property_values["model_name"], - serial_number=device.device_serial_number, - sw_version=device.property_values["mcu_firmware_version"], - ) self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE @@ -111,11 +98,6 @@ class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity): self._attr_supported_features |= ClimateEntityFeature.SWING_MODE self._set_attr() - @property - def device(self) -> FujitsuHVAC: - """Return the device object from the coordinator data.""" - return self.coordinator.data[self.coordinator_context] - @property def available(self) -> bool: """Return if the device is available.""" diff --git a/homeassistant/components/fujitsu_fglair/coordinator.py b/homeassistant/components/fujitsu_fglair/coordinator.py index eac3cfd6ce5..d98464e4751 100644 --- a/homeassistant/components/fujitsu_fglair/coordinator.py +++ b/homeassistant/components/fujitsu_fglair/coordinator.py @@ -48,10 +48,16 @@ class FGLairCoordinator(DataUpdateCoordinator[dict[str, FujitsuHVAC]]): raise ConfigEntryAuthFailed("Credentials expired for Ayla IoT API") from e if not listening_entities: - devices = [dev for dev in devices if isinstance(dev, FujitsuHVAC)] + devices = [ + dev + for dev in devices + if isinstance(dev, FujitsuHVAC) and dev.is_online() + ] else: devices = [ - dev for dev in devices if dev.device_serial_number in listening_entities + dev + for dev in devices + if dev.device_serial_number in listening_entities and dev.is_online() ] try: diff --git a/homeassistant/components/fujitsu_fglair/entity.py b/homeassistant/components/fujitsu_fglair/entity.py new file mode 100644 index 00000000000..5c41a8ab18e --- /dev/null +++ b/homeassistant/components/fujitsu_fglair/entity.py @@ -0,0 +1,38 @@ +"""Fujitsu FGlair base entity.""" + +from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FGLairCoordinator + + +class FGLairEntity(CoordinatorEntity[FGLairCoordinator]): + """Generic Fglair entity (base class).""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: + """Store the representation of the device.""" + super().__init__(coordinator, context=device.device_serial_number) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_serial_number)}, + name=device.device_name, + manufacturer="Fujitsu", + model=device.property_values["model_name"], + serial_number=device.device_serial_number, + sw_version=device.property_values["mcu_firmware_version"], + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator_context in self.coordinator.data + + @property + def device(self) -> FujitsuHVAC: + """Return the device object from the coordinator data.""" + return self.coordinator.data[self.coordinator_context] diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index ea08a2cfe02..330685f89fc 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "iot_class": "cloud_polling", - "requirements": ["ayla-iot-unofficial==1.4.4"] + "requirements": ["ayla-iot-unofficial==1.4.5"] } diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py new file mode 100644 index 00000000000..1426e2349ea --- /dev/null +++ b/homeassistant/components/fujitsu_fglair/sensor.py @@ -0,0 +1,47 @@ +"""Outside temperature sensor for Fujitsu FGlair HVAC systems.""" + +from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .climate import FGLairConfigEntry +from .coordinator import FGLairCoordinator +from .entity import FGLairEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FGLairConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up one Fujitsu HVAC device.""" + async_add_entities( + FGLairOutsideTemperature(entry.runtime_data, device) + for device in entry.runtime_data.data.values() + ) + + +class FGLairOutsideTemperature(FGLairEntity, SensorEntity): + """Entity representing outside temperature sensed by the outside unit of a Fujitsu Heatpump.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_translation_key = "fglair_outside_temp" + + def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: + """Store the representation of the device.""" + super().__init__(coordinator, device) + self._attr_unique_id = f"{device.device_serial_number}_outside_temperature" + + @property + def native_value(self) -> float | None: + """Return the sensed outdoor temperature un celsius.""" + return self.device.outdoor_temperature # type: ignore[no-any-return] diff --git a/homeassistant/components/fujitsu_fglair/strings.json b/homeassistant/components/fujitsu_fglair/strings.json index 3ad4e59ec1c..ea97ca416e5 100644 --- a/homeassistant/components/fujitsu_fglair/strings.json +++ b/homeassistant/components/fujitsu_fglair/strings.json @@ -35,5 +35,12 @@ "cn": "China" } } + }, + "entity": { + "sensor": { + "fglair_outside_temp": { + "name": "Outside temperature" + } + } } } diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index 15771d12b5d..53185e8ab76 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -11,7 +11,6 @@ from fullykiosk import FullyKiosk from fullykiosk.exceptions import FullyKioskError import voluptuous as vol -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -22,6 +21,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from .const import DEFAULT_PORT, DOMAIN, LOGGER diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index ec7bd7b1c03..a4b466926f0 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -1,16 +1,22 @@ { "common": { - "data_description_password": "The Remote Admin Password from the Fully Kiosk Browser app settings." + "data_description_password": "The Remote Admin Password from the Fully Kiosk Browser app settings.", + "data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?", + "data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates." }, "config": { "step": { "discovery_confirm": { "description": "Do you want to set up {name} ({host})?", "data": { - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "password": "[%key:component::fully_kiosk::common::data_description_password%]" + "password": "[%key:component::fully_kiosk::common::data_description_password%]", + "ssl": "[%key:component::fully_kiosk::common::data_description_ssl%]", + "verify_ssl": "[%key:component::fully_kiosk::common::data_description_verify_ssl%]" } }, "user": { @@ -22,7 +28,9 @@ }, "data_description": { "host": "The hostname or IP address of the device running your Fully Kiosk Browser application.", - "password": "[%key:component::fully_kiosk::common::data_description_password%]" + "password": "[%key:component::fully_kiosk::common::data_description_password%]", + "ssl": "[%key:component::fully_kiosk::common::data_description_ssl%]", + "verify_ssl": "[%key:component::fully_kiosk::common::data_description_verify_ssl%]" } } }, diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index b29789be87e..77724e3f673 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -24,6 +24,7 @@ from .coordinator import FytaCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.SENSOR, ] type FytaConfigEntry = ConfigEntry[FytaCoordinator] @@ -55,13 +56,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool: """Unload Fyta entity.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: FytaConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) diff --git a/homeassistant/components/fyta/binary_sensor.py b/homeassistant/components/fyta/binary_sensor.py new file mode 100644 index 00000000000..bcef609d01a --- /dev/null +++ b/homeassistant/components/fyta/binary_sensor.py @@ -0,0 +1,117 @@ +"""Binary sensors for Fyta.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from fyta_cli.fyta_models import Plant + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FytaConfigEntry +from .entity import FytaPlantEntity + + +@dataclass(frozen=True, kw_only=True) +class FytaBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Fyta binary sensor entity.""" + + value_fn: Callable[[Plant], bool] + + +BINARY_SENSORS: Final[list[FytaBinarySensorEntityDescription]] = [ + FytaBinarySensorEntityDescription( + key="low_battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda plant: plant.low_battery, + ), + FytaBinarySensorEntityDescription( + key="notification_light", + translation_key="notification_light", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_light, + ), + FytaBinarySensorEntityDescription( + key="notification_nutrition", + translation_key="notification_nutrition", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_nutrition, + ), + FytaBinarySensorEntityDescription( + key="notification_temperature", + translation_key="notification_temperature", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_temperature, + ), + FytaBinarySensorEntityDescription( + key="notification_water", + translation_key="notification_water", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda plant: plant.notification_water, + ), + FytaBinarySensorEntityDescription( + key="sensor_update_available", + device_class=BinarySensorDeviceClass.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda plant: plant.sensor_update_available, + ), + FytaBinarySensorEntityDescription( + key="productive_plant", + translation_key="productive_plant", + value_fn=lambda plant: plant.productive_plant, + ), + FytaBinarySensorEntityDescription( + key="repotted", + translation_key="repotted", + value_fn=lambda plant: plant.repotted, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the FYTA binary sensors.""" + coordinator = entry.runtime_data + + async_add_entities( + FytaPlantBinarySensor(coordinator, entry, sensor, plant_id) + for plant_id in coordinator.fyta.plant_list + for sensor in BINARY_SENSORS + if sensor.key in dir(coordinator.data.get(plant_id)) + ) + + def _async_add_new_device(plant_id: int) -> None: + async_add_entities( + FytaPlantBinarySensor(coordinator, entry, sensor, plant_id) + for sensor in BINARY_SENSORS + if sensor.key in dir(coordinator.data.get(plant_id)) + ) + + coordinator.new_device_callbacks.append(_async_add_new_device) + + +class FytaPlantBinarySensor(FytaPlantEntity, BinarySensorEntity): + """Represents a Fyta binary sensor.""" + + entity_description: FytaBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return value of the binary sensor.""" + + return self.entity_description.value_fn(self.plant) diff --git a/homeassistant/components/fyta/entity.py b/homeassistant/components/fyta/entity.py index 18c52d74e25..0d0ec533c44 100644 --- a/homeassistant/components/fyta/entity.py +++ b/homeassistant/components/fyta/entity.py @@ -2,11 +2,11 @@ from fyta_cli.fyta_models import Plant -from homeassistant.components.sensor import SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import FytaConfigEntry from .const import DOMAIN from .coordinator import FytaCoordinator @@ -19,8 +19,8 @@ class FytaPlantEntity(CoordinatorEntity[FytaCoordinator]): def __init__( self, coordinator: FytaCoordinator, - entry: ConfigEntry, - description: SensorEntityDescription, + entry: FytaConfigEntry, + description: EntityDescription, plant_id: int, ) -> None: """Initialize the Fyta sensor.""" diff --git a/homeassistant/components/fyta/icons.json b/homeassistant/components/fyta/icons.json index b96eeb15e62..5b6380196f4 100644 --- a/homeassistant/components/fyta/icons.json +++ b/homeassistant/components/fyta/icons.json @@ -1,5 +1,25 @@ { "entity": { + "binary_sensor": { + "notification_light": { + "default": "mdi:lightbulb-alert-outline" + }, + "notification_nutrition": { + "default": "mdi:beaker-alert-outline" + }, + "notification_temperature": { + "default": "mdi:thermometer-alert" + }, + "notification_water": { + "default": "mdi:watering-can-outline" + }, + "productive_plant": { + "default": "mdi:fruit-grapes" + }, + "repotted": { + "default": "mdi:shovel" + } + }, "sensor": { "status": { "default": "mdi:flower" @@ -13,6 +33,9 @@ "moisture_status": { "default": "mdi:water-percent-alert" }, + "nutrients_status": { + "default": "mdi:emoticon-poop" + }, "salinity_status": { "default": "mdi:sprout-outline" }, @@ -21,6 +44,12 @@ }, "salinity": { "default": "mdi:sprout-outline" + }, + "last_fertilised": { + "default": "mdi:calendar-check" + }, + "next_fertilisation": { + "default": "mdi:calendar-end" } } } diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index 0df9eca2e38..ea628f55c6c 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -3,9 +3,11 @@ "name": "FYTA", "codeowners": ["@dontinelli"], "config_flow": true, + "dhcp": [{ "hostname": "fyta*" }], "documentation": "https://www.home-assistant.io/integrations/fyta", "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["fyta_cli"], + "quality_scale": "platinum", "requirements": ["fyta_cli==0.7.0"] } diff --git a/homeassistant/components/fyta/quality_scale.yaml b/homeassistant/components/fyta/quality_scale.yaml new file mode 100644 index 00000000000..0fbacd0e12e --- /dev/null +++ b/homeassistant/components/fyta/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: done + dependency-transparency: done + action-setup: + status: exempt + comment: No custom action. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: No custom action. + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No custom action. + reauthentication-flow: done + parallel-updates: + status: exempt + comment: | + Coordinator and only sensor platform. + + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + No options flow. + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: + status: exempt + comment: No noisy entities. + discovery: + status: done + comment: DHCP + stale-devices: done + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: No configuration besides credentials. + dynamic-devices: done + discovery-update-info: + status: exempt + comment: Fyta can be discovered but does not have a local connection. + repair-issues: + status: exempt + comment: | + No issues/repairs. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: + status: exempt + comment: | + No known issues that could be resolved by the user. + docs-examples: + status: exempt + comment: | + As only sensors are provided, no examples deemed necessary/appropriate. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index 89ee22265cf..254e4522819 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -82,6 +82,13 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ options=PLANT_MEASUREMENT_STATUS_LIST, value_fn=lambda plant: plant.moisture_status.name.lower(), ), + FytaSensorEntityDescription( + key="nutrients_status", + translation_key="nutrients_status", + device_class=SensorDeviceClass.ENUM, + options=PLANT_MEASUREMENT_STATUS_LIST, + value_fn=lambda plant: plant.nutrients_status.name.lower(), + ), FytaSensorEntityDescription( key="salinity_status", translation_key="salinity_status", @@ -124,6 +131,18 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ state_class=SensorStateClass.MEASUREMENT, value_fn=lambda plant: plant.ph, ), + FytaSensorEntityDescription( + key="fertilise_last", + translation_key="last_fertilised", + device_class=SensorDeviceClass.DATE, + value_fn=lambda plant: plant.fertilise_last, + ), + FytaSensorEntityDescription( + key="fertilise_next", + translation_key="next_fertilisation", + device_class=SensorDeviceClass.DATE, + value_fn=lambda plant: plant.fertilise_next, + ), FytaSensorEntityDescription( key="battery_level", native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 5adde02c0cb..1a25f654e19 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -3,13 +3,13 @@ "step": { "user": { "title": "Credentials for FYTA API", - "description": "Provide username and password to connect to the FYTA server", + "description": "Provide email and password to connect to the FYTA server", "data": { - "username": "[%key:common::config_flow::data::username%]", + "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "username": "The username to login to your FYTA account.", + "username": "The email address to login to your FYTA account.", "password": "The password to login to your FYTA account." } }, @@ -26,7 +26,8 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { @@ -37,6 +38,29 @@ } }, "entity": { + "binary_sensor": { + "notification_light": { + "name": "Light notification" + }, + "notification_nutrition": { + "name": "Nutrition notification" + }, + "notification_temperature": { + "name": "Temperature notification" + }, + "notification_water": { + "name": "Water notification" + }, + "productive_plant": { + "name": "Productive plant" + }, + "repotted": { + "name": "Repotted" + }, + "sensor_update_available": { + "name": "Sensor update available" + } + }, "sensor": { "scientific_name": { "name": "Scientific name" @@ -83,6 +107,17 @@ "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, + "nutrients_status": { + "name": "Nutrients state", + "state": { + "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", + "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" + } + }, "salinity_status": { "name": "Salinity state", "state": { @@ -99,6 +134,12 @@ }, "salinity": { "name": "Salinity" + }, + "last_fertilised": { + "name": "Last fertilized" + }, + "next_fertilisation": { + "name": "Next fertilization" } } }, diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py index 7c763307ddf..b93b43e1173 100644 --- a/homeassistant/components/garages_amsterdam/binary_sensor.py +++ b/homeassistant/components/garages_amsterdam/binary_sensor.py @@ -2,19 +2,39 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + +from odp_amsterdam import Garage + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import GaragesAmsterdamConfigEntry +from .coordinator import GaragesAmsterdamDataUpdateCoordinator from .entity import GaragesAmsterdamEntity -BINARY_SENSORS = { - "state", -} + +@dataclass(frozen=True, kw_only=True) +class GaragesAmsterdamBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Garages Amsterdam binary sensor entity.""" + + is_on: Callable[[Garage], bool] + + +BINARY_SENSORS: tuple[GaragesAmsterdamBinarySensorEntityDescription, ...] = ( + GaragesAmsterdamBinarySensorEntityDescription( + key="state", + translation_key="state", + device_class=BinarySensorDeviceClass.PROBLEM, + is_on=lambda garage: garage.state != "ok", + ), +) async def async_setup_entry( @@ -26,20 +46,33 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - GaragesAmsterdamBinarySensor(coordinator, entry.data["garage_name"], info_type) - for info_type in BINARY_SENSORS + GaragesAmsterdamBinarySensor( + coordinator=coordinator, + garage_name=entry.data["garage_name"], + description=description, + ) + for description in BINARY_SENSORS ) class GaragesAmsterdamBinarySensor(GaragesAmsterdamEntity, BinarySensorEntity): """Binary Sensor representing garages amsterdam data.""" - _attr_device_class = BinarySensorDeviceClass.PROBLEM - _attr_name = None + entity_description: GaragesAmsterdamBinarySensorEntityDescription + + def __init__( + self, + *, + coordinator: GaragesAmsterdamDataUpdateCoordinator, + garage_name: str, + description: GaragesAmsterdamBinarySensorEntityDescription, + ) -> None: + """Initialize garages amsterdam binary sensor.""" + super().__init__(coordinator, garage_name) + self.entity_description = description + self._attr_unique_id = f"{garage_name}-{description.key}" @property def is_on(self) -> bool: """If the binary sensor is currently on or off.""" - return ( - getattr(self.coordinator.data[self._garage_name], self._info_type) != "ok" - ) + return self.entity_description.is_on(self.coordinator.data[self._garage_name]) diff --git a/homeassistant/components/garages_amsterdam/const.py b/homeassistant/components/garages_amsterdam/const.py index 0f1e6505f9f..be5e2216a81 100644 --- a/homeassistant/components/garages_amsterdam/const.py +++ b/homeassistant/components/garages_amsterdam/const.py @@ -7,7 +7,7 @@ import logging from typing import Final DOMAIN: Final = "garages_amsterdam" -ATTRIBUTION = f'{"Data provided by municipality of Amsterdam"}' +ATTRIBUTION = "Data provided by municipality of Amsterdam" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(minutes=10) diff --git a/homeassistant/components/garages_amsterdam/entity.py b/homeassistant/components/garages_amsterdam/entity.py index a8b030157bc..433bc75b962 100644 --- a/homeassistant/components/garages_amsterdam/entity.py +++ b/homeassistant/components/garages_amsterdam/entity.py @@ -19,13 +19,10 @@ class GaragesAmsterdamEntity(CoordinatorEntity[GaragesAmsterdamDataUpdateCoordin self, coordinator: GaragesAmsterdamDataUpdateCoordinator, garage_name: str, - info_type: str, ) -> None: """Initialize garages amsterdam entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{garage_name}-{info_type}" self._garage_name = garage_name - self._info_type = info_type self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, garage_name)}, name=garage_name, diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index 4f262b6e667..b562fff841a 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -2,20 +2,56 @@ from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from collections.abc import Callable +from dataclasses import dataclass + +from odp_amsterdam import Garage + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import GaragesAmsterdamConfigEntry from .coordinator import GaragesAmsterdamDataUpdateCoordinator from .entity import GaragesAmsterdamEntity -SENSORS = { - "free_space_short", - "free_space_long", - "short_capacity", - "long_capacity", -} + +@dataclass(frozen=True, kw_only=True) +class GaragesAmsterdamSensorEntityDescription(SensorEntityDescription): + """Class describing Garages Amsterdam sensor entity.""" + + value_fn: Callable[[Garage], StateType] + + +SENSORS: tuple[GaragesAmsterdamSensorEntityDescription, ...] = ( + GaragesAmsterdamSensorEntityDescription( + key="free_space_short", + translation_key="free_space_short", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda garage: garage.free_space_short, + ), + GaragesAmsterdamSensorEntityDescription( + key="free_space_long", + translation_key="free_space_long", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda garage: garage.free_space_long, + ), + GaragesAmsterdamSensorEntityDescription( + key="short_capacity", + translation_key="short_capacity", + value_fn=lambda garage: garage.short_capacity, + ), + GaragesAmsterdamSensorEntityDescription( + key="long_capacity", + translation_key="long_capacity", + value_fn=lambda garage: garage.long_capacity, + ), +) async def async_setup_entry( @@ -27,26 +63,32 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - GaragesAmsterdamSensor(coordinator, entry.data["garage_name"], info_type) - for info_type in SENSORS - if getattr(coordinator.data[entry.data["garage_name"]], info_type) is not None + GaragesAmsterdamSensor( + coordinator=coordinator, + garage_name=entry.data["garage_name"], + description=description, + ) + for description in SENSORS + if description.value_fn(coordinator.data[entry.data["garage_name"]]) is not None ) class GaragesAmsterdamSensor(GaragesAmsterdamEntity, SensorEntity): """Sensor representing garages amsterdam data.""" - _attr_native_unit_of_measurement = "cars" + entity_description: GaragesAmsterdamSensorEntityDescription def __init__( self, + *, coordinator: GaragesAmsterdamDataUpdateCoordinator, garage_name: str, - info_type: str, + description: GaragesAmsterdamSensorEntityDescription, ) -> None: """Initialize garages amsterdam sensor.""" - super().__init__(coordinator, garage_name, info_type) - self._attr_translation_key = info_type + super().__init__(coordinator, garage_name) + self.entity_description = description + self._attr_unique_id = f"{garage_name}-{description.key}" @property def available(self) -> bool: @@ -56,6 +98,8 @@ class GaragesAmsterdamSensor(GaragesAmsterdamEntity, SensorEntity): ) @property - def native_value(self) -> str: + def native_value(self) -> StateType: """Return the state of the sensor.""" - return getattr(self.coordinator.data[self._garage_name], self._info_type) + return self.entity_description.value_fn( + self.coordinator.data[self._garage_name] + ) diff --git a/homeassistant/components/garages_amsterdam/strings.json b/homeassistant/components/garages_amsterdam/strings.json index 89a85f97448..19157afdafb 100644 --- a/homeassistant/components/garages_amsterdam/strings.json +++ b/homeassistant/components/garages_amsterdam/strings.json @@ -3,8 +3,13 @@ "config": { "step": { "user": { - "title": "Pick a garage to monitor", - "data": { "garage_name": "Garage name" } + "description": "Select a garage from the list", + "data": { + "garage_name": "Garage name" + }, + "data_description": { + "garage_name": "The name of the garage you want to monitor." + } } }, "abort": { @@ -16,16 +21,25 @@ "entity": { "sensor": { "free_space_short": { - "name": "Short parking free space" + "name": "Short parking free space", + "unit_of_measurement": "cars" }, "free_space_long": { - "name": "Long parking free space" + "name": "Long parking free space", + "unit_of_measurement": "cars" }, "short_capacity": { - "name": "Short parking capacity" + "name": "Short parking capacity", + "unit_of_measurement": "cars" }, "long_capacity": { - "name": "Long parking capacity" + "name": "Long parking capacity", + "unit_of_measurement": "cars" + } + }, + "binary_sensor": { + "state": { + "name": "State" } } } diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index da5c08c38c5..28bba1015f5 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", "loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"], - "requirements": ["gardena-bluetooth==1.4.4"] + "requirements": ["gardena-bluetooth==1.5.0"] } diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 3aac5145ca5..edefbc55ca6 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -96,10 +96,9 @@ class GenericCamera(Camera): self._stream_source = device_info.get(CONF_STREAM_SOURCE) if self._stream_source: self._stream_source = Template(self._stream_source, hass) - self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE] - self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE] - if self._stream_source: self._attr_supported_features = CameraEntityFeature.STREAM + self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False) + self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE] self.content_type = device_info[CONF_CONTENT_TYPE] self.verify_ssl = device_info[CONF_VERIFY_SSL] if device_info.get(CONF_RTSP_TRANSPORT): diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 84243101bd6..b20793fe060 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping import contextlib -from datetime import datetime +from datetime import datetime, timedelta from errno import EHOSTUNREACH, EIO import io import logging @@ -17,18 +17,21 @@ import PIL.Image import voluptuous as vol import yarl +from homeassistant.components import websocket_api from homeassistant.components.camera import ( CAMERA_IMAGE_TIMEOUT, + DOMAIN as CAMERA_DOMAIN, DynamicStreamSettings, _async_get_image, ) -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, HLS_PROVIDER, RTSP_TRANSPORTS, SOURCE_TIMEOUT, + Stream, create_stream, ) from homeassistant.config_entries import ( @@ -49,7 +52,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper +from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.setup import async_prepare_setup_platform from homeassistant.util import slugify from .camera import GenericCamera, generate_auth @@ -79,6 +84,15 @@ SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"} IMAGE_PREVIEWS_ACTIVE = "previews" +class InvalidStreamException(HomeAssistantError): + """Error to indicate an invalid stream.""" + + def __init__(self, error: str, details: str | None = None) -> None: + """Initialize the error.""" + super().__init__(error) + self.details = details + + def build_schema( user_input: Mapping[str, Any], is_options_flow: bool = False, @@ -157,6 +171,8 @@ async def async_test_still( """Verify that the still image is valid before we create an entity.""" fmt = None if not (url := info.get(CONF_STILL_IMAGE_URL)): + # If user didn't specify a still image URL,the automatically generated + # still image that stream generates is always jpeg. return {}, info.get(CONF_CONTENT_TYPE, "image/jpeg") try: if not isinstance(url, template_helper.Template): @@ -231,16 +247,16 @@ def slug( return None -async def async_test_stream( +async def async_test_and_preview_stream( hass: HomeAssistant, info: Mapping[str, Any] -) -> dict[str, str]: - """Verify that the stream is valid before we create an entity.""" +) -> Stream | None: + """Verify that the stream is valid before we create an entity. + + Returns the stream object if valid. Raises InvalidStreamException if not. + The stream object is used to preview the video in the UI. + """ if not (stream_source := info.get(CONF_STREAM_SOURCE)): - return {} - # Import from stream.worker as stream cannot reexport from worker - # without forcing the av dependency on default_config - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.stream.worker import StreamWorkerError + return None if not isinstance(stream_source, template_helper.Template): stream_source = template_helper.Template(stream_source, hass) @@ -248,7 +264,7 @@ async def async_test_stream( stream_source = stream_source.async_render(parse_result=False) except TemplateError as err: _LOGGER.warning("Problem rendering template %s: %s", stream_source, err) - return {CONF_STREAM_SOURCE: "template_error"} + raise InvalidStreamException("template_error") from err stream_options: dict[str, str | bool | float] = {} if rtsp_transport := info.get(CONF_RTSP_TRANSPORT): stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport @@ -257,10 +273,10 @@ async def async_test_stream( try: url = yarl.URL(stream_source) - except ValueError: - return {CONF_STREAM_SOURCE: "malformed_url"} + except ValueError as err: + raise InvalidStreamException("malformed_url") from err if not url.is_absolute(): - return {CONF_STREAM_SOURCE: "relative_url"} + raise InvalidStreamException("relative_url") if not url.user and not url.password: username = info.get(CONF_USERNAME) password = info.get(CONF_PASSWORD) @@ -273,33 +289,30 @@ async def async_test_stream( stream_source, stream_options, DynamicStreamSettings(), - "test_stream", + f"{DOMAIN}.test_stream", ) hls_provider = stream.add_provider(HLS_PROVIDER) - await stream.start() - if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT): - hass.async_create_task(stream.stop()) - return {CONF_STREAM_SOURCE: "timeout"} - await stream.stop() - except StreamWorkerError as err: - return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)} - except PermissionError: - return {CONF_STREAM_SOURCE: "stream_not_permitted"} + except PermissionError as err: + raise InvalidStreamException("stream_not_permitted") from err except OSError as err: if err.errno == EHOSTUNREACH: - return {CONF_STREAM_SOURCE: "stream_no_route_to_host"} + raise InvalidStreamException("stream_no_route_to_host") from err if err.errno == EIO: # input/output error - return {CONF_STREAM_SOURCE: "stream_io_error"} + raise InvalidStreamException("stream_io_error") from err raise except HomeAssistantError as err: if "Stream integration is not set up" in str(err): - return {CONF_STREAM_SOURCE: "stream_not_set_up"} + raise InvalidStreamException("stream_not_set_up") from err raise - return {} + await stream.start() + if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT): + hass.async_create_task(stream.stop()) + raise InvalidStreamException("timeout") + return stream -def register_preview(hass: HomeAssistant) -> None: - """Set up previews for camera feeds during config flow.""" +def register_still_preview(hass: HomeAssistant) -> None: + """Set up still image preview for camera feeds during config flow.""" hass.data.setdefault(DOMAIN, {}) if not hass.data[DOMAIN].get(IMAGE_PREVIEWS_ACTIVE): @@ -315,7 +328,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize Generic ConfigFlow.""" - self.preview_cam: dict[str, Any] = {} + self.preview_image_settings: dict[str, Any] = {} + self.preview_stream: Stream | None = None self.user_input: dict[str, Any] = {} self.title = "" @@ -326,20 +340,11 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return GenericOptionsFlowHandler() - def check_for_existing(self, options: dict[str, Any]) -> bool: - """Check whether an existing entry is using the same URLs.""" - return any( - entry.options.get(CONF_STILL_IMAGE_URL) == options.get(CONF_STILL_IMAGE_URL) - and entry.options.get(CONF_STREAM_SOURCE) == options.get(CONF_STREAM_SOURCE) - for entry in self._async_current_entries() - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the start of the config flow.""" errors = {} - description_placeholders = {} hass = self.hass if user_input: # Secondary validation because serialised vol can't seem to handle this complexity: @@ -349,32 +354,25 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "no_still_image_or_stream_url" else: errors, still_format = await async_test_still(hass, user_input) - errors = errors | await async_test_stream(hass, user_input) + try: + self.preview_stream = await async_test_and_preview_stream( + hass, user_input + ) + except InvalidStreamException as err: + errors[CONF_STREAM_SOURCE] = str(err) + self.preview_stream = None if not errors: user_input[CONF_CONTENT_TYPE] = still_format - user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False still_url = user_input.get(CONF_STILL_IMAGE_URL) stream_url = user_input.get(CONF_STREAM_SOURCE) name = ( slug(hass, still_url) or slug(hass, stream_url) or DEFAULT_NAME ) - if still_url is None: - # If user didn't specify a still image URL, - # The automatically generated still image that stream generates - # is always jpeg - user_input[CONF_CONTENT_TYPE] = "image/jpeg" self.user_input = user_input self.title = name - - if still_url is None: - return self.async_create_entry( - title=self.title, data={}, options=self.user_input - ) # temporary preview for user to check the image - self.preview_cam = user_input - return await self.async_step_user_confirm_still() - if "error_details" in errors: - description_placeholders["error"] = errors.pop("error_details") + self.preview_image_settings = user_input + return await self.async_step_user_confirm() elif self.user_input: user_input = self.user_input else: @@ -382,40 +380,47 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=build_schema(user_input), - description_placeholders=description_placeholders, errors=errors, ) - async def async_step_user_confirm_still( + async def async_step_user_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle user clicking confirm after still preview.""" if user_input: + if ha_stream := self.preview_stream: + # Kill off the temp stream we created. + await ha_stream.stop() if not user_input.get(CONF_CONFIRMED_OK): return await self.async_step_user() return self.async_create_entry( title=self.title, data={}, options=self.user_input ) - register_preview(self.hass) - preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}" + register_still_preview(self.hass) return self.async_show_form( - step_id="user_confirm_still", + step_id="user_confirm", data_schema=vol.Schema( { vol.Required(CONF_CONFIRMED_OK, default=False): bool, } ), - description_placeholders={"preview_url": preview_url}, errors=None, + preview="generic_camera", ) + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + websocket_api.async_register_command(hass, ws_start_preview) + class GenericOptionsFlowHandler(OptionsFlow): """Handle Generic IP Camera options.""" def __init__(self) -> None: """Initialize Generic IP Camera options flow.""" - self.preview_cam: dict[str, Any] = {} + self.preview_image_settings: dict[str, Any] = {} + self.preview_stream: Stream | None = None self.user_input: dict[str, Any] = {} async def async_step_init( @@ -425,30 +430,36 @@ class GenericOptionsFlowHandler(OptionsFlow): errors: dict[str, str] = {} hass = self.hass - if user_input is not None: - errors, still_format = await async_test_still( - hass, self.config_entry.options | user_input - ) - errors = errors | await async_test_stream(hass, user_input) - still_url = user_input.get(CONF_STILL_IMAGE_URL) - if not errors: - if still_url is None: - # If user didn't specify a still image URL, - # The automatically generated still image that stream generates - # is always jpeg - still_format = "image/jpeg" - data = { - CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get( - CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False - ), - **user_input, - CONF_CONTENT_TYPE: still_format - or self.config_entry.options.get(CONF_CONTENT_TYPE), - } - self.user_input = data - # temporary preview for user to check the image - self.preview_cam = data - return await self.async_step_confirm_still() + if user_input: + # Secondary validation because serialised vol can't seem to handle this complexity: + if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get( + CONF_STREAM_SOURCE + ): + errors["base"] = "no_still_image_or_stream_url" + else: + errors, still_format = await async_test_still(hass, user_input) + try: + self.preview_stream = await async_test_and_preview_stream( + hass, user_input + ) + except InvalidStreamException as err: + errors[CONF_STREAM_SOURCE] = str(err) + self.preview_stream = None + if not errors: + data = { + CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get( + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False + ), + **user_input, + CONF_CONTENT_TYPE: still_format + or self.config_entry.options.get(CONF_CONTENT_TYPE), + } + self.user_input = data + # temporary preview for user to check the image + self.preview_image_settings = data + return await self.async_step_user_confirm() + elif self.user_input: + user_input = self.user_input return self.async_show_form( step_id="init", data_schema=build_schema( @@ -459,30 +470,37 @@ class GenericOptionsFlowHandler(OptionsFlow): errors=errors, ) - async def async_step_confirm_still( + async def async_step_user_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle user clicking confirm after still preview.""" if user_input: + if ha_stream := self.preview_stream: + # Kill off the temp stream we created. + await ha_stream.stop() if not user_input.get(CONF_CONFIRMED_OK): return await self.async_step_init() return self.async_create_entry( title=self.config_entry.title, data=self.user_input, ) - register_preview(self.hass) - preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}" + register_still_preview(self.hass) return self.async_show_form( - step_id="confirm_still", + step_id="user_confirm", data_schema=vol.Schema( { vol.Required(CONF_CONFIRMED_OK, default=False): bool, } ), - description_placeholders={"preview_url": preview_url}, errors=None, + preview="generic_camera", ) + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + websocket_api.async_register_command(hass, ws_start_preview) + class CameraImagePreview(HomeAssistantView): """Camera view to temporarily serve an image.""" @@ -508,7 +526,7 @@ class CameraImagePreview(HomeAssistantView): if not flow: _LOGGER.warning("Unknown flow while getting image preview") raise web.HTTPNotFound - user_input = flow.preview_cam + user_input = flow.preview_image_settings camera = GenericCamera(self.hass, user_input, flow_id, "preview") if not camera.is_on: _LOGGER.debug("Camera is off") @@ -518,3 +536,66 @@ class CameraImagePreview(HomeAssistantView): CAMERA_IMAGE_TIMEOUT, ) return web.Response(body=image.content, content_type=image.content_type) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "generic_camera/start_preview", + vol.Required("flow_id"): str, + vol.Optional("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Optional("user_input"): dict, + } +) +@websocket_api.async_response +async def ws_start_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate websocket handler for the camera still/stream preview.""" + _LOGGER.debug("Generating websocket handler for generic camera preview") + + flow_id = msg["flow_id"] + flow: GenericIPCamConfigFlow | GenericOptionsFlowHandler + if msg.get("flow_type", "config_flow") == "config_flow": + flow = cast( + GenericIPCamConfigFlow, + hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001 + ) + else: # (flow type == "options flow") + flow = cast( + GenericOptionsFlowHandler, + hass.config_entries.options._progress.get(flow_id), # noqa: SLF001 + ) + user_input = flow.preview_image_settings + + # Create an EntityPlatform, needed for name translations + platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN) + entity_platform = EntityPlatform( + hass=hass, + logger=_LOGGER, + domain=CAMERA_DOMAIN, + platform_name=DOMAIN, + platform=platform, + scan_interval=timedelta(seconds=3600), + entity_namespace=None, + ) + await entity_platform.async_load_translations() + + ha_still_url = None + ha_stream_url = None + + if user_input.get(CONF_STILL_IMAGE_URL): + ha_still_url = f"/api/generic/preview_flow_image/{msg['flow_id']}?t={datetime.now().isoformat()}" + _LOGGER.debug("Got preview still URL: %s", ha_still_url) + + if ha_stream := flow.preview_stream: + ha_stream_url = ha_stream.endpoint_url(HLS_PROVIDER) + _LOGGER.debug("Got preview stream URL: %s", ha_stream_url) + + connection.send_message( + websocket_api.event_message( + msg["id"], + {"attributes": {"still_url": ha_still_url, "stream_url": ha_stream_url}}, + ) + ) diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index c1fbc16d9be..35c5ae93b72 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -3,9 +3,9 @@ "name": "Generic Camera", "codeowners": ["@davet2001"], "config_flow": true, - "dependencies": ["http"], + "dependencies": ["http", "stream"], "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==13.1.0", "Pillow==11.0.0"] + "requirements": ["av==13.1.0", "Pillow==11.1.0"] } diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 94360a5b7c2..854ceb93b3e 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -39,11 +39,11 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } }, - "user_confirm_still": { - "title": "Preview", - "description": "![Camera Still Image Preview]({preview_url})", + "user_confirm": { + "title": "Confirmation", + "description": "Please wait for previews to load...", "data": { - "confirmed_ok": "This image looks good." + "confirmed_ok": "Everything looks good." } } } @@ -67,16 +67,17 @@ "use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras" } }, - "confirm_still": { - "title": "[%key:component::generic::config::step::user_confirm_still::title%]", - "description": "[%key:component::generic::config::step::user_confirm_still::description%]", + "user_confirm": { + "title": "Confirmation", + "description": "Please wait for previews to load...", "data": { - "confirmed_ok": "[%key:component::generic::config::step::user_confirm_still::data::confirmed_ok%]" + "confirmed_ok": "Everything looks good." } } }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_with_details": "[%key:component::generic::config::error::unknown_with_details%]", "already_exists": "[%key:component::generic::config::error::already_exists%]", "unable_still_load": "[%key:component::generic::config::error::unable_still_load%]", "unable_still_load_auth": "[%key:component::generic::config::error::unable_still_load_auth%]", diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index d68eaccbb0c..fe6f0253f48 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -63,7 +63,9 @@ from .const import ( CONF_COLD_TOLERANCE, CONF_HEATER, CONF_HOT_TOLERANCE, + CONF_MAX_TEMP, CONF_MIN_DUR, + CONF_MIN_TEMP, CONF_PRESETS, CONF_SENSOR, DEFAULT_TOLERANCE, @@ -77,8 +79,6 @@ DEFAULT_NAME = "Generic Thermostat" CONF_INITIAL_HVAC_MODE = "initial_hvac_mode" CONF_KEEP_ALIVE = "keep_alive" -CONF_MIN_TEMP = "min_temp" -CONF_MAX_TEMP = "max_temp" CONF_PRECISION = "precision" CONF_TARGET_TEMP = "target_temp" CONF_TEMP_STEP = "target_temp_step" @@ -205,7 +205,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity): """Representation of a Generic Thermostat device.""" _attr_should_poll = False - _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -269,6 +268,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): else: self._attr_preset_modes = [PRESET_NONE] self._presets = presets + self._presets_inv = {v: k for k, v in presets.items()} async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" @@ -422,6 +422,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return + self._attr_preset_mode = self._presets_inv.get(temperature, PRESET_NONE) self._target_temp = temperature await self._async_control_heating(force=True) self.async_write_ha_state() diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py index 5b0eae8ff66..1fbeaefde6b 100644 --- a/homeassistant/components/generic_thermostat/config_flow.py +++ b/homeassistant/components/generic_thermostat/config_flow.py @@ -21,7 +21,9 @@ from .const import ( CONF_COLD_TOLERANCE, CONF_HEATER, CONF_HOT_TOLERANCE, + CONF_MAX_TEMP, CONF_MIN_DUR, + CONF_MIN_TEMP, CONF_PRESETS, CONF_SENSOR, DEFAULT_TOLERANCE, @@ -57,6 +59,16 @@ OPTIONS_SCHEMA = { vol.Optional(CONF_MIN_DUR): selector.DurationSelector( selector.DurationSelectorConfig(allow_negative=False) ), + vol.Optional(CONF_MIN_TEMP): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1 + ) + ), + vol.Optional(CONF_MAX_TEMP): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1 + ) + ), } PRESETS_SCHEMA = { diff --git a/homeassistant/components/generic_thermostat/const.py b/homeassistant/components/generic_thermostat/const.py index 51927297b63..f0e6f1a7d73 100644 --- a/homeassistant/components/generic_thermostat/const.py +++ b/homeassistant/components/generic_thermostat/const.py @@ -18,7 +18,9 @@ CONF_AC_MODE = "ac_mode" CONF_COLD_TOLERANCE = "cold_tolerance" CONF_HEATER = "heater" CONF_HOT_TOLERANCE = "hot_tolerance" +CONF_MAX_TEMP = "max_temp" CONF_MIN_DUR = "min_cycle_duration" +CONF_MIN_TEMP = "min_temp" CONF_PRESETS = { p: f"{p}_temp" for p in ( diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index fd89bec6349..58280e99543 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -12,7 +12,9 @@ "min_cycle_duration": "Minimum cycle duration", "name": "[%key:common::config_flow::data::name%]", "cold_tolerance": "Cold tolerance", - "hot_tolerance": "Hot tolerance" + "hot_tolerance": "Hot tolerance", + "min_temp": "Minimum target temperature", + "max_temp": "Maximum target temperature" }, "data_description": { "ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.", @@ -45,7 +47,9 @@ "target_sensor": "[%key:component::generic_thermostat::config::step::user::data::target_sensor%]", "min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]", "cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]", - "hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]" + "hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]", + "min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]", + "max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]" }, "data_description": { "heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]", diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index 99d1bde8099..e20d649541e 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -51,7 +51,6 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, broker, zone) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/geniushub/config_flow.py b/homeassistant/components/geniushub/config_flow.py index b106f9907bb..18f50593dca 100644 --- a/homeassistant/components/geniushub/config_flow.py +++ b/homeassistant/components/geniushub/config_flow.py @@ -78,7 +78,7 @@ class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_host" except (TimeoutError, aiohttp.ClientConnectionError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -113,7 +113,7 @@ class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_host" except (TimeoutError, aiohttp.ClientConnectionError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json index faf5011d752..42d53c7fa00 100644 --- a/homeassistant/components/geniushub/strings.json +++ b/homeassistant/components/geniushub/strings.json @@ -37,11 +37,11 @@ "services": { "set_zone_mode": { "name": "Set zone mode", - "description": "Set the zone to an operating mode.", + "description": "Sets the zone to an operating mode.", "fields": { "entity_id": { "name": "Entity", - "description": "The zone's entity_id." + "description": "The zone's entity ID." }, "mode": { "name": "[%key:common::config_flow::data::mode%]", @@ -51,7 +51,7 @@ }, "set_zone_override": { "name": "Set zone override", - "description": "Overrides the zone's set point for a given duration.", + "description": "Overrides the zone's setpoint for a given duration.", "fields": { "entity_id": { "name": "Entity", diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 877471f002a..06b0320c805 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index 96244e08d1b..5f0d6e92ee1 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -83,13 +83,8 @@ async def async_attach_trigger( ) to_match = condition.zone(hass, zone_state, to_state) if to_state else False - if ( - trigger_event == EVENT_ENTER - and not from_match - and to_match - or trigger_event == EVENT_LEAVE - and from_match - and not to_match + if (trigger_event == EVENT_ENTER and not from_match and to_match) or ( + trigger_event == EVENT_LEAVE and from_match and not to_match ): hass.async_run_hass_job( job, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index ee0f50ef40c..fc82f1c843d 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -34,6 +34,18 @@ "moderate": "Moderate", "good": "Good", "very_good": "Very good" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } }, "c6h6": { @@ -51,6 +63,18 @@ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } }, "o3_index": { @@ -62,6 +86,18 @@ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } }, "pm10_index": { @@ -73,6 +109,18 @@ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } }, "pm25_index": { @@ -84,6 +132,18 @@ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } }, "so2_index": { @@ -95,6 +155,18 @@ "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + }, + "state_attributes": { + "options": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + } } } } diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 59eba69d60a..0741926296e 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -375,6 +375,8 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit self._data_valid = self._attr_native_value is not None and ( not self._numeric_state_expected or isinstance(self._attr_native_value, (int, float)) - or isinstance(self._attr_native_value, str) - and self._attr_native_value.isnumeric() + or ( + isinstance(self._attr_native_value, str) + and self._attr_native_value.isnumeric() + ) ) diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index 3c1c84c42b5..234411936cb 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" -RECOMMENDED_VERSION = "1.9.7" +RECOMMENDED_VERSION = "1.9.8" diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index bedee99f930..07dbd3bd29b 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "quality_scale": "legacy", - "requirements": ["go2rtc-client==0.1.1"], + "quality_scale": "internal", + "requirements": ["go2rtc-client==0.1.2"], "single_config_entry": true } diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index dabe642b658..9764d36e42c 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -8,11 +8,11 @@ from typing import Any from goalzero import Yeti, exceptions import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_NAME, DOMAIN, MANUFACTURER @@ -27,7 +27,7 @@ class GoalZeroFlowHandler(ConfigFlow, domain=DOMAIN): _discovered_ip: str async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index 837c0454719..0348d0b428c 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -10,7 +10,6 @@ from ismartgate.common import AbstractInfoResponse, ApiError from ismartgate.const import GogoGate2ApiErrorCode, ISmartGateApiErrorCode import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_DEVICE, @@ -19,6 +18,11 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .common import get_api from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN @@ -40,16 +44,14 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): self._device_type: str | None = None async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle homekit discovery.""" - await self.async_set_unique_id( - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] - ) + await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID]) return await self._async_discovery_handler(discovery_info.host) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" await self.async_set_unique_id(discovery_info.macaddress) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 85c4714432b..bd04597e513 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==6.2.0", "oauth2client==4.1.3", "ical==8.2.0"] + "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==8.3.0"] } diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index acc69c3799a..5ee0cdd9c14 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -13,20 +13,22 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", - "code_expired": "Authentication code expired or credential setup is invalid, please try again.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "code_expired": "Authentication code expired or credential setup is invalid, please try again.", + "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 76869487ee3..4309a99c0ca 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -521,7 +521,7 @@ def supported_traits_for_state(state: State) -> list[type[trait._Trait]]: class GoogleEntity: """Adaptation of Entity expressed in Google's terms.""" - __slots__ = ("hass", "config", "state", "entity_id", "_traits") + __slots__ = ("_traits", "config", "entity_id", "hass", "state") def __init__( self, hass: HomeAssistant, config: AbstractConfig, state: State diff --git a/homeassistant/components/google_assistant/strings.json b/homeassistant/components/google_assistant/strings.json index 70fac8db6c1..d67600fece1 100644 --- a/homeassistant/components/google_assistant/strings.json +++ b/homeassistant/components/google_assistant/strings.json @@ -13,7 +13,7 @@ "fields": { "agent_user_id": { "name": "Agent user ID", - "description": "Only needed for automations. Specific Home Assistant user id (not username, ID in configuration > users > under username) to sync with Google Assistant. Do not need when you use this action through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing." + "description": "Only needed for automations. Specific Home Assistant user ID (not username, ID in Settings > People > Users > under username) to sync with Google Assistant. Not needed when you use this action through Home Assistant frontend or API. Used in automation, script or other place where context.user_id is missing." } } } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index f99f1574038..44251a3be04 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -553,15 +553,9 @@ class ColorSettingTrait(_Trait): response["colorModel"] = "hsv" if light.color_temp_supported(color_modes): - # Max Kelvin is Min Mireds K = 1000000 / mireds - # Min Kelvin is Max Mireds K = 1000000 / mireds response["colorTemperatureRange"] = { - "temperatureMaxK": color_util.color_temperature_mired_to_kelvin( - attrs.get(light.ATTR_MIN_MIREDS) - ), - "temperatureMinK": color_util.color_temperature_mired_to_kelvin( - attrs.get(light.ATTR_MAX_MIREDS) - ), + "temperatureMaxK": int(attrs.get(light.ATTR_MAX_COLOR_TEMP_KELVIN)), + "temperatureMinK": int(attrs.get(light.ATTR_MIN_COLOR_TEMP_KELVIN)), } return response @@ -583,7 +577,7 @@ class ColorSettingTrait(_Trait): } if light.color_temp_supported([color_mode]): - temp = self.state.attributes.get(light.ATTR_COLOR_TEMP) + temp = self.state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) # Some faulty integrations might put 0 in here, raising exception. if temp == 0: _LOGGER.warning( @@ -592,9 +586,7 @@ class ColorSettingTrait(_Trait): temp, ) elif temp is not None: - color["temperatureK"] = color_util.color_temperature_mired_to_kelvin( - temp - ) + color["temperatureK"] = temp response = {} @@ -606,11 +598,9 @@ class ColorSettingTrait(_Trait): async def execute(self, command, data, params, challenge): """Execute a color temperature command.""" if "temperature" in params["color"]: - temp = color_util.color_temperature_kelvin_to_mired( - params["color"]["temperature"] - ) - min_temp = self.state.attributes[light.ATTR_MIN_MIREDS] - max_temp = self.state.attributes[light.ATTR_MAX_MIREDS] + temp = params["color"]["temperature"] + max_temp = self.state.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN] + min_temp = self.state.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN] if temp < min_temp or temp > max_temp: raise SmartHomeError( @@ -621,7 +611,10 @@ class ColorSettingTrait(_Trait): await self.hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_COLOR_TEMP: temp}, + { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_COLOR_TEMP_KELVIN: temp, + }, blocking=not self.config.should_report_state, context=data.context, ) @@ -729,7 +722,7 @@ class DockTrait(_Trait): def query_attributes(self) -> dict[str, Any]: """Return dock query attributes.""" - return {"isDocked": self.state.state == vacuum.STATE_DOCKED} + return {"isDocked": self.state.state == vacuum.VacuumActivity.DOCKED} async def execute(self, command, data, params, challenge): """Execute a dock command.""" @@ -825,8 +818,8 @@ class EnergyStorageTrait(_Trait): "capacityUntilFull": [ {"rawValue": 100 - battery_level, "unit": "PERCENTAGE"} ], - "isCharging": self.state.state == vacuum.STATE_DOCKED, - "isPluggedIn": self.state.state == vacuum.STATE_DOCKED, + "isCharging": self.state.state == vacuum.VacuumActivity.DOCKED, + "isPluggedIn": self.state.state == vacuum.VacuumActivity.DOCKED, } async def execute(self, command, data, params, challenge): @@ -882,8 +875,8 @@ class StartStopTrait(_Trait): if domain == vacuum.DOMAIN: return { - "isRunning": state == vacuum.STATE_CLEANING, - "isPaused": state == vacuum.STATE_PAUSED, + "isRunning": state == vacuum.VacuumActivity.CLEANING, + "isPaused": state == vacuum.VacuumActivity.PAUSED, } if domain in COVER_VALVE_DOMAINS: diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 4fd817aadce..87c93023900 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -4,27 +4,27 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "auth": { - "title": "Link Google Account" - }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Google Assistant SDK integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_cloud/const.py b/homeassistant/components/google_cloud/const.py index f416d36483a..16b1463f0f3 100644 --- a/homeassistant/components/google_cloud/const.py +++ b/homeassistant/components/google_cloud/const.py @@ -20,6 +20,10 @@ CONF_GAIN = "gain" CONF_PROFILES = "profiles" CONF_TEXT_TYPE = "text_type" +DEFAULT_SPEED = 1.0 +DEFAULT_PITCH = 0 +DEFAULT_GAIN = 0 + # STT constants CONF_STT_MODEL = "stt_model" diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py index f6e89fae7fa..f1adc42b4cd 100644 --- a/homeassistant/components/google_cloud/helpers.py +++ b/homeassistant/components/google_cloud/helpers.py @@ -31,7 +31,10 @@ from .const import ( CONF_SPEED, CONF_TEXT_TYPE, CONF_VOICE, + DEFAULT_GAIN, DEFAULT_LANG, + DEFAULT_PITCH, + DEFAULT_SPEED, ) DEFAULT_VOICE = "" @@ -104,15 +107,15 @@ def tts_options_schema( ), vol.Optional( CONF_SPEED, - default=defaults.get(CONF_SPEED, 1.0), + default=defaults.get(CONF_SPEED, DEFAULT_SPEED), ): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)), vol.Optional( CONF_PITCH, - default=defaults.get(CONF_PITCH, 0), + default=defaults.get(CONF_PITCH, DEFAULT_PITCH), ): NumberSelector(NumberSelectorConfig(min=-20.0, max=20.0, step=0.1)), vol.Optional( CONF_GAIN, - default=defaults.get(CONF_GAIN, 0), + default=defaults.get(CONF_GAIN, DEFAULT_GAIN), ): NumberSelector(NumberSelectorConfig(min=-96.0, max=16.0, step=0.1)), vol.Optional( CONF_PROFILES, diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index 99b7dadbb0e..ebca586d1a3 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -114,9 +114,9 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): ) ) - async def request_generator() -> ( - AsyncGenerator[speech_v1.StreamingRecognizeRequest] - ): + async def request_generator() -> AsyncGenerator[ + speech_v1.StreamingRecognizeRequest + ]: # The first request must only contain a streaming_config yield speech_v1.StreamingRecognizeRequest(streaming_config=streaming_config) # All subsequent requests must only contain audio_content diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index c3a8254ad90..7f22dda4faf 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -35,7 +35,10 @@ from .const import ( CONF_SPEED, CONF_TEXT_TYPE, CONF_VOICE, + DEFAULT_GAIN, DEFAULT_LANG, + DEFAULT_PITCH, + DEFAULT_SPEED, DOMAIN, ) from .helpers import async_tts_voices, tts_options_schema, tts_platform_schema @@ -191,11 +194,23 @@ class BaseGoogleCloudProvider: ssml_gender=gender, name=voice, ), + # Avoid: "This voice does not support speaking rate or pitch parameters at this time." + # by not specifying the fields unless they differ from the defaults audio_config=texttospeech.AudioConfig( audio_encoding=encoding, - speaking_rate=options[CONF_SPEED], - pitch=options[CONF_PITCH], - volume_gain_db=options[CONF_GAIN], + speaking_rate=( + options[CONF_SPEED] + if options[CONF_SPEED] != DEFAULT_SPEED + else None + ), + pitch=( + options[CONF_PITCH] + if options[CONF_PITCH] != DEFAULT_PITCH + else None + ), + volume_gain_db=( + options[CONF_GAIN] if options[CONF_GAIN] != DEFAULT_GAIN else None + ), effects_profile_id=options[CONF_PROFILES], ), ) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 0d24ddbf39f..81cc7ab8a73 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import device_registry as dr, intent, llm, template from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from .const import ( CONF_CHAT_MODEL, @@ -204,9 +204,7 @@ class GoogleGenerativeAIConversationEntity( """Process a sentence.""" result = conversation.ConversationResult( response=intent.IntentResponse(language=user_input.language), - conversation_id=user_input.conversation_id - if user_input.conversation_id in self.history - else ulid.ulid_now(), + conversation_id=user_input.conversation_id or ulid_util.ulid_now(), ) assert result.conversation_id diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index 485d640a04d..3e455f645ad 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -49,10 +49,8 @@ class AsyncConfigEntryAuth: "OAuth session is not valid, reauth required" ) from ex raise ConfigEntryNotReady from ex - if ( - isinstance(ex, RefreshError) - or hasattr(ex, "status") - and ex.status == 400 + if isinstance(ex, RefreshError) or ( + hasattr(ex, "status") and ex.status == 400 ): self.oauth_session.config_entry.async_start_reauth( self.oauth_session.hass diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index 2c6e24109c3..759242593ff 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -13,19 +13,19 @@ } }, "abort": { + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with {email}.", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "wrong_account": "Wrong account: Please authenticate with {email}." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -68,10 +68,10 @@ }, "restrict_domain": { "name": "Restrict to domain", - "description": "Restrict automatic reply to domain. This only affects GSuite accounts." + "description": "Restrict automatic reply to domain. This only affects Google Workspace accounts." }, "start": { - "name": "[%key:common::action::start%]", + "name": "Start", "description": "First day of the vacation." }, "end": { diff --git a/homeassistant/components/google_photos/quality_scale.yaml b/homeassistant/components/google_photos/quality_scale.yaml new file mode 100644 index 00000000000..ed313e13d6a --- /dev/null +++ b/homeassistant/components/google_photos/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + config-flow: done + brands: done + dependency-transparency: done + common-modules: done + has-entity-name: + status: exempt + comment: Integration does not have entities + action-setup: + status: todo + comment: | + The integration does action setup in `async_setup_entry` which needs to be + moved to `async_setup`. + appropriate-polling: done + test-before-configure: done + entity-event-setup: + status: exempt + comment: Integration does not subscribe to events. + unique-config-entry: done + entity-unique-id: done + docs-installation-instructions: done + docs-removal-instructions: todo + test-before-setup: done + docs-high-level-description: done + config-flow-test-coverage: done + docs-actions: done + runtime-data: done + + # Silver + log-when-unavailable: todo + config-entry-unloading: todo + reauthentication-flow: done + action-exceptions: todo + docs-installation-parameters: todo + integration-owner: todo + parallel-updates: todo + test-coverage: todo + docs-configuration-parameters: todo + entity-unavailable: todo + + # Gold + docs-examples: todo + discovery-update-info: todo + entity-device-class: todo + entity-translations: todo + docs-data-update: todo + entity-disabled-by-default: todo + discovery: todo + exception-translations: todo + devices: todo + docs-supported-devices: todo + icon-translations: todo + docs-known-limitations: todo + stale-devices: todo + docs-supported-functions: todo + repair-issues: todo + reconfiguration-flow: todo + entity-category: todo + dynamic-devices: todo + docs-troubleshooting: todo + diagnostics: todo + docs-use-cases: todo + + # Platinum + async-dependency: todo + strict-typing: todo + inject-websession: todo diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index f23a706b2e2..22d3cc7deb0 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -144,11 +144,9 @@ def async_register_services(hass: HomeAssistant) -> None: if call.return_response: return { "media_items": [ - { - "media_item_id": item_result.media_item.id - for item_result in upload_result.new_media_item_results - if item_result.media_item and item_result.media_item.id - } + {"media_item_id": item_result.media_item.id} + for item_result in upload_result.new_media_item_results + if item_result.media_item and item_result.media_item.id ], "album_id": album_id, } diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index bd565a6122d..5695192dd27 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -6,23 +6,31 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Photos integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "access_not_configured": "Unable to access the Google API:\n\n{message}", - "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with the right account.", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with the right account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -48,7 +56,7 @@ "message": "`{filename}` is not an image" }, "missing_upload_permission": { - "message": "Home Assistnt was not granted permission to upload to Google Photos" + "message": "Home Assistant was not granted permission to upload to Google Photos" }, "upload_error": { "message": "Failed to upload content: {message}" @@ -66,11 +74,11 @@ "services": { "upload": { "name": "Upload media", - "description": "Upload images or videos to Google Photos.", + "description": "Uploads images or videos to Google Photos.", "fields": { "config_entry_id": { - "name": "Integration Id", - "description": "The Google Photos integration id." + "name": "Integration ID", + "description": "The Google Photos integration ID." }, "filename": { "name": "Filename", diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index d8cb06d9bcd..406c4440d00 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -4,27 +4,29 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "auth": { "title": "Link Google Account" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Google Sheets integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]", "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", - "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details" }, "create_entry": { "default": "Successfully authenticated and spreadsheet created at: {url}" diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py index 29a1b20f2bc..45ad1777aa0 100644 --- a/homeassistant/components/google_tasks/__init__.py +++ b/homeassistant/components/google_tasks/__init__.py @@ -2,9 +2,10 @@ from __future__ import annotations +import asyncio + from aiohttp import ClientError, ClientResponseError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -12,11 +13,18 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import api from .const import DOMAIN +from .coordinator import TaskUpdateCoordinator +from .exceptions import GoogleTasksApiError +from .types import GoogleTasksConfigEntry + +__all__ = [ + "DOMAIN", +] PLATFORMS: list[Platform] = [Platform.TODO] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GoogleTasksConfigEntry) -> bool: """Set up Google Tasks from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -36,16 +44,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ClientError as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = auth + try: + task_lists = await auth.list_task_lists() + except GoogleTasksApiError as err: + raise ConfigEntryNotReady from err + + coordinators = [ + TaskUpdateCoordinator( + hass, + auth, + task_list["id"], + task_list["title"], + ) + for task_list in task_lists + ] + # Refresh all coordinators in parallel + await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators + ) + ) + entry.runtime_data = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleTasksConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index 2a294b84654..f51c5103b87 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -9,6 +9,7 @@ from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build from googleapiclient.errors import HttpError from googleapiclient.http import BatchHttpRequest, HttpRequest +from httplib2 import ServerNotFoundError from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant @@ -115,7 +116,7 @@ class AsyncConfigEntryAuth: def response_handler(_, response, exception: HttpError) -> None: if exception is not None: raise GoogleTasksApiError( - f"Google Tasks API responded with error ({exception.status_code})" + f"Google Tasks API responded with error ({exception.reason or exception.status_code})" ) from exception if response: data = json.loads(response) @@ -150,9 +151,9 @@ class AsyncConfigEntryAuth: async def _execute(self, request: HttpRequest | BatchHttpRequest) -> Any: try: result = await self._hass.async_add_executor_job(request.execute) - except HttpError as err: + except (HttpError, ServerNotFoundError) as err: raise GoogleTasksApiError( - f"Google Tasks API responded with error ({err.status_code})" + f"Google Tasks API responded with: {err.reason or err.status_code})" ) from err if result: _raise_if_error(result) diff --git a/homeassistant/components/google_tasks/coordinator.py b/homeassistant/components/google_tasks/coordinator.py index 5377e2be567..a06faf00a91 100644 --- a/homeassistant/components/google_tasks/coordinator.py +++ b/homeassistant/components/google_tasks/coordinator.py @@ -20,7 +20,11 @@ class TaskUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Coordinator for fetching Google Tasks for a Task List form the API.""" def __init__( - self, hass: HomeAssistant, api: AsyncConfigEntryAuth, task_list_id: str + self, + hass: HomeAssistant, + api: AsyncConfigEntryAuth, + task_list_id: str, + task_list_title: str, ) -> None: """Initialize TaskUpdateCoordinator.""" super().__init__( @@ -30,9 +34,10 @@ class TaskUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): update_interval=UPDATE_INTERVAL, ) self.api = api - self._task_list_id = task_list_id + self.task_list_id = task_list_id + self.task_list_title = task_list_title async def _async_update_data(self) -> list[dict[str, Any]]: """Fetch tasks from API endpoint.""" async with asyncio.timeout(TIMEOUT): - return await self.api.list_tasks(self._task_list_id) + return await self.api.list_tasks(self.task_list_id) diff --git a/homeassistant/components/google_tasks/quality_scale.yaml b/homeassistant/components/google_tasks/quality_scale.yaml new file mode 100644 index 00000000000..dd1cd67d8e2 --- /dev/null +++ b/homeassistant/components/google_tasks/quality_scale.yaml @@ -0,0 +1,71 @@ +rules: + # Bronze + config-flow: done + brands: done + dependency-transparency: todo + common-modules: + status: exempt + comment: | + The integration has a coordinator.py and no base entities. + has-entity-name: done + action-setup: + status: exempt + comment: The integration does not register any actions. + appropriate-polling: done + test-before-configure: done + entity-event-setup: + status: exempt + comment: Integration does not subscribe to events. + unique-config-entry: done + entity-unique-id: done + docs-installation-instructions: done + docs-removal-instructions: done + test-before-setup: done + docs-high-level-description: done + config-flow-test-coverage: done + docs-actions: + status: exempt + comment: The integration does not register any actions. + runtime-data: done + + # Silver + log-when-unavailable: done + config-entry-unloading: done + reauthentication-flow: done + action-exceptions: done + docs-installation-parameters: done + integration-owner: done + parallel-updates: done + test-coverage: done + docs-configuration-parameters: + status: exempt + comment: The integration does not have any configuration parameters. + entity-unavailable: done + + # Gold + docs-examples: done + discovery-update-info: todo + entity-device-class: todo + entity-translations: todo + docs-data-update: done + entity-disabled-by-default: todo + discovery: todo + exception-translations: todo + devices: todo + docs-supported-devices: done + icon-translations: todo + docs-known-limitations: done + stale-devices: todo + docs-supported-functions: done + repair-issues: todo + reconfiguration-flow: todo + entity-category: todo + dynamic-devices: todo + docs-troubleshooting: done + diagnostics: todo + docs-use-cases: done + + # Platinum + async-dependency: todo + strict-typing: todo + inject-websession: todo diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index a26cf8c58ec..b58678f6d30 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -6,23 +6,31 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Tasks integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "access_not_configured": "Unable to access the Google API:\n\n{message}", - "unknown": "[%key:common::config_flow::error::unknown%]", - "wrong_account": "Wrong account: Please authenticate with the right account.", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with the right account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 5196f89728d..1df5e5fc2e9 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date, datetime, timedelta +from datetime import UTC, date, datetime from typing import Any, cast from homeassistant.components.todo import ( @@ -11,17 +11,15 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .api import AsyncConfigEntryAuth -from .const import DOMAIN from .coordinator import TaskUpdateCoordinator +from .types import GoogleTasksConfigEntry -SCAN_INTERVAL = timedelta(minutes=15) +PARALLEL_UPDATES = 0 TODO_STATUS_MAP = { "needsAction": TodoItemStatus.NEEDS_ACTION, @@ -39,8 +37,10 @@ def _convert_todo_item(item: TodoItem) -> dict[str, str | None]: else: result["status"] = TodoItemStatus.NEEDS_ACTION if (due := item.due) is not None: - # due API field is a timestamp string, but with only date resolution - result["due"] = dt_util.start_of_local_day(due).isoformat() + # due API field is a timestamp string, but with only date resolution. + # The time portion of the date is always discarded by the API, so we + # always set to UTC. + result["due"] = dt_util.start_of_local_day(due).replace(tzinfo=UTC).isoformat() else: result["due"] = None result["notes"] = item.description @@ -51,6 +51,8 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem: """Convert tasks API items into a TodoItem.""" due: date | None = None if (due_str := item.get("due")) is not None: + # Due dates are returned always in UTC so we only need to + # parse the date portion which will be interpreted as a a local date. due = datetime.fromisoformat(due_str).date() return TodoItem( summary=item["title"], @@ -65,22 +67,21 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: GoogleTasksConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Google Tasks todo platform.""" - api: AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id] - task_lists = await api.list_task_lists() async_add_entities( ( GoogleTaskTodoListEntity( - TaskUpdateCoordinator(hass, api, task_list["id"]), - task_list["title"], + coordinator, + coordinator.task_list_title, entry.entry_id, - task_list["id"], + coordinator.task_list_id, ) - for task_list in task_lists + for coordinator in entry.runtime_data ), - True, ) @@ -115,8 +116,6 @@ class GoogleTaskTodoListEntity( @property def todo_items(self) -> list[TodoItem] | None: """Get the current set of To-do items.""" - if self.coordinator.data is None: - return None return [_convert_api_item(item) for item in _order_tasks(self.coordinator.data)] async def async_create_todo_item(self, item: TodoItem) -> None: diff --git a/homeassistant/components/google_tasks/types.py b/homeassistant/components/google_tasks/types.py new file mode 100644 index 00000000000..21500d11eb8 --- /dev/null +++ b/homeassistant/components/google_tasks/types.py @@ -0,0 +1,7 @@ +"""Types for the Google Tasks integration.""" + +from homeassistant.config_entries import ConfigEntry + +from .coordinator import TaskUpdateCoordinator + +type GoogleTasksConfigEntry = ConfigEntry[list[TaskUpdateCoordinator]] diff --git a/homeassistant/components/google_translate/const.py b/homeassistant/components/google_translate/const.py index ed9709d2811..ab0291bc58f 100644 --- a/homeassistant/components/google_translate/const.py +++ b/homeassistant/components/google_translate/const.py @@ -88,6 +88,7 @@ SUPPORT_LANGUAGES = [ "uk", "ur", "vi", + "yue", # dialects "zh-CN", "zh-cn", diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index 7074d0ed444..b5b1f670675 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_translate", "iot_class": "cloud_push", "loggers": ["gtts"], - "requirements": ["gTTS==2.2.4"] + "requirements": ["gTTS==2.5.3"] } diff --git a/homeassistant/components/govee_ble/binary_sensor.py b/homeassistant/components/govee_ble/binary_sensor.py index bd92093c29c..7b7a1fb5a50 100644 --- a/homeassistant/components/govee_ble/binary_sensor.py +++ b/homeassistant/components/govee_ble/binary_sensor.py @@ -39,6 +39,10 @@ BINARY_SENSOR_DESCRIPTIONS = { key=GoveeBLEBinarySensorDeviceClass.OCCUPANCY, device_class=BinarySensorDeviceClass.OCCUPANCY, ), + GoveeBLEBinarySensorDeviceClass.PRESENCE: BinarySensorEntityDescription( + key=GoveeBLEBinarySensorDeviceClass.PRESENCE, + device_class=BinarySensorDeviceClass.PRESENCE, + ), } diff --git a/homeassistant/components/govee_ble/event.py b/homeassistant/components/govee_ble/event.py index 55275477164..5e5aa6354be 100644 --- a/homeassistant/components/govee_ble/event.py +++ b/homeassistant/components/govee_ble/event.py @@ -102,8 +102,7 @@ async def async_setup_entry( descriptions = [MOTION_DESCRIPTION] elif sensor_type is SensorType.VIBRATION: descriptions = [VIBRATION_DESCRIPTION] - elif sensor_type is SensorType.BUTTON: - button_count = model_info.button_count + elif button_count := model_info.button_count: descriptions = BUTTON_DESCRIPTIONS[0:button_count] else: return diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index d9827e9155c..5a123de7066 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -42,6 +42,10 @@ "local_name": "GVH5127*", "connectable": false }, + { + "local_name": "GVH5130*", + "connectable": false + }, { "manufacturer_id": 1, "service_uuid": "0000ec88-0000-1000-8000-00805f9b34fb", @@ -122,10 +126,10 @@ "connectable": false } ], - "codeowners": ["@bdraco", "@PierreAronnax"], + "codeowners": ["@bdraco"], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.40.0"] + "requirements": ["govee-ble==0.42.0"] } diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 6a8f48780c8..f197f21a4e1 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -126,7 +126,6 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_min_temp = TEMP_MIN _attr_max_temp = TEMP_MAX - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index 03a8be4bed5..40db70a2eb3 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -440,10 +440,8 @@ class Group(Entity): if not self._on_off: return - if ( - tr_state is None - or self._assumed_state - and not tr_state.attributes.get(ATTR_ASSUMED_STATE) + if tr_state is None or ( + self._assumed_state and not tr_state.attributes.get(ATTR_ASSUMED_STATE) ): self._assumed_state = self.mode(self._assumed.values()) diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 03341b0f46b..87d9cb281f4 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -109,7 +109,6 @@ class FanGroup(GroupEntity, FanEntity): """Representation of a FanGroup.""" _attr_available: bool = False - _enable_turn_on_off_backwards_compatibility = False def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a FanGroup entity.""" diff --git a/homeassistant/components/group/registry.py b/homeassistant/components/group/registry.py index 7ac5770f171..2f3c4aa5221 100644 --- a/homeassistant/components/group/registry.py +++ b/homeassistant/components/group/registry.py @@ -11,7 +11,7 @@ from typing import Protocol from homeassistant.components.alarm_control_panel import AlarmControlPanelState from homeassistant.components.climate import HVACMode from homeassistant.components.lock import LockState -from homeassistant.components.vacuum import STATE_CLEANING, STATE_ERROR, STATE_RETURNING +from homeassistant.components.vacuum import VacuumActivity from homeassistant.components.water_heater import ( STATE_ECO, STATE_ELECTRIC, @@ -105,9 +105,9 @@ ON_OFF_STATES: dict[Platform | str, tuple[set[str], str, str]] = { Platform.VACUUM: ( { STATE_ON, - STATE_CLEANING, - STATE_RETURNING, - STATE_ERROR, + VacuumActivity.CLEANING, + VacuumActivity.RETURNING, + VacuumActivity.ERROR, }, STATE_ON, STATE_OFF, diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index cf694af0d98..fb90eb9b22c 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -238,7 +238,7 @@ }, "set": { "name": "Set", - "description": "Creates/Updates a user group.", + "description": "Creates/Updates a group.", "fields": { "object_id": { "name": "Object ID", diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index fbc65050704..f9e9c31ce46 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -342,7 +342,7 @@ def get_next_departure( {tomorrow_order} origin_stop_time.departure_time LIMIT :limit - """ # noqa: S608 + """ result = schedule.engine.connect().execute( text(sql_query), { diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py index c4146d72469..55e4893e31b 100644 --- a/homeassistant/components/guardian/config_flow.py +++ b/homeassistant/components/guardian/config_flow.py @@ -8,10 +8,11 @@ from aioguardian import Client from aioguardian.errors import GuardianError import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_UID, DOMAIN, LOGGER @@ -101,7 +102,7 @@ class GuardianConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle the configuration via dhcp.""" self.discovery_info = { @@ -114,7 +115,7 @@ class GuardianConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle the configuration via zeroconf.""" self.discovery_info = { diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 5843e14d63e..1972e89c58a 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -1,27 +1,14 @@ """The habitica integration.""" -from http import HTTPStatus +from habiticalib import Habitica -from aiohttp import ClientResponseError -from habitipy.aio import HabitipyAsync - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - APPLICATION_NAME, - CONF_API_KEY, - CONF_NAME, - CONF_URL, - CONF_VERIFY_SSL, - Platform, - __version__, -) +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import CONF_API_USER, DEVELOPER_ID, DOMAIN +from .const import CONF_API_USER, DOMAIN, X_CLIENT from .coordinator import HabiticaDataUpdateCoordinator from .services import async_setup_services from .types import HabiticaConfigEntry @@ -33,6 +20,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CALENDAR, + Platform.IMAGE, Platform.SENSOR, Platform.SWITCH, Platform.TODO, @@ -51,47 +39,17 @@ async def async_setup_entry( ) -> bool: """Set up habitica from a config entry.""" - class HAHabitipyAsync(HabitipyAsync): - """Closure API class to hold session.""" - - def __call__(self, **kwargs): - return super().__call__(websession, **kwargs) - - def _make_headers(self) -> dict[str, str]: - headers = super()._make_headers() - headers.update( - {"x-client": f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"} - ) - return headers - - websession = async_get_clientsession( + session = async_get_clientsession( hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True) ) - api = await hass.async_add_executor_job( - HAHabitipyAsync, - { - "url": config_entry.data[CONF_URL], - "login": config_entry.data[CONF_API_USER], - "password": config_entry.data[CONF_API_KEY], - }, + api = Habitica( + session, + api_user=config_entry.data[CONF_API_USER], + api_key=config_entry.data[CONF_API_KEY], + url=config_entry.data[CONF_URL], + x_client=X_CLIENT, ) - try: - user = await api.user.get(userFields="profile") - except ClientResponseError as e: - if e.status == HTTPStatus.TOO_MANY_REQUESTS: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - ) from e - raise ConfigEntryNotReady(e) from e - - if not config_entry.data.get(CONF_NAME): - name = user["profile"]["name"] - hass.config_entries.async_update_entry( - config_entry, - data={**config_entry.data, CONF_NAME: name}, - ) coordinator = HabiticaDataUpdateCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() @@ -102,6 +60,6 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HabiticaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py index bc79370ea63..5e3040e0606 100644 --- a/homeassistant/components/habitica/binary_sensor.py +++ b/homeassistant/components/habitica/binary_sensor.py @@ -5,7 +5,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from typing import Any + +from habiticalib import UserData from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -18,13 +19,15 @@ from .const import ASSETS_URL from .entity import HabiticaBase from .types import HabiticaConfigEntry +PARALLEL_UPDATES = 1 + @dataclass(kw_only=True, frozen=True) class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription): """Habitica Binary Sensor Description.""" - value_fn: Callable[[dict[str, Any]], bool | None] - entity_picture: Callable[[dict[str, Any]], str | None] + value_fn: Callable[[UserData], bool | None] + entity_picture: Callable[[UserData], str | None] class HabiticaBinarySensor(StrEnum): @@ -33,10 +36,10 @@ class HabiticaBinarySensor(StrEnum): PENDING_QUEST = "pending_quest" -def get_scroll_image_for_pending_quest_invitation(user: dict[str, Any]) -> str | None: +def get_scroll_image_for_pending_quest_invitation(user: UserData) -> str | None: """Entity picture for pending quest invitation.""" - if user["party"]["quest"].get("key") and user["party"]["quest"]["RSVPNeeded"]: - return f"inventory_quest_scroll_{user["party"]["quest"]["key"]}.png" + if user.party.quest.key and user.party.quest.RSVPNeeded: + return f"inventory_quest_scroll_{user.party.quest.key}.png" return None @@ -44,7 +47,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] = HabiticaBinarySensorEntityDescription( key=HabiticaBinarySensor.PENDING_QUEST, translation_key=HabiticaBinarySensor.PENDING_QUEST, - value_fn=lambda user: user["party"]["quest"]["RSVPNeeded"], + value_fn=lambda user: user.party.quest.RSVPNeeded, entity_picture=get_scroll_image_for_pending_quest_invitation, ), ) diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py index 30e326f79a0..450a5cdcf20 100644 --- a/homeassistant/components/habitica/button.py +++ b/homeassistant/components/habitica/button.py @@ -5,10 +5,17 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from http import HTTPStatus from typing import Any -from aiohttp import ClientResponseError +from aiohttp import ClientError +from habiticalib import ( + HabiticaClass, + HabiticaException, + NotAuthorizedError, + Skill, + TaskType, + TooManyRequestsError, +) from homeassistant.components.button import ( DOMAIN as BUTTON_DOMAIN, @@ -20,7 +27,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ASSETS_URL, DOMAIN, HEALER, MAGE, ROGUE, WARRIOR +from .const import ASSETS_URL, DOMAIN from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator from .entity import HabiticaBase from .types import HabiticaConfigEntry @@ -33,12 +40,12 @@ class HabiticaButtonEntityDescription(ButtonEntityDescription): """Describes Habitica button entity.""" press_fn: Callable[[HabiticaDataUpdateCoordinator], Any] - available_fn: Callable[[HabiticaData], bool] | None = None - class_needed: str | None = None + available_fn: Callable[[HabiticaData], bool] + class_needed: HabiticaClass | None = None entity_picture: str | None = None -class HabitipyButtonEntity(StrEnum): +class HabiticaButtonEntity(StrEnum): """Habitica button entities.""" RUN_CRON = "run_cron" @@ -61,205 +68,207 @@ class HabitipyButtonEntity(StrEnum): BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.RUN_CRON, - translation_key=HabitipyButtonEntity.RUN_CRON, - press_fn=lambda coordinator: coordinator.api.cron.post(), - available_fn=lambda data: data.user["needsCron"], + key=HabiticaButtonEntity.RUN_CRON, + translation_key=HabiticaButtonEntity.RUN_CRON, + press_fn=lambda coordinator: coordinator.habitica.run_cron(), + available_fn=lambda data: data.user.needsCron is True, ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.BUY_HEALTH_POTION, - translation_key=HabitipyButtonEntity.BUY_HEALTH_POTION, - press_fn=( - lambda coordinator: coordinator.api["user"]["buy-health-potion"].post() - ), + key=HabiticaButtonEntity.BUY_HEALTH_POTION, + translation_key=HabiticaButtonEntity.BUY_HEALTH_POTION, + press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(), available_fn=( - lambda data: data.user["stats"]["gp"] >= 25 - and data.user["stats"]["hp"] < 50 + lambda data: (data.user.stats.gp or 0) >= 25 + and (data.user.stats.hp or 0) < 50 ), entity_picture="shop_potion.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS, - translation_key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS, - press_fn=lambda coordinator: coordinator.api["user"]["allocate-now"].post(), + key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS, + translation_key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS, + press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(), available_fn=( - lambda data: data.user["preferences"].get("automaticAllocation") is True - and data.user["stats"]["points"] > 0 + lambda data: data.user.preferences.automaticAllocation is True + and (data.user.stats.points or 0) > 0 ), ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.REVIVE, - translation_key=HabitipyButtonEntity.REVIVE, - press_fn=lambda coordinator: coordinator.api["user"]["revive"].post(), - available_fn=lambda data: data.user["stats"]["hp"] == 0, + key=HabiticaButtonEntity.REVIVE, + translation_key=HabiticaButtonEntity.REVIVE, + press_fn=lambda coordinator: coordinator.habitica.revive(), + available_fn=lambda data: data.user.stats.hp == 0, ), ) CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.MPHEAL, - translation_key=HabitipyButtonEntity.MPHEAL, - press_fn=lambda coordinator: coordinator.api.user.class_.cast["mpheal"].post(), - available_fn=( - lambda data: data.user["stats"]["lvl"] >= 12 - and data.user["stats"]["mp"] >= 30 + key=HabiticaButtonEntity.MPHEAL, + translation_key=HabiticaButtonEntity.MPHEAL, + press_fn=( + lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE) ), - class_needed=MAGE, + available_fn=( + lambda data: (data.user.stats.lvl or 0) >= 12 + and (data.user.stats.mp or 0) >= 30 + ), + class_needed=HabiticaClass.MAGE, entity_picture="shop_mpheal.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.EARTH, - translation_key=HabitipyButtonEntity.EARTH, - press_fn=lambda coordinator: coordinator.api.user.class_.cast["earth"].post(), + key=HabiticaButtonEntity.EARTH, + translation_key=HabiticaButtonEntity.EARTH, + press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 13 - and data.user["stats"]["mp"] >= 35 + lambda data: (data.user.stats.lvl or 0) >= 13 + and (data.user.stats.mp or 0) >= 35 ), - class_needed=MAGE, + class_needed=HabiticaClass.MAGE, entity_picture="shop_earth.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.FROST, - translation_key=HabitipyButtonEntity.FROST, - press_fn=lambda coordinator: coordinator.api.user.class_.cast["frost"].post(), + key=HabiticaButtonEntity.FROST, + translation_key=HabiticaButtonEntity.FROST, + press_fn=( + lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST) + ), # chilling frost can only be cast once per day (streaks buff is false) available_fn=( - lambda data: data.user["stats"]["lvl"] >= 14 - and data.user["stats"]["mp"] >= 40 - and not data.user["stats"]["buffs"]["streaks"] + lambda data: (data.user.stats.lvl or 0) >= 14 + and (data.user.stats.mp or 0) >= 40 + and not data.user.stats.buffs.streaks ), - class_needed=MAGE, + class_needed=HabiticaClass.MAGE, entity_picture="shop_frost.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.DEFENSIVE_STANCE, - translation_key=HabitipyButtonEntity.DEFENSIVE_STANCE, + key=HabiticaButtonEntity.DEFENSIVE_STANCE, + translation_key=HabiticaButtonEntity.DEFENSIVE_STANCE, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast[ - "defensiveStance" - ].post() + lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 12 - and data.user["stats"]["mp"] >= 25 + lambda data: (data.user.stats.lvl or 0) >= 12 + and (data.user.stats.mp or 0) >= 25 ), - class_needed=WARRIOR, + class_needed=HabiticaClass.WARRIOR, entity_picture="shop_defensiveStance.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.VALOROUS_PRESENCE, - translation_key=HabitipyButtonEntity.VALOROUS_PRESENCE, + key=HabiticaButtonEntity.VALOROUS_PRESENCE, + translation_key=HabiticaButtonEntity.VALOROUS_PRESENCE, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast[ - "valorousPresence" - ].post() + lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 13 - and data.user["stats"]["mp"] >= 20 + lambda data: (data.user.stats.lvl or 0) >= 13 + and (data.user.stats.mp or 0) >= 20 ), - class_needed=WARRIOR, + class_needed=HabiticaClass.WARRIOR, entity_picture="shop_valorousPresence.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.INTIMIDATE, - translation_key=HabitipyButtonEntity.INTIMIDATE, + key=HabiticaButtonEntity.INTIMIDATE, + translation_key=HabiticaButtonEntity.INTIMIDATE, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast["intimidate"].post() + lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 14 - and data.user["stats"]["mp"] >= 15 + lambda data: (data.user.stats.lvl or 0) >= 14 + and (data.user.stats.mp or 0) >= 15 ), - class_needed=WARRIOR, + class_needed=HabiticaClass.WARRIOR, entity_picture="shop_intimidate.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.TOOLS_OF_TRADE, - translation_key=HabitipyButtonEntity.TOOLS_OF_TRADE, + key=HabiticaButtonEntity.TOOLS_OF_TRADE, + translation_key=HabiticaButtonEntity.TOOLS_OF_TRADE, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast["toolsOfTrade"].post() + lambda coordinator: coordinator.habitica.cast_skill( + Skill.TOOLS_OF_THE_TRADE + ) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 13 - and data.user["stats"]["mp"] >= 25 + lambda data: (data.user.stats.lvl or 0) >= 13 + and (data.user.stats.mp or 0) >= 25 ), - class_needed=ROGUE, + class_needed=HabiticaClass.ROGUE, entity_picture="shop_toolsOfTrade.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.STEALTH, - translation_key=HabitipyButtonEntity.STEALTH, - press_fn=( - lambda coordinator: coordinator.api.user.class_.cast["stealth"].post() - ), + key=HabiticaButtonEntity.STEALTH, + translation_key=HabiticaButtonEntity.STEALTH, + press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH), # Stealth buffs stack and it can only be cast if the amount of - # unfinished dailies is smaller than the amount of buffs + # buffs is smaller than the amount of unfinished dailies available_fn=( - lambda data: data.user["stats"]["lvl"] >= 14 - and data.user["stats"]["mp"] >= 45 - and data.user["stats"]["buffs"]["stealth"] + lambda data: (data.user.stats.lvl or 0) >= 14 + and (data.user.stats.mp or 0) >= 45 + and (data.user.stats.buffs.stealth or 0) < len( [ r for r in data.tasks - if r.get("type") == "daily" - and r.get("isDue") is True - and r.get("completed") is False + if r.Type is TaskType.DAILY + and r.isDue is True + and r.completed is False ] ) ), - class_needed=ROGUE, + class_needed=HabiticaClass.ROGUE, entity_picture="shop_stealth.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.HEAL, - translation_key=HabitipyButtonEntity.HEAL, - press_fn=lambda coordinator: coordinator.api.user.class_.cast["heal"].post(), - available_fn=( - lambda data: data.user["stats"]["lvl"] >= 11 - and data.user["stats"]["mp"] >= 15 - and data.user["stats"]["hp"] < 50 + key=HabiticaButtonEntity.HEAL, + translation_key=HabiticaButtonEntity.HEAL, + press_fn=( + lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT) ), - class_needed=HEALER, + available_fn=( + lambda data: (data.user.stats.lvl or 0) >= 11 + and (data.user.stats.mp or 0) >= 15 + and (data.user.stats.hp or 0) < 50 + ), + class_needed=HabiticaClass.HEALER, entity_picture="shop_heal.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.BRIGHTNESS, - translation_key=HabitipyButtonEntity.BRIGHTNESS, + key=HabiticaButtonEntity.BRIGHTNESS, + translation_key=HabiticaButtonEntity.BRIGHTNESS, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast["brightness"].post() + lambda coordinator: coordinator.habitica.cast_skill( + Skill.SEARING_BRIGHTNESS + ) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 12 - and data.user["stats"]["mp"] >= 15 + lambda data: (data.user.stats.lvl or 0) >= 12 + and (data.user.stats.mp or 0) >= 15 ), - class_needed=HEALER, + class_needed=HabiticaClass.HEALER, entity_picture="shop_brightness.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.PROTECT_AURA, - translation_key=HabitipyButtonEntity.PROTECT_AURA, + key=HabiticaButtonEntity.PROTECT_AURA, + translation_key=HabiticaButtonEntity.PROTECT_AURA, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast["protectAura"].post() + lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 13 - and data.user["stats"]["mp"] >= 30 + lambda data: (data.user.stats.lvl or 0) >= 13 + and (data.user.stats.mp or 0) >= 30 ), - class_needed=HEALER, + class_needed=HabiticaClass.HEALER, entity_picture="shop_protectAura.png", ), HabiticaButtonEntityDescription( - key=HabitipyButtonEntity.HEAL_ALL, - translation_key=HabitipyButtonEntity.HEAL_ALL, - press_fn=lambda coordinator: coordinator.api.user.class_.cast["healAll"].post(), + key=HabiticaButtonEntity.HEAL_ALL, + translation_key=HabiticaButtonEntity.HEAL_ALL, + press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 14 - and data.user["stats"]["mp"] >= 25 + lambda data: (data.user.stats.lvl or 0) >= 14 + and (data.user.stats.mp or 0) >= 25 ), - class_needed=HEALER, + class_needed=HabiticaClass.HEALER, entity_picture="shop_healAll.png", ), ) @@ -285,10 +294,10 @@ async def async_setup_entry( for description in CLASS_SKILLS: if ( - coordinator.data.user["stats"]["lvl"] >= 10 - and coordinator.data.user["flags"]["classSelected"] - and not coordinator.data.user["preferences"]["disableClasses"] - and description.class_needed == coordinator.data.user["stats"]["class"] + (coordinator.data.user.stats.lvl or 0) >= 10 + and coordinator.data.user.flags.classSelected + and not coordinator.data.user.preferences.disableClasses + and description.class_needed is coordinator.data.user.stats.Class ): if description.key not in skills_added: buttons.append(HabiticaButton(coordinator, description)) @@ -322,20 +331,28 @@ class HabiticaButton(HabiticaBase, ButtonEntity): """Handle the button press.""" try: await self.entity_description.press_fn(self.coordinator) - except ClientResponseError as e: - if e.status == HTTPStatus.TOO_MANY_REQUESTS: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - ) from e - if e.status == HTTPStatus.UNAUTHORIZED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="service_call_unallowed", - ) from e + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_call_unallowed", + ) from e + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await self.coordinator.async_request_refresh() @@ -343,11 +360,10 @@ class HabiticaButton(HabiticaBase, ButtonEntity): @property def available(self) -> bool: """Is entity available.""" - if not super().available: - return False - if self.entity_description.available_fn: - return self.entity_description.available_fn(self.coordinator.data) - return True + + return super().available and self.entity_description.available_fn( + self.coordinator.data + ) @property def entity_picture(self) -> str | None: diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index be4433cb355..f33f3c3c12f 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -2,10 +2,15 @@ from __future__ import annotations +from abc import abstractmethod +from dataclasses import asdict from datetime import date, datetime, timedelta from enum import StrEnum +from typing import TYPE_CHECKING, cast +from uuid import UUID from dateutil.rrule import rrule +from habiticalib import Frequency, TaskType from homeassistant.components.calendar import ( CalendarEntity, @@ -19,9 +24,10 @@ from homeassistant.util import dt as dt_util from . import HabiticaConfigEntry from .coordinator import HabiticaDataUpdateCoordinator from .entity import HabiticaBase -from .types import HabiticaTaskType from .util import build_rrule, get_recurrence_rule +PARALLEL_UPDATES = 1 + class HabiticaCalendar(StrEnum): """Habitica calendars.""" @@ -60,6 +66,43 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity): """Initialize calendar entity.""" super().__init__(coordinator, self.entity_description) + @abstractmethod + def get_events( + self, start_date: datetime, end_date: datetime | None = None + ) -> list[CalendarEvent]: + """Return events.""" + + @property + def event(self) -> CalendarEvent | None: + """Return the current or next upcoming event.""" + + return next(iter(self.get_events(dt_util.now())), None) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + + return self.get_events(start_date, end_date) + + @property + def start_of_today(self) -> datetime: + """Habitica daystart.""" + return dt_util.start_of_local_day(self.coordinator.data.user.lastCron) + + def get_recurrence_dates( + self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None + ) -> list[datetime]: + """Calculate recurrence dates based on start_date and end_date.""" + if end_date: + recurrence_dates = recurrences.between( + start_date, end_date - timedelta(days=1), inc=True + ) + + return cast(list[datetime], recurrence_dates) + # if no end_date is given, return only the next recurrence + return [recurrences.after(start_date, inc=True)] + class HabiticaTodosCalendarEntity(HabiticaCalendarEntity): """Habitica todos calendar entity.""" @@ -69,7 +112,7 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity): translation_key=HabiticaCalendar.TODOS, ) - def dated_todos( + def get_events( self, start_date: datetime, end_date: datetime | None = None ) -> list[CalendarEvent]: """Get all dated todos.""" @@ -77,13 +120,13 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity): events = [] for task in self.coordinator.data.tasks: if not ( - task["type"] == HabiticaTaskType.TODO - and not task["completed"] - and task.get("date") # only if has due date + task.Type is TaskType.TODO + and not task.completed + and task.date is not None # only if has due date ): continue - start = dt_util.start_of_local_day(datetime.fromisoformat(task["date"])) + start = dt_util.start_of_local_day(task.date) end = start + timedelta(days=1) # return current and upcoming events or events within the requested range @@ -94,36 +137,26 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity): if end_date and start > end_date: # Event starts after date range continue - + if TYPE_CHECKING: + assert task.text + assert task.id events.append( CalendarEvent( start=start.date(), end=end.date(), - summary=task["text"], - description=task["notes"], - uid=task["id"], + summary=task.text, + description=task.notes, + uid=str(task.id), ) ) return sorted( events, key=lambda event: ( event.start, - self.coordinator.data.user["tasksOrder"]["todos"].index(event.uid), + self.coordinator.data.user.tasksOrder.todos.index(UUID(event.uid)), ), ) - @property - def event(self) -> CalendarEvent | None: - """Return the current or next upcoming event.""" - - return next(iter(self.dated_todos(dt_util.now())), None) - - async def async_get_events( - self, hass: HomeAssistant, start_date: datetime, end_date: datetime - ) -> list[CalendarEvent]: - """Return calendar events within a datetime range.""" - return self.dated_todos(start_date, end_date) - class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity): """Habitica dailies calendar entity.""" @@ -133,13 +166,6 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity): translation_key=HabiticaCalendar.DAILIES, ) - @property - def today(self) -> datetime: - """Habitica daystart.""" - return dt_util.start_of_local_day( - datetime.fromisoformat(self.coordinator.data.user["lastCron"]) - ) - def end_date(self, recurrence: datetime, end: datetime | None = None) -> date: """Calculate the end date for a yesterdaily. @@ -152,34 +178,29 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity): if end: return recurrence.date() + timedelta(days=1) return ( - dt_util.start_of_local_day() if recurrence == self.today else recurrence + dt_util.start_of_local_day() + if recurrence == self.start_of_today + else recurrence ).date() + timedelta(days=1) - def get_recurrence_dates( - self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None - ) -> list[datetime]: - """Calculate recurrence dates based on start_date and end_date.""" - if end_date: - return recurrences.between( - start_date, end_date - timedelta(days=1), inc=True - ) - # if no end_date is given, return only the next recurrence - return [recurrences.after(self.today, inc=True)] - - def due_dailies( + def get_events( self, start_date: datetime, end_date: datetime | None = None ) -> list[CalendarEvent]: """Get dailies and recurrences for a given period or the next upcoming.""" # we only have dailies for today and future recurrences - if end_date and end_date < self.today: + if end_date and end_date < self.start_of_today: return [] - start_date = max(start_date, self.today) + start_date = max(start_date, self.start_of_today) events = [] for task in self.coordinator.data.tasks: # only dailies that that are not 'grey dailies' - if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]): + if not (task.Type is TaskType.DAILY and task.everyX): + continue + if task.frequency is Frequency.WEEKLY and not any( + asdict(task.repeat).values() + ): continue recurrences = build_rrule(task) @@ -187,19 +208,23 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity): recurrences, start_date, end_date ) for recurrence in recurrence_dates: - is_future_event = recurrence > self.today - is_current_event = recurrence <= self.today and not task["completed"] + is_future_event = recurrence > self.start_of_today + is_current_event = ( + recurrence <= self.start_of_today and not task.completed + ) - if not (is_future_event or is_current_event): + if not is_future_event and not is_current_event: continue - + if TYPE_CHECKING: + assert task.text + assert task.id events.append( CalendarEvent( start=recurrence.date(), end=self.end_date(recurrence, end_date), - summary=task["text"], - description=task["notes"], - uid=task["id"], + summary=task.text, + description=task.notes, + uid=str(task.id), rrule=get_recurrence_rule(recurrences), ) ) @@ -207,27 +232,22 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity): events, key=lambda event: ( event.start, - self.coordinator.data.user["tasksOrder"]["dailys"].index(event.uid), + self.coordinator.data.user.tasksOrder.dailys.index(UUID(event.uid)), ), ) @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - return next(iter(self.due_dailies(self.today)), None) - - async def async_get_events( - self, hass: HomeAssistant, start_date: datetime, end_date: datetime - ) -> list[CalendarEvent]: - """Return calendar events within a datetime range.""" - - return self.due_dailies(start_date, end_date) + return next(iter(self.get_events(self.start_of_today)), None) @property def extra_state_attributes(self) -> dict[str, bool | None] | None: """Return entity specific state attributes.""" return { - "yesterdaily": self.event.start < self.today.date() if self.event else None + "yesterdaily": self.event.start < self.start_of_today.date() + if self.event + else None } @@ -239,7 +259,7 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity): translation_key=HabiticaCalendar.TODO_REMINDERS, ) - def reminders( + def get_events( self, start_date: datetime, end_date: datetime | None = None ) -> list[CalendarEvent]: """Reminders for todos.""" @@ -247,14 +267,14 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity): events = [] for task in self.coordinator.data.tasks: - if task["type"] != HabiticaTaskType.TODO or task["completed"]: + if task.Type is not TaskType.TODO or task.completed: continue - for reminder in task.get("reminders", []): + for reminder in task.reminders: # reminders are returned by the API in local time but with wrong # timezone (UTC) and arbitrary added seconds/microseconds. When # creating reminders in Habitica only hours and minutes can be defined. - start = datetime.fromisoformat(reminder["time"]).replace( + start = reminder.time.replace( tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0 ) end = start + timedelta(hours=1) @@ -266,14 +286,16 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity): if end_date and start > end_date: # Event starts after date range continue - + if TYPE_CHECKING: + assert task.text + assert task.id events.append( CalendarEvent( start=start, end=end, - summary=task["text"], - description=task["notes"], - uid=f"{task["id"]}_{reminder["id"]}", + summary=task.text, + description=task.notes, + uid=f"{task.id}_{reminder.id}", ) ) @@ -282,18 +304,6 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity): key=lambda event: event.start, ) - @property - def event(self) -> CalendarEvent | None: - """Return the next upcoming event.""" - return next(iter(self.reminders(dt_util.now())), None) - - async def async_get_events( - self, hass: HomeAssistant, start_date: datetime, end_date: datetime - ) -> list[CalendarEvent]: - """Return calendar events within a datetime range.""" - - return self.reminders(start_date, end_date) - class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity): """Habitica daily reminders calendar entity.""" @@ -303,7 +313,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity): translation_key=HabiticaCalendar.DAILY_REMINDERS, ) - def start(self, reminder_time: str, reminder_date: date) -> datetime: + def start(self, reminder_time: datetime, reminder_date: date) -> datetime: """Generate reminder times for dailies. Reminders for dailies have a datetime but the date part is arbitrary, @@ -312,78 +322,65 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity): """ return datetime.combine( reminder_date, - datetime.fromisoformat(reminder_time) - .replace( + reminder_time.replace( second=0, microsecond=0, - ) - .time(), + ).time(), tzinfo=dt_util.DEFAULT_TIME_ZONE, ) - @property - def today(self) -> datetime: - """Habitica daystart.""" - return dt_util.start_of_local_day( - datetime.fromisoformat(self.coordinator.data.user["lastCron"]) - ) - - def get_recurrence_dates( - self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None - ) -> list[datetime]: - """Calculate recurrence dates based on start_date and end_date.""" - if end_date: - return recurrences.between( - start_date, end_date - timedelta(days=1), inc=True - ) - # if no end_date is given, return only the next recurrence - return [recurrences.after(self.today, inc=True)] - - def reminders( + def get_events( self, start_date: datetime, end_date: datetime | None = None ) -> list[CalendarEvent]: """Reminders for dailies.""" events = [] - if end_date and end_date < self.today: + if end_date and end_date < self.start_of_today: return [] - start_date = max(start_date, self.today) + start_date = max(start_date, self.start_of_today) for task in self.coordinator.data.tasks: - if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]): + if not (task.Type is TaskType.DAILY and task.everyX): + continue + + if task.frequency is Frequency.WEEKLY and not any( + asdict(task.repeat).values() + ): continue recurrences = build_rrule(task) - recurrences_start = self.today + recurrences_start = self.start_of_today recurrence_dates = self.get_recurrence_dates( recurrences, recurrences_start, end_date ) for recurrence in recurrence_dates: - is_future_event = recurrence > self.today - is_current_event = recurrence <= self.today and not task["completed"] + is_future_event = recurrence > self.start_of_today + is_current_event = ( + recurrence <= self.start_of_today and not task.completed + ) if not is_future_event and not is_current_event: continue - for reminder in task.get("reminders", []): - start = self.start(reminder["time"], recurrence) + for reminder in task.reminders: + start = self.start(reminder.time, recurrence) end = start + timedelta(hours=1) if end < start_date: # Event ends before date range continue - if end_date and start > end_date: - # Event starts after date range - continue + if TYPE_CHECKING: + assert task.id + assert task.text events.append( CalendarEvent( start=start, end=end, - summary=task["text"], - description=task["notes"], - uid=f"{task["id"]}_{reminder["id"]}", + summary=task.text, + description=task.notes, + uid=f"{task.id}_{reminder.id}", ) ) @@ -391,15 +388,3 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity): events, key=lambda event: event.start, ) - - @property - def event(self) -> CalendarEvent | None: - """Return the next upcoming event.""" - return next(iter(self.reminders(dt_util.now())), None) - - async def async_get_events( - self, hass: HomeAssistant, start_date: datetime, end_date: datetime - ) -> list[CalendarEvent]: - """Return calendar events within a datetime range.""" - - return self.reminders(start_date, end_date) diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index d168a5f57b4..7a7f369cb09 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -2,17 +2,25 @@ from __future__ import annotations -from http import HTTPStatus +from collections.abc import Mapping import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from aiohttp import ClientResponseError -from habitipy.aio import HabitipyAsync +from aiohttp import ClientError +from habiticalib import ( + Habitica, + HabiticaException, + LoginData, + NotAuthorizedError, + UserData, +) import voluptuous as vol +from homeassistant import data_entry_flow from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, @@ -25,14 +33,19 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) +from . import HabiticaConfigEntry from .const import ( CONF_API_USER, DEFAULT_URL, DOMAIN, FORGOT_PASSWORD_URL, HABITICANS_URL, + SECTION_DANGER_ZONE, + SECTION_REAUTH_API_KEY, + SECTION_REAUTH_LOGIN, SIGN_UP_URL, SITE_DATA_URL, + X_CLIENT, ) STEP_ADVANCED_DATA_SCHEMA = vol.Schema( @@ -61,14 +74,59 @@ STEP_LOGIN_DATA_SCHEMA = vol.Schema( } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(SECTION_REAUTH_LOGIN): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ) + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + }, + ), + {"collapsed": False}, + ), + vol.Required(SECTION_REAUTH_API_KEY): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_API_KEY): str, + }, + ), + {"collapsed": True}, + ), + } +) + +STEP_RECONF_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(SECTION_DANGER_ZONE): data_entry_flow.section( + vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_VERIFY_SSL): bool, + }, + ), + {"collapsed": True}, + ), + } +) + _LOGGER = logging.getLogger(__name__) class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for habitica.""" - VERSION = 1 - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -93,39 +151,20 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): """ errors: dict[str, str] = {} if user_input is not None: - try: - session = async_get_clientsession(self.hass) - api = await self.hass.async_add_executor_job( - HabitipyAsync, - { - "login": "", - "password": "", - "url": DEFAULT_URL, - }, - ) - login_response = await api.user.auth.local.login.post( - session=session, - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - ) - - except ClientResponseError as ex: - if ex.status == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_auth" - else: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(login_response["id"]) + errors, login, user = await self.validate_login( + {**user_input, CONF_URL: DEFAULT_URL} + ) + if not errors and login is not None and user is not None: + await self.async_set_unique_id(str(login.id)) self._abort_if_unique_id_configured() + if TYPE_CHECKING: + assert user.profile.name return self.async_create_entry( - title=login_response["username"], + title=user.profile.name, data={ - CONF_API_USER: login_response["id"], - CONF_API_KEY: login_response["apiToken"], - CONF_USERNAME: login_response["username"], + CONF_API_USER: str(login.id), + CONF_API_KEY: login.apiToken, + CONF_NAME: user.profile.name, # needed for api_call action CONF_URL: DEFAULT_URL, CONF_VERIFY_SSL: True, }, @@ -150,36 +189,19 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): """ errors: dict[str, str] = {} if user_input is not None: - try: - session = async_get_clientsession( - self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True) - ) - api = await self.hass.async_add_executor_job( - HabitipyAsync, - { - "login": user_input[CONF_API_USER], - "password": user_input[CONF_API_KEY], - "url": user_input.get(CONF_URL, DEFAULT_URL), - }, - ) - api_response = await api.user.get( - session=session, - userFields="auth", - ) - except ClientResponseError as ex: - if ex.status == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_auth" - else: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(user_input[CONF_API_USER]) - self._abort_if_unique_id_configured() - user_input[CONF_USERNAME] = api_response["auth"]["local"]["username"] + await self.async_set_unique_id(user_input[CONF_API_USER]) + self._abort_if_unique_id_configured() + errors, user = await self.validate_api_key(user_input) + if not errors and user is not None: + if TYPE_CHECKING: + assert user.profile.name return self.async_create_entry( - title=user_input[CONF_USERNAME], data=user_input + title=user.profile.name, + data={ + **user_input, + CONF_URL: user_input.get(CONF_URL, DEFAULT_URL), + CONF_NAME: user.profile.name, # needed for api_call action + }, ) return self.async_show_form( @@ -193,3 +215,164 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): "default_url": DEFAULT_URL, }, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + reauth_entry: HabiticaConfigEntry = self._get_reauth_entry() + + if user_input is not None: + if user_input[SECTION_REAUTH_LOGIN].get(CONF_USERNAME) and user_input[ + SECTION_REAUTH_LOGIN + ].get(CONF_PASSWORD): + errors, login, _ = await self.validate_login( + {**reauth_entry.data, **user_input[SECTION_REAUTH_LOGIN]} + ) + if not errors and login is not None: + await self.async_set_unique_id(str(login.id)) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_API_KEY: login.apiToken}, + ) + elif user_input[SECTION_REAUTH_API_KEY].get(CONF_API_KEY): + errors, user = await self.validate_api_key( + { + **reauth_entry.data, + **user_input[SECTION_REAUTH_API_KEY], + } + ) + if not errors and user is not None: + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input[SECTION_REAUTH_API_KEY] + ) + else: + errors["base"] = "invalid_credentials" + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, + suggested_values={ + CONF_USERNAME: ( + user_input[SECTION_REAUTH_LOGIN].get(CONF_USERNAME) + if user_input + else None, + ) + }, + ), + description_placeholders={ + CONF_NAME: reauth_entry.title, + "habiticans": HABITICANS_URL, + }, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconf_entry = self._get_reconfigure_entry() + suggested_values = { + CONF_API_KEY: reconf_entry.data[CONF_API_KEY], + SECTION_DANGER_ZONE: { + CONF_URL: reconf_entry.data[CONF_URL], + CONF_VERIFY_SSL: reconf_entry.data.get(CONF_VERIFY_SSL, True), + }, + } + + if user_input: + errors, user = await self.validate_api_key( + { + **reconf_entry.data, + **user_input, + **user_input[SECTION_DANGER_ZONE], + } + ) + if not errors and user is not None: + return self.async_update_reload_and_abort( + reconf_entry, + data_updates={ + CONF_API_KEY: user_input[CONF_API_KEY], + **user_input[SECTION_DANGER_ZONE], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_RECONF_DATA_SCHEMA, + suggested_values=user_input or suggested_values, + ), + errors=errors, + description_placeholders={ + "site_data": SITE_DATA_URL, + "habiticans": HABITICANS_URL, + }, + ) + + async def validate_login( + self, user_input: Mapping[str, Any] + ) -> tuple[dict[str, str], LoginData | None, UserData | None]: + """Validate login with login credentials.""" + errors: dict[str, str] = {} + session = async_get_clientsession( + self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True) + ) + api = Habitica(session=session, x_client=X_CLIENT) + try: + login = await api.login( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + user = await api.get_user(user_fields="profile") + + except NotAuthorizedError: + errors["base"] = "invalid_auth" + except (HabiticaException, ClientError): + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return errors, login.data, user.data + + return errors, None, None + + async def validate_api_key( + self, user_input: Mapping[str, Any] + ) -> tuple[dict[str, str], UserData | None]: + """Validate authentication with api key.""" + errors: dict[str, str] = {} + session = async_get_clientsession( + self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True) + ) + api = Habitica( + session=session, + x_client=X_CLIENT, + api_user=user_input[CONF_API_USER], + api_key=user_input[CONF_API_KEY], + url=user_input.get(CONF_URL, DEFAULT_URL), + ) + try: + user = await api.get_user(user_fields="profile") + except NotAuthorizedError: + errors["base"] = "invalid_auth" + except (HabiticaException, ClientError): + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return errors, user.data + + return errors, None diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index dce417b60a5..5eb616142e5 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -1,6 +1,6 @@ """Constants for the habitica integration.""" -from homeassistant.const import CONF_PATH +from homeassistant.const import APPLICATION_NAME, CONF_PATH, __version__ CONF_API_USER = "api_user" @@ -25,14 +25,17 @@ ATTR_DATA = "data" MANUFACTURER = "HabitRPG, Inc." NAME = "Habitica" -UNIT_TASKS = "tasks" - ATTR_CONFIG_ENTRY = "config_entry" ATTR_SKILL = "skill" ATTR_TASK = "task" ATTR_DIRECTION = "direction" ATTR_TARGET = "target" ATTR_ITEM = "item" +ATTR_TYPE = "type" +ATTR_PRIORITY = "priority" +ATTR_TAG = "tag" +ATTR_KEYWORD = "keyword" + SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" SERVICE_ACCEPT_QUEST = "accept_quest" @@ -40,15 +43,17 @@ SERVICE_CANCEL_QUEST = "cancel_quest" SERVICE_ABORT_QUEST = "abort_quest" SERVICE_REJECT_QUEST = "reject_quest" SERVICE_LEAVE_QUEST = "leave_quest" +SERVICE_GET_TASKS = "get_tasks" + SERVICE_SCORE_HABIT = "score_habit" SERVICE_SCORE_REWARD = "score_reward" SERVICE_TRANSFORMATION = "transformation" -WARRIOR = "warrior" -ROGUE = "rogue" -HEALER = "healer" -MAGE = "wizard" - DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" +X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" + +SECTION_REAUTH_LOGIN = "reauth_login" +SECTION_REAUTH_API_KEY = "reauth_api_key" +SECTION_DANGER_ZONE = "danger_zone" diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index f9ffb1b53bd..f97b98410bb 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -5,16 +5,31 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -from http import HTTPStatus +from io import BytesIO import logging from typing import Any -from aiohttp import ClientResponseError -from habitipy.aio import HabitipyAsync +from aiohttp import ClientError +from habiticalib import ( + ContentData, + Habitica, + HabiticaException, + NotAuthorizedError, + TaskData, + TaskFilter, + TooManyRequestsError, + UserData, + UserStyles, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,10 +40,10 @@ _LOGGER = logging.getLogger(__name__) @dataclass class HabiticaData: - """Coordinator data class.""" + """Habitica data.""" - user: dict[str, Any] - tasks: list[dict] + user: UserData + tasks: list[TaskData] class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): @@ -36,7 +51,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, habitipy: HabitipyAsync) -> None: + def __init__(self, hass: HomeAssistant, habitica: Habitica) -> None: """Initialize the Habitica data coordinator.""" super().__init__( hass, @@ -50,25 +65,71 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): immediate=False, ), ) - self.api = habitipy - self.content: dict[str, Any] = {} + self.habitica = habitica + self.content: ContentData + + async def _async_setup(self) -> None: + """Set up Habitica integration.""" + + try: + user = await self.habitica.get_user() + self.content = ( + await self.habitica.get_content(user.data.preferences.language) + ).data + except NotAuthorizedError as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from e + except TooManyRequestsError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + + if not self.config_entry.data.get(CONF_NAME): + self.hass.config_entries.async_update_entry( + self.config_entry, + data={**self.config_entry.data, CONF_NAME: user.data.profile.name}, + ) async def _async_update_data(self) -> HabiticaData: try: - user_response = await self.api.user.get() - tasks_response = await self.api.tasks.user.get() - tasks_response.extend(await self.api.tasks.user.get(type="completedTodos")) - if not self.content: - self.content = await self.api.content.get( - language=user_response["preferences"]["language"] - ) - except ClientResponseError as error: - if error.status == HTTPStatus.TOO_MANY_REQUESTS: - _LOGGER.debug("Rate limit exceeded, will try again later") - return self.data - raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error - - return HabiticaData(user=user_response, tasks=tasks_response) + user = (await self.habitica.get_user()).data + tasks = (await self.habitica.get_tasks()).data + completed_todos = ( + await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS) + ).data + except TooManyRequestsError: + _LOGGER.debug("Rate limit exceeded, will try again later") + return self.data + except HabiticaException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return HabiticaData(user=user, tasks=tasks + completed_todos) async def execute( self, func: Callable[[HabiticaDataUpdateCoordinator], Any] @@ -77,15 +138,33 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): try: await func(self) - except ClientResponseError as e: - if e.status == HTTPStatus.TOO_MANY_REQUESTS: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - ) from e + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await self.async_request_refresh() + + async def generate_avatar(self, user_styles: UserStyles) -> bytes: + """Generate Avatar.""" + + avatar = BytesIO() + await self.habitica.generate_avatar( + fp=avatar, user_styles=user_styles, fmt="PNG" + ) + + return avatar.getvalue() diff --git a/homeassistant/components/habitica/diagnostics.py b/homeassistant/components/habitica/diagnostics.py new file mode 100644 index 00000000000..abfa0f35c4b --- /dev/null +++ b/homeassistant/components/habitica/diagnostics.py @@ -0,0 +1,27 @@ +"""Diagnostics platform for Habitica integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from .const import CONF_API_USER +from .types import HabiticaConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: HabiticaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + habitica_data = await config_entry.runtime_data.habitica.get_user_anonymized() + + return { + "config_entry_data": { + CONF_URL: config_entry.data[CONF_URL], + CONF_API_USER: config_entry.data[CONF_API_USER], + }, + "habitica_data": habitica_data.to_dict()["data"], + } diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index d4ca5dba10d..6ae6ebd728b 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -121,12 +121,6 @@ "rogue": "mdi:ninja" } }, - "todos": { - "default": "mdi:checkbox-outline" - }, - "dailys": { - "default": "mdi:calendar-month" - }, "habits": { "default": "mdi:contrast-box" }, @@ -144,6 +138,27 @@ }, "constitution": { "default": "mdi:run-fast" + }, + "food_total": { + "default": "mdi:candy", + "state": { + "0": "mdi:candy-off" + } + }, + "eggs_total": { + "default": "mdi:egg", + "state": { + "0": "mdi:egg-off" + } + }, + "hatching_potions_total": { + "default": "mdi:flask-round-bottom" + }, + "saddle": { + "default": "mdi:horse" + }, + "quest_scrolls": { + "default": "mdi:script-text-outline" } }, "switch": { @@ -196,6 +211,12 @@ }, "transformation": { "service": "mdi:flask-round-bottom" + }, + "get_tasks": { + "service": "mdi:calendar-export", + "sections": { + "filter": "mdi:calendar-filter" + } } } } diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py new file mode 100644 index 00000000000..f1dbbc64d41 --- /dev/null +++ b/homeassistant/components/habitica/image.py @@ -0,0 +1,78 @@ +"""Image platform for Habitica integration.""" + +from __future__ import annotations + +from dataclasses import asdict +from enum import StrEnum + +from habiticalib import UserStyles + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from . import HabiticaConfigEntry +from .coordinator import HabiticaDataUpdateCoordinator +from .entity import HabiticaBase + +PARALLEL_UPDATES = 1 + + +class HabiticaImageEntity(StrEnum): + """Image entities.""" + + AVATAR = "avatar" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HabiticaConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the habitica image platform.""" + + coordinator = config_entry.runtime_data + + async_add_entities([HabiticaImage(hass, coordinator)]) + + +class HabiticaImage(HabiticaBase, ImageEntity): + """A Habitica image entity.""" + + entity_description = ImageEntityDescription( + key=HabiticaImageEntity.AVATAR, + translation_key=HabiticaImageEntity.AVATAR, + ) + _attr_content_type = "image/png" + _current_appearance: UserStyles | None = None + _cache: bytes | None = None + + def __init__( + self, + hass: HomeAssistant, + coordinator: HabiticaDataUpdateCoordinator, + ) -> None: + """Initialize the image entity.""" + super().__init__(coordinator, self.entity_description) + ImageEntity.__init__(self, hass) + self._attr_image_last_updated = dt_util.utcnow() + + def _handle_coordinator_update(self) -> None: + """Check if equipped gear and other things have changed since last avatar image generation.""" + new_appearance = UserStyles.from_dict(asdict(self.coordinator.data.user)) + + if self._current_appearance != new_appearance: + self._current_appearance = new_appearance + self._attr_image_last_updated = dt_util.utcnow() + self._cache = None + + return super()._handle_coordinator_update() + + async def async_image(self) -> bytes | None: + """Return cached bytes, otherwise generate new avatar.""" + if not self._cache and self._current_appearance: + self._cache = await self.coordinator.generate_avatar( + self._current_appearance + ) + return self._cache diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index a01697c3945..1c92c314e66 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", - "loggers": ["habitipy", "plumbum"], - "requirements": ["habitipy==0.3.3"] + "loggers": ["habiticalib"], + "requirements": ["habiticalib==0.3.3"] } diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml new file mode 100644 index 00000000000..9eadba496f2 --- /dev/null +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No events are registered by the integration. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: There is no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Integration represents a service + discovery: + status: exempt + comment: Integration represents a service + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: No supportable devices. + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Integration is a service, no devices that could be added at runtime. + Button entities for casting skills are created/removed dynamically if unlocked or on class change + entity-category: + status: done + comment: Default categories are appropriate for currently available entities. + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: done + comment: Used to inform of deprecated entities and actions. + stale-devices: + status: done + comment: Not applicable. Only one device per config entry. Removed together with the config entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 41d0168d748..57c391f5c12 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -3,11 +3,23 @@ from __future__ import annotations from collections.abc import Callable, Mapping -from dataclasses import dataclass +from dataclasses import asdict, dataclass from enum import StrEnum import logging -from typing import TYPE_CHECKING, Any +from typing import Any +from habiticalib import ( + ContentData, + HabiticaClass, + TaskData, + TaskType, + UserData, + deserialize_task, + ha, +) + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, @@ -24,34 +36,45 @@ from homeassistant.helpers.issue_registry import ( ) from homeassistant.helpers.typing import StateType -from .const import ASSETS_URL, DOMAIN, UNIT_TASKS +from .const import ASSETS_URL, DOMAIN +from .coordinator import HabiticaDataUpdateCoordinator from .entity import HabiticaBase from .types import HabiticaConfigEntry -from .util import entity_used_in, get_attribute_points, get_attributes_total +from .util import get_attribute_points, get_attributes_total, inventory_list _LOGGER = logging.getLogger(__name__) +SVG_CLASS = { + HabiticaClass.WARRIOR: ha.WARRIOR, + HabiticaClass.ROGUE: ha.ROGUE, + HabiticaClass.MAGE: ha.WIZARD, + HabiticaClass.HEALER: ha.HEALER, +} + + +PARALLEL_UPDATES = 1 + @dataclass(kw_only=True, frozen=True) -class HabitipySensorEntityDescription(SensorEntityDescription): - """Habitipy Sensor Description.""" +class HabiticaSensorEntityDescription(SensorEntityDescription): + """Habitica Sensor Description.""" - value_fn: Callable[[dict[str, Any], dict[str, Any]], StateType] - attributes_fn: ( - Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None - ) = None + value_fn: Callable[[UserData, ContentData], StateType] + attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = ( + None + ) entity_picture: str | None = None @dataclass(kw_only=True, frozen=True) -class HabitipyTaskSensorEntityDescription(SensorEntityDescription): - """Habitipy Task Sensor Description.""" +class HabiticaTaskSensorEntityDescription(SensorEntityDescription): + """Habitica Task Sensor Description.""" - value_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]] + value_fn: Callable[[list[TaskData]], list[TaskData]] -class HabitipySensorEntity(StrEnum): - """Habitipy Entities.""" +class HabiticaSensorEntity(StrEnum): + """Habitica Entities.""" DISPLAY_NAME = "display_name" HEALTH = "health" @@ -64,8 +87,6 @@ class HabitipySensorEntity(StrEnum): GOLD = "gold" CLASS = "class" HABITS = "habits" - DAILIES = "dailys" - TODOS = "todos" REWARDS = "rewards" GEMS = "gems" TRINKETS = "trinkets" @@ -73,125 +94,161 @@ class HabitipySensorEntity(StrEnum): INTELLIGENCE = "intelligence" CONSTITUTION = "constitution" PERCEPTION = "perception" + EGGS_TOTAL = "eggs_total" + HATCHING_POTIONS_TOTAL = "hatching_potions_total" + FOOD_TOTAL = "food_total" + SADDLE = "saddle" + QUEST_SCROLLS = "quest_scrolls" -SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = ( - HabitipySensorEntityDescription( - key=HabitipySensorEntity.DISPLAY_NAME, - translation_key=HabitipySensorEntity.DISPLAY_NAME, - value_fn=lambda user, _: user.get("profile", {}).get("name"), +SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.DISPLAY_NAME, + translation_key=HabiticaSensorEntity.DISPLAY_NAME, + value_fn=lambda user, _: user.profile.name, ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.HEALTH, - translation_key=HabitipySensorEntity.HEALTH, - native_unit_of_measurement="HP", + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.HEALTH, + translation_key=HabiticaSensorEntity.HEALTH, suggested_display_precision=0, - value_fn=lambda user, _: user.get("stats", {}).get("hp"), + value_fn=lambda user, _: user.stats.hp, + entity_picture=ha.HP, ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.HEALTH_MAX, - translation_key=HabitipySensorEntity.HEALTH_MAX, - native_unit_of_measurement="HP", + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.HEALTH_MAX, + translation_key=HabiticaSensorEntity.HEALTH_MAX, entity_registry_enabled_default=False, - value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"), + value_fn=lambda user, _: 50, ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.MANA, - translation_key=HabitipySensorEntity.MANA, - native_unit_of_measurement="MP", + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.MANA, + translation_key=HabiticaSensorEntity.MANA, suggested_display_precision=0, - value_fn=lambda user, _: user.get("stats", {}).get("mp"), + value_fn=lambda user, _: user.stats.mp, + entity_picture=ha.MP, ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.MANA_MAX, - translation_key=HabitipySensorEntity.MANA_MAX, - native_unit_of_measurement="MP", - value_fn=lambda user, _: user.get("stats", {}).get("maxMP"), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.MANA_MAX, + translation_key=HabiticaSensorEntity.MANA_MAX, + value_fn=lambda user, _: user.stats.maxMP, + entity_picture=ha.MP, ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.EXPERIENCE, - translation_key=HabitipySensorEntity.EXPERIENCE, - native_unit_of_measurement="XP", - value_fn=lambda user, _: user.get("stats", {}).get("exp"), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.EXPERIENCE, + translation_key=HabiticaSensorEntity.EXPERIENCE, + value_fn=lambda user, _: user.stats.exp, + entity_picture=ha.XP, ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.EXPERIENCE_MAX, - translation_key=HabitipySensorEntity.EXPERIENCE_MAX, - native_unit_of_measurement="XP", - value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.EXPERIENCE_MAX, + translation_key=HabiticaSensorEntity.EXPERIENCE_MAX, + value_fn=lambda user, _: user.stats.toNextLevel, + entity_picture=ha.XP, ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.LEVEL, - translation_key=HabitipySensorEntity.LEVEL, - value_fn=lambda user, _: user.get("stats", {}).get("lvl"), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.LEVEL, + translation_key=HabiticaSensorEntity.LEVEL, + value_fn=lambda user, _: user.stats.lvl, ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.GOLD, - translation_key=HabitipySensorEntity.GOLD, - native_unit_of_measurement="GP", + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.GOLD, + translation_key=HabiticaSensorEntity.GOLD, suggested_display_precision=2, - value_fn=lambda user, _: user.get("stats", {}).get("gp"), + value_fn=lambda user, _: user.stats.gp, + entity_picture=ha.GP, ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.CLASS, - translation_key=HabitipySensorEntity.CLASS, - value_fn=lambda user, _: user.get("stats", {}).get("class"), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.CLASS, + translation_key=HabiticaSensorEntity.CLASS, + value_fn=lambda user, _: user.stats.Class.value if user.stats.Class else None, device_class=SensorDeviceClass.ENUM, - options=["warrior", "healer", "wizard", "rogue"], + options=[item.value for item in HabiticaClass], ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.GEMS, - translation_key=HabitipySensorEntity.GEMS, - value_fn=lambda user, _: user.get("balance", 0) * 4, + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.GEMS, + translation_key=HabiticaSensorEntity.GEMS, + value_fn=lambda user, _: None if (b := user.balance) is None else round(b * 4), suggested_display_precision=0, - native_unit_of_measurement="gems", entity_picture="shop_gem.png", ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.TRINKETS, - translation_key=HabitipySensorEntity.TRINKETS, - value_fn=( - lambda user, _: user.get("purchased", {}) - .get("plan", {}) - .get("consecutive", {}) - .get("trinkets", 0) - ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.TRINKETS, + translation_key=HabiticaSensorEntity.TRINKETS, + value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets, suggested_display_precision=0, native_unit_of_measurement="⧖", entity_picture="notif_subscriber_reward.png", ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.STRENGTH, - translation_key=HabitipySensorEntity.STRENGTH, - value_fn=lambda user, content: get_attributes_total(user, content, "str"), - attributes_fn=lambda user, content: get_attribute_points(user, content, "str"), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.STRENGTH, + translation_key=HabiticaSensorEntity.STRENGTH, + value_fn=lambda user, content: get_attributes_total(user, content, "Str"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "Str"), suggested_display_precision=0, native_unit_of_measurement="STR", ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.INTELLIGENCE, - translation_key=HabitipySensorEntity.INTELLIGENCE, - value_fn=lambda user, content: get_attributes_total(user, content, "int"), - attributes_fn=lambda user, content: get_attribute_points(user, content, "int"), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.INTELLIGENCE, + translation_key=HabiticaSensorEntity.INTELLIGENCE, + value_fn=lambda user, content: get_attributes_total(user, content, "Int"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "Int"), suggested_display_precision=0, native_unit_of_measurement="INT", ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.PERCEPTION, - translation_key=HabitipySensorEntity.PERCEPTION, + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.PERCEPTION, + translation_key=HabiticaSensorEntity.PERCEPTION, value_fn=lambda user, content: get_attributes_total(user, content, "per"), attributes_fn=lambda user, content: get_attribute_points(user, content, "per"), suggested_display_precision=0, native_unit_of_measurement="PER", ), - HabitipySensorEntityDescription( - key=HabitipySensorEntity.CONSTITUTION, - translation_key=HabitipySensorEntity.CONSTITUTION, + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.CONSTITUTION, + translation_key=HabiticaSensorEntity.CONSTITUTION, value_fn=lambda user, content: get_attributes_total(user, content, "con"), attributes_fn=lambda user, content: get_attribute_points(user, content, "con"), suggested_display_precision=0, native_unit_of_measurement="CON", ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.EGGS_TOTAL, + translation_key=HabiticaSensorEntity.EGGS_TOTAL, + value_fn=lambda user, _: sum(n for n in user.items.eggs.values()), + entity_picture="Pet_Egg_Egg.png", + attributes_fn=lambda user, content: inventory_list(user, content, "eggs"), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL, + translation_key=HabiticaSensorEntity.HATCHING_POTIONS_TOTAL, + value_fn=lambda user, _: sum(n for n in user.items.hatchingPotions.values()), + entity_picture="Pet_HatchingPotion_RoyalPurple.png", + attributes_fn=( + lambda user, content: inventory_list(user, content, "hatchingPotions") + ), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.FOOD_TOTAL, + translation_key=HabiticaSensorEntity.FOOD_TOTAL, + value_fn=( + lambda user, _: sum(n for k, n in user.items.food.items() if k != "Saddle") + ), + entity_picture=ha.FOOD, + attributes_fn=lambda user, content: inventory_list(user, content, "food"), + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.SADDLE, + translation_key=HabiticaSensorEntity.SADDLE, + value_fn=lambda user, _: user.items.food.get("Saddle", 0), + entity_picture="Pet_Food_Saddle.png", + ), + HabiticaSensorEntityDescription( + key=HabiticaSensorEntity.QUEST_SCROLLS, + translation_key=HabiticaSensorEntity.QUEST_SCROLLS, + value_fn=(lambda user, _: sum(n for n in user.items.quests.values())), + entity_picture="inventory_quest_scroll_dustbunnies.png", + attributes_fn=lambda user, content: inventory_list(user, content, "quests"), + ), ) @@ -211,7 +268,7 @@ TASKS_MAP = { "yester_daily": "yesterDaily", "completed": "completed", "collapse_checklist": "collapseChecklist", - "type": "type", + "type": "Type", "notes": "notes", "tags": "tags", "value": "value", @@ -225,38 +282,27 @@ TASKS_MAP = { } -TASK_SENSOR_DESCRIPTION: tuple[HabitipyTaskSensorEntityDescription, ...] = ( - HabitipyTaskSensorEntityDescription( - key=HabitipySensorEntity.HABITS, - translation_key=HabitipySensorEntity.HABITS, - native_unit_of_measurement=UNIT_TASKS, - value_fn=lambda tasks: [r for r in tasks if r.get("type") == "habit"], +TASK_SENSOR_DESCRIPTION: tuple[HabiticaTaskSensorEntityDescription, ...] = ( + HabiticaTaskSensorEntityDescription( + key=HabiticaSensorEntity.HABITS, + translation_key=HabiticaSensorEntity.HABITS, + value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.HABIT], ), - HabitipyTaskSensorEntityDescription( - key=HabitipySensorEntity.DAILIES, - translation_key=HabitipySensorEntity.DAILIES, - native_unit_of_measurement=UNIT_TASKS, - value_fn=lambda tasks: [r for r in tasks if r.get("type") == "daily"], - entity_registry_enabled_default=False, - ), - HabitipyTaskSensorEntityDescription( - key=HabitipySensorEntity.TODOS, - translation_key=HabitipySensorEntity.TODOS, - native_unit_of_measurement=UNIT_TASKS, - value_fn=lambda tasks: [ - r for r in tasks if r.get("type") == "todo" and not r.get("completed") - ], - entity_registry_enabled_default=False, - ), - HabitipyTaskSensorEntityDescription( - key=HabitipySensorEntity.REWARDS, - translation_key=HabitipySensorEntity.REWARDS, - native_unit_of_measurement=UNIT_TASKS, - value_fn=lambda tasks: [r for r in tasks if r.get("type") == "reward"], + HabiticaTaskSensorEntityDescription( + key=HabiticaSensorEntity.REWARDS, + translation_key=HabiticaSensorEntity.REWARDS, + value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.REWARD], ), ) +def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: + """Get list of related automations and scripts.""" + used_in = automations_with_entity(hass, entity_id) + used_in += scripts_with_entity(hass, entity_id) + return used_in + + async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, @@ -265,21 +311,65 @@ async def async_setup_entry( """Set up the habitica sensors.""" coordinator = config_entry.runtime_data + ent_reg = er.async_get(hass) + entities: list[SensorEntity] = [] + description: SensorEntityDescription + + def add_deprecated_entity( + description: SensorEntityDescription, + entity_cls: Callable[ + [HabiticaDataUpdateCoordinator, SensorEntityDescription], SensorEntity + ], + ) -> None: + """Add deprecated entities.""" + if entity_id := ent_reg.async_get_entity_id( + SENSOR_DOMAIN, + DOMAIN, + f"{config_entry.unique_id}_{description.key}", + ): + entity_entry = ent_reg.async_get(entity_id) + if entity_entry and entity_entry.disabled: + ent_reg.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + f"deprecated_entity_{description.key}", + ) + elif entity_entry: + entities.append(entity_cls(coordinator, description)) + if entity_used_in(hass, entity_id): + async_create_issue( + hass, + DOMAIN, + f"deprecated_entity_{description.key}", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_entity", + translation_placeholders={ + "name": str( + entity_entry.name or entity_entry.original_name + ), + "entity": entity_id, + }, + ) + + for description in SENSOR_DESCRIPTIONS: + if description.key is HabiticaSensorEntity.HEALTH_MAX: + add_deprecated_entity(description, HabiticaSensor) + else: + entities.append(HabiticaSensor(coordinator, description)) + + for description in TASK_SENSOR_DESCRIPTION: + add_deprecated_entity(description, HabiticaTaskSensor) - entities: list[SensorEntity] = [ - HabitipySensor(coordinator, description) for description in SENSOR_DESCRIPTIONS - ] - entities.extend( - HabitipyTaskSensor(coordinator, description) - for description in TASK_SENSOR_DESCRIPTION - ) async_add_entities(entities, True) -class HabitipySensor(HabiticaBase, SensorEntity): +class HabiticaSensor(HabiticaBase, SensorEntity): """A generic Habitica sensor.""" - entity_description: HabitipySensorEntityDescription + entity_description: HabiticaSensorEntityDescription @property def native_value(self) -> StateType: @@ -299,15 +389,25 @@ class HabitipySensor(HabiticaBase, SensorEntity): @property def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" + if self.entity_description.key is HabiticaSensorEntity.CLASS and ( + _class := self.coordinator.data.user.stats.Class + ): + return SVG_CLASS[_class] + if entity_picture := self.entity_description.entity_picture: - return f"{ASSETS_URL}{entity_picture}" + return ( + entity_picture + if entity_picture.startswith("data:image") + else f"{ASSETS_URL}{entity_picture}" + ) + return None -class HabitipyTaskSensor(HabiticaBase, SensorEntity): +class HabiticaTaskSensor(HabiticaBase, SensorEntity): """A Habitica task sensor.""" - entity_description: HabitipyTaskSensorEntityDescription + entity_description: HabiticaTaskSensorEntityDescription @property def native_value(self) -> StateType: @@ -321,47 +421,12 @@ class HabitipyTaskSensor(HabiticaBase, SensorEntity): attrs = {} # Map tasks to TASKS_MAP - for received_task in self.entity_description.value_fn( - self.coordinator.data.tasks - ): + for task_data in self.entity_description.value_fn(self.coordinator.data.tasks): + received_task = deserialize_task(asdict(task_data)) task_id = received_task[TASKS_MAP_ID] task = {} for map_key, map_value in TASKS_MAP.items(): if value := received_task.get(map_value): task[map_key] = value - attrs[task_id] = task + attrs[str(task_id)] = task return attrs - - async def async_added_to_hass(self) -> None: - """Raise issue when entity is registered and was not disabled.""" - if TYPE_CHECKING: - assert self.unique_id - if entity_id := er.async_get(self.hass).async_get_entity_id( - SENSOR_DOMAIN, DOMAIN, self.unique_id - ): - if ( - self.enabled - and self.entity_description.key - in (HabitipySensorEntity.TODOS, HabitipySensorEntity.DAILIES) - and entity_used_in(self.hass, entity_id) - ): - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_task_entity_{self.entity_description.key}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_task_entity", - translation_placeholders={ - "task_name": str(self.name), - "entity": entity_id, - }, - ) - else: - async_delete_issue( - self.hass, - DOMAIN, - f"deprecated_task_entity_{self.entity_description.key}", - ) - await super().async_added_to_hass() diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 7f2d66e4690..a28aada85fa 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -2,11 +2,22 @@ from __future__ import annotations -from http import HTTPStatus +from dataclasses import asdict import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from aiohttp import ClientResponseError +from aiohttp import ClientError +from habiticalib import ( + Direction, + HabiticaException, + NotAuthorizedError, + NotFoundError, + Skill, + TaskData, + TaskPriority, + TaskType, + TooManyRequestsError, +) import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -28,10 +39,14 @@ from .const import ( ATTR_DATA, ATTR_DIRECTION, ATTR_ITEM, + ATTR_KEYWORD, ATTR_PATH, + ATTR_PRIORITY, ATTR_SKILL, + ATTR_TAG, ATTR_TARGET, ATTR_TASK, + ATTR_TYPE, DOMAIN, EVENT_API_CALL_SUCCESS, SERVICE_ABORT_QUEST, @@ -39,6 +54,7 @@ from .const import ( SERVICE_API_CALL, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, SERVICE_SCORE_HABIT, @@ -88,6 +104,40 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( } ) +SERVICE_GET_TASKS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Optional(ATTR_TYPE): vol.All( + cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskType}))] + ), + vol.Optional(ATTR_PRIORITY): vol.All( + cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskPriority}))] + ), + vol.Optional(ATTR_TASK): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_KEYWORD): cv.string, + } +) + +SKILL_MAP = { + "pickpocket": Skill.PICKPOCKET, + "backstab": Skill.BACKSTAB, + "smash": Skill.BRUTAL_SMASH, + "fireball": Skill.BURST_OF_FLAMES, +} +COST_MAP = { + "pickpocket": "10 MP", + "backstab": "15 MP", + "smash": "10 MP", + "fireball": "10 MP", +} +ITEMID_MAP = { + "snowball": Skill.SNOWBALL, + "spooky_sparkles": Skill.SPOOKY_SPARKLES, + "seafoam": Skill.SEAFOAM, + "shiny_seed": Skill.SHINY_SEED, +} + def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: """Return config entry or raise if not found or not loaded.""" @@ -123,12 +173,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 name = call.data[ATTR_NAME] path = call.data[ATTR_PATH] - entries = hass.config_entries.async_entries(DOMAIN) + entries: list[HabiticaConfigEntry] = hass.config_entries.async_entries(DOMAIN) api = None for entry in entries: if entry.data[CONF_NAME] == name: - api = entry.runtime_data.api + api = await entry.runtime_data.habitica.habitipy() break if api is None: _LOGGER.error("API_CALL: User '%s' not configured", name) @@ -151,18 +201,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Skill action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data - skill = { - "pickpocket": {"spellId": "pickPocket", "cost": "10 MP"}, - "backstab": {"spellId": "backStab", "cost": "15 MP"}, - "smash": {"spellId": "smash", "cost": "10 MP"}, - "fireball": {"spellId": "fireball", "cost": "10 MP"}, - } + + skill = SKILL_MAP[call.data[ATTR_SKILL]] + cost = COST_MAP[call.data[ATTR_SKILL]] + try: task_id = next( - task["id"] + task.id for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (task["id"], task.get("alias")) - or call.data[ATTR_TASK] == task["text"] + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) ) except StopIteration as e: raise ServiceValidationError( @@ -172,75 +219,93 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 ) from e try: - response: dict[str, Any] = await coordinator.api.user.class_.cast[ - skill[call.data[ATTR_SKILL]]["spellId"] - ].post(targetId=task_id) - except ClientResponseError as e: - if e.status == HTTPStatus.TOO_MANY_REQUESTS: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - ) from e - if e.status == HTTPStatus.UNAUTHORIZED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="not_enough_mana", - translation_placeholders={ - "cost": skill[call.data[ATTR_SKILL]]["cost"], - "mana": f"{int(coordinator.data.user.get("stats", {}).get("mp", 0))} MP", - }, - ) from e - if e.status == HTTPStatus.NOT_FOUND: - # could also be task not found, but the task is looked up - # before the request, so most likely wrong skill selected - # or the skill hasn't been unlocked yet. - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="skill_not_found", - translation_placeholders={"skill": call.data[ATTR_SKILL]}, - ) from e + response = await coordinator.habitica.cast_skill(skill, task_id) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_enough_mana", + translation_placeholders={ + "cost": cost, + "mana": f"{int(coordinator.data.user.stats.mp or 0)} MP", + }, + ) from e + except NotFoundError as e: + # could also be task not found, but the task is looked up + # before the request, so most likely wrong skill selected + # or the skill hasn't been unlocked yet. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="skill_not_found", + translation_placeholders={"skill": call.data[ATTR_SKILL]}, + ) from e + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await coordinator.async_request_refresh() - return response + return asdict(response.data) async def manage_quests(call: ServiceCall) -> ServiceResponse: """Accept, reject, start, leave or cancel quests.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data - COMMAND_MAP = { - SERVICE_ABORT_QUEST: "abort", - SERVICE_ACCEPT_QUEST: "accept", - SERVICE_CANCEL_QUEST: "cancel", - SERVICE_LEAVE_QUEST: "leave", - SERVICE_REJECT_QUEST: "reject", - SERVICE_START_QUEST: "force-start", + FUNC_MAP = { + SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest, + SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest, + SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest, + SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest, + SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest, + SERVICE_START_QUEST: coordinator.habitica.start_quest, } + + func = FUNC_MAP[call.service] + try: - return await coordinator.api.groups.party.quests[ - COMMAND_MAP[call.service] - ].post() - except ClientResponseError as e: - if e.status == HTTPStatus.TOO_MANY_REQUESTS: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - ) from e - if e.status == HTTPStatus.UNAUTHORIZED: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="quest_action_unallowed" - ) from e - if e.status == HTTPStatus.NOT_FOUND: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="quest_not_found" - ) from e + response = await func() + except TooManyRequestsError as e: raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="service_call_exception" + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_action_unallowed" + ) from e + except NotFoundError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_not_found" + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return asdict(response.data) for service in ( SERVICE_ABORT_QUEST, @@ -262,12 +327,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Score a task action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data + + direction = ( + Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP + ) try: task_id, task_value = next( - (task["id"], task.get("value")) + (task.id, task.value) for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (task["id"], task.get("alias")) - or call.data[ATTR_TASK] == task["text"] + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) ) except StopIteration as e: raise ServiceValidationError( @@ -276,81 +344,92 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, ) from e + if TYPE_CHECKING: + assert task_id try: - response: dict[str, Any] = ( - await coordinator.api.tasks[task_id] - .score[call.data.get(ATTR_DIRECTION, "up")] - .post() - ) - except ClientResponseError as e: - if e.status == HTTPStatus.TOO_MANY_REQUESTS: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - ) from e - if e.status == HTTPStatus.UNAUTHORIZED and task_value is not None: + response = await coordinator.habitica.update_score(task_id, direction) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + if task_value is not None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="not_enough_gold", translation_placeholders={ - "gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP", - "cost": f"{task_value} GP", + "gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP", + "cost": f"{task_value:.2f} GP", }, ) from e raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: await coordinator.async_request_refresh() - return response + return asdict(response.data) async def transformation(call: ServiceCall) -> ServiceResponse: """User a transformation item on a player character.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data - ITEMID_MAP = { - "snowball": {"itemId": "snowball"}, - "spooky_sparkles": {"itemId": "spookySparkles"}, - "seafoam": {"itemId": "seafoam"}, - "shiny_seed": {"itemId": "shinySeed"}, - } + + item = ITEMID_MAP[call.data[ATTR_ITEM]] # check if target is self if call.data[ATTR_TARGET] in ( - coordinator.data.user["id"], - coordinator.data.user["profile"]["name"], - coordinator.data.user["auth"]["local"]["username"], + str(coordinator.data.user.id), + coordinator.data.user.profile.name, + coordinator.data.user.auth.local.username, ): - target_id = coordinator.data.user["id"] + target_id = coordinator.data.user.id else: # check if target is a party member try: - party = await coordinator.api.groups.party.members.get() - except ClientResponseError as e: - if e.status == HTTPStatus.TOO_MANY_REQUESTS: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - ) from e - if e.status == HTTPStatus.NOT_FOUND: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="party_not_found", - ) from e + party = await coordinator.habitica.get_group_members(public_fields=True) + except NotFoundError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="party_not_found", + ) from e + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e try: target_id = next( - member["id"] - for member in party - if call.data[ATTR_TARGET].lower() + member.id + for member in party.data + if member.id + and call.data[ATTR_TARGET].lower() in ( - member["id"], - member["auth"]["local"]["username"].lower(), - member["profile"]["name"].lower(), + str(member.id), + str(member.auth.local.username).lower(), + str(member.profile.name).lower(), ) ) except StopIteration as e: @@ -360,27 +439,79 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"}, ) from e try: - response: dict[str, Any] = await coordinator.api.user.class_.cast[ - ITEMID_MAP[call.data[ATTR_ITEM]]["itemId"] - ].post(targetId=target_id) - except ClientResponseError as e: - if e.status == HTTPStatus.TOO_MANY_REQUESTS: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - ) from e - if e.status == HTTPStatus.UNAUTHORIZED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="item_not_found", - translation_placeholders={"item": call.data[ATTR_ITEM]}, - ) from e + response = await coordinator.habitica.cast_skill(item, target_id) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": call.data[ATTR_ITEM]}, + ) from e + except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, ) from e else: - return response + return asdict(response.data) + + async def get_tasks(call: ServiceCall) -> ServiceResponse: + """Get tasks action.""" + + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + response: list[TaskData] = coordinator.data.tasks + + if types := {TaskType[x] for x in call.data.get(ATTR_TYPE, [])}: + response = [task for task in response if task.Type in types] + + if priority := {TaskPriority[x] for x in call.data.get(ATTR_PRIORITY, [])}: + response = [task for task in response if task.priority in priority] + + if tasks := call.data.get(ATTR_TASK): + response = [ + task + for task in response + if str(task.id) in tasks or task.alias in tasks or task.text in tasks + ] + + if tags := call.data.get(ATTR_TAG): + tag_ids = { + tag.id + for tag in coordinator.data.user.tags + if (tag.name and tag.name.lower()) + in (tag.lower() for tag in tags) # Case-insensitive matching + and tag.id + } + + response = [ + task + for task in response + if any(tag_id in task.tags for tag_id in tag_ids if task.tags) + ] + if keyword := call.data.get(ATTR_KEYWORD): + keyword = keyword.lower() + response = [ + task + for task in response + if (task.text and keyword in task.text.lower()) + or (task.notes and keyword in task.notes.lower()) + or any(keyword in item.text.lower() for item in task.checklist) + ] + result: dict[str, Any] = {"tasks": response} + return result hass.services.async_register( DOMAIN, @@ -419,3 +550,10 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_TRANSFORMATION_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_TASKS, + get_tasks, + schema=SERVICE_GET_TASKS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index a89c935b630..f3095518290 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -94,3 +94,49 @@ transformation: required: true selector: text: +get_tasks: + fields: + config_entry: *config_entry + filter: + collapsed: true + fields: + type: + required: false + selector: + select: + options: + - "habit" + - "daily" + - "todo" + - "reward" + mode: dropdown + translation_key: "type" + multiple: true + sort: true + priority: + required: false + selector: + select: + options: + - "trivial" + - "easy" + - "medium" + - "hard" + mode: dropdown + translation_key: "priority" + multiple: true + sort: false + task: + required: false + selector: + text: + multiple: true + tag: + required: false + selector: + text: + multiple: true + keyword: + required: false + selector: + text: diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 81691327aec..4d353cec40e 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -2,23 +2,32 @@ "common": { "todos": "To-Do's", "dailies": "Dailies", - "config_entry_name": "Select character" + "config_entry_name": "Select character", + "task_name": "Task name", + "unit_tasks": "tasks", + "unit_health_points": "HP", + "unit_mana_points": "MP", + "unit_experience_points": "XP" }, "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "unique_id_mismatch": "Hmm, those login details are correct, but they're not for this adventurer. Got another account to try?", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_credentials": "Input is incomplete. You must provide either your login details or an API token" }, "step": { "user": { "title": "Habitica - Gamify your life", "menu_options": { - "login": "Login to Habitica", - "advanced": "Login to other instances" + "login": "Log in to Habitica", + "advanced": "Log in to other instances" }, "description": "![Habiticans]({habiticans}) Connect your Habitica account to keep track of your adventurer's stats, progress, and manage your to-dos and daily tasks.\n\n[Don't have a Habitica account? Sign up here.]({signup})" }, @@ -45,9 +54,62 @@ "data_description": { "url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`", "api_user": "User ID of your Habitica account", - "api_key": "API Token of the Habitica account" + "api_key": "API Token of the Habitica account", + "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate" }, "description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to" + }, + "reauth_confirm": { + "title": "Re-authorize {name} with Habitica", + "description": "![Habiticans]({habiticans}) It seems your API token for **{name}** has been reset. To re-authorize the integration, you can either log in with your username or email, and password, or directly provide your new API token.", + "sections": { + "reauth_login": { + "name": "Re-authorize via login", + "description": "Enter your login details below to re-authorize the Home Assistant integration with Habitica", + "data": { + "username": "[%key:component::habitica::config::step::login::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::habitica::config::step::login::data_description::username%]", + "password": "[%key:component::habitica::config::step::login::data_description::password%]" + } + }, + "reauth_api_key": { + "description": "Enter your new API token below. You can find it in Habitica under 'Settings -> Site Data'", + "name": "Re-authorize via API Token", + "data": { + "api_key": "[%key:component::habitica::config::step::advanced::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]" + } + } + } + }, + "reconfigure": { + "title": "Update Habitica configuration", + "description": "![Habiticans]({habiticans})\n\nEnter your new API token below. You can find it in Habitica under [**Settings -> Site Data**]({site_data})", + "data": { + "api_key": "[%key:component::habitica::config::step::advanced::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]" + }, + "sections": { + "danger_zone": { + "name": "Critical configuration options", + "description": "These settings impact core functionality. Modifications are unnecessary if connected to the official Habitica instance and may disrupt the integration. Proceed with caution.", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "URL of the Habitica instance", + "verify_ssl": "[%key:component::habitica::config::step::advanced::data_description::verify_ssl%]" + } + } + } } } }, @@ -130,36 +192,49 @@ "name": "Daily reminders" } }, + "image": { + "avatar": { + "name": "Avatar" + } + }, "sensor": { "display_name": { "name": "Display name" }, "health": { - "name": "Health" + "name": "Health", + "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" }, "health_max": { - "name": "Max. health" + "name": "Max. health", + "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" }, "mana": { - "name": "Mana" + "name": "Mana", + "unit_of_measurement": "[%key:component::habitica::common::unit_mana_points%]" }, "mana_max": { - "name": "Max. mana" + "name": "Max. mana", + "unit_of_measurement": "[%key:component::habitica::common::unit_mana_points%]" }, "experience": { - "name": "Experience" + "name": "Experience", + "unit_of_measurement": "[%key:component::habitica::common::unit_experience_points%]" }, "experience_max": { - "name": "Next level" + "name": "Next level", + "unit_of_measurement": "[%key:component::habitica::common::unit_experience_points%]" }, "level": { "name": "Level" }, "gold": { - "name": "Gold" + "name": "Gold", + "unit_of_measurement": "GP" }, "gems": { - "name": "Gems" + "name": "Gems", + "unit_of_measurement": "gems" }, "trinkets": { "name": "Mystic hourglasses" @@ -173,17 +248,13 @@ "rogue": "Rogue" } }, - "todos": { - "name": "[%key:component::habitica::common::todos%]" - }, - "dailys": { - "name": "[%key:component::habitica::common::dailies%]" - }, "habits": { - "name": "Habits" + "name": "Habits", + "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]" }, "rewards": { - "name": "Rewards" + "name": "Rewards", + "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]" }, "strength": { "name": "Strength", @@ -264,6 +335,26 @@ "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]" } } + }, + "eggs_total": { + "name": "Eggs", + "unit_of_measurement": "eggs" + }, + "hatching_potions_total": { + "name": "Hatching potions", + "unit_of_measurement": "potions" + }, + "food_total": { + "name": "Pet food", + "unit_of_measurement": "foods" + }, + "saddle": { + "name": "Saddles", + "unit_of_measurement": "saddles" + }, + "quest_scrolls": { + "name": "Quest scrolls", + "unit_of_measurement": "scrolls" } }, "switch": { @@ -309,13 +400,13 @@ "message": "Unable to create new to-do `{name}` for Habitica, please try again" }, "setup_rate_limit_exception": { - "message": "Rate limit exceeded, try again later" + "message": "Rate limit exceeded, try again in {retry_after} seconds" }, "service_call_unallowed": { "message": "Unable to complete action, the required conditions are not met" }, "service_call_exception": { - "message": "Unable to connect to Habitica, try again later" + "message": "Unable to connect to Habitica: {reason}" }, "not_enough_mana": { "message": "Unable to cast skill, not enough mana. Your character has {mana}, but the skill costs {cost}." @@ -349,12 +440,15 @@ }, "item_not_found": { "message": "Unable to use {item}, you don't own this item." + }, + "authentication_failed": { + "message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token" } }, "issues": { - "deprecated_task_entity": { - "title": "The Habitica {task_name} sensor is deprecated", - "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`." + "deprecated_entity": { + "title": "The Habitica {name} entity is deprecated", + "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue." }, "deprecated_api_call": { "title": "The Habitica action habitica.api_call is deprecated", @@ -382,7 +476,7 @@ }, "cast_skill": { "name": "Cast a skill", - "description": "Use a skill or spell from your Habitica character on a specific task to affect its progress or status.", + "description": "Uses a skill or spell from your Habitica character on a specific task to affect its progress or status.", "fields": { "config_entry": { "name": "[%key:component::habitica::common::config_entry_name%]", @@ -393,14 +487,14 @@ "description": "Select the skill or spell you want to cast on the task. Only skills corresponding to your character's class can be used." }, "task": { - "name": "Task name", + "name": "[%key:component::habitica::common::task_name%]", "description": "The name (or task ID) of the task you want to target with the skill or spell." } } }, "accept_quest": { "name": "Accept a quest invitation", - "description": "Accept a pending invitation to a quest.", + "description": "Accepts a pending invitation to a quest.", "fields": { "config_entry": { "name": "[%key:component::habitica::common::config_entry_name%]", @@ -410,7 +504,7 @@ }, "reject_quest": { "name": "Reject a quest invitation", - "description": "Reject a pending invitation to a quest.", + "description": "Rejects a pending invitation to a quest.", "fields": { "config_entry": { "name": "[%key:component::habitica::common::config_entry_name%]", @@ -420,7 +514,7 @@ }, "leave_quest": { "name": "Leave a quest", - "description": "Leave the current quest you are participating in.", + "description": "Leaves the current quest you are participating in.", "fields": { "config_entry": { "name": "[%key:component::habitica::common::config_entry_name%]", @@ -430,7 +524,7 @@ }, "abort_quest": { "name": "Abort an active quest", - "description": "Terminate your party's ongoing quest. All progress will be lost and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.", + "description": "Terminates your party's ongoing quest. All progress will be lost and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.", "fields": { "config_entry": { "name": "[%key:component::habitica::common::config_entry_name%]", @@ -440,7 +534,7 @@ }, "cancel_quest": { "name": "Cancel a pending quest", - "description": "Cancel a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.", + "description": "Cancels a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.", "fields": { "config_entry": { "name": "[%key:component::habitica::common::config_entry_name%]", @@ -450,7 +544,7 @@ }, "start_quest": { "name": "Force-start a pending quest", - "description": "Begin the quest immediately, bypassing any pending invitations that haven't been accepted or rejected. Only quest leader or group leader can perform this action.", + "description": "Begins the quest immediately, bypassing any pending invitations that haven't been accepted or rejected. Only quest leader or group leader can perform this action.", "fields": { "config_entry": { "name": "[%key:component::habitica::common::config_entry_name%]", @@ -460,7 +554,7 @@ }, "score_habit": { "name": "Track a habit", - "description": "Increase the positive or negative streak of a habit to track its progress.", + "description": "Increases the positive or negative streak of a habit to track its progress.", "fields": { "config_entry": { "name": "[%key:component::habitica::common::config_entry_name%]", @@ -478,7 +572,7 @@ }, "score_reward": { "name": "Buy a reward", - "description": "Reward yourself and buy one of your custom rewards with gold earned by fulfilling tasks.", + "description": "Buys one of your custom rewards with gold earned by fulfilling tasks.", "fields": { "config_entry": { "name": "[%key:component::habitica::common::config_entry_name%]", @@ -492,7 +586,7 @@ }, "transformation": { "name": "Use a transformation item", - "description": "Use a transformation item from your Habitica character's inventory on a member of your party or yourself.", + "description": "Uses a transformation item from your Habitica character's inventory on a member of your party or yourself.", "fields": { "config_entry": { "name": "Select character", @@ -507,6 +601,42 @@ "description": "The name of the character you want to use the transformation item on. You can also specify the players username or user ID." } } + }, + "get_tasks": { + "name": "Get tasks", + "description": "Retrieves tasks from your Habitica character.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Choose the Habitica character to retrieve tasks from." + }, + "type": { + "name": "Task type", + "description": "Filter tasks by type." + }, + "priority": { + "name": "Difficulty", + "description": "Filter tasks by difficulty." + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "Select tasks by matching their name (or task ID)." + }, + "tag": { + "name": "Tag", + "description": "Filter tasks that have one or more of the selected tags." + }, + "keyword": { + "name": "Keyword", + "description": "Filter tasks by keyword, searching across titles, notes, and checklists." + } + }, + "sections": { + "filter": { + "name": "Filter options", + "description": "Use the optional filters to narrow the returned tasks." + } + } } }, "selector": { @@ -525,6 +655,22 @@ "seafoam": "Seafoam", "shiny_seed": "Shiny seed" } + }, + "type": { + "options": { + "daily": "Daily", + "habit": "Habit", + "todo": "To-do", + "reward": "Reward" + } + }, + "priority": { + "options": { + "trivial": "Trivial", + "easy": "Easy", + "medium": "Medium", + "hard": "Hard" + } } } } diff --git a/homeassistant/components/habitica/switch.py b/homeassistant/components/habitica/switch.py index de0cc533050..ddc0db27108 100644 --- a/homeassistant/components/habitica/switch.py +++ b/homeassistant/components/habitica/switch.py @@ -28,7 +28,7 @@ class HabiticaSwitchEntityDescription(SwitchEntityDescription): turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any] turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any] - is_on_fn: Callable[[HabiticaData], bool] + is_on_fn: Callable[[HabiticaData], bool | None] class HabiticaSwitchEntity(StrEnum): @@ -42,9 +42,9 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = ( key=HabiticaSwitchEntity.SLEEP, translation_key=HabiticaSwitchEntity.SLEEP, device_class=SwitchDeviceClass.SWITCH, - turn_on_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(), - turn_off_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(), - is_on_fn=lambda data: data.user["preferences"]["sleep"], + turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(), + turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(), + is_on_fn=lambda data: data.user.preferences.sleep, ), ) diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index 0ca5f723c45..c1786059300 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -2,11 +2,19 @@ from __future__ import annotations -import datetime from enum import StrEnum +import logging from typing import TYPE_CHECKING +from uuid import UUID -from aiohttp import ClientResponseError +from aiohttp import ClientError +from habiticalib import ( + Direction, + HabiticaException, + Task, + TaskType, + TooManyRequestsError, +) from homeassistant.components import persistent_notification from homeassistant.components.todo import ( @@ -16,7 +24,7 @@ from homeassistant.components.todo import ( TodoListEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -24,9 +32,11 @@ from homeassistant.util import dt as dt_util from .const import ASSETS_URL, DOMAIN from .coordinator import HabiticaDataUpdateCoordinator from .entity import HabiticaBase -from .types import HabiticaConfigEntry, HabiticaTaskType +from .types import HabiticaConfigEntry from .util import next_due_date +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 1 @@ -70,8 +80,15 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): """Delete Habitica tasks.""" if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS: try: - await self.coordinator.api.tasks.clearCompletedTodos.post() - except ClientResponseError as e: + await self.coordinator.habitica.delete_completed_todos() + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key="delete_completed_todos_failed", @@ -79,8 +96,15 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): else: for task_id in uids: try: - await self.coordinator.api.tasks[task_id].delete() - except ClientResponseError as e: + await self.coordinator.habitica.delete_task(UUID(task_id)) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"delete_{self.entity_description.key}_failed", @@ -106,9 +130,15 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): pos = 0 try: - await self.coordinator.api.tasks[uid].move.to[str(pos)].post() - - except ClientResponseError as e: + await self.coordinator.habitica.reorder_task(UUID(uid), pos) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"move_{self.entity_description.key}_item_failed", @@ -118,12 +148,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): # move tasks in the coordinator until we have fresh data tasks = self.coordinator.data.tasks new_pos = ( - tasks.index(next(task for task in tasks if task["id"] == previous_uid)) + tasks.index( + next(task for task in tasks if task.id == UUID(previous_uid)) + ) + 1 if previous_uid else 0 ) - old_pos = tasks.index(next(task for task in tasks if task["id"] == uid)) + old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid))) tasks.insert(new_pos, tasks.pop(old_pos)) await self.coordinator.async_request_refresh() @@ -138,14 +170,17 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): if TYPE_CHECKING: assert item.uid assert current_item + assert item.summary + + task = Task( + text=item.summary, + notes=item.description or "", + ) if ( self.entity_description.key is HabiticaTodoList.TODOS - and item.due is not None ): # Only todos support a due date. - date = item.due.isoformat() - else: - date = None + task["date"] = item.due if ( item.summary != current_item.summary @@ -153,13 +188,16 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): or item.due != current_item.due ): try: - await self.coordinator.api.tasks[item.uid].put( - text=item.summary, - notes=item.description or "", - date=date, - ) + await self.coordinator.habitica.update_task(UUID(item.uid), task) refresh_required = True - except ClientResponseError as e: + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"update_{self.entity_description.key}_item_failed", @@ -172,32 +210,39 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): current_item.status is TodoItemStatus.NEEDS_ACTION and item.status == TodoItemStatus.COMPLETED ): - score_result = ( - await self.coordinator.api.tasks[item.uid].score["up"].post() + score_result = await self.coordinator.habitica.update_score( + UUID(item.uid), Direction.UP ) refresh_required = True elif ( current_item.status is TodoItemStatus.COMPLETED and item.status == TodoItemStatus.NEEDS_ACTION ): - score_result = ( - await self.coordinator.api.tasks[item.uid].score["down"].post() + score_result = await self.coordinator.habitica.update_score( + UUID(item.uid), Direction.DOWN ) refresh_required = True else: score_result = None - - except ClientResponseError as e: + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"score_{self.entity_description.key}_item_failed", translation_placeholders={"name": item.summary or ""}, ) from e - if score_result and (drop := score_result.get("_tmp", {}).get("drop", False)): + if score_result and score_result.data.tmp.drop.key: + drop = score_result.data.tmp.drop msg = ( - f"![{drop["key"]}]({ASSETS_URL}Pet_{drop["type"]}_{drop["key"]}.png)\n" - f"{drop["dialog"]}" + f"![{drop.key}]({ASSETS_URL}Pet_{drop.Type}_{drop.key}.png)\n" + f"{drop.dialog}" ) persistent_notification.async_create( self.hass, message=msg, title="Habitica" @@ -229,38 +274,43 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity): return [ *( TodoItem( - uid=task["id"], - summary=task["text"], - description=task["notes"], - due=( - dt_util.as_local( - datetime.datetime.fromisoformat(task["date"]) - ).date() - if task.get("date") - else None - ), + uid=str(task.id), + summary=task.text, + description=task.notes, + due=dt_util.as_local(task.date).date() if task.date else None, status=( TodoItemStatus.NEEDS_ACTION - if not task["completed"] + if not task.completed else TodoItemStatus.COMPLETED ), ) for task in self.coordinator.data.tasks - if task["type"] == HabiticaTaskType.TODO + if task.Type is TaskType.TODO ), ] async def async_create_todo_item(self, item: TodoItem) -> None: """Create a Habitica todo.""" - + if TYPE_CHECKING: + assert item.summary + assert item.description try: - await self.coordinator.api.tasks.user.post( - text=item.summary, - type=HabiticaTaskType.TODO, - notes=item.description, - date=item.due.isoformat() if item.due else None, + await self.coordinator.habitica.create_task( + Task( + text=item.summary, + type=TaskType.TODO, + notes=item.description, + date=item.due, + ) ) - except ClientResponseError as e: + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except (HabiticaException, ClientError) as e: + _LOGGER.debug(str(e)) raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"create_{self.entity_description.key}_item_failed", @@ -295,23 +345,23 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity): that have been completed but forgotten to mark as completed before resetting the dailies. Changes of the date input field in Home Assistant will be ignored. """ - - last_cron = self.coordinator.data.user["lastCron"] + if TYPE_CHECKING: + assert self.coordinator.data.user.lastCron return [ *( TodoItem( - uid=task["id"], - summary=task["text"], - description=task["notes"], - due=next_due_date(task, last_cron), + uid=str(task.id), + summary=task.text, + description=task.notes, + due=next_due_date(task, self.coordinator.data.user.lastCron), status=( TodoItemStatus.COMPLETED - if task["completed"] + if task.completed else TodoItemStatus.NEEDS_ACTION ), ) for task in self.coordinator.data.tasks - if task["type"] == HabiticaTaskType.DAILY + if task.Type is TaskType.DAILY ) ] diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 03acb08baf9..757c675b045 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -2,9 +2,10 @@ from __future__ import annotations +from dataclasses import asdict, fields import datetime from math import floor -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from dateutil.rrule import ( DAILY, @@ -20,94 +21,66 @@ from dateutil.rrule import ( YEARLY, rrule, ) +from habiticalib import ContentData, Frequency, TaskData, UserData -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity -from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None: +def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | None: """Calculate due date for dailies and yesterdailies.""" - if task["everyX"] == 0 or not task.get("nextDue"): # grey dailies never become due + if task.everyX == 0 or not task.nextDue: # grey dailies never become due + return None + if task.frequency is Frequency.WEEKLY and not any(asdict(task.repeat).values()): return None - today = to_date(last_cron) - startdate = to_date(task["startDate"]) if TYPE_CHECKING: - assert today - assert startdate + assert task.startDate - if task["isDue"] and not task["completed"]: - return to_date(last_cron) + if task.isDue is True and not task.completed: + return dt_util.as_local(today).date() - if startdate > today: - if task["frequency"] == "daily" or ( - task["frequency"] in ("monthly", "yearly") and task["daysOfMonth"] + if task.startDate > today: + if task.frequency is Frequency.DAILY or ( + task.frequency in (Frequency.MONTHLY, Frequency.YEARLY) and task.daysOfMonth ): - return startdate + return dt_util.as_local(task.startDate).date() if ( - task["frequency"] in ("weekly", "monthly") - and (nextdue := to_date(task["nextDue"][0])) - and startdate > nextdue + task.frequency in (Frequency.WEEKLY, Frequency.MONTHLY) + and (nextdue := task.nextDue[0]) + and task.startDate > nextdue ): - return to_date(task["nextDue"][1]) + return dt_util.as_local(task.nextDue[1]).date() - return to_date(task["nextDue"][0]) - - -def to_date(date: str) -> datetime.date | None: - """Convert an iso date to a datetime.date object.""" - try: - return dt_util.as_local(datetime.datetime.fromisoformat(date)).date() - except ValueError: - # sometimes nextDue dates are JavaScript datetime strings instead of iso: - # "Mon May 06 2024 00:00:00 GMT+0200" - try: - return dt_util.as_local( - datetime.datetime.strptime(date, "%a %b %d %Y %H:%M:%S %Z%z") - ).date() - except ValueError: - return None - - -def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: - """Get list of related automations and scripts.""" - used_in = automations_with_entity(hass, entity_id) - used_in += scripts_with_entity(hass, entity_id) - return used_in + return dt_util.as_local(task.nextDue[0]).date() FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly": YEARLY} WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU} -def build_rrule(task: dict[str, Any]) -> rrule: +def build_rrule(task: TaskData) -> rrule: """Build rrule string.""" - rrule_frequency = FREQUENCY_MAP.get(task["frequency"], DAILY) - weekdays = [ - WEEKDAY_MAP[day] for day, is_active in task["repeat"].items() if is_active - ] + if TYPE_CHECKING: + assert task.frequency + assert task.everyX + rrule_frequency = FREQUENCY_MAP.get(task.frequency, DAILY) + weekdays = [day for key, day in WEEKDAY_MAP.items() if getattr(task.repeat, key)] bymonthday = ( - task["daysOfMonth"] - if rrule_frequency == MONTHLY and task["daysOfMonth"] - else None + task.daysOfMonth if rrule_frequency == MONTHLY and task.daysOfMonth else None ) bysetpos = None - if rrule_frequency == MONTHLY and task["weeksOfMonth"]: - bysetpos = task["weeksOfMonth"] + if rrule_frequency == MONTHLY and task.weeksOfMonth: + bysetpos = task.weeksOfMonth weekdays = weekdays if weekdays else [MO] return rrule( freq=rrule_frequency, - interval=task["everyX"], - dtstart=dt_util.start_of_local_day( - datetime.datetime.fromisoformat(task["startDate"]) - ), + interval=task.everyX, + dtstart=dt_util.start_of_local_day(task.startDate), byweekday=weekdays if rrule_frequency in [WEEKLY, MONTHLY] else None, bymonthday=bymonthday, bysetpos=bysetpos, @@ -143,49 +116,49 @@ def get_recurrence_rule(recurrence: rrule) -> str: def get_attribute_points( - user: dict[str, Any], content: dict[str, Any], attribute: str + user: UserData, content: ContentData, attribute: str ) -> dict[str, float]: - """Get modifiers contributing to strength attribute.""" - - gear_set = { - "weapon", - "armor", - "head", - "shield", - "back", - "headAccessory", - "eyewear", - "body", - } + """Get modifiers contributing to STR/INT/CON/PER attributes.""" equipment = sum( - stats[attribute] - for gear in gear_set - if (equipped := user["items"]["gear"]["equipped"].get(gear)) - and (stats := content["gear"]["flat"].get(equipped)) + getattr(stats, attribute) + for gear in fields(user.items.gear.equipped) + if (equipped := getattr(user.items.gear.equipped, gear.name)) + and (stats := content.gear.flat[equipped]) ) class_bonus = sum( - stats[attribute] / 2 - for gear in gear_set - if (equipped := user["items"]["gear"]["equipped"].get(gear)) - and (stats := content["gear"]["flat"].get(equipped)) - and stats["klass"] == user["stats"]["class"] + getattr(stats, attribute) / 2 + for gear in fields(user.items.gear.equipped) + if (equipped := getattr(user.items.gear.equipped, gear.name)) + and (stats := content.gear.flat[equipped]) + and stats.klass == user.stats.Class ) + if TYPE_CHECKING: + assert user.stats.lvl return { - "level": min(round(user["stats"]["lvl"] / 2), 50), + "level": min(floor(user.stats.lvl / 2), 50), "equipment": equipment, "class": class_bonus, - "allocated": user["stats"][attribute], - "buffs": user["stats"]["buffs"][attribute], + "allocated": getattr(user.stats, attribute), + "buffs": getattr(user.stats.buffs, attribute), } -def get_attributes_total( - user: dict[str, Any], content: dict[str, Any], attribute: str -) -> int: +def get_attributes_total(user: UserData, content: ContentData, attribute: str) -> int: """Get total attribute points.""" return floor( sum(value for value in get_attribute_points(user, content, attribute).values()) ) + + +def inventory_list( + user: UserData, content: ContentData, item_type: str +) -> dict[str, int]: + """List inventory items of given type.""" + return { + getattr(content, item_type)[k].text: v + for k, v in getattr(user.items, item_type, {}).items() + if k != "Saddle" + } diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index b75ad617b39..b507c0ae112 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -11,7 +11,6 @@ from aioharmony.hubconnector_websocket import HubConnector import aiohttp import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.components.remote import ( ATTR_ACTIVITY, ATTR_DELAY_SECS, @@ -26,6 +25,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) from .const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY, UNIQUE_ID from .util import ( @@ -93,13 +96,13 @@ class HarmonyConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Harmony device.""" _LOGGER.debug("SSDP discovery_info: %s", discovery_info) parsed_url = urlparse(discovery_info.ssdp_location) - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] self._async_abort_entries_match({CONF_HOST: parsed_url.hostname}) diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index 41c55bfc855..4dba412a17c 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -124,8 +124,7 @@ class HarmonyData(HarmonySubscriberMixin): except (ValueError, AttributeError) as err: await self._client.close() raise ConfigEntryNotReady( - f"{self.name}: Error {err} while connected HUB at:" - f" {self._address}:8088" + f"{self.name}: Error {err} while connected HUB at: {self._address}:8088" ) from err if not connected: await self._client.close() diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index d37801376ec..aab4f51b09a 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.2.10"], + "requirements": ["aioharmony==0.4.1"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/homeassistant/components/harvey/__init__.py b/homeassistant/components/harvey/__init__.py new file mode 100644 index 00000000000..e40d1799a64 --- /dev/null +++ b/homeassistant/components/harvey/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Harvey.""" diff --git a/homeassistant/components/harvey/manifest.json b/homeassistant/components/harvey/manifest.json new file mode 100644 index 00000000000..3cb2a1b9aff --- /dev/null +++ b/homeassistant/components/harvey/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "harvey", + "name": "Harvey", + "integration_type": "virtual", + "supported_by": "aquacell" +} diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 306c9d43d72..d71b2b85f7b 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -64,7 +64,10 @@ from homeassistant.util.dt import now # config_flow, diagnostics, system_health, and entity platforms are imported to # ensure other dependencies that wait for hassio are not waiting # for hassio to import its platforms +# backup is pre-imported to ensure that the backup integration does not load +# it from the event loop from . import ( # noqa: F401 + backup, binary_sensor, config_flow, diagnostics, @@ -87,6 +90,7 @@ from .const import ( ATTR_LOCATION, ATTR_PASSWORD, ATTR_SLUG, + DATA_COMPONENT, DATA_CORE_INFO, DATA_HOST_INFO, DATA_INFO, @@ -112,14 +116,13 @@ from .coordinator import ( get_supervisor_info, # noqa: F401 get_supervisor_stats, # noqa: F401 ) -from .discovery import async_setup_discovery_view # noqa: F401 +from .discovery import async_setup_discovery_view from .handler import ( # noqa: F401 HassIO, HassioAPIError, async_create_backup, async_get_green_settings, async_get_yellow_settings, - async_reboot_host, async_set_green_settings, async_set_yellow_settings, async_update_diagnostics, @@ -324,7 +327,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: host = os.environ["SUPERVISOR"] websession = async_get_clientsession(hass) - hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) + hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host) supervisor_client = get_supervisor_client(hass) try: diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py new file mode 100644 index 00000000000..2ebd3f6aab4 --- /dev/null +++ b/homeassistant/components/hassio/backup.py @@ -0,0 +1,479 @@ +"""Backup functionality for supervised installations.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable, Coroutine, Mapping +import logging +from pathlib import Path +from typing import Any, cast + +from aiohasupervisor.exceptions import ( + SupervisorBadRequestError, + SupervisorError, + SupervisorNotFoundError, +) +from aiohasupervisor.models import ( + backups as supervisor_backups, + mounts as supervisor_mounts, +) + +from homeassistant.components.backup import ( + DATA_MANAGER, + AddonInfo, + AgentBackup, + BackupAgent, + BackupReaderWriter, + BackupReaderWriterError, + CreateBackupEvent, + Folder, + IncorrectPasswordError, + NewBackup, + RestoreBackupEvent, + WrittenBackup, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DOMAIN, EVENT_SUPERVISOR_EVENT +from .handler import get_supervisor_client + +LOCATION_CLOUD_BACKUP = ".cloud_backup" +MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") +_LOGGER = logging.getLogger(__name__) + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return the hassio backup agents.""" + client = get_supervisor_client(hass) + mounts = await client.mounts.info() + agents: list[BackupAgent] = [SupervisorBackupAgent(hass, "local", None)] + for mount in mounts.mounts: + if mount.usage is not supervisor_mounts.MountUsage.BACKUP: + continue + agents.append(SupervisorBackupAgent(hass, mount.name, mount.name)) + return agents + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + + @callback + def unsub() -> None: + """Unsubscribe from job events.""" + unsub_signal() + + @callback + def handle_signal(data: Mapping[str, Any]) -> None: + """Handle a job signal.""" + if ( + data.get("event") != "job" + or not (event_data := data.get("data")) + or event_data.get("name") not in MOUNT_JOBS + or event_data.get("done") is not True + ): + return + _LOGGER.debug("Mount added or removed %s, calling listener", data) + listener() + + unsub_signal = async_dispatcher_connect(hass, EVENT_SUPERVISOR_EVENT, handle_signal) + return unsub + + +def _backup_details_to_agent_backup( + details: supervisor_backups.BackupComplete, +) -> AgentBackup: + """Convert a supervisor backup details object to an agent backup.""" + homeassistant_included = details.homeassistant is not None + if not homeassistant_included: + database_included = False + else: + database_included = details.homeassistant_exclude_database is False + addons = [ + AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) + for addon in details.addons + ] + return AgentBackup( + addons=addons, + backup_id=details.slug, + database_included=database_included, + date=details.date.isoformat(), + extra_metadata=details.extra or {}, + folders=[Folder(folder) for folder in details.folders], + homeassistant_included=homeassistant_included, + homeassistant_version=details.homeassistant, + name=details.name, + protected=details.protected, + size=details.size_bytes, + ) + + +class SupervisorBackupAgent(BackupAgent): + """Backup agent for supervised installations.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, name: str, location: str | None) -> None: + """Initialize the backup agent.""" + super().__init__() + self._hass = hass + self._backup_dir = Path("/backups") + self._client = get_supervisor_client(hass) + self.name = name + self.location = location + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + return await self._client.backups.download_backup( + backup_id, + options=supervisor_backups.DownloadBackupOptions(location=self.location), + ) + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + Not required for supervisor, the SupervisorBackupReaderWriter stores files. + """ + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backup_list = await self._client.backups.list() + result = [] + for backup in backup_list: + if not backup.locations or self.location not in backup.locations: + continue + details = await self._client.backups.backup_info(backup.slug) + result.append(_backup_details_to_agent_backup(details)) + return result + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + details = await self._client.backups.backup_info(backup_id) + if self.location not in details.locations: + return None + return _backup_details_to_agent_backup(details) + + async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: + """Remove a backup.""" + try: + await self._client.backups.remove_backup( + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={self.location} + ), + ) + except SupervisorBadRequestError as err: + if err.args[0] != "Backup does not exist": + raise + _LOGGER.debug("Backup %s does not exist", backup_id) + except SupervisorNotFoundError: + _LOGGER.debug("Backup %s does not exist", backup_id) + + +class SupervisorBackupReaderWriter(BackupReaderWriter): + """Class for reading and writing backups in supervised installations.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the backup reader/writer.""" + self._hass = hass + self._client = get_supervisor_client(hass) + + async def async_create_backup( + self, + *, + agent_ids: list[str], + backup_name: str, + extra_metadata: dict[str, bool | str], + include_addons: list[str] | None, + include_all_addons: bool, + include_database: bool, + include_folders: list[Folder] | None, + include_homeassistant: bool, + on_progress: Callable[[CreateBackupEvent], None], + password: str | None, + ) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]: + """Create a backup.""" + if not include_homeassistant and include_database: + raise HomeAssistantError( + "Cannot create a backup with database but without Home Assistant" + ) + manager = self._hass.data[DATA_MANAGER] + + include_addons_set: supervisor_backups.AddonSet | set[str] | None = None + if include_all_addons: + include_addons_set = supervisor_backups.AddonSet.ALL + elif include_addons: + include_addons_set = set(include_addons) + include_folders_set = { + supervisor_backups.Folder(folder) for folder in include_folders or [] + } + # Always include SSL if Home Assistant is included + if include_homeassistant: + include_folders_set.add(supervisor_backups.Folder.SSL) + + hassio_agents: list[SupervisorBackupAgent] = [ + cast(SupervisorBackupAgent, manager.backup_agents[agent_id]) + for agent_id in agent_ids + if manager.backup_agents[agent_id].domain == DOMAIN + ] + locations = [agent.location for agent in hassio_agents] + + try: + backup = await self._client.backups.partial_backup( + supervisor_backups.PartialBackupOptions( + addons=include_addons_set, + folders=include_folders_set, + homeassistant=include_homeassistant, + name=backup_name, + password=password, + compressed=True, + location=locations or LOCATION_CLOUD_BACKUP, + homeassistant_exclude_database=not include_database, + background=True, + extra=extra_metadata, + ) + ) + except SupervisorError as err: + raise BackupReaderWriterError(f"Error creating backup: {err}") from err + backup_task = self._hass.async_create_task( + self._async_wait_for_backup( + backup, remove_after_upload=not bool(locations) + ), + name="backup_manager_create_backup", + eager_start=False, # To ensure the task is not started before we return + ) + + return (NewBackup(backup_job_id=backup.job_id), backup_task) + + async def _async_wait_for_backup( + self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool + ) -> WrittenBackup: + """Wait for a backup to complete.""" + backup_complete = asyncio.Event() + backup_id: str | None = None + + @callback + def on_job_progress(data: Mapping[str, Any]) -> None: + """Handle backup progress.""" + nonlocal backup_id + if data.get("done") is True: + backup_id = data.get("reference") + backup_complete.set() + + try: + unsub = self._async_listen_job_events(backup.job_id, on_job_progress) + await backup_complete.wait() + finally: + unsub() + if not backup_id: + raise BackupReaderWriterError("Backup failed") + + async def open_backup() -> AsyncIterator[bytes]: + try: + return await self._client.backups.download_backup(backup_id) + except SupervisorError as err: + raise BackupReaderWriterError( + f"Error downloading backup: {err}" + ) from err + + async def remove_backup() -> None: + if not remove_after_upload: + return + try: + await self._client.backups.remove_backup( + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_CLOUD_BACKUP} + ), + ) + except SupervisorError as err: + raise BackupReaderWriterError(f"Error removing backup: {err}") from err + + try: + details = await self._client.backups.backup_info(backup_id) + except SupervisorError as err: + raise BackupReaderWriterError( + f"Error getting backup details: {err}" + ) from err + + return WrittenBackup( + backup=_backup_details_to_agent_backup(details), + open_stream=open_backup, + release_stream=remove_backup, + ) + + async def async_receive_backup( + self, + *, + agent_ids: list[str], + stream: AsyncIterator[bytes], + suggested_filename: str, + ) -> WrittenBackup: + """Receive a backup.""" + manager = self._hass.data[DATA_MANAGER] + + hassio_agents: list[SupervisorBackupAgent] = [ + cast(SupervisorBackupAgent, manager.backup_agents[agent_id]) + for agent_id in agent_ids + if manager.backup_agents[agent_id].domain == DOMAIN + ] + locations = {agent.location for agent in hassio_agents} + + backup_id = await self._client.backups.upload_backup( + stream, + supervisor_backups.UploadBackupOptions( + location=locations or {LOCATION_CLOUD_BACKUP} + ), + ) + + async def open_backup() -> AsyncIterator[bytes]: + return await self._client.backups.download_backup(backup_id) + + async def remove_backup() -> None: + if locations: + return + await self._client.backups.remove_backup( + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_CLOUD_BACKUP} + ), + ) + + details = await self._client.backups.backup_info(backup_id) + + return WrittenBackup( + backup=_backup_details_to_agent_backup(details), + open_stream=open_backup, + release_stream=remove_backup, + ) + + async def async_restore_backup( + self, + backup_id: str, + *, + agent_id: str, + on_progress: Callable[[RestoreBackupEvent], None], + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, + restore_addons: list[str] | None, + restore_database: bool, + restore_folders: list[Folder] | None, + restore_homeassistant: bool, + ) -> None: + """Restore a backup.""" + manager = self._hass.data[DATA_MANAGER] + # The backup manager has already checked that the backup exists so we don't need to + # check that here. + backup = await manager.backup_agents[agent_id].async_get_backup(backup_id) + if ( + backup + and restore_homeassistant + and restore_database != backup.database_included + ): + raise HomeAssistantError("Restore database must match backup") + if not restore_homeassistant and restore_database: + raise HomeAssistantError("Cannot restore database without Home Assistant") + restore_addons_set = set(restore_addons) if restore_addons else None + restore_folders_set = ( + {supervisor_backups.Folder(folder) for folder in restore_folders} + if restore_folders + else None + ) + + restore_location: str | None + if manager.backup_agents[agent_id].domain != DOMAIN: + # Download the backup to the supervisor. Supervisor will clean up the backup + # two days after the restore is done. + await self.async_receive_backup( + agent_ids=[], + stream=await open_stream(), + suggested_filename=f"{backup_id}.tar", + ) + restore_location = LOCATION_CLOUD_BACKUP + else: + agent = cast(SupervisorBackupAgent, manager.backup_agents[agent_id]) + restore_location = agent.location + + try: + job = await self._client.backups.partial_restore( + backup_id, + supervisor_backups.PartialRestoreOptions( + addons=restore_addons_set, + folders=restore_folders_set, + homeassistant=restore_homeassistant, + password=password, + background=True, + location=restore_location, + ), + ) + except SupervisorBadRequestError as err: + # Supervisor currently does not transmit machine parsable error types + message = err.args[0] + if message.startswith("Invalid password for backup"): + raise IncorrectPasswordError(message) from err + raise HomeAssistantError(message) from err + + restore_complete = asyncio.Event() + + @callback + def on_job_progress(data: Mapping[str, Any]) -> None: + """Handle backup progress.""" + if data.get("done") is True: + restore_complete.set() + + try: + unsub = self._async_listen_job_events(job.job_id, on_job_progress) + await restore_complete.wait() + finally: + unsub() + + @callback + def _async_listen_job_events( + self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + ) -> Callable[[], None]: + """Listen for job events.""" + + @callback + def unsub() -> None: + """Unsubscribe from job events.""" + unsub_signal() + + @callback + def handle_signal(data: Mapping[str, Any]) -> None: + """Handle a job signal.""" + if ( + data.get("event") != "job" + or not (event_data := data.get("data")) + or event_data.get("uuid") != job_id + ): + return + on_event(event_data) + + unsub_signal = async_dispatcher_connect( + self._hass, EVENT_SUPERVISOR_EVENT, handle_signal + ) + return unsub diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 82ce74832c2..d1cda51ec7b 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,7 +1,16 @@ """Hass.io const variables.""" +from __future__ import annotations + from datetime import timedelta from enum import StrEnum +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .handler import HassIO + DOMAIN = "hassio" @@ -64,6 +73,7 @@ UPDATE_KEY_SUPERVISOR = "supervisor" ADDONS_COORDINATOR = "hassio_addons_coordinator" +DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN) DATA_CORE_INFO = "hassio_core_info" DATA_CORE_STATS = "hassio_core_stats" DATA_HOST_INFO = "hassio_host_info" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index cb1dda8aeed..2d39e740e63 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -35,6 +35,7 @@ from .const import ( DATA_ADDONS_CHANGELOGS, DATA_ADDONS_INFO, DATA_ADDONS_STATS, + DATA_COMPONENT, DATA_CORE_INFO, DATA_CORE_STATS, DATA_HOST_INFO, @@ -56,7 +57,7 @@ from .const import ( SUPERVISOR_CONTAINER, SupervisorEntityModel, ) -from .handler import HassIO, HassioAPIError, get_supervisor_client +from .handler import HassioAPIError, get_supervisor_client if TYPE_CHECKING: from .issues import SupervisorIssues @@ -310,7 +311,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), ) - self.hassio: HassIO = hass.data[DOMAIN] + self.hassio = hass.data[DATA_COMPONENT] self.data = {} self.entry_id = config_entry.entry_id self.dev_reg = dev_reg diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 58f2aa8c144..752f535ca04 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.singleton import singleton from homeassistant.loader import bind_hass -from .const import ATTR_MESSAGE, ATTR_RESULT, DOMAIN, X_HASS_SOURCE +from .const import ATTR_MESSAGE, ATTR_RESULT, DATA_COMPONENT, X_HASS_SOURCE _LOGGER = logging.getLogger(__name__) @@ -72,7 +72,7 @@ async def async_update_diagnostics(hass: HomeAssistant, diagnostics: bool) -> bo The caller of the function should handle HassioAPIError. """ - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return await hassio.update_diagnostics(diagnostics) @@ -85,7 +85,7 @@ async def async_create_backup( The caller of the function should handle HassioAPIError. """ - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] backup_type = "partial" if partial else "full" command = f"/backups/new/{backup_type}" return await hassio.send_command(command, payload=payload, timeout=None) @@ -94,7 +94,7 @@ async def async_create_backup( @api_data async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]: """Return settings specific to Home Assistant Green.""" - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return await hassio.send_command("/os/boards/green", method="get") @@ -106,7 +106,7 @@ async def async_set_green_settings( Returns an empty dict. """ - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return await hassio.send_command( "/os/boards/green", method="post", payload=settings ) @@ -115,7 +115,7 @@ async def async_set_green_settings( @api_data async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]: """Return settings specific to Home Assistant Yellow.""" - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return await hassio.send_command("/os/boards/yellow", method="get") @@ -127,22 +127,12 @@ async def async_set_yellow_settings( Returns an empty dict. """ - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return await hassio.send_command( "/os/boards/yellow", method="post", payload=settings ) -@api_data -async def async_reboot_host(hass: HomeAssistant) -> dict: - """Reboot the host. - - Returns an empty dict. - """ - hassio: HassIO = hass.data[DOMAIN] - return await hassio.send_command("/host/reboot", method="post", timeout=60) - - class HassIO: """Small API wrapper for Hass.io.""" @@ -343,7 +333,7 @@ class HassIO: @singleton(KEY_SUPERVISOR_CLIENT) def get_supervisor_client(hass: HomeAssistant) -> SupervisorClient: """Return supervisor client.""" - hassio: HassIO = hass.data[DOMAIN] + hassio = hass.data[DATA_COMPONENT] return SupervisorClient( str(hassio.base_url), os.environ.get("SUPERVISOR_TOKEN", ""), diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index 31fa27a92c4..c9ecf6657e8 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.2.1"], + "requirements": ["aiohasupervisor==0.2.2b5"], "single_config_entry": true } diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 556a5a13f95..799067b8215 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -362,7 +362,7 @@ }, "addons": { "name": "Add-ons", - "description": "List of add-ons to include in the backup. Use the name slug of the add-on." + "description": "List of add-ons to include in the backup. Use the name slug of each add-on." }, "folders": { "name": "Folders", @@ -418,11 +418,11 @@ }, "folders": { "name": "[%key:component::hassio::services::backup_partial::fields::folders::name%]", - "description": "[%key:component::hassio::services::backup_partial::fields::folders::description%]" + "description": "List of directories to restore from the backup." }, "addons": { "name": "[%key:component::hassio::services::backup_partial::fields::addons::name%]", - "description": "[%key:component::hassio::services::backup_partial::fields::addons::description%]" + "description": "List of add-ons to restore from the backup. Use the name slug of each add-on." }, "password": { "name": "[%key:common::config_flow::data::password%]", diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 954d9ee8a02..f9d1b40575b 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -25,7 +25,7 @@ from .const import ( ATTR_SESSION_DATA_USER_ID, ATTR_TIMEOUT, ATTR_WS_EVENT, - DOMAIN, + DATA_COMPONENT, EVENT_SUPERVISOR_EVENT, WS_ID, WS_TYPE, @@ -33,7 +33,6 @@ from .const import ( WS_TYPE_EVENT, WS_TYPE_SUBSCRIBE, ) -from .handler import HassIO SCHEMA_WEBSOCKET_EVENT = vol.Schema( {vol.Required(ATTR_WS_EVENT): cv.string}, @@ -113,7 +112,7 @@ async def websocket_supervisor_api( msg[ATTR_ENDPOINT] ): raise Unauthorized - supervisor: HassIO = hass.data[DOMAIN] + supervisor = hass.data[DATA_COMPONENT] command = msg[ATTR_ENDPOINT] payload = msg.get(ATTR_DATA, {}) diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index fbb6a6b48f9..7ff00b8e282 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -5,9 +5,9 @@ from __future__ import annotations from datetime import timedelta import logging import socket -from telnetlib import Telnet # pylint: disable=deprecated-module from typing import Any +from telnetlib import Telnet # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.sensor import ( diff --git a/homeassistant/components/hdmi_cec/strings.json b/homeassistant/components/hdmi_cec/strings.json index d280cfc1a2b..70848b0514e 100644 --- a/homeassistant/components/hdmi_cec/strings.json +++ b/homeassistant/components/hdmi_cec/strings.json @@ -2,15 +2,15 @@ "services": { "power_on": { "name": "Power on", - "description": "Power on all devices which supports it." + "description": "Powers on all devices which support this function." }, "select_device": { "name": "Select device", - "description": "Select HDMI device.", + "description": "Selects an HDMI device.", "fields": { "device": { "name": "[%key:common::config_flow::data::device%]", - "description": "Address of device to select. Can be entity_id, physical address or alias from configuration." + "description": "Address of device to select. Can be an entity ID, physical address or alias from configuration." } } }, @@ -42,7 +42,7 @@ }, "standby": { "name": "[%key:common::state::standby%]", - "description": "Standby all devices which supports it." + "description": "Places in standby all devices which support this function." }, "update": { "name": "Update", @@ -50,19 +50,19 @@ }, "volume": { "name": "Volume", - "description": "Increases or decreases volume of system.", + "description": "Increases or decreases the system volume.", "fields": { "down": { "name": "Down", - "description": "Decreases volume x levels." + "description": "Decreases the volume x levels." }, "mute": { "name": "Mute", - "description": "Mutes audio system." + "description": "Mutes the audio system." }, "up": { "name": "Up", - "description": "Increases volume x levels." + "description": "Increases the volume x levels." } } } diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py index 1102dbc0c74..de66315a467 100644 --- a/homeassistant/components/heatmiser/climate.py +++ b/homeassistant/components/heatmiser/climate.py @@ -82,7 +82,6 @@ class HeatmiserV3Thermostat(ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, therm, device, uh1): """Initialize the thermostat.""" diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 1573ff3f23e..10fd2bfcff3 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -2,510 +2,61 @@ from __future__ import annotations -import asyncio from datetime import timedelta -import logging -from pyheos import Heos, HeosError, const as heos_const -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle from . import services -from .config_flow import format_title -from .const import ( - COMMAND_RETRY_ATTEMPTS, - COMMAND_RETRY_DELAY, - DATA_CONTROLLER_MANAGER, - DATA_ENTITY_ID_MAP, - DATA_GROUP_MANAGER, - DATA_SOURCE_MANAGER, - DOMAIN, - SIGNAL_HEOS_PLAYER_ADDED, - SIGNAL_HEOS_UPDATED, -) +from .const import DOMAIN +from .coordinator import HeosCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema({vol.Required(CONF_HOST): cv.string})}, - ), - extra=vol.ALLOW_EXTRA, -) - MIN_UPDATE_SOURCES = timedelta(seconds=1) -_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + +type HeosConfigEntry = ConfigEntry[HeosCoordinator] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HEOS component.""" - if DOMAIN not in config: - return True - host = config[DOMAIN][CONF_HOST] - entries = hass.config_entries.async_entries(DOMAIN) - if not entries: - # Create new entry based on config - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: host} - ) - ) - else: - # Check if host needs to be updated - entry = entries[0] - if entry.data[CONF_HOST] != host: - hass.config_entries.async_update_entry( - entry, title=format_title(host), data={**entry.data, CONF_HOST: host} - ) - + services.register(hass) return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool: """Initialize config entry which represents the HEOS controller.""" # For backwards compat if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=DOMAIN) - host = entry.data[CONF_HOST] - # Setting all_progress_events=False ensures that we only receive a - # media position update upon start of playback or when media changes - controller = Heos(host, all_progress_events=False) - try: - await controller.connect(auto_reconnect=True) - # Auto reconnect only operates if initial connection was successful. - except HeosError as error: - await controller.disconnect() - _LOGGER.debug("Unable to connect to controller %s: %s", host, error) - raise ConfigEntryNotReady from error + # Migrate non-string device identifiers. + device_registry = dr.async_get(hass) + for device in device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id + ): + for domain, player_id in device.identifiers: + if domain == DOMAIN and not isinstance(player_id, str): + device_registry.async_update_device( + device.id, new_identifiers={(DOMAIN, str(player_id))} + ) + break - # Disconnect when shutting down - async def disconnect_controller(event): - await controller.disconnect() - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect_controller) - ) - - # Get players and sources - try: - players = await controller.get_players() - favorites = {} - if controller.is_signed_in: - favorites = await controller.get_favorites() - else: - _LOGGER.warning( - ( - "%s is not logged in to a HEOS account and will be unable to" - " retrieve HEOS favorites: Use the 'heos.sign_in' service to" - " sign-in to a HEOS account" - ), - host, - ) - inputs = await controller.get_input_sources() - except HeosError as error: - await controller.disconnect() - _LOGGER.debug("Unable to retrieve players and sources: %s", error) - raise ConfigEntryNotReady from error - - controller_manager = ControllerManager(hass, controller) - await controller_manager.connect_listeners() - - source_manager = SourceManager(favorites, inputs) - source_manager.connect_update(hass, controller) - - group_manager = GroupManager(hass, controller) - - hass.data[DOMAIN] = { - DATA_CONTROLLER_MANAGER: controller_manager, - DATA_GROUP_MANAGER: group_manager, - DATA_SOURCE_MANAGER: source_manager, - Platform.MEDIA_PLAYER: players, - # Maps player_id to entity_id. Populated by the individual - # HeosMediaPlayer entities. - DATA_ENTITY_ID_MAP: {}, - } - - services.register(hass, controller) - group_manager.connect_update() - entry.async_on_unload(group_manager.disconnect_update) + coordinator = HeosCoordinator(hass, entry) + await coordinator.async_setup() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool: """Unload a config entry.""" - controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER] - await controller_manager.disconnect() - hass.data.pop(DOMAIN) - - services.remove(hass) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class ControllerManager: - """Class that manages events of the controller.""" - - def __init__(self, hass, controller): - """Init the controller manager.""" - self._hass = hass - self._device_registry = None - self._entity_registry = None - self.controller = controller - self._signals = [] - - async def connect_listeners(self): - """Subscribe to events of interest.""" - self._device_registry = dr.async_get(self._hass) - self._entity_registry = er.async_get(self._hass) - - # Handle controller events - self._signals.append( - self.controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event - ) - ) - # Handle connection-related events - self._signals.append( - self.controller.dispatcher.connect( - heos_const.SIGNAL_HEOS_EVENT, self._heos_event - ) - ) - - async def disconnect(self): - """Disconnect subscriptions.""" - for signal_remove in self._signals: - signal_remove() - self._signals.clear() - self.controller.dispatcher.disconnect_all() - await self.controller.disconnect() - - async def _controller_event(self, event, data): - """Handle controller event.""" - if event == heos_const.EVENT_PLAYERS_CHANGED: - self.update_ids(data[heos_const.DATA_MAPPED_IDS]) - # Update players - async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) - - async def _heos_event(self, event): - """Handle connection event.""" - if event == heos_const.EVENT_CONNECTED: - try: - # Retrieve latest players and refresh status - data = await self.controller.load_players() - self.update_ids(data[heos_const.DATA_MAPPED_IDS]) - except HeosError as ex: - _LOGGER.error("Unable to refresh players: %s", ex) - # Update players - async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) - - def update_ids(self, mapped_ids: dict[int, int]): - """Update the IDs in the device and entity registry.""" - # mapped_ids contains the mapped IDs (new:old) - for new_id, old_id in mapped_ids.items(): - # update device registry - entry = self._device_registry.async_get_device( - identifiers={(DOMAIN, old_id)} - ) - new_identifiers = {(DOMAIN, new_id)} - if entry: - self._device_registry.async_update_device( - entry.id, new_identifiers=new_identifiers - ) - _LOGGER.debug( - "Updated device %s identifiers to %s", entry.id, new_identifiers - ) - # update entity registry - entity_id = self._entity_registry.async_get_entity_id( - Platform.MEDIA_PLAYER, DOMAIN, str(old_id) - ) - if entity_id: - self._entity_registry.async_update_entity( - entity_id, new_unique_id=str(new_id) - ) - _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id) - - -class GroupManager: - """Class that manages HEOS groups.""" - - def __init__(self, hass, controller): - """Init group manager.""" - self._hass = hass - self._group_membership = {} - self._disconnect_player_added = None - self._initialized = False - self.controller = controller - - def _get_entity_id_to_player_id_map(self) -> dict: - """Return mapping of all HeosMediaPlayer entity_ids to player_ids.""" - return {v: k for k, v in self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP].items()} - - async def async_get_group_membership(self): - """Return all group members for each player as entity_ids.""" - group_info_by_entity_id = { - player_entity_id: [] - for player_entity_id in self._get_entity_id_to_player_id_map() - } - - try: - groups = await self.controller.get_groups(refresh=True) - except HeosError as err: - _LOGGER.error("Unable to get HEOS group info: %s", err) - return group_info_by_entity_id - - player_id_to_entity_id_map = self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP] - for group in groups.values(): - leader_entity_id = player_id_to_entity_id_map.get(group.leader.player_id) - member_entity_ids = [ - player_id_to_entity_id_map[member.player_id] - for member in group.members - if member.player_id in player_id_to_entity_id_map - ] - # Make sure the group leader is always the first element - group_info = [leader_entity_id, *member_entity_ids] - if leader_entity_id: - group_info_by_entity_id[leader_entity_id] = group_info - for member_entity_id in member_entity_ids: - group_info_by_entity_id[member_entity_id] = group_info - - return group_info_by_entity_id - - async def async_join_players( - self, leader_entity_id: str, member_entity_ids: list[str] - ) -> None: - """Create a group a group leader and member players.""" - entity_id_to_player_id_map = self._get_entity_id_to_player_id_map() - leader_id = entity_id_to_player_id_map.get(leader_entity_id) - if not leader_id: - raise HomeAssistantError( - f"The group leader {leader_entity_id} could not be resolved to a HEOS" - " player." - ) - member_ids = [ - entity_id_to_player_id_map[member] - for member in member_entity_ids - if member in entity_id_to_player_id_map - ] - - try: - await self.controller.create_group(leader_id, member_ids) - except HeosError as err: - _LOGGER.error( - "Failed to group %s with %s: %s", - leader_entity_id, - member_entity_ids, - err, - ) - - async def async_unjoin_player(self, player_entity_id: str): - """Remove `player_entity_id` from any group.""" - player_id = self._get_entity_id_to_player_id_map().get(player_entity_id) - if not player_id: - raise HomeAssistantError( - f"The player {player_entity_id} could not be resolved to a HEOS player." - ) - - try: - await self.controller.create_group(player_id, []) - except HeosError as err: - _LOGGER.error( - "Failed to ungroup %s: %s", - player_entity_id, - err, - ) - - async def async_update_groups(self, event, data=None): - """Update the group membership from the controller.""" - if event in ( - heos_const.EVENT_GROUPS_CHANGED, - heos_const.EVENT_CONNECTED, - SIGNAL_HEOS_PLAYER_ADDED, - ): - if groups := await self.async_get_group_membership(): - self._group_membership = groups - _LOGGER.debug("Groups updated due to change event") - # Let players know to update - async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) - else: - _LOGGER.debug("Groups empty") - - def connect_update(self): - """Connect listener for when groups change and signal player update.""" - self.controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, self.async_update_groups - ) - self.controller.dispatcher.connect( - heos_const.SIGNAL_HEOS_EVENT, self.async_update_groups - ) - - # When adding a new HEOS player we need to update the groups. - async def _async_handle_player_added(): - # Avoid calling async_update_groups when `DATA_ENTITY_ID_MAP` has not been - # fully populated yet. This may only happen during early startup. - if ( - len(self._hass.data[DOMAIN][Platform.MEDIA_PLAYER]) - <= len(self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP]) - and not self._initialized - ): - self._initialized = True - await self.async_update_groups(SIGNAL_HEOS_PLAYER_ADDED) - - self._disconnect_player_added = async_dispatcher_connect( - self._hass, SIGNAL_HEOS_PLAYER_ADDED, _async_handle_player_added - ) - - @callback - def disconnect_update(self): - """Disconnect the listeners.""" - if self._disconnect_player_added: - self._disconnect_player_added() - self._disconnect_player_added = None - - @property - def group_membership(self): - """Provide access to group members for player entities.""" - return self._group_membership - - -class SourceManager: - """Class that manages sources for players.""" - - def __init__( - self, - favorites, - inputs, - *, - retry_delay: int = COMMAND_RETRY_DELAY, - max_retry_attempts: int = COMMAND_RETRY_ATTEMPTS, - ) -> None: - """Init input manager.""" - self.retry_delay = retry_delay - self.max_retry_attempts = max_retry_attempts - self.favorites = favorites - self.inputs = inputs - self.source_list = self._build_source_list() - - def _build_source_list(self): - """Build a single list of inputs from various types.""" - source_list = [] - source_list.extend([favorite.name for favorite in self.favorites.values()]) - source_list.extend([source.name for source in self.inputs]) - return source_list - - async def play_source(self, source: str, player): - """Determine type of source and play it.""" - index = next( - ( - index - for index, favorite in self.favorites.items() - if favorite.name == source - ), - None, - ) - if index is not None: - await player.play_favorite(index) - return - - input_source = next( - ( - input_source - for input_source in self.inputs - if input_source.name == source - ), - None, - ) - if input_source is not None: - await player.play_input_source(input_source) - return - - _LOGGER.error("Unknown source: %s", source) - - def get_current_source(self, now_playing_media): - """Determine current source from now playing media.""" - # Match input by input_name:media_id - if now_playing_media.source_id == heos_const.MUSIC_SOURCE_AUX_INPUT: - return next( - ( - input_source.name - for input_source in self.inputs - if input_source.input_name == now_playing_media.media_id - ), - None, - ) - # Try matching favorite by name:station or media_id:album_id - return next( - ( - source.name - for source in self.favorites.values() - if source.name == now_playing_media.station - or source.media_id == now_playing_media.album_id - ), - None, - ) - - def connect_update(self, hass, controller): - """Connect listener for when sources change and signal player update. - - EVENT_SOURCES_CHANGED is often raised multiple times in response to a - physical event therefore throttle it. Retrieving sources immediately - after the event may fail so retry. - """ - - @Throttle(MIN_UPDATE_SOURCES) - async def get_sources(): - retry_attempts = 0 - while True: - try: - favorites = {} - if controller.is_signed_in: - favorites = await controller.get_favorites() - inputs = await controller.get_input_sources() - except HeosError as error: - if retry_attempts < self.max_retry_attempts: - retry_attempts += 1 - _LOGGER.debug( - "Error retrieving sources and will retry: %s", error - ) - await asyncio.sleep(self.retry_delay) - else: - _LOGGER.error("Unable to update sources: %s", error) - return None - else: - return favorites, inputs - - async def update_sources(event, data=None): - if event in ( - heos_const.EVENT_SOURCES_CHANGED, - heos_const.EVENT_USER_CHANGED, - heos_const.EVENT_CONNECTED, - ): - # If throttled, it will return None - if sources := await get_sources(): - self.favorites, self.inputs = sources - self.source_list = self._build_source_list() - _LOGGER.debug("Sources updated due to changed event") - # Let players know to update - async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED) - - controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, update_sources - ) - controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, update_sources) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 57ed51a3c05..18b8f1f7918 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -1,21 +1,103 @@ """Config flow to configure Heos.""" +from collections.abc import Mapping +import logging from typing import TYPE_CHECKING, Any from urllib.parse import urlparse -from pyheos import Heos, HeosError +from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions import voluptuous as vol -from homeassistant.components import ssdp -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers import selector +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) -from .const import DATA_DISCOVERED_HOSTS, DOMAIN +from . import HeosConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +AUTH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USERNAME): selector.TextSelector(), + vol.Optional(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + } +) def format_title(host: str) -> str: """Format the title for config entries.""" - return f"Controller ({host})" + return f"HEOS System (via {host})" + + +async def _validate_host(host: str, errors: dict[str, str]) -> bool: + """Validate host is reachable, return True, otherwise populate errors and return False.""" + heos = Heos(HeosOptions(host, events=False, heart_beat=False)) + try: + await heos.connect() + except HeosError: + errors[CONF_HOST] = "cannot_connect" + return False + finally: + await heos.disconnect() + return True + + +async def _validate_auth( + user_input: dict[str, str], heos: Heos, errors: dict[str, str] +) -> bool: + """Validate authentication by signing in or out, otherwise populate errors if needed.""" + if not user_input: + # Log out (neither username nor password provided) + try: + await heos.sign_out() + except HeosError: + errors["base"] = "unknown" + _LOGGER.exception("Unexpected error occurred during sign-out") + return False + else: + _LOGGER.debug("Successfully signed-out of HEOS Account") + return True + + # Ensure both username and password are provided + authentication = CONF_USERNAME in user_input or CONF_PASSWORD in user_input + if authentication and CONF_USERNAME not in user_input: + errors[CONF_USERNAME] = "username_missing" + return False + if authentication and CONF_PASSWORD not in user_input: + errors[CONF_PASSWORD] = "password_missing" + return False + + # Attempt to login (both username and password provided) + try: + await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + except CommandAuthenticationError as err: + errors["base"] = "invalid_auth" + _LOGGER.warning("Failed to sign-in to HEOS Account: %s", err) + return False + except HeosError: + errors["base"] = "unknown" + _LOGGER.exception("Unexpected error occurred during sign-in") + return False + else: + _LOGGER.debug( + "Successfully signed-in to HEOS Account: %s", + heos.signed_in_username, + ) + return True class HeosFlowHandler(ConfigFlow, domain=DOMAIN): @@ -23,67 +105,118 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Create the options flow.""" + return HeosOptionsFlowHandler() + async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Heos device.""" # Store discovered host if TYPE_CHECKING: assert discovery_info.ssdp_location hostname = urlparse(discovery_info.ssdp_location).hostname - friendly_name = ( - f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" - ) - self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {}) - self.hass.data[DATA_DISCOVERED_HOSTS][friendly_name] = hostname - # Abort if other flows in progress or an entry already exists - if self._async_in_progress() or self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + friendly_name = f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" + self.hass.data.setdefault(DOMAIN, {}) + self.hass.data[DOMAIN][friendly_name] = hostname await self.async_set_unique_id(DOMAIN) # Show selection form return self.async_show_form(step_id="user") - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Occurs when an entry is setup through config.""" - host = import_data[CONF_HOST] - # raise_on_progress is False here in case ssdp discovers - # heos first which would block the import - await self.async_set_unique_id(DOMAIN, raise_on_progress=False) - return self.async_create_entry(title=format_title(host), data={CONF_HOST: host}) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Obtain host and validate connection.""" - self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {}) - # Only a single entry is needed for all devices - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + self.hass.data.setdefault(DOMAIN, {}) + await self.async_set_unique_id(DOMAIN) # Try connecting to host if provided - errors = {} + errors: dict[str, str] = {} host = None if user_input is not None: host = user_input[CONF_HOST] # Map host from friendly name if in discovered hosts - host = self.hass.data[DATA_DISCOVERED_HOSTS].get(host, host) - heos = Heos(host) - try: - await heos.connect() - self.hass.data.pop(DATA_DISCOVERED_HOSTS) - return await self.async_step_import({CONF_HOST: host}) - except HeosError: - errors[CONF_HOST] = "cannot_connect" - finally: - await heos.disconnect() + host = self.hass.data[DOMAIN].get(host, host) + if await _validate_host(host, errors): + self.hass.data.pop(DOMAIN) # Remove discovery data + return self.async_create_entry( + title=format_title(host), data={CONF_HOST: host} + ) # Return form host_type = ( - str - if not self.hass.data[DATA_DISCOVERED_HOSTS] - else vol.In(list(self.hass.data[DATA_DISCOVERED_HOSTS])) + str if not self.hass.data[DOMAIN] else vol.In(list(self.hass.data[DOMAIN])) ) return self.async_show_form( step_id="user", data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): host_type}), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Allow reconfiguration of entry.""" + entry = self._get_reconfigure_entry() + host = entry.data[CONF_HOST] # Get current host value + errors: dict[str, str] = {} + if user_input is not None: + host = user_input[CONF_HOST] + if await _validate_host(host, errors): + return self.async_update_reload_and_abort( + entry, data_updates={CONF_HOST: host} + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication after auth failure event.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Validate account credentials and update options.""" + errors: dict[str, str] = {} + entry: HeosConfigEntry = self._get_reauth_entry() + if user_input is not None: + assert entry.state is ConfigEntryState.LOADED + if await _validate_auth(user_input, entry.runtime_data.heos, errors): + return self.async_update_reload_and_abort(entry, options=user_input) + + return self.async_show_form( + step_id="reauth_confirm", + errors=errors, + data_schema=self.add_suggested_values_to_schema( + AUTH_SCHEMA, user_input or entry.options + ), + ) + + +class HeosOptionsFlowHandler(OptionsFlow): + """Define HEOS options flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + if user_input is not None: + entry: HeosConfigEntry = self.config_entry + if await _validate_auth(user_input, entry.runtime_data.heos, errors): + return self.async_create_entry(data=user_input) + + return self.async_show_form( + errors=errors, + step_id="init", + data_schema=self.add_suggested_values_to_schema( + AUTH_SCHEMA, user_input or self.config_entry.options + ), + ) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 636751d150b..7f03fa11e79 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -2,15 +2,6 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" -COMMAND_RETRY_ATTEMPTS = 2 -COMMAND_RETRY_DELAY = 1 -DATA_CONTROLLER_MANAGER = "controller" -DATA_ENTITY_ID_MAP = "entity_id_map" -DATA_GROUP_MANAGER = "group_manager" -DATA_SOURCE_MANAGER = "source_manager" -DATA_DISCOVERED_HOSTS = "heos_discovered_hosts" DOMAIN = "heos" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" -SIGNAL_HEOS_PLAYER_ADDED = "heos_player_added" -SIGNAL_HEOS_UPDATED = "heos_updated" diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py new file mode 100644 index 00000000000..8ed8449685a --- /dev/null +++ b/homeassistant/components/heos/coordinator.py @@ -0,0 +1,285 @@ +"""HEOS integration coordinator. + +Control of all HEOS devices is through connection to a single device. Data is pushed through events. +The coordinator is responsible for refreshing data in response to system-wide events and notifying +entities to update. Entities subscribe to entity-specific updates within the entity class itself. +""" + +from collections.abc import Callable +from datetime import datetime, timedelta +import logging + +from pyheos import ( + Credentials, + Heos, + HeosError, + HeosNowPlayingMedia, + HeosOptions, + HeosPlayer, + MediaItem, + MediaType, + PlayerUpdateResult, + const, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HassJob, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class HeosCoordinator(DataUpdateCoordinator[None]): + """Define the HEOS integration coordinator.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Set up the coordinator and set in config_entry.""" + self.host: str = config_entry.data[CONF_HOST] + credentials: Credentials | None = None + if config_entry.options: + credentials = Credentials( + config_entry.options[CONF_USERNAME], config_entry.options[CONF_PASSWORD] + ) + # Setting all_progress_events=False ensures that we only receive a + # media position update upon start of playback or when media changes + self.heos = Heos( + HeosOptions( + self.host, + all_progress_events=False, + auto_reconnect=True, + credentials=credentials, + ) + ) + self._update_sources_pending: bool = False + self._source_list: list[str] = [] + self._favorites: dict[int, MediaItem] = {} + self._inputs: list[MediaItem] = [] + super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) + + async def async_setup(self) -> None: + """Set up the coordinator; connect to the host; and retrieve initial data.""" + # Add before connect as it may occur during initial connection + self.heos.add_on_user_credentials_invalid(self._async_on_auth_failure) + # Connect to the device + try: + await self.heos.connect() + except HeosError as error: + raise ConfigEntryNotReady from error + # Load players + try: + await self.heos.get_players() + except HeosError as error: + raise ConfigEntryNotReady from error + + if not self.heos.is_signed_in: + _LOGGER.warning( + "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services" + ) + # Retrieve initial data + await self._async_update_groups() + await self._async_update_sources() + # Attach event callbacks + self.heos.add_on_disconnected(self._async_on_disconnected) + self.heos.add_on_connected(self._async_on_reconnected) + self.heos.add_on_controller_event(self._async_on_controller_event) + + async def async_shutdown(self) -> None: + """Disconnect all callbacks and disconnect from the device.""" + self.heos.dispatcher.disconnect_all() # Removes all connected through heos.add_on_* and player.add_on_* + await self.heos.disconnect() + await super().async_shutdown() + + def async_add_listener(self, update_callback, context=None) -> Callable[[], None]: + """Add a listener for the coordinator.""" + remove_listener = super().async_add_listener(update_callback, context) + # Update entities so group_member entity_ids fully populate. + self.async_update_listeners() + return remove_listener + + async def _async_on_auth_failure(self) -> None: + """Handle when the user credentials are no longer valid.""" + assert self.config_entry is not None + self.config_entry.async_start_reauth(self.hass) + + async def _async_on_disconnected(self) -> None: + """Handle when disconnected so entities are marked unavailable.""" + _LOGGER.warning("Connection to HEOS host %s lost", self.host) + self.async_update_listeners() + + async def _async_on_reconnected(self) -> None: + """Handle when reconnected so resources are updated and entities marked available.""" + await self._async_update_players() + await self._async_update_sources() + _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host) + self.async_update_listeners() + + async def _async_on_controller_event( + self, event: str, data: PlayerUpdateResult | None + ) -> None: + """Handle a controller event, such as players or groups changed.""" + if event == const.EVENT_PLAYERS_CHANGED: + assert data is not None + if data.updated_player_ids: + self._async_update_player_ids(data.updated_player_ids) + elif event == const.EVENT_GROUPS_CHANGED: + await self._async_update_players() + elif ( + event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) + and not self._update_sources_pending + ): + # Update the sources after a brief delay as we may have received multiple qualifying + # events at once and devices cannot handle immediately attempting to refresh sources. + self._update_sources_pending = True + + async def update_sources_job(_: datetime | None = None) -> None: + await self._async_update_sources() + self._update_sources_pending = False + self.async_update_listeners() + + assert self.config_entry is not None + self.config_entry.async_on_unload( + async_call_later( + self.hass, + timedelta(seconds=1), + HassJob( + update_sources_job, + "heos_update_sources", + cancel_on_shutdown=True, + ), + ) + ) + self.async_update_listeners() + + def _async_update_player_ids(self, updated_player_ids: dict[int, int]) -> None: + """Update the IDs in the device and entity registry.""" + device_registry = dr.async_get(self.hass) + entity_registry = er.async_get(self.hass) + # updated_player_ids contains the mapped IDs in format old:new + for old_id, new_id in updated_player_ids.items(): + # update device registry + entry = device_registry.async_get_device( + identifiers={(DOMAIN, str(old_id))} + ) + if entry: + new_identifiers = entry.identifiers.copy() + new_identifiers.remove((DOMAIN, str(old_id))) + new_identifiers.add((DOMAIN, str(new_id))) + device_registry.async_update_device( + entry.id, + new_identifiers=new_identifiers, + ) + _LOGGER.debug( + "Updated device %s identifiers to %s", entry.id, new_identifiers + ) + # update entity registry + entity_id = entity_registry.async_get_entity_id( + Platform.MEDIA_PLAYER, DOMAIN, str(old_id) + ) + if entity_id: + entity_registry.async_update_entity( + entity_id, new_unique_id=str(new_id) + ) + _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id) + + async def _async_update_groups(self) -> None: + """Update group information.""" + try: + await self.heos.get_groups(refresh=True) + except HeosError as error: + _LOGGER.error("Unable to retrieve groups: %s", error) + + async def _async_update_sources(self) -> None: + """Build source list for entities.""" + self._source_list.clear() + # Get favorites only if reportedly signed in. + if self.heos.is_signed_in: + try: + self._favorites = await self.heos.get_favorites() + except HeosError as error: + _LOGGER.error("Unable to retrieve favorites: %s", error) + else: + self._source_list.extend( + favorite.name for favorite in self._favorites.values() + ) + # Get input sources (across all devices in the HEOS system) + try: + self._inputs = await self.heos.get_input_sources() + except HeosError as error: + _LOGGER.error("Unable to retrieve input sources: %s", error) + else: + self._source_list.extend([source.name for source in self._inputs]) + + async def _async_update_players(self) -> None: + """Update players after reconnection.""" + try: + player_updates = await self.heos.load_players() + except HeosError as error: + _LOGGER.error("Unable to refresh players: %s", error) + return + # After reconnecting, player_id may have changed + if player_updates.updated_player_ids: + self._async_update_player_ids(player_updates.updated_player_ids) + + @callback + def async_get_source_list(self) -> list[str]: + """Return the list of sources for players.""" + return list(self._source_list) + + @callback + def async_get_favorite_index(self, name: str) -> int | None: + """Get the index of a favorite by name.""" + for index, favorite in self._favorites.items(): + if favorite.name == name: + return index + return None + + @callback + def async_get_current_source( + self, now_playing_media: HeosNowPlayingMedia + ) -> str | None: + """Determine current source from now playing media (either input source or favorite).""" + # Try matching input source + if now_playing_media.source_id == const.MUSIC_SOURCE_AUX_INPUT: + # If playing a remote input, name will match station + for input_source in self._inputs: + if input_source.name == now_playing_media.station: + return input_source.name + # If playing a local input, match media_id. This needs to be a second loop as media_id + # will match both local and remote inputs, so prioritize remote match by name first. + for input_source in self._inputs: + if input_source.media_id == now_playing_media.media_id: + return input_source.name + # Try matching favorite + if now_playing_media.type == MediaType.STATION: + # Some stations match on name:station, others match on media_id:album_id + for favorite in self._favorites.values(): + if ( + favorite.name == now_playing_media.station + or favorite.media_id == now_playing_media.album_id + ): + return favorite.name + return None + + async def async_play_source(self, source: str, player: HeosPlayer) -> None: + """Determine type of source and play it.""" + # Favorite + if (index := self.async_get_favorite_index(source)) is not None: + await player.play_preset_station(index) + return + # Input source + for input_source in self._inputs: + if input_source.name == source: + await player.play_media(input_source) + return + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unknown_source", + translation_placeholders={"source": source}, + ) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index a90f0aebaae..e3d2632e340 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -4,9 +4,11 @@ "codeowners": ["@andrewsayre"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/heos", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyheos"], - "requirements": ["pyheos==0.7.2"], + "requirements": ["pyheos==1.0.0"], + "single_config_entry": true, "ssdp": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 0f9f7facd33..d405b235f76 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -4,49 +4,51 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from functools import reduce, wraps -import logging from operator import ior from typing import Any -from pyheos import HeosError, const as heos_const +from pyheos import ( + AddCriteriaType, + ControlType, + HeosError, + HeosPlayer, + PlayState, + RepeatType, + const as heos_const, +) from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, - DOMAIN as MEDIA_PLAYER_DOMAIN, BrowseMedia, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, + RepeatMode, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from .const import ( - DATA_ENTITY_ID_MAP, - DATA_GROUP_MANAGER, - DATA_SOURCE_MANAGER, - DOMAIN as HEOS_DOMAIN, - SIGNAL_HEOS_PLAYER_ADDED, - SIGNAL_HEOS_UPDATED, -) +from . import HeosConfigEntry +from .const import DOMAIN as HEOS_DOMAIN +from .coordinator import HeosCoordinator + +PARALLEL_UPDATES = 0 BASE_SUPPORTED_FEATURES = ( MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.CLEAR_PLAYLIST - | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.GROUPING @@ -55,47 +57,54 @@ BASE_SUPPORTED_FEATURES = ( ) PLAY_STATE_TO_STATE = { - heos_const.PLAY_STATE_PLAY: MediaPlayerState.PLAYING, - heos_const.PLAY_STATE_STOP: MediaPlayerState.IDLE, - heos_const.PLAY_STATE_PAUSE: MediaPlayerState.PAUSED, + PlayState.PLAY: MediaPlayerState.PLAYING, + PlayState.STOP: MediaPlayerState.IDLE, + PlayState.PAUSE: MediaPlayerState.PAUSED, } CONTROL_TO_SUPPORT = { - heos_const.CONTROL_PLAY: MediaPlayerEntityFeature.PLAY, - heos_const.CONTROL_PAUSE: MediaPlayerEntityFeature.PAUSE, - heos_const.CONTROL_STOP: MediaPlayerEntityFeature.STOP, - heos_const.CONTROL_PLAY_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK, - heos_const.CONTROL_PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, + ControlType.PLAY: MediaPlayerEntityFeature.PLAY, + ControlType.PAUSE: MediaPlayerEntityFeature.PAUSE, + ControlType.STOP: MediaPlayerEntityFeature.STOP, + ControlType.PLAY_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK, + ControlType.PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, } HA_HEOS_ENQUEUE_MAP = { - None: heos_const.ADD_QUEUE_REPLACE_AND_PLAY, - MediaPlayerEnqueue.ADD: heos_const.ADD_QUEUE_ADD_TO_END, - MediaPlayerEnqueue.REPLACE: heos_const.ADD_QUEUE_REPLACE_AND_PLAY, - MediaPlayerEnqueue.NEXT: heos_const.ADD_QUEUE_PLAY_NEXT, - MediaPlayerEnqueue.PLAY: heos_const.ADD_QUEUE_PLAY_NOW, + None: AddCriteriaType.REPLACE_AND_PLAY, + MediaPlayerEnqueue.ADD: AddCriteriaType.ADD_TO_END, + MediaPlayerEnqueue.REPLACE: AddCriteriaType.REPLACE_AND_PLAY, + MediaPlayerEnqueue.NEXT: AddCriteriaType.PLAY_NEXT, + MediaPlayerEnqueue.PLAY: AddCriteriaType.PLAY_NOW, } -_LOGGER = logging.getLogger(__name__) +HEOS_HA_REPEAT_TYPE_MAP = { + RepeatType.OFF: RepeatMode.OFF, + RepeatType.ON_ALL: RepeatMode.ALL, + RepeatType.ON_ONE: RepeatMode.ONE, +} +HA_HEOS_REPEAT_TYPE_MAP = {v: k for k, v in HEOS_HA_REPEAT_TYPE_MAP.items()} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: HeosConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Add media players for a config entry.""" - players = hass.data[HEOS_DOMAIN][MEDIA_PLAYER_DOMAIN] - devices = [HeosMediaPlayer(player) for player in players.values()] - async_add_entities(devices, True) + devices = [ + HeosMediaPlayer(entry.runtime_data, player) + for player in entry.runtime_data.heos.players.values() + ] + async_add_entities(devices) type _FuncType[**_P] = Callable[_P, Awaitable[Any]] type _ReturnFuncType[**_P] = Callable[_P, Coroutine[Any, Any, None]] -def log_command_error[**_P]( - command: str, +def catch_action_error[**_P]( + action: str, ) -> Callable[[_FuncType[_P]], _ReturnFuncType[_P]]: - """Return decorator that logs command failure.""" + """Return decorator that catches errors and raises HomeAssistantError.""" def decorator(func: _FuncType[_P]) -> _ReturnFuncType[_P]: @wraps(func) @@ -103,110 +112,140 @@ def log_command_error[**_P]( try: await func(*args, **kwargs) except (HeosError, ValueError) as ex: - _LOGGER.error("Unable to %s: %s", command, ex) + raise HomeAssistantError( + translation_domain=HEOS_DOMAIN, + translation_key="action_error", + translation_placeholders={"action": action, "error": str(ex)}, + ) from ex return wrapper return decorator -class HeosMediaPlayer(MediaPlayerEntity): +class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """The HEOS player.""" _attr_media_content_type = MediaType.MUSIC - _attr_should_poll = False _attr_supported_features = BASE_SUPPORTED_FEATURES _attr_media_image_remotely_accessible = True _attr_has_entity_name = True _attr_name = None - def __init__(self, player): + def __init__(self, coordinator: HeosCoordinator, player: HeosPlayer) -> None: """Initialize.""" self._media_position_updated_at = None - self._player = player - self._signals = [] - self._source_manager = None - self._group_manager = None + self._player: HeosPlayer = player self._attr_unique_id = str(player.player_id) + model_parts = player.model.split(maxsplit=1) + manufacturer = model_parts[0] if len(model_parts) == 2 else "HEOS" + model = model_parts[1] if len(model_parts) == 2 else player.model self._attr_device_info = DeviceInfo( - identifiers={(HEOS_DOMAIN, player.player_id)}, - manufacturer="HEOS", - model=player.model, + identifiers={(HEOS_DOMAIN, str(player.player_id))}, + manufacturer=manufacturer, + model=model, name=player.name, + serial_number=player.serial, # Only available for some models sw_version=player.version, ) + super().__init__(coordinator, context=player.player_id) - async def _player_update(self, player_id, event): + async def _player_update(self, event): """Handle player attribute updated.""" - if self._player.player_id != player_id: - return if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: self._media_position_updated_at = utcnow() - await self.async_update_ha_state(True) + self._handle_coordinator_update() - async def _heos_updated(self) -> None: - """Handle sources changed.""" - await self.async_update_ha_state(True) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attributes() + super()._handle_coordinator_update() + + @callback + def _get_group_members(self) -> list[str] | None: + """Get group member entity IDs for the group.""" + if self._player.group_id is None: + return None + if not (group := self.coordinator.heos.groups.get(self._player.group_id)): + return None + player_ids = [group.lead_player_id, *group.member_player_ids] + # Resolve player_ids to entity_ids + entity_registry = er.async_get(self.hass) + entity_ids = [ + entity_id + for member_id in player_ids + if ( + entity_id := entity_registry.async_get_entity_id( + Platform.MEDIA_PLAYER, HEOS_DOMAIN, str(member_id) + ) + ) + ] + return entity_ids or None + + @callback + def _update_attributes(self) -> None: + """Update core attributes of the media player.""" + self._attr_group_members = self._get_group_members() + self._attr_source_list = self.coordinator.async_get_source_list() + self._attr_source = self.coordinator.async_get_current_source( + self._player.now_playing_media + ) + self._attr_repeat = HEOS_HA_REPEAT_TYPE_MAP[self._player.repeat] + controls = self._player.now_playing_media.supported_controls + current_support = [CONTROL_TO_SUPPORT[control] for control in controls] + self._attr_supported_features = reduce( + ior, current_support, BASE_SUPPORTED_FEATURES + ) + if self.support_next_track and self.support_previous_track: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.SHUFFLE_SET + ) async def async_added_to_hass(self) -> None: """Device added to hass.""" # Update state when attributes of the player change - self._signals.append( - self._player.heos.dispatcher.connect( - heos_const.SIGNAL_PLAYER_EVENT, self._player_update - ) - ) - # Update state when heos changes - self._signals.append( - async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated) - ) - # Register this player's entity_id so it can be resolved by the group manager - self.hass.data[HEOS_DOMAIN][DATA_ENTITY_ID_MAP][self._player.player_id] = ( - self.entity_id - ) - async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED) + self._update_attributes() + self.async_on_remove(self._player.add_on_player_event(self._player_update)) + await super().async_added_to_hass() - @log_command_error("clear playlist") + @catch_action_error("clear playlist") async def async_clear_playlist(self) -> None: """Clear players playlist.""" await self._player.clear_queue() - @log_command_error("join_players") - async def async_join_players(self, group_members: list[str]) -> None: - """Join `group_members` as a player group with the current player.""" - await self._group_manager.async_join_players(self.entity_id, group_members) - - @log_command_error("pause") + @catch_action_error("pause") async def async_media_pause(self) -> None: """Send pause command.""" await self._player.pause() - @log_command_error("play") + @catch_action_error("play") async def async_media_play(self) -> None: """Send play command.""" await self._player.play() - @log_command_error("move to previous track") + @catch_action_error("move to previous track") async def async_media_previous_track(self) -> None: """Send previous track command.""" await self._player.play_previous() - @log_command_error("move to next track") + @catch_action_error("move to next track") async def async_media_next_track(self) -> None: """Send next track command.""" await self._player.play_next() - @log_command_error("stop") + @catch_action_error("stop") async def async_media_stop(self) -> None: """Send stop command.""" await self._player.stop() - @log_command_error("set mute") + @catch_action_error("set mute") async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" await self._player.set_mute(mute) - @log_command_error("play media") + @catch_action_error("play media") async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: @@ -256,60 +295,75 @@ class HeosMediaPlayer(MediaPlayerEntity): index = int(media_id) except ValueError: # Try finding index by name - index = next( - ( - index - for index, favorite in self._source_manager.favorites.items() - if favorite.name == media_id - ), - None, - ) + index = self.coordinator.async_get_favorite_index(media_id) if index is None: raise ValueError(f"Invalid favorite '{media_id}'") - await self._player.play_favorite(index) + await self._player.play_preset_station(index) return raise ValueError(f"Unsupported media type '{media_type}'") - @log_command_error("select source") + @catch_action_error("select source") async def async_select_source(self, source: str) -> None: """Select input source.""" - await self._source_manager.play_source(source, self._player) + await self.coordinator.async_play_source(source, self._player) - @log_command_error("set shuffle") + @catch_action_error("set repeat") + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + await self._player.set_play_mode( + HA_HEOS_REPEAT_TYPE_MAP[repeat], self._player.shuffle + ) + + @catch_action_error("set shuffle") async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/disable shuffle mode.""" await self._player.set_play_mode(self._player.repeat, shuffle) - @log_command_error("set volume level") + @catch_action_error("set volume level") async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._player.set_volume(int(volume * 100)) - async def async_update(self) -> None: - """Update supported features of the player.""" - controls = self._player.now_playing_media.supported_controls - current_support = [CONTROL_TO_SUPPORT[control] for control in controls] - self._attr_supported_features = reduce( - ior, current_support, BASE_SUPPORTED_FEATURES - ) + @catch_action_error("join players") + async def async_join_players(self, group_members: list[str]) -> None: + """Join `group_members` as a player group with the current player.""" + player_ids: list[int] = [self._player.player_id] + # Resolve entity_ids to player_ids + entity_registry = er.async_get(self.hass) + for entity_id in group_members: + entity_entry = entity_registry.async_get(entity_id) + if entity_entry is None: + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + if entity_entry.platform != HEOS_DOMAIN: + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="not_heos_media_player", + translation_placeholders={"entity_id": entity_id}, + ) + player_id = int(entity_entry.unique_id) + if player_id not in player_ids: + player_ids.append(player_id) + await self.coordinator.heos.set_group(player_ids) - if self._group_manager is None: - self._group_manager = self.hass.data[HEOS_DOMAIN][DATA_GROUP_MANAGER] - - if self._source_manager is None: - self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER] - - @log_command_error("unjoin_player") + @catch_action_error("unjoin player") async def async_unjoin_player(self) -> None: """Remove this player from any group.""" - await self._group_manager.async_unjoin_player(self.entity_id) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect the device when removed.""" - for signal_remove in self._signals: - signal_remove() - self._signals.clear() + for group in self.coordinator.heos.groups.values(): + if group.lead_player_id == self._player.player_id: + # Player is the group leader, this effectively removes the group. + await self.coordinator.heos.set_group([self._player.player_id]) + return + if self._player.player_id in group.member_player_ids: + # Player is a group member, update the group to exclude it + new_members = [group.lead_player_id, *group.member_player_ids] + new_members.remove(self._player.player_id) + await self.coordinator.heos.set_group(new_members) + return @property def available(self) -> bool: @@ -327,11 +381,6 @@ class HeosMediaPlayer(MediaPlayerEntity): "media_type": self._player.now_playing_media.type, } - @property - def group_members(self) -> list[str]: - """List of players which are grouped together.""" - return self._group_manager.group_membership.get(self.entity_id, []) - @property def is_volume_muted(self) -> bool: """Boolean if volume is currently muted.""" @@ -393,16 +442,6 @@ class HeosMediaPlayer(MediaPlayerEntity): """Boolean if shuffle is enabled.""" return self._player.shuffle - @property - def source(self) -> str: - """Name of the current input source.""" - return self._source_manager.get_current_source(self._player.now_playing_media) - - @property - def source_list(self) -> list[str]: - """List of available input sources.""" - return self._source_manager.source_list - @property def state(self) -> MediaPlayerState: """State of the player.""" diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml new file mode 100644 index 00000000000..d48bcc492cd --- /dev/null +++ b/homeassistant/components/heos/quality_scale.yaml @@ -0,0 +1,67 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: done + comment: Integration is a local push integration + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: done + comment: Consider enhnacement to automatically select a host when multiple are discovered. + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: + status: done + comment: 99% test coverage + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: todo + comment: Explore if this is possible. + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: done + repair-issues: todo + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: + status: done + comment: The integration does not use websession + strict-typing: todo diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 2ef80b6efd9..5a0105f830e 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -1,13 +1,14 @@ """Services for the HEOS integration.""" -import functools import logging -from pyheos import CommandFailedError, Heos, HeosError, const +from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, issue_registry as ir from .const import ( ATTR_PASSWORD, @@ -26,48 +27,63 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema( HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) -def register(hass: HomeAssistant, controller: Heos): +def register(hass: HomeAssistant): """Register HEOS services.""" hass.services.async_register( DOMAIN, SERVICE_SIGN_IN, - functools.partial(_sign_in_handler, controller), + _sign_in_handler, schema=HEOS_SIGN_IN_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_SIGN_OUT, - functools.partial(_sign_out_handler, controller), + _sign_out_handler, schema=HEOS_SIGN_OUT_SCHEMA, ) -def remove(hass: HomeAssistant): - """Unregister HEOS services.""" - hass.services.async_remove(DOMAIN, SERVICE_SIGN_IN) - hass.services.async_remove(DOMAIN, SERVICE_SIGN_OUT) +def _get_controller(hass: HomeAssistant) -> Heos: + """Get the HEOS controller instance.""" + + _LOGGER.warning( + "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release" + ) + ir.async_create_issue( + hass, + DOMAIN, + "sign_in_out_deprecated", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="sign_in_out_deprecated", + ) + + entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN) + if not entry or not entry.state == ConfigEntryState.LOADED: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="integration_not_loaded" + ) + return entry.runtime_data.heos -async def _sign_in_handler(controller: Heos, service: ServiceCall) -> None: +async def _sign_in_handler(service: ServiceCall) -> None: """Sign in to the HEOS account.""" - if controller.connection_state != const.STATE_CONNECTED: - _LOGGER.error("Unable to sign in because HEOS is not connected") - return + controller = _get_controller(service.hass) username = service.data[ATTR_USERNAME] password = service.data[ATTR_PASSWORD] try: await controller.sign_in(username, password) - except CommandFailedError as err: + except CommandAuthenticationError as err: _LOGGER.error("Sign in failed: %s", err) except HeosError as err: _LOGGER.error("Unable to sign in: %s", err) -async def _sign_out_handler(controller: Heos, service: ServiceCall) -> None: +async def _sign_out_handler(service: ServiceCall) -> None: """Sign out of the HEOS account.""" - if controller.connection_state != const.STATE_CONNECTED: - _LOGGER.error("Unable to sign out because HEOS is not connected") - return + + controller = _get_controller(service.hass) try: await controller.sign_out() except HeosError as err: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index df18fc7834a..907804d10e1 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -2,27 +2,78 @@ "config": { "step": { "user": { - "title": "Connect to Heos", - "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).", + "title": "Connect to HEOS", + "description": "Please enter the host name or IP address of a HEOS-capable product to access your HEOS System.", "data": { "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of your HEOS device." + "host": "Host name or IP address of a HEOS-capable product (preferrably one connected via wire to the network)." + } + }, + "reconfigure": { + "title": "Reconfigure HEOS", + "description": "Change the host name or IP address of the HEOS-capable product used to access your HEOS System.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::heos::config::step::user::data_description::host%]" + } + }, + "reauth_confirm": { + "title": "Reauthenticate HEOS", + "description": "Please update your HEOS Account credentials. Alternatively, you can clear the credentials if you do not want the integration to access favorites, playlists, and streaming services.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::heos::options::step::init::data_description::username%]", + "password": "[%key:component::heos::options::step::init::data_description::password%]" } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "username_missing": "[%key:component::heos::options::error::username_missing%]", + "password_missing": "[%key:component::heos::options::error::password_missing%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, + "options": { + "step": { + "init": { + "title": "HEOS Options", + "description": "You can sign-in to your HEOS Account to access favorites, streaming services, and other features. Clearing the credentials will sign-out of your account.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The username or email address of your HEOS Account.", + "password": "The password to your HEOS Account." + } + } + }, + "error": { + "username_missing": "Username is missing", + "password_missing": "Password is missing", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, "services": { "sign_in": { "name": "Sign in", - "description": "Signs the controller in to a HEOS account.", + "description": "Signs in to a HEOS account.", "fields": { "username": { "name": "[%key:common::config_flow::data::username%]", @@ -36,7 +87,30 @@ }, "sign_out": { "name": "Sign out", - "description": "Signs the controller out of the HEOS account." + "description": "Signs out of the HEOS account." + } + }, + "exceptions": { + "action_error": { + "message": "Unable to {action}: {error}" + }, + "entity_not_found": { + "message": "Entity {entity_id} was not found" + }, + "integration_not_loaded": { + "message": "The HEOS integration is not loaded" + }, + "not_heos_media_player": { + "message": "Entity {entity_id} is not a HEOS media player entity" + }, + "unknown_source": { + "message": "Unknown source: {source}" + } + }, + "issues": { + "sign_in_out_deprecated": { + "title": "HEOS Actions Deprecated", + "description": "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release. Enter your HEOS Account credentials in the configuration options and the integration will manage authentication automatically." } } } diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index 656ba6c68c0..1dc1eaabcaa 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -155,7 +155,6 @@ class ClimateAehW4a1(ClimateEntity): _attr_target_temperature_step = 1 _previous_state: HVACMode | str | None = None _on: str | None = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device): """Initialize the climate device.""" @@ -196,7 +195,7 @@ class ClimateAehW4a1(ClimateEntity): fan_mode = status["wind_status"] self._attr_fan_mode = AC_TO_HA_FAN_MODES[fan_mode] - swing_mode = f'{status["up_down"]}{status["left_right"]}' + swing_mode = f"{status['up_down']}{status['left_right']}" self._attr_swing_mode = AC_TO_HA_SWING[swing_mode] if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.HEAT): diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 365be06fd2d..ba4614bbc35 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -22,7 +22,7 @@ import homeassistant.util.dt as dt_util from . import websocket_api from .const import DOMAIN -from .helpers import entities_may_have_state_changes_after, has_recorder_run_after +from .helpers import entities_may_have_state_changes_after, has_states_before CONF_ORDER = "use_include_order" @@ -107,11 +107,16 @@ class HistoryPeriodView(HomeAssistantView): no_attributes = "no_attributes" in request.query if ( - (end_time and not has_recorder_run_after(hass, end_time)) - or not include_start_time_state - and entity_ids - and not entities_may_have_state_changes_after( - hass, entity_ids, start_time, no_attributes + # has_states_before will return True if there are states older than + # end_time. If it's false, we know there are no states in the + # database up until end_time. + (end_time and not has_states_before(hass, end_time)) + or ( + not include_start_time_state + and entity_ids + and not entities_may_have_state_changes_after( + hass, entity_ids, start_time, no_attributes + ) ) ): return self.json([]) diff --git a/homeassistant/components/history/helpers.py b/homeassistant/components/history/helpers.py index bd477e7e4ed..2010b7373ff 100644 --- a/homeassistant/components/history/helpers.py +++ b/homeassistant/components/history/helpers.py @@ -6,7 +6,6 @@ from collections.abc import Iterable from datetime import datetime as dt from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import process_timestamp from homeassistant.core import HomeAssistant @@ -26,8 +25,10 @@ def entities_may_have_state_changes_after( return False -def has_recorder_run_after(hass: HomeAssistant, run_time: dt) -> bool: - """Check if the recorder has any runs after a specific time.""" - return run_time >= process_timestamp( - get_instance(hass).recorder_runs_manager.first.start - ) +def has_states_before(hass: HomeAssistant, run_time: dt) -> bool: + """Check if the recorder has states as old or older than run_time. + + Returns True if there may be such states. + """ + oldest_ts = get_instance(hass).states_manager.oldest_ts + return oldest_ts is not None and run_time.timestamp() >= oldest_ts diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index c85d975c3c9..e6c91453213 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -39,7 +39,7 @@ from homeassistant.util.async_ import create_eager_task import homeassistant.util.dt as dt_util from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES -from .helpers import entities_may_have_state_changes_after, has_recorder_run_after +from .helpers import entities_may_have_state_changes_after, has_states_before _LOGGER = logging.getLogger(__name__) @@ -142,11 +142,16 @@ async def ws_get_history_during_period( no_attributes = msg["no_attributes"] if ( - (end_time and not has_recorder_run_after(hass, end_time)) - or not include_start_time_state - and entity_ids - and not entities_may_have_state_changes_after( - hass, entity_ids, start_time, no_attributes + # has_states_before will return True if there are states older than + # end_time. If it's false, we know there are no states in the + # database up until end_time. + (end_time and not has_states_before(hass, end_time)) + or ( + not include_start_time_state + and entity_ids + and not entities_may_have_state_changes_after( + hass, entity_ids, start_time, no_attributes + ) ) ): connection.send_result(msg["id"], {}) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 40cf351fd9e..83528b73f6f 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -4,6 +4,8 @@ from __future__ import annotations from dataclasses import dataclass import datetime +import logging +import math from homeassistant.components.recorder import get_instance, history from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State @@ -14,6 +16,8 @@ from .helpers import async_calculate_period, floored_timestamp MIN_TIME_UTC = datetime.datetime.min.replace(tzinfo=dt_util.UTC) +_LOGGER = logging.getLogger(__name__) + @dataclass class HistoryStatsState: @@ -114,9 +118,7 @@ class HistoryStats: <= current_period_end_timestamp ): self._history_current_period.append( - HistoryState( - new_state.state, new_state.last_changed.timestamp() - ) + HistoryState(new_state.state, new_state.last_changed_timestamp) ) new_data = True if not new_data and current_period_end_timestamp < now_timestamp: @@ -127,6 +129,16 @@ class HistoryStats: await self._async_history_from_db( current_period_start_timestamp, current_period_end_timestamp ) + if event and (new_state := event.data["new_state"]) is not None: + if ( + current_period_start_timestamp + <= floored_timestamp(new_state.last_changed) + <= current_period_end_timestamp + ): + self._history_current_period.append( + HistoryState(new_state.state, new_state.last_changed_timestamp) + ) + self._previous_run_before_start = False seconds_matched, match_count = self._async_compute_seconds_and_changes( @@ -186,8 +198,13 @@ class HistoryStats: current_state_matches = history_state.state in self._entity_states state_change_timestamp = history_state.last_changed - if state_change_timestamp > now_timestamp: + if math.floor(state_change_timestamp) > now_timestamp: # Shouldn't count states that are in the future + _LOGGER.debug( + "Skipping future timestamp %s (now %s)", + state_change_timestamp, + now_timestamp, + ) continue if previous_state_matches: diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 1c11ccad595..ac008b857af 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -10,65 +10,24 @@ from typing import Any, Concatenate from aiohttp.web_exceptions import HTTPException from apyhiveapi import Auth, Hive from apyhiveapi.helper.hive_exceptions import HiveReauthRequired -import voluptuous as vol -from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORM_LOOKUP, PLATFORMS from .entity import HiveEntity _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=2): cv.positive_int, - }, - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Hive configuration setup.""" - hass.data[DOMAIN] = {} - - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - if not hass.config_entries.async_entries(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_USERNAME: conf[CONF_USERNAME], - CONF_PASSWORD: conf[CONF_PASSWORD], - }, - ) - ) - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Hive from a config entry.""" + hass.data.setdefault(DOMAIN, {}) web_session = aiohttp_client.async_get_clientsession(hass) hive_config = dict(entry.data) diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index d14d98bcf50..d2938896f92 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -113,12 +113,17 @@ class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): await self.hive.session.updateData(self.device) self.device = await self.hive.sensor.getSensor(self.device) self.attributes = self.device.get("attributes", {}) - self._attr_is_on = self.device["status"]["state"] + if self.device["hiveType"] != "Connectivity": - self._attr_available = self.device["deviceData"].get("online") + self._attr_available = ( + self.device["deviceData"].get("online") and "status" in self.device + ) else: self._attr_available = True + if self._attr_available: + self._attr_is_on = self.device["status"].get("state") + class HiveSensorEntity(HiveEntity, BinarySensorEntity): """Hive Sensor Entity.""" diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 4e5ea95f2fa..c76379cf940 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -100,7 +100,6 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None: """Initialize the Climate device.""" diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index a997954f4cc..e3180dc9734 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -104,7 +104,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "no_internet_available" if not errors: - if self.context["source"] == SOURCE_REAUTH: + if self.source == SOURCE_REAUTH: return await self.async_setup_hive_entry() self.device_registration = True return await self.async_step_configuration() @@ -144,7 +144,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): # Setup the config entry self.data["tokens"] = self.tokens - if self.context["source"] == SOURCE_REAUTH: + if self.source == SOURCE_REAUTH: assert self.entry self.hass.config_entries.async_update_entry( self.entry, title=self.data["username"], data=self.data @@ -163,10 +163,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): } return await self.async_step_user(data) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import user.""" - return await self.async_step_user(import_data) - @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/hive/entity.py b/homeassistant/components/hive/entity.py index 1209e8c8f05..f5648690201 100644 --- a/homeassistant/components/hive/entity.py +++ b/homeassistant/components/hive/entity.py @@ -21,7 +21,7 @@ class HiveEntity(Entity): self.hive = hive self.device = hive_device self._attr_name = self.device["haName"] - self._attr_unique_id = f'{self.device["hiveID"]}-{self.device["hiveType"]}' + self._attr_unique_id = f"{self.device['hiveID']}-{self.device['hiveType']}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device["device_id"])}, model=self.device["deviceData"]["model"], diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 10de781bf1d..8d09c902f36 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -43,6 +43,9 @@ async def async_setup_entry( class HiveDeviceLight(HiveEntity, LightEntity): """Hive Active Light Device.""" + _attr_min_color_temp_kelvin = 2700 # 370 Mireds + _attr_max_color_temp_kelvin = 6500 # 153 Mireds + def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None: """Initialise hive light.""" super().__init__(hive, hive_device) @@ -56,9 +59,6 @@ class HiveDeviceLight(HiveEntity, LightEntity): self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} self._attr_color_mode = ColorMode.UNKNOWN - self._attr_min_mireds = 153 - self._attr_max_mireds = 370 - @refresh_system async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" @@ -71,9 +71,8 @@ class HiveDeviceLight(HiveEntity, LightEntity): new_brightness = int(round(percentage_brightness / 5.0) * 5.0) if new_brightness == 0: new_brightness = 5 - if ATTR_COLOR_TEMP in kwargs: - tmp_new_color_temp = kwargs[ATTR_COLOR_TEMP] - new_color_temp = round(1000000 / tmp_new_color_temp) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + new_color_temp = kwargs[ATTR_COLOR_TEMP_KELVIN] if ATTR_HS_COLOR in kwargs: get_new_color = kwargs[ATTR_HS_COLOR] hue = int(get_new_color[0]) @@ -102,12 +101,23 @@ class HiveDeviceLight(HiveEntity, LightEntity): self._attr_is_on = self.device["status"]["state"] self._attr_brightness = self.device["status"]["brightness"] if self.device["hiveType"] == "tuneablelight": - self._attr_color_temp = self.device["status"].get("color_temp") + color_temp = self.device["status"].get("color_temp") + self._attr_color_temp_kelvin = ( + None + if color_temp is None + else color_util.color_temperature_mired_to_kelvin(color_temp) + ) + if self.device["hiveType"] == "colourtuneablelight": if self.device["status"]["mode"] == "COLOUR": rgb = self.device["status"]["hs_color"] self._attr_hs_color = color_util.color_RGB_to_hs(*rgb) self._attr_color_mode = ColorMode.HS else: - self._attr_color_temp = self.device["status"].get("color_temp") + color_temp = self.device["status"].get("color_temp") + self._attr_color_temp_kelvin = ( + None + if color_temp is None + else color_util.color_temperature_mired_to_kelvin(color_temp) + ) self._attr_color_mode = ColorMode.COLOR_TEMP diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 870223f8fe6..f68478516ab 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhiveapi==0.5.16"] + "requirements": ["pyhive-integration==1.0.1"] } diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index c8062a64ade..219776ad7e6 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -35,7 +35,7 @@ }, "error": { "invalid_username": "Failed to sign into Hive. Your email address is not recognised.", - "invalid_password": "Failed to sign into Hive. Incorrect password please try again.", + "invalid_password": "Failed to sign into Hive. Incorrect password, please try again.", "invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.", "no_internet_available": "An internet connection is required to connect to Hive.", "unknown": "[%key:common::config_flow::error::unknown%]" @@ -52,7 +52,7 @@ "title": "Options for Hive", "description": "Update the scan interval to poll for data more often.", "data": { - "scan_interval": "Scan Interval (seconds)" + "scan_interval": "Scan interval (seconds)" } } } @@ -60,15 +60,15 @@ "services": { "boost_heating_on": { "name": "Boost heating on", - "description": "Sets the boost mode ON defining the period of time and the desired target temperature for the boost.", + "description": "Sets the boost mode ON, defining the period of time and the desired target temperature for the boost.", "fields": { "time_period": { - "name": "Time Period", - "description": "Set the time period for the boost." + "name": "[%key:component::hive::services::boost_hot_water::fields::time_period::name%]", + "description": "[%key:component::hive::services::boost_hot_water::fields::time_period::description%]" }, "temperature": { "name": "Temperature", - "description": "Set the target temperature for the boost period." + "description": "The target temperature for the boost period." } } }, @@ -78,21 +78,21 @@ "fields": { "entity_id": { "name": "Entity ID", - "description": "Select entity_id to turn boost off." + "description": "The entity ID to turn boost off." } } }, "boost_hot_water": { "name": "Boost hotwater", - "description": "Sets the boost mode ON or OFF defining the period of time for the boost.", + "description": "Sets the boost mode ON or OFF, defining the period of time for the boost.", "fields": { "entity_id": { "name": "Entity ID", - "description": "Select entity_id to boost." + "description": "The entity ID to boost." }, "time_period": { "name": "Time period", - "description": "Set the time period for the boost." + "description": "The time period for the boost." }, "on_off": { "name": "[%key:common::config_flow::data::mode%]", diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py index c9a58f29215..b364f2c67a4 100644 --- a/homeassistant/components/holiday/__init__.py +++ b/homeassistant/components/holiday/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_COUNTRY, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import SetupPhases, async_pause_setup -from .const import CONF_PROVINCE +from .const import CONF_CATEGORIES, CONF_PROVINCE PLATFORMS: list[Platform] = [Platform.CALENDAR] @@ -20,6 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Holiday from a config entry.""" country: str = entry.data[CONF_COUNTRY] province: str | None = entry.data.get(CONF_PROVINCE) + categories: list[str] | None = entry.options.get(CONF_CATEGORIES) # We only import here to ensure that that its not imported later # in the event loop since the platforms will call country_holidays @@ -29,14 +30,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # the holidays library and it is not thread safe to import it in parallel # https://github.com/python/cpython/issues/83065 await hass.async_add_import_executor_job( - partial(country_holidays, country, subdiv=province) + partial(country_holidays, country, subdiv=province, categories=categories) ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py index 6a336870857..6dccd972164 100644 --- a/homeassistant/components/holiday/calendar.py +++ b/homeassistant/components/holiday/calendar.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta -from holidays import HolidayBase, country_holidays +from holidays import PUBLIC, HolidayBase, country_holidays from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.config_entries import ConfigEntry @@ -15,18 +15,27 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util -from .const import CONF_PROVINCE, DOMAIN +from .const import CONF_CATEGORIES, CONF_PROVINCE, DOMAIN def _get_obj_holidays_and_language( - country: str, province: str | None, language: str + country: str, + province: str | None, + language: str, + selected_categories: list[str] | None, ) -> tuple[HolidayBase, str]: """Get the object for the requested country and year.""" + if selected_categories is None: + categories = [PUBLIC] + else: + categories = [PUBLIC, *selected_categories] + obj_holidays = country_holidays( country, subdiv=province, years={dt_util.now().year, dt_util.now().year + 1}, language=language, + categories=categories, ) if language == "en": for lang in obj_holidays.supported_languages: @@ -36,6 +45,7 @@ def _get_obj_holidays_and_language( subdiv=province, years={dt_util.now().year, dt_util.now().year + 1}, language=lang, + categories=categories, ) language = lang break @@ -49,6 +59,7 @@ def _get_obj_holidays_and_language( subdiv=province, years={dt_util.now().year, dt_util.now().year + 1}, language=default_language, + categories=categories, ) language = default_language @@ -63,10 +74,11 @@ async def async_setup_entry( """Set up the Holiday Calendar config entry.""" country: str = config_entry.data[CONF_COUNTRY] province: str | None = config_entry.data.get(CONF_PROVINCE) + categories: list[str] | None = config_entry.options.get(CONF_CATEGORIES) language = hass.config.language obj_holidays, language = await hass.async_add_executor_job( - _get_obj_holidays_and_language, country, province, language + _get_obj_holidays_and_language, country, province, language, categories ) async_add_entities( @@ -76,6 +88,7 @@ async def async_setup_entry( country, province, language, + categories, obj_holidays, config_entry.entry_id, ) @@ -99,6 +112,7 @@ class HolidayCalendarEntity(CalendarEntity): country: str, province: str | None, language: str, + categories: list[str] | None, obj_holidays: HolidayBase, unique_id: str, ) -> None: @@ -107,6 +121,7 @@ class HolidayCalendarEntity(CalendarEntity): self._province = province self._location = name self._language = language + self._categories = categories self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, @@ -172,6 +187,7 @@ class HolidayCalendarEntity(CalendarEntity): subdiv=self._province, years=list({start_date.year, end_date.year}), language=self._language, + categories=self._categories, ) event_list: list[CalendarEvent] = [] diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 27b13e34851..538d9971109 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -5,24 +5,103 @@ from __future__ import annotations from typing import Any from babel import Locale, UnknownLocaleError -from holidays import list_supported_countries +from holidays import PUBLIC, country_holidays, list_supported_countries import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_COUNTRY +from homeassistant.core import callback from homeassistant.helpers.selector import ( CountrySelector, CountrySelectorConfig, + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, ) +from homeassistant.util import dt as dt_util -from .const import CONF_PROVINCE, DOMAIN +from .const import CONF_CATEGORIES, CONF_PROVINCE, DOMAIN SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False) +def get_optional_provinces(country: str) -> list[Any]: + """Return the country provinces (territories). + + Some territories can have extra or different holidays + from another within the same country. + Some territories can have different names (aliases). + """ + province_options: list[Any] = [] + + if provinces := SUPPORTED_COUNTRIES[country]: + country_data = country_holidays(country, years=dt_util.utcnow().year) + if country_data.subdivisions_aliases and ( + subdiv_aliases := country_data.get_subdivision_aliases() + ): + province_options = [ + SelectOptionDict(value=k, label=", ".join(v)) + for k, v in subdiv_aliases.items() + ] + else: + province_options = provinces + + return province_options + + +def get_optional_categories(country: str) -> list[str]: + """Return the country categories. + + public holidays are always included so they + don't need to be presented to the user. + """ + country_data = country_holidays(country, years=dt_util.utcnow().year) + return [ + category for category in country_data.supported_categories if category != PUBLIC + ] + + +def get_options_schema(country: str) -> vol.Schema: + """Return the options schema.""" + schema = {} + if provinces := get_optional_provinces(country): + schema[vol.Optional(CONF_PROVINCE)] = SelectSelector( + SelectSelectorConfig( + options=provinces, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + if categories := get_optional_categories(country): + schema[vol.Optional(CONF_CATEGORIES)] = SelectSelector( + SelectSelectorConfig( + options=categories, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="categories", + ) + ) + return vol.Schema(schema) + + +def get_entry_name(language: str, country: str, province: str | None) -> str: + """Generate the entity name from the user language and location.""" + try: + locale = Locale.parse(language, sep="-") + except UnknownLocaleError: + # Default to (US) English if language not recognized by babel + # Mainly an issue with English flavors such as "en-GB" + locale = Locale("en") + country_str = locale.territories[country] # blocking I/O + province_str = f", {province}" if province else "" + return f"{country_str}{province_str}" + + class HolidayConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Holiday.""" @@ -32,6 +111,12 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize the config flow.""" self.data: dict[str, Any] = {} + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> HolidayOptionsFlowHandler: + """Get the options flow for this handler.""" + return HolidayOptionsFlowHandler() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -41,8 +126,11 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN): selected_country = user_input[CONF_COUNTRY] - if SUPPORTED_COUNTRIES[selected_country]: - return await self.async_step_province() + options_schema = await self.hass.async_add_executor_job( + get_options_schema, selected_country + ) + if options_schema.schema: + return await self.async_step_options() self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) @@ -67,91 +155,112 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN): } ) - return self.async_show_form(step_id="user", data_schema=user_schema) + return self.async_show_form(data_schema=user_schema) - async def async_step_province( + async def async_step_options( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the province step.""" + """Handle the options step.""" if user_input is not None: - combined_input: dict[str, Any] = {**self.data, **user_input} + country = self.data[CONF_COUNTRY] + data = {CONF_COUNTRY: country} + options: dict[str, Any] | None = None + if province := user_input.get(CONF_PROVINCE): + data[CONF_PROVINCE] = province + if categories := user_input.get(CONF_CATEGORIES): + options = {CONF_CATEGORIES: categories} - country = combined_input[CONF_COUNTRY] - province = combined_input.get(CONF_PROVINCE) + self._async_abort_entries_match({**data, **(options or {})}) - self._async_abort_entries_match( - { - CONF_COUNTRY: country, - CONF_PROVINCE: province, - } + name = await self.hass.async_add_executor_job( + get_entry_name, self.hass.config.language, country, province ) + return self.async_create_entry(title=name, data=data, options=options) - try: - locale = Locale.parse(self.hass.config.language, sep="-") - except UnknownLocaleError: - # Default to (US) English if language not recognized by babel - # Mainly an issue with English flavors such as "en-GB" - locale = Locale("en") - province_str = f", {province}" if province else "" - name = f"{locale.territories[country]}{province_str}" - - return self.async_create_entry(title=name, data=combined_input) - - province_schema = vol.Schema( - { - vol.Optional(CONF_PROVINCE): SelectSelector( - SelectSelectorConfig( - options=SUPPORTED_COUNTRIES[self.data[CONF_COUNTRY]], - mode=SelectSelectorMode.DROPDOWN, - ) - ), - } + options_schema = await self.hass.async_add_executor_job( + get_options_schema, self.data[CONF_COUNTRY] + ) + return self.async_show_form( + step_id="options", + data_schema=options_schema, + description_placeholders={CONF_COUNTRY: self.data[CONF_COUNTRY]}, ) - - return self.async_show_form(step_id="province", data_schema=province_schema) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the re-configuration of a province.""" + """Handle the re-configuration of the options.""" reconfigure_entry = self._get_reconfigure_entry() + if user_input is not None: - combined_input: dict[str, Any] = {**reconfigure_entry.data, **user_input} + country = reconfigure_entry.data[CONF_COUNTRY] + data = {CONF_COUNTRY: country} + options: dict[str, Any] | None = None + if province := user_input.get(CONF_PROVINCE): + data[CONF_PROVINCE] = province + if categories := user_input.get(CONF_CATEGORIES): + options = {CONF_CATEGORIES: categories} - country = combined_input[CONF_COUNTRY] - province = combined_input.get(CONF_PROVINCE) + self._async_abort_entries_match({**data, **(options or {})}) - self._async_abort_entries_match( - { - CONF_COUNTRY: country, - CONF_PROVINCE: province, - } + name = await self.hass.async_add_executor_job( + get_entry_name, self.hass.config.language, country, province ) - try: - locale = Locale.parse(self.hass.config.language, sep="-") - except UnknownLocaleError: - # Default to (US) English if language not recognized by babel - # Mainly an issue with English flavors such as "en-GB" - locale = Locale("en") - province_str = f", {province}" if province else "" - name = f"{locale.territories[country]}{province_str}" - + if options: + return self.async_update_reload_and_abort( + reconfigure_entry, title=name, data=data, options=options + ) return self.async_update_reload_and_abort( - reconfigure_entry, title=name, data=combined_input + reconfigure_entry, title=name, data=data ) - province_schema = vol.Schema( + options_schema = await self.hass.async_add_executor_job( + get_options_schema, reconfigure_entry.data[CONF_COUNTRY] + ) + + return self.async_show_form( + data_schema=options_schema, + description_placeholders={ + CONF_COUNTRY: reconfigure_entry.data[CONF_COUNTRY] + }, + ) + + +class HolidayOptionsFlowHandler(OptionsFlow): + """Handle Holiday options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage Holiday options.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + categories = await self.hass.async_add_executor_job( + get_optional_categories, self.config_entry.data[CONF_COUNTRY] + ) + if not categories: + return self.async_abort(reason="no_categories") + + schema = vol.Schema( { - vol.Optional(CONF_PROVINCE): SelectSelector( + vol.Optional(CONF_CATEGORIES): SelectSelector( SelectSelectorConfig( - options=SUPPORTED_COUNTRIES[ - reconfigure_entry.data[CONF_COUNTRY] - ], + options=categories, + multiple=True, mode=SelectSelectorMode.DROPDOWN, + translation_key="categories", ) ) } ) - return self.async_show_form(step_id="reconfigure", data_schema=province_schema) + return self.async_show_form( + data_schema=self.add_suggested_values_to_schema( + schema, self.config_entry.options + ), + description_placeholders={ + CONF_COUNTRY: self.config_entry.data[CONF_COUNTRY] + }, + ) diff --git a/homeassistant/components/holiday/const.py b/homeassistant/components/holiday/const.py index ed283f82412..6a28ae1ffec 100644 --- a/homeassistant/components/holiday/const.py +++ b/homeassistant/components/holiday/const.py @@ -5,3 +5,4 @@ from typing import Final DOMAIN: Final = "holiday" CONF_PROVINCE: Final = "province" +CONF_CATEGORIES: Final = "categories" diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index a3c0a4514d3..edf3ebe7f04 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.61", "babel==2.15.0"] + "requirements": ["holidays==0.65", "babel==2.15.0"] } diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json index ae4930ecdb4..d464f9e8bfd 100644 --- a/homeassistant/components/holiday/strings.json +++ b/homeassistant/components/holiday/strings.json @@ -2,7 +2,7 @@ "title": "Holiday", "config": { "abort": { - "already_configured": "Already configured. Only a single configuration for country/province combination possible.", + "already_configured": "Already configured. Only a single configuration for country/province/categories combination is possible.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "step": { @@ -11,16 +11,62 @@ "country": "Country" } }, - "province": { + "options": { "data": { - "province": "Province" + "province": "Province", + "categories": "Categories" + }, + "data_description": { + "province": "Optionally choose a province / subdivision of {country}", + "categories": "Optionally choose additional holiday categories, public holidays are already included" } }, "reconfigure": { "data": { - "province": "[%key:component::holiday::config::step::province::data::province%]" + "province": "[%key:component::holiday::config::step::options::data::province%]", + "categories": "[%key:component::holiday::config::step::options::data::categories%]" + }, + "data_description": { + "province": "[%key:component::holiday::config::step::options::data_description::province%]", + "categories": "[%key:component::holiday::config::step::options::data_description::categories%]" } } } + }, + "options": { + "abort": { + "already_configured": "[%key:component::holiday::config::abort::already_configured%]", + "no_categories": "The country has no additional categories to configure." + }, + "step": { + "init": { + "data": { + "categories": "[%key:component::holiday::config::step::options::data::categories%]" + }, + "data_description": { + "categories": "[%key:component::holiday::config::step::options::data_description::categories%]" + } + } + } + }, + "selector": { + "device_class": { + "options": { + "armed_forces": "Armed forces", + "bank": "Bank", + "catholic": "Catholic", + "chinese": "Chinese", + "christian": "Christian", + "government": "Government", + "half_day": "Half day", + "hebrew": "Hebrew", + "hindu": "Hindu", + "islamic": "Islamic", + "optional": "Optional", + "school": "School", + "unofficial": "Unofficial", + "workday": "Workday" + } + } } } diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 2c351f4dfa1..d7c042c2a91 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging +import re from typing import Any, cast from requests import HTTPError @@ -12,6 +13,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, @@ -38,12 +40,17 @@ from .const import ( SERVICE_SELECT_PROGRAM, SERVICE_SETTING, SERVICE_START_PROGRAM, + SVE_TRANSLATION_PLACEHOLDER_KEY, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + SVE_TRANSLATION_PLACEHOLDER_VALUE, ) type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth] _LOGGER = logging.getLogger(__name__) +RE_CAMEL_CASE = re.compile(r"(? api.HomeConnectAppliance: + """Return a Home Connect appliance instance or raise a service validation error.""" + try: + return _get_appliance(hass, device_id) + except (ValueError, AssertionError) as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="appliance_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) from err + + +async def _run_appliance_service[*_Ts]( + hass: HomeAssistant, + appliance: api.HomeConnectAppliance, + method: str, + *args: *_Ts, + error_translation_key: str, + error_translation_placeholders: dict[str, str], +) -> None: + try: + await hass.async_add_executor_job(getattr(appliance, method), *args) + except api.HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + **error_translation_placeholders, + }, + ) from err + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" @@ -154,16 +199,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: option[ATTR_UNIT] = option_unit options.append(option) - - appliance = _get_appliance(hass, device_id) - await hass.async_add_executor_job(getattr(appliance, method), program, options) + await _run_appliance_service( + hass, + _get_appliance_or_raise_service_validation_error(hass, device_id), + method, + program, + options, + error_translation_key=method, + error_translation_placeholders={ + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, + }, + ) async def _async_service_command(call, command): """Execute calls to services executing a command.""" device_id = call.data[ATTR_DEVICE_ID] - appliance = _get_appliance(hass, device_id) - await hass.async_add_executor_job(appliance.execute_command, command) + appliance = _get_appliance_or_raise_service_validation_error(hass, device_id) + await _run_appliance_service( + hass, + appliance, + "execute_command", + command, + error_translation_key="execute_command", + error_translation_placeholders={"command": command}, + ) async def _async_service_key_value(call, method): """Execute calls to services taking a key and value.""" @@ -172,20 +232,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: unit = call.data.get(ATTR_UNIT) device_id = call.data[ATTR_DEVICE_ID] - appliance = _get_appliance(hass, device_id) - if unit is not None: - await hass.async_add_executor_job( - getattr(appliance, method), - key, - value, - unit, - ) - else: - await hass.async_add_executor_job( - getattr(appliance, method), - key, - value, - ) + await _run_appliance_service( + hass, + _get_appliance_or_raise_service_validation_error(hass, device_id), + method, + *((key, value) if unit is None else (key, value, unit)), + error_translation_key=method, + error_translation_placeholders={ + SVE_TRANSLATION_PLACEHOLDER_KEY: key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) async def async_service_option_active(call): """Service for setting an option for an active program.""" @@ -336,3 +393,14 @@ def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any if len(err.args) > 0 and isinstance(err.args[0], str) else "?", } + + +def bsh_key_to_translation_key(bsh_key: str) -> str: + """Convert a BSH key to a translation key format. + + This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, + and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. + """ + return "_".join( + RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") + ).lower() diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index e49a56b9b97..e20cf3b1fa0 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -5,10 +5,23 @@ DOMAIN = "home_connect" OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize" OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token" +APPLIANCES_WITH_PROGRAMS = ( + "CleaningRobot", + "CoffeeMaker", + "Dishwasher", + "Dryer", + "Hood", + "Oven", + "WarmingDrawer", + "Washer", + "WasherDryer", +) + BSH_POWER_STATE = "BSH.Common.Setting.PowerState" BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" +BSH_SELECTED_PROGRAM = "BSH.Common.Root.SelectedProgram" BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" @@ -114,9 +127,12 @@ ATTR_STEPSIZE = "stepsize" ATTR_UNIT = "unit" ATTR_VALUE = "value" +SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id" -SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY = "setting_key" +SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program" +SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index d2505853d23..e095bc503ab 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from homeconnect.api import HomeConnectAppliance +from homeconnect.api import HomeConnectAppliance, HomeConnectError from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -14,9 +14,14 @@ from .api import HomeConnectDevice def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]: + try: + programs = appliance.get_programs_available() + except HomeConnectError: + programs = None return { + "connected": appliance.connected, "status": appliance.status, - "programs": appliance.get_programs_available(), + "programs": programs, } diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 97efc0413ab..e33017cd51f 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( LightEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util @@ -150,7 +150,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): self.device.appliance.set_setting, self.bsh_key, True ) except HomeConnectError as err: - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_on_light", translation_placeholders={ @@ -169,7 +169,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): self._enable_custom_color_value_key, ) except HomeConnectError as err: - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="select_light_custom_color", translation_placeholders={ @@ -187,7 +187,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): f"#{hex_val}", ) except HomeConnectError as err: - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="set_light_color", translation_placeholders={ @@ -219,7 +219,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): f"#{hex_val}", ) except HomeConnectError as err: - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="set_light_color", translation_placeholders={ @@ -244,7 +244,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): self.device.appliance.set_setting, self._brightness_key, brightness ) except HomeConnectError as err: - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="set_light_brightness", translation_placeholders={ @@ -263,7 +263,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): self.device.appliance.set_setting, self.bsh_key, False ) except HomeConnectError as err: - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off_light", translation_placeholders={ diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index d1063a2026f..0703b4772bb 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -12,7 +12,7 @@ from homeassistant.components.number import ( NumberEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeConnectConfigEntry, get_dict_from_home_connect_error @@ -22,8 +22,9 @@ from .const import ( ATTR_UNIT, ATTR_VALUE, DOMAIN, + SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .entity import HomeConnectEntity @@ -117,13 +118,13 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): value, ) except HomeConnectError as err: - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="set_setting", + translation_key=SVE_TRANSLATION_KEY_SET_SETTING, translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), }, ) from err diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py new file mode 100644 index 00000000000..a4a5861afbe --- /dev/null +++ b/homeassistant/components/home_connect/select.py @@ -0,0 +1,301 @@ +"""Provides a select platform for Home Connect.""" + +import contextlib +import logging + +from homeconnect.api import HomeConnectError + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ( + HomeConnectConfigEntry, + bsh_key_to_translation_key, + get_dict_from_home_connect_error, +) +from .api import HomeConnectDevice +from .const import ( + APPLIANCES_WITH_PROGRAMS, + ATTR_VALUE, + BSH_ACTIVE_PROGRAM, + BSH_SELECTED_PROGRAM, + DOMAIN, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM, +) +from .entity import HomeConnectEntity + +_LOGGER = logging.getLogger(__name__) + +TRANSLATION_KEYS_PROGRAMS_MAP = { + bsh_key_to_translation_key(program): program + for program in ( + "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll", + "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanMap", + "ConsumerProducts.CleaningRobot.Program.Basic.GoHome", + "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto", + "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso", + "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio", + "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee", + "ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee", + "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeGrande", + "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato", + "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino", + "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato", + "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte", + "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth", + "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Doppio", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye", + "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye", + "ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater", + "Dishcare.Dishwasher.Program.PreRinse", + "Dishcare.Dishwasher.Program.Auto1", + "Dishcare.Dishwasher.Program.Auto2", + "Dishcare.Dishwasher.Program.Auto3", + "Dishcare.Dishwasher.Program.Eco50", + "Dishcare.Dishwasher.Program.Quick45", + "Dishcare.Dishwasher.Program.Intensiv70", + "Dishcare.Dishwasher.Program.Normal65", + "Dishcare.Dishwasher.Program.Glas40", + "Dishcare.Dishwasher.Program.GlassCare", + "Dishcare.Dishwasher.Program.NightWash", + "Dishcare.Dishwasher.Program.Quick65", + "Dishcare.Dishwasher.Program.Normal45", + "Dishcare.Dishwasher.Program.Intensiv45", + "Dishcare.Dishwasher.Program.AutoHalfLoad", + "Dishcare.Dishwasher.Program.IntensivPower", + "Dishcare.Dishwasher.Program.MagicDaily", + "Dishcare.Dishwasher.Program.Super60", + "Dishcare.Dishwasher.Program.Kurz60", + "Dishcare.Dishwasher.Program.ExpressSparkle65", + "Dishcare.Dishwasher.Program.MachineCare", + "Dishcare.Dishwasher.Program.SteamFresh", + "Dishcare.Dishwasher.Program.MaximumCleaning", + "Dishcare.Dishwasher.Program.MixedLoad", + "LaundryCare.Dryer.Program.Cotton", + "LaundryCare.Dryer.Program.Synthetic", + "LaundryCare.Dryer.Program.Mix", + "LaundryCare.Dryer.Program.Blankets", + "LaundryCare.Dryer.Program.BusinessShirts", + "LaundryCare.Dryer.Program.DownFeathers", + "LaundryCare.Dryer.Program.Hygiene", + "LaundryCare.Dryer.Program.Jeans", + "LaundryCare.Dryer.Program.Outdoor", + "LaundryCare.Dryer.Program.SyntheticRefresh", + "LaundryCare.Dryer.Program.Towels", + "LaundryCare.Dryer.Program.Delicates", + "LaundryCare.Dryer.Program.Super40", + "LaundryCare.Dryer.Program.Shirts15", + "LaundryCare.Dryer.Program.Pillow", + "LaundryCare.Dryer.Program.AntiShrink", + "LaundryCare.Dryer.Program.MyTime.MyDryingTime", + "LaundryCare.Dryer.Program.TimeCold", + "LaundryCare.Dryer.Program.TimeWarm", + "LaundryCare.Dryer.Program.InBasket", + "LaundryCare.Dryer.Program.TimeColdFix.TimeCold20", + "LaundryCare.Dryer.Program.TimeColdFix.TimeCold30", + "LaundryCare.Dryer.Program.TimeColdFix.TimeCold60", + "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm30", + "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm40", + "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm60", + "LaundryCare.Dryer.Program.Dessous", + "Cooking.Common.Program.Hood.Automatic", + "Cooking.Common.Program.Hood.Venting", + "Cooking.Common.Program.Hood.DelayedShutOff", + "Cooking.Oven.Program.HeatingMode.PreHeating", + "Cooking.Oven.Program.HeatingMode.HotAir", + "Cooking.Oven.Program.HeatingMode.HotAirEco", + "Cooking.Oven.Program.HeatingMode.HotAirGrilling", + "Cooking.Oven.Program.HeatingMode.TopBottomHeating", + "Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco", + "Cooking.Oven.Program.HeatingMode.BottomHeating", + "Cooking.Oven.Program.HeatingMode.PizzaSetting", + "Cooking.Oven.Program.HeatingMode.SlowCook", + "Cooking.Oven.Program.HeatingMode.IntensiveHeat", + "Cooking.Oven.Program.HeatingMode.KeepWarm", + "Cooking.Oven.Program.HeatingMode.PreheatOvenware", + "Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial", + "Cooking.Oven.Program.HeatingMode.Desiccation", + "Cooking.Oven.Program.HeatingMode.Defrost", + "Cooking.Oven.Program.HeatingMode.Proof", + "Cooking.Oven.Program.HeatingMode.HotAir30Steam", + "Cooking.Oven.Program.HeatingMode.HotAir60Steam", + "Cooking.Oven.Program.HeatingMode.HotAir80Steam", + "Cooking.Oven.Program.HeatingMode.HotAir100Steam", + "Cooking.Oven.Program.HeatingMode.SabbathProgramme", + "Cooking.Oven.Program.Microwave.90Watt", + "Cooking.Oven.Program.Microwave.180Watt", + "Cooking.Oven.Program.Microwave.360Watt", + "Cooking.Oven.Program.Microwave.600Watt", + "Cooking.Oven.Program.Microwave.900Watt", + "Cooking.Oven.Program.Microwave.1000Watt", + "Cooking.Oven.Program.Microwave.Max", + "Cooking.Oven.Program.HeatingMode.WarmingDrawer", + "LaundryCare.Washer.Program.Cotton", + "LaundryCare.Washer.Program.Cotton.CottonEco", + "LaundryCare.Washer.Program.Cotton.Eco4060", + "LaundryCare.Washer.Program.Cotton.Colour", + "LaundryCare.Washer.Program.EasyCare", + "LaundryCare.Washer.Program.Mix", + "LaundryCare.Washer.Program.Mix.NightWash", + "LaundryCare.Washer.Program.DelicatesSilk", + "LaundryCare.Washer.Program.Wool", + "LaundryCare.Washer.Program.Sensitive", + "LaundryCare.Washer.Program.Auto30", + "LaundryCare.Washer.Program.Auto40", + "LaundryCare.Washer.Program.Auto60", + "LaundryCare.Washer.Program.Chiffon", + "LaundryCare.Washer.Program.Curtains", + "LaundryCare.Washer.Program.DarkWash", + "LaundryCare.Washer.Program.Dessous", + "LaundryCare.Washer.Program.Monsoon", + "LaundryCare.Washer.Program.Outdoor", + "LaundryCare.Washer.Program.PlushToy", + "LaundryCare.Washer.Program.ShirtsBlouses", + "LaundryCare.Washer.Program.SportFitness", + "LaundryCare.Washer.Program.Towels", + "LaundryCare.Washer.Program.WaterProof", + "LaundryCare.Washer.Program.PowerSpeed59", + "LaundryCare.Washer.Program.Super153045.Super15", + "LaundryCare.Washer.Program.Super153045.Super1530", + "LaundryCare.Washer.Program.DownDuvet.Duvet", + "LaundryCare.Washer.Program.Rinse.RinseSpinDrain", + "LaundryCare.Washer.Program.DrumClean", + "LaundryCare.WasherDryer.Program.Cotton", + "LaundryCare.WasherDryer.Program.Cotton.Eco4060", + "LaundryCare.WasherDryer.Program.Mix", + "LaundryCare.WasherDryer.Program.EasyCare", + "LaundryCare.WasherDryer.Program.WashAndDry60", + "LaundryCare.WasherDryer.Program.WashAndDry90", + ) +} + +PROGRAMS_TRANSLATION_KEYS_MAP = { + value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() +} + +PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( + SelectEntityDescription( + key=BSH_ACTIVE_PROGRAM, + translation_key="active_program", + ), + SelectEntityDescription( + key=BSH_SELECTED_PROGRAM, + translation_key="selected_program", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomeConnectConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Home Connect select entities.""" + + def get_entities() -> list[HomeConnectProgramSelectEntity]: + """Get a list of entities.""" + entities: list[HomeConnectProgramSelectEntity] = [] + programs_not_found = set() + for device in entry.runtime_data.devices: + if device.appliance.type in APPLIANCES_WITH_PROGRAMS: + with contextlib.suppress(HomeConnectError): + programs = device.appliance.get_programs_available() + if programs: + for program in programs.copy(): + if program not in PROGRAMS_TRANSLATION_KEYS_MAP: + programs.remove(program) + if program not in programs_not_found: + _LOGGER.info( + 'The program "%s" is not part of the official Home Connect API specification', + program, + ) + programs_not_found.add(program) + entities.extend( + HomeConnectProgramSelectEntity(device, programs, desc) + for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS + ) + return entities + + async_add_entities(await hass.async_add_executor_job(get_entities), True) + + +class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): + """Select class for Home Connect programs.""" + + def __init__( + self, + device: HomeConnectDevice, + programs: list[str], + desc: SelectEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__( + device, + desc, + ) + self._attr_options = [ + PROGRAMS_TRANSLATION_KEYS_MAP[program] for program in programs + ] + self.start_on_select = desc.key == BSH_ACTIVE_PROGRAM + + async def async_update(self) -> None: + """Update the program selection status.""" + program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) + if not program: + program_translation_key = None + elif not ( + program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program) + ): + _LOGGER.debug( + 'The program "%s" is not part of the official Home Connect API specification', + program, + ) + self._attr_current_option = program_translation_key + _LOGGER.debug("Updated, new program: %s", self._attr_current_option) + + async def async_select_option(self, option: str) -> None: + """Select new program.""" + bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] + _LOGGER.debug( + "Starting program: %s" if self.start_on_select else "Selecting program: %s", + bsh_key, + ) + if self.start_on_select: + target = self.device.appliance.start_program + else: + target = self.device.appliance.select_program + try: + await self.hass.async_add_executor_job(target, bsh_key) + except HomeConnectError as err: + if self.start_on_select: + translation_key = "start_program" + else: + translation_key = "select_program" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key, + }, + ) from err + self.async_entity_update() diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 3ccf55bac6e..7b82ef8b676 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,13 +1,10 @@ """Provides a sensor for Home Connect.""" -import contextlib from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import cast -from homeconnect.api import HomeConnectError - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -22,6 +19,7 @@ import homeassistant.util.dt as dt_util from . import HomeConnectConfigEntry from .const import ( + APPLIANCES_WITH_PROGRAMS, ATTR_VALUE, BSH_DOOR_STATE, BSH_OPERATION_STATE, @@ -51,27 +49,35 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription): default_value: str | None = None appliance_types: tuple[str, ...] | None = None - sign: int = 1 BSH_PROGRAM_SENSORS = ( HomeConnectSensorEntityDescription( key="BSH.Common.Option.RemainingProgramTime", device_class=SensorDeviceClass.TIMESTAMP, - sign=1, translation_key="program_finish_time", + appliance_types=( + "CoffeMaker", + "CookProcessor", + "Dishwasher", + "Dryer", + "Hood", + "Oven", + "Washer", + "WasherDryer", + ), ), HomeConnectSensorEntityDescription( key="BSH.Common.Option.Duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, - sign=1, + appliance_types=("Oven",), ), HomeConnectSensorEntityDescription( key="BSH.Common.Option.ProgramProgress", native_unit_of_measurement=PERCENTAGE, - sign=1, translation_key="program_progress", + appliance_types=APPLIANCES_WITH_PROGRAMS, ), ) @@ -269,11 +275,12 @@ async def async_setup_entry( if description.appliance_types and device.appliance.type in description.appliance_types ) - with contextlib.suppress(HomeConnectError): - if device.appliance.get_programs_available(): - entities.extend( - HomeConnectSensor(device, desc) for desc in BSH_PROGRAM_SENSORS - ) + entities.extend( + HomeConnectProgramSensor(device, desc) + for desc in BSH_PROGRAM_SENSORS + if desc.appliance_types + and device.appliance.type in desc.appliance_types + ) entities.extend( HomeConnectSensor(device, description) for description in SENSORS @@ -289,11 +296,6 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): entity_description: HomeConnectSensorEntityDescription - @property - def available(self) -> bool: - """Return true if the sensor is available.""" - return self._attr_native_value is not None - async def async_update(self) -> None: """Update the sensor's status.""" appliance_status = self.device.appliance.status @@ -311,30 +313,17 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): self._attr_native_value = None elif ( self._attr_native_value is not None - and self.entity_description.sign == 1 and isinstance(self._attr_native_value, datetime) and self._attr_native_value < dt_util.utcnow() ): # if the date is supposed to be in the future but we're # already past it, set state to None. self._attr_native_value = None - elif ( - BSH_OPERATION_STATE - in (appliance_status := self.device.appliance.status) - and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] - and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] - in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] - ): - seconds = self.entity_description.sign * float(status[ATTR_VALUE]) + else: + seconds = float(status[ATTR_VALUE]) self._attr_native_value = dt_util.utcnow() + timedelta( seconds=seconds ) - else: - self._attr_native_value = None case SensorDeviceClass.ENUM: # Value comes back as an enum, we only really care about the # last part, so split it off @@ -345,3 +334,34 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): case _: self._attr_native_value = status.get(ATTR_VALUE) _LOGGER.debug("Updated, new state: %s", self._attr_native_value) + + +class HomeConnectProgramSensor(HomeConnectSensor): + """Sensor class for Home Connect sensors that reports information related to the running program.""" + + program_running: bool = False + + @property + def available(self) -> bool: + """Return true if the sensor is available.""" + # These sensors are only available if the program is running, paused or finished. + # Otherwise, some sensors report erroneous values. + return super().available and self.program_running + + async def async_update(self) -> None: + """Update the sensor's status.""" + self.program_running = ( + BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status) + and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] + and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] + in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + ) + if self.program_running: + await super().async_update() + else: + # reset the value when the program is not running, paused or finished + self._attr_native_value = None diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 934aed5b7d5..7ededaae5b7 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -22,41 +22,62 @@ } }, "exceptions": { + "appliance_not_found": { + "message": "Appliance for device ID {device_id} not found" + }, "turn_on_light": { - "message": "Error while trying to turn on {entity_id}: {description}" + "message": "Error turning on {entity_id}: {description}" }, "turn_off_light": { - "message": "Error while trying to turn off {entity_id}: {description}" + "message": "Error turning off {entity_id}: {description}" }, "set_light_brightness": { - "message": "Error while trying to set brightness of {entity_id}: {description}" + "message": "Error setting brightness of {entity_id}: {description}" }, "select_light_custom_color": { - "message": "Error while trying to select custom color of {entity_id}: {description}" + "message": "Error selecting custom color of {entity_id}: {description}" }, "set_light_color": { - "message": "Error while trying to set color of {entity_id}: {description}" + "message": "Error setting color of {entity_id}: {description}" + }, + "set_setting_entity": { + "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" }, "set_setting": { - "message": "Error while trying to assign the value \"{value}\" to the setting \"{setting_key}\" for {entity_id}: {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {description}" }, "turn_on": { - "message": "Error while trying to turn on {entity_id} ({setting_key}): {description}" + "message": "Error turning on {entity_id} ({key}): {description}" }, "turn_off": { - "message": "Error while trying to turn off {entity_id} ({setting_key}): {description}" + "message": "Error turning off {entity_id} ({key}): {description}" + }, + "select_program": { + "message": "Error selecting program {program}: {description}" }, "start_program": { - "message": "Error while trying to start program {program}: {description}" + "message": "Error starting program {program}: {description}" + }, + "pause_program": { + "message": "Error pausing program: {description}" }, "stop_program": { - "message": "Error while trying to stop program {program}: {description}" + "message": "Error stopping program: {description}" + }, + "set_options_active_program": { + "message": "Error setting options for the active program: {description}" + }, + "set_options_selected_program": { + "message": "Error setting options for the selected program: {description}" + }, + "execute_command": { + "message": "Error executing command {command}: {description}" }, "power_on": { - "message": "Error while trying to turn on {appliance_name}: {description}" + "message": "Error turning on {appliance_name}: {description}" }, "power_off": { - "message": "Error while trying to turn off {appliance_name} with value \"{value}\": {description}" + "message": "Error turning off {appliance_name} with value \"{value}\": {description}" }, "turn_off_not_supported": { "message": "{appliance_name} does not support turning off or entering standby mode." @@ -69,6 +90,10 @@ "deprecated_binary_common_door_sensor": { "title": "Deprecated binary door sensor detected in some automations or scripts", "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." + }, + "deprecated_program_switch": { + "title": "Deprecated program switch detected in some automations or scripts", + "description": "Program switch are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use active program select entity to run the program without any additional option and get the current running program on the above automations or scripts to fix this issue." } }, "services": { @@ -78,7 +103,7 @@ "fields": { "device_id": { "name": "Device ID", - "description": "Id of the device." + "description": "ID of the device." }, "program": { "name": "Program", "description": "Program to select." }, "key": { "name": "Option key", "description": "Key of the option." }, @@ -267,6 +292,326 @@ "name": "Wine compartment 3 temperature" } }, + "select": { + "selected_program": { + "name": "Selected program", + "state": { + "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map", + "consumer_products_cleaning_robot_program_basic_go_home": "Go home", + "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto", + "consumer_products_coffee_maker_program_beverage_espresso": "Espresso", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio", + "consumer_products_coffee_maker_program_beverage_coffee": "Coffee", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato", + "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte", + "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", + "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white", + "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait", + "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd", + "consumer_products_coffee_maker_program_coffee_world_galao": "Galao", + "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto", + "consumer_products_coffee_maker_program_coffee_world_americano": "Americano", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye", + "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water", + "dishcare_dishwasher_program_pre_rinse": "Pre_rinse", + "dishcare_dishwasher_program_auto_1": "Auto 1", + "dishcare_dishwasher_program_auto_2": "Auto 2", + "dishcare_dishwasher_program_auto_3": "Auto 3", + "dishcare_dishwasher_program_eco_50": "Eco 50ºC", + "dishcare_dishwasher_program_quick_45": "Quick 45ºC", + "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC", + "dishcare_dishwasher_program_normal_65": "Normal 65ºC", + "dishcare_dishwasher_program_glas_40": "Glass 40ºC", + "dishcare_dishwasher_program_glass_care": "Glass care", + "dishcare_dishwasher_program_night_wash": "Night wash", + "dishcare_dishwasher_program_quick_65": "Quick 65ºC", + "dishcare_dishwasher_program_normal_45": "Normal 45ºC", + "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC", + "dishcare_dishwasher_program_auto_half_load": "Auto half load", + "dishcare_dishwasher_program_intensiv_power": "Intensive power", + "dishcare_dishwasher_program_magic_daily": "Magic daily", + "dishcare_dishwasher_program_super_60": "Super 60ºC", + "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC", + "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC", + "dishcare_dishwasher_program_machine_care": "Machine care", + "dishcare_dishwasher_program_steam_fresh": "Steam fresh", + "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning", + "dishcare_dishwasher_program_mixed_load": "Mixed load", + "laundry_care_dryer_program_cotton": "Cotton", + "laundry_care_dryer_program_synthetic": "Synthetic", + "laundry_care_dryer_program_mix": "Mix", + "laundry_care_dryer_program_blankets": "Blankets", + "laundry_care_dryer_program_business_shirts": "Business shirts", + "laundry_care_dryer_program_down_feathers": "Down feathers", + "laundry_care_dryer_program_hygiene": "Hygiene", + "laundry_care_dryer_program_jeans": "Jeans", + "laundry_care_dryer_program_outdoor": "Outdoor", + "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh", + "laundry_care_dryer_program_towels": "Towels", + "laundry_care_dryer_program_delicates": "Delicates", + "laundry_care_dryer_program_super_40": "Super 40ºC", + "laundry_care_dryer_program_shirts_15": "Shirts 15ºC", + "laundry_care_dryer_program_pillow": "Pillow", + "laundry_care_dryer_program_anti_shrink": "Anti shrink", + "laundry_care_dryer_program_my_time_my_drying_time": "My drying time", + "laundry_care_dryer_program_time_cold": "Cold (variable time)", + "laundry_care_dryer_program_time_warm": "Warm (variable time)", + "laundry_care_dryer_program_in_basket": "In basket", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)", + "laundry_care_dryer_program_dessous": "Dessous", + "cooking_common_program_hood_automatic": "Automatic", + "cooking_common_program_hood_venting": "Venting", + "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", + "cooking_oven_program_heating_mode_pre_heating": "Pre-heating", + "cooking_oven_program_heating_mode_hot_air": "Hot air", + "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco", + "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling", + "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco", + "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", + "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting", + "cooking_oven_program_heating_mode_slow_cook": "Slow cook", + "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat", + "cooking_oven_program_heating_mode_keep_warm": "Keep warm", + "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", + "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products", + "cooking_oven_program_heating_mode_desiccation": "Desiccation", + "cooking_oven_program_heating_mode_defrost": "Defrost", + "cooking_oven_program_heating_mode_proof": "Proof", + "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH", + "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH", + "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH", + "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH", + "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme", + "cooking_oven_program_microwave_90_watt": "90 Watt", + "cooking_oven_program_microwave_180_watt": "180 Watt", + "cooking_oven_program_microwave_360_watt": "360 Watt", + "cooking_oven_program_microwave_600_watt": "600 Watt", + "cooking_oven_program_microwave_900_watt": "900 Watt", + "cooking_oven_program_microwave_1000_watt": "1000 Watt", + "cooking_oven_program_microwave_max": "Max", + "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer", + "laundry_care_washer_program_cotton": "Cotton", + "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco", + "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC", + "laundry_care_washer_program_cotton_colour": "Cotton color", + "laundry_care_washer_program_easy_care": "Easy care", + "laundry_care_washer_program_mix": "Mix", + "laundry_care_washer_program_mix_night_wash": "Mix night wash", + "laundry_care_washer_program_delicates_silk": "Delicates silk", + "laundry_care_washer_program_wool": "Wool", + "laundry_care_washer_program_sensitive": "Sensitive", + "laundry_care_washer_program_auto_30": "Auto 30ºC", + "laundry_care_washer_program_auto_40": "Auto 40ºC", + "laundry_care_washer_program_auto_60": "Auto 60ºC", + "laundry_care_washer_program_chiffon": "Chiffon", + "laundry_care_washer_program_curtains": "Curtains", + "laundry_care_washer_program_dark_wash": "Dark wash", + "laundry_care_washer_program_dessous": "Dessous", + "laundry_care_washer_program_monsoon": "Monsoon", + "laundry_care_washer_program_outdoor": "Outdoor", + "laundry_care_washer_program_plush_toy": "Plush toy", + "laundry_care_washer_program_shirts_blouses": "Shirts blouses", + "laundry_care_washer_program_sport_fitness": "Sport fitness", + "laundry_care_washer_program_towels": "Towels", + "laundry_care_washer_program_water_proof": "Water proof", + "laundry_care_washer_program_power_speed_59": "Power speed <60 min", + "laundry_care_washer_program_super_153045_super_15": "Super 15 min", + "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min", + "laundry_care_washer_program_down_duvet_duvet": "Down duvet", + "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain", + "laundry_care_washer_program_drum_clean": "Drum clean", + "laundry_care_washer_dryer_program_cotton": "Cotton", + "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60 ºC", + "laundry_care_washer_dryer_program_mix": "Mix", + "laundry_care_washer_dryer_program_easy_care": "Easy care", + "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)", + "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)" + } + }, + "active_program": { + "name": "Active program", + "state": { + "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_all%]", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_map%]", + "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_basic_go_home%]", + "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_ristretto%]", + "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso%]", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_doppio%]", + "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_coffee%]", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_x_l_coffee%]", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_grande%]", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]", + "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_cappuccino%]", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_latte_macchiato%]", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_latte%]", + "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_milk_froth%]", + "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_warm_milk%]", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_flat_white%]", + "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]", + "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_doppio%]", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kaapi%]", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]", + "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_galao%]", + "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_garoto%]", + "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_americano%]", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_red_eye%]", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_black_eye%]", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_dead_eye%]", + "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_hot_water%]", + "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_pre_rinse%]", + "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_1%]", + "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_2%]", + "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_3%]", + "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_eco_50%]", + "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_45%]", + "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_70%]", + "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_65%]", + "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glas_40%]", + "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glass_care%]", + "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_night_wash%]", + "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_65%]", + "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_45%]", + "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_45%]", + "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_half_load%]", + "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_power%]", + "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_magic_daily%]", + "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_super_60%]", + "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_kurz_60%]", + "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_express_sparkle_65%]", + "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_machine_care%]", + "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_steam_fresh%]", + "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_maximum_cleaning%]", + "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_mixed_load%]", + "laundry_care_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_cotton%]", + "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic%]", + "laundry_care_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_mix%]", + "laundry_care_dryer_program_blankets": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_blankets%]", + "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_business_shirts%]", + "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_down_feathers%]", + "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_hygiene%]", + "laundry_care_dryer_program_jeans": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_jeans%]", + "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_outdoor%]", + "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic_refresh%]", + "laundry_care_dryer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_towels%]", + "laundry_care_dryer_program_delicates": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_delicates%]", + "laundry_care_dryer_program_super_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_super_40%]", + "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_shirts_15%]", + "laundry_care_dryer_program_pillow": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_pillow%]", + "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_anti_shrink%]", + "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_my_time_my_drying_time%]", + "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold%]", + "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm%]", + "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_in_basket%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_20%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_30%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_60%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_30%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_40%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_60%]", + "laundry_care_dryer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_dessous%]", + "cooking_common_program_hood_automatic": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_automatic%]", + "cooking_common_program_hood_venting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_venting%]", + "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_delayed_shut_off%]", + "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pre_heating%]", + "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air%]", + "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_eco%]", + "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_grilling%]", + "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating%]", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating_eco%]", + "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_bottom_heating%]", + "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pizza_setting%]", + "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_slow_cook%]", + "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_intensive_heat%]", + "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_keep_warm%]", + "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_preheat_ovenware%]", + "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_frozen_heatup_special%]", + "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_desiccation%]", + "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_defrost%]", + "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_proof%]", + "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_30_steam%]", + "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_60_steam%]", + "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_80_steam%]", + "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_100_steam%]", + "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_sabbath_programme%]", + "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_90_watt%]", + "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_180_watt%]", + "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_360_watt%]", + "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_600_watt%]", + "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_900_watt%]", + "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_1000_watt%]", + "cooking_oven_program_microwave_max": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_max%]", + "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_warming_drawer%]", + "laundry_care_washer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton%]", + "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_cotton_eco%]", + "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_eco_4060%]", + "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_colour%]", + "laundry_care_washer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_easy_care%]", + "laundry_care_washer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix%]", + "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix_night_wash%]", + "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_delicates_silk%]", + "laundry_care_washer_program_wool": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_wool%]", + "laundry_care_washer_program_sensitive": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sensitive%]", + "laundry_care_washer_program_auto_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_30%]", + "laundry_care_washer_program_auto_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_40%]", + "laundry_care_washer_program_auto_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_60%]", + "laundry_care_washer_program_chiffon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_chiffon%]", + "laundry_care_washer_program_curtains": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_curtains%]", + "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dark_wash%]", + "laundry_care_washer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dessous%]", + "laundry_care_washer_program_monsoon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_monsoon%]", + "laundry_care_washer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_outdoor%]", + "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_plush_toy%]", + "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_shirts_blouses%]", + "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sport_fitness%]", + "laundry_care_washer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_towels%]", + "laundry_care_washer_program_water_proof": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_water_proof%]", + "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_power_speed_59%]", + "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_15%]", + "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_1530%]", + "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_down_duvet_duvet%]", + "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_rinse_rinse_spin_drain%]", + "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_drum_clean%]", + "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton%]", + "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton_eco_4060%]", + "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_mix%]", + "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_easy_care%]", + "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_60%]", + "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_90%]" + } + } + }, "sensor": { "program_progress": { "name": "Program progress" diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index cad6e810816..1bd02e03eb1 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -6,19 +6,27 @@ from typing import Any from homeconnect.api import HomeConnectError +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( + APPLIANCES_WITH_PROGRAMS, ATTR_ALLOWED_VALUES, ATTR_CONSTRAINTS, ATTR_VALUE, BSH_ACTIVE_PROGRAM, BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, @@ -29,25 +37,13 @@ from .const import ( REFRIGERATION_SUPERMODEREFRIGERATOR, SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .entity import HomeConnectDevice, HomeConnectEntity _LOGGER = logging.getLogger(__name__) -APPLIANCES_WITH_PROGRAMS = ( - "CleaningRobot", - "CoffeeMaker", - "Dishwasher", - "Dryer", - "Hood", - "Oven", - "WarmingDrawer", - "Washer", - "WasherDryer", -) - SWITCHES = ( SwitchEntityDescription( @@ -101,6 +97,12 @@ SWITCHES = ( ) +POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( + key=BSH_POWER_STATE, + translation_key="power", +) + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -120,7 +122,8 @@ async def async_setup_entry( HomeConnectProgramSwitch(device, program) for program in programs ) - entities.append(HomeConnectPowerSwitch(device)) + if BSH_POWER_STATE in device.appliance.status: + entities.append(HomeConnectPowerSwitch(device)) entities.extend( HomeConnectSwitch(device, description) for description in SWITCHES @@ -145,13 +148,13 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): ) except HomeConnectError as err: self._attr_available = False - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_on", translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, }, ) from err @@ -169,13 +172,13 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): except HomeConnectError as err: _LOGGER.error("Error while trying to turn off: %s", err) self._attr_available = False - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off", translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, }, ) from err @@ -212,6 +215,55 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): self._attr_has_entity_name = False self.program_name = program_name + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + automations = automations_with_entity(self.hass, self.entity_id) + scripts = scripts_with_entity(self.hass, self.entity_id) + items = automations + scripts + if not items: + return + + entity_reg: er.EntityRegistry = er.async_get(self.hass) + entity_automations = [ + automation_entity + for automation_id in automations + if (automation_entity := entity_reg.async_get(automation_id)) + ] + entity_scripts = [ + script_entity + for script_id in scripts + if (script_entity := entity_reg.async_get(script_id)) + ] + + items_list = [ + f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" + for item in entity_automations + ] + [ + f"- [{item.original_name}](/config/script/edit/{item.unique_id})" + for item in entity_scripts + ] + + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_program_switch_{self.entity_id}", + breaks_in_ha_version="2025.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_program_switch", + translation_placeholders={ + "entity_id": self.entity_id, + "items": "\n".join(items_list), + }, + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + async_delete_issue( + self.hass, DOMAIN, f"deprecated_program_switch_{self.entity_id}" + ) + async def async_turn_on(self, **kwargs: Any) -> None: """Start the program.""" _LOGGER.debug("Tried to turn on program %s", self.program_name) @@ -220,7 +272,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): self.device.appliance.start_program, self.program_name ) except HomeConnectError as err: - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="start_program", translation_placeholders={ @@ -236,12 +288,11 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): try: await self.hass.async_add_executor_job(self.device.appliance.stop_program) except HomeConnectError as err: - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="stop_program", translation_placeholders={ **get_dict_from_home_connect_error(err), - "program": self.program_name, }, ) from err self.async_entity_update() @@ -265,7 +316,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): """Initialize the entity.""" super().__init__( device, - SwitchEntityDescription(key=BSH_POWER_STATE, translation_key="power"), + POWER_SWITCH_DESCRIPTION, ) if ( power_state := device.appliance.status.get(BSH_POWER_STATE, {}).get( @@ -289,7 +340,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): ) except HomeConnectError as err: self._attr_is_on = False - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="power_on", translation_placeholders={ @@ -302,7 +353,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Switch the device off.""" if not hasattr(self, "power_off_state"): - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="unable_to_retrieve_turn_off", translation_placeholders={ @@ -311,7 +362,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): ) if self.power_off_state is None: - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off_not_supported", translation_placeholders={ @@ -327,7 +378,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): ) except HomeConnectError as err: self._attr_is_on = True - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, translation_key="power_off", translation_placeholders={ @@ -351,23 +402,6 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): == self.power_off_state ): self._attr_is_on = False - elif self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get( - ATTR_VALUE, None - ) in [ - "BSH.Common.EnumType.OperationState.Ready", - "BSH.Common.EnumType.OperationState.DelayedStart", - "BSH.Common.EnumType.OperationState.Run", - "BSH.Common.EnumType.OperationState.Pause", - "BSH.Common.EnumType.OperationState.ActionRequired", - "BSH.Common.EnumType.OperationState.Aborting", - "BSH.Common.EnumType.OperationState.Finished", - ]: - self._attr_is_on = True - elif ( - self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get(ATTR_VALUE) - == "BSH.Common.EnumType.OperationState.Inactive" - ): - self._attr_is_on = False else: self._attr_is_on = None _LOGGER.debug("Updated, new state: %s", self._attr_is_on) diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index f28339b3595..c1f125cd2f7 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -7,15 +7,16 @@ from homeconnect.api import HomeConnectError from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( ATTR_VALUE, DOMAIN, + SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .entity import HomeConnectEntity @@ -80,13 +81,13 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): time_to_seconds(value), ) except HomeConnectError as err: - raise ServiceValidationError( + raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="set_setting", + translation_key=SVE_TRANSLATION_KEY_SET_SETTING, translation_placeholders={ **get_dict_from_home_connect_error(err), SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_SETTING_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), }, ) from err diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index da8a1015d79..3283d480fdd 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -10,6 +10,10 @@ "title": "The country has not been configured", "description": "No country has been configured, please update the configuration by clicking on the \"learn more\" button below." }, + "imperial_unit_system": { + "title": "The imperial unit system is deprecated", + "description": "The imperial unit system is deprecated and your system is currently using us customary. Please update your configuration to use the us customary unit system and reload the core configuration to fix this issue." + }, "deprecated_yaml": { "title": "The {integration_title} YAML configuration is being removed", "description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." @@ -224,6 +228,9 @@ "service_not_found": { "message": "Action {domain}.{service} not found." }, + "service_not_supported": { + "message": "Entity {entity_id} does not support action {domain}.{service}." + }, "service_does_not_support_response": { "message": "An action which does not return responses can't be called with {return_response}." }, diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index bea6e8a66a7..5cd1921d8a8 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -42,7 +42,7 @@ _TIME_AT_SCHEMA = vol.Any(cv.time, _TIME_TRIGGER_ENTITY) _TIME_TRIGGER_ENTITY_WITH_OFFSET = vol.Schema( { - vol.Required(CONF_ENTITY_ID): cv.entity_domain(["sensor"]), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(["input_datetime", "sensor"]), vol.Optional(CONF_OFFSET): cv.time_period, } ) @@ -156,14 +156,17 @@ async def async_attach_trigger( if has_date: # If input_datetime has date, then track point in time. - trigger_dt = datetime( - year, - month, - day, - hour, - minute, - second, - tzinfo=dt_util.get_default_time_zone(), + trigger_dt = ( + datetime( + year, + month, + day, + hour, + minute, + second, + tzinfo=dt_util.get_default_time_zone(), + ) + + offset ) # Only set up listener if time is now or in the future. if trigger_dt >= dt_util.now(): @@ -178,6 +181,17 @@ async def async_attach_trigger( ) elif has_time: # Else if it has time, then track time change. + if offset != timedelta(0): + # Create a temporary datetime object to get an offset. + temp_dt = dt_util.now().replace( + hour=hour, minute=minute, second=second, microsecond=0 + ) + temp_dt += offset + # Ignore the date and apply the offset even if it wraps + # around to the next day. + hour = temp_dt.hour + minute = temp_dt.minute + second = temp_dt.second remove = async_track_time_change( hass, partial( diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index a91fb00c142..fac3d2d9735 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -7,17 +7,12 @@ import asyncio import logging from typing import Any -from universal_silabs_flasher.const import ApplicationType - from homeassistant.components.hassio import ( AddonError, AddonInfo, AddonManager, AddonState, ) -from homeassistant.components.zha.repairs.wrong_silabs_firmware import ( - probe_silabs_firmware_type, -) from homeassistant.config_entries import ( ConfigEntry, ConfigEntryBaseFlow, @@ -32,9 +27,11 @@ from homeassistant.helpers.hassio import is_hassio from . import silabs_multiprotocol_addon from .const import ZHA_DOMAIN from .util import ( + ApplicationType, get_otbr_addon_manager, get_zha_device_path, get_zigbee_flasher_addon_manager, + probe_silabs_firmware_type, ) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index f692094bc67..2efa12ccfda 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -1,8 +1,9 @@ { "domain": "homeassistant_hardware", "name": "Home Assistant Hardware", - "after_dependencies": ["hassio", "zha"], + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", - "integration_type": "system" + "integration_type": "system", + "requirements": ["universal-silabs-flasher==0.0.25"] } diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 0c06ff05e5c..3fd5bc60037 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -3,11 +3,14 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Iterable from dataclasses import dataclass +from enum import StrEnum import logging from typing import cast -from universal_silabs_flasher.const import ApplicationType +from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType +from universal_silabs_flasher.flasher import Flasher from homeassistant.components.hassio import AddonError, AddonState from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -32,6 +35,26 @@ from .silabs_multiprotocol_addon import ( _LOGGER = logging.getLogger(__name__) +class ApplicationType(StrEnum): + """Application type running on a device.""" + + GECKO_BOOTLOADER = "bootloader" + CPC = "cpc" + EZSP = "ezsp" + SPINEL = "spinel" + + @classmethod + def from_flasher_application_type( + cls, app_type: FlasherApplicationType + ) -> ApplicationType: + """Convert a USF application type enum.""" + return cls(app_type.value) + + def as_flasher_application_type(self) -> FlasherApplicationType: + """Convert the application type enum into one compatible with USF.""" + return FlasherApplicationType(self.value) + + def get_zha_device_path(config_entry: ConfigEntry) -> str | None: """Get the device path from a ZHA config entry.""" return cast(str | None, config_entry.data.get("device", {}).get("path", None)) @@ -137,3 +160,27 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware assert guesses return guesses[-1] + + +async def probe_silabs_firmware_type( + device: str, *, probe_methods: Iterable[ApplicationType] | None = None +) -> ApplicationType | None: + """Probe the running firmware on a Silabs device.""" + flasher = Flasher( + device=device, + **( + {"probe_methods": [m.as_flasher_application_type() for m in probe_methods]} + if probe_methods + else {} + ), + ) + + try: + await flasher.probe_app_type() + except Exception: # noqa: BLE001 + _LOGGER.debug("Failed to probe application type", exc_info=True) + + if flasher.app_type is None: + return None + + return ApplicationType.from_flasher_application_type(flasher.app_type) diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 5c35732312b..b3b4f68ba96 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -5,13 +5,12 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any, Protocol -from universal_silabs_flasher.const import ApplicationType - from homeassistant.components import usb from homeassistant.components.homeassistant_hardware import ( firmware_config_flow, silabs_multiprotocol_addon, ) +from homeassistant.components.homeassistant_hardware.util import ApplicationType from homeassistant.config_entries import ( ConfigEntry, ConfigEntryBaseFlow, @@ -20,6 +19,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DOCS_WEB_FLASHER_URL, DOMAIN, HardwareVariant from .util import get_hardware_variant, get_usb_service_info @@ -70,7 +70,7 @@ class HomeAssistantSkyConnectConfigFlow( """Initialize the config flow.""" super().__init__(*args, **kwargs) - self._usb_info: usb.UsbServiceInfo | None = None + self._usb_info: UsbServiceInfo | None = None self._hw_variant: HardwareVariant | None = None @staticmethod @@ -86,9 +86,7 @@ class HomeAssistantSkyConnectConfigFlow( return HomeAssistantSkyConnectOptionsFlowHandler(config_entry) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle usb discovery.""" device = discovery_info.device vid = discovery_info.vid @@ -146,11 +144,8 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( self, ) -> silabs_multiprotocol_addon.SerialPortSettings: """Return the radio serial port settings.""" - usb_dev = self.config_entry.data["device"] - # The call to get_serial_by_id can be removed in HA Core 2024.1 - dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) return silabs_multiprotocol_addon.SerialPortSettings( - device=dev_path, + device=self.config_entry.data["device"], baudrate="115200", flow_control=True, ) diff --git a/homeassistant/components/homeassistant_sky_connect/util.py b/homeassistant/components/homeassistant_sky_connect/util.py index f8c5d004d0e..c463c1b9275 100644 --- a/homeassistant/components/homeassistant_sky_connect/util.py +++ b/homeassistant/components/homeassistant_sky_connect/util.py @@ -4,17 +4,17 @@ from __future__ import annotations import logging -from homeassistant.components import usb from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import HardwareVariant _LOGGER = logging.getLogger(__name__) -def get_usb_service_info(config_entry: ConfigEntry) -> usb.UsbServiceInfo: +def get_usb_service_info(config_entry: ConfigEntry) -> UsbServiceInfo: """Return UsbServiceInfo.""" - return usb.UsbServiceInfo( + return UsbServiceInfo( device=config_entry.data["device"], vid=config_entry.data["vid"], pid=config_entry.data["pid"], diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 9edc5009171..502a20db07c 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -8,14 +8,13 @@ import logging from typing import Any, final import aiohttp -from universal_silabs_flasher.const import ApplicationType import voluptuous as vol from homeassistant.components.hassio import ( HassioAPIError, async_get_yellow_settings, - async_reboot_host, async_set_yellow_settings, + get_supervisor_client, ) from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( BaseFirmwareConfigFlow, @@ -25,13 +24,14 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon OptionsFlowHandler as MultiprotocolOptionsFlowHandler, SerialPortSettings as MultiprotocolSerialPortSettings, ) +from homeassistant.components.homeassistant_hardware.util import ApplicationType from homeassistant.config_entries import ( SOURCE_HARDWARE, ConfigEntry, ConfigFlowResult, OptionsFlow, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, async_get_hass, callback from homeassistant.helpers import discovery_flow, selector from .const import DOMAIN, FIRMWARE, RADIO_DEVICE, ZHA_DOMAIN, ZHA_HW_DISCOVERY_DATA @@ -67,11 +67,12 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN): ) -> OptionsFlow: """Return the options flow.""" firmware_type = ApplicationType(config_entry.data[FIRMWARE]) + hass = async_get_hass() if firmware_type is ApplicationType.CPC: - return HomeAssistantYellowMultiPanOptionsFlowHandler(config_entry) + return HomeAssistantYellowMultiPanOptionsFlowHandler(hass, config_entry) - return HomeAssistantYellowOptionsFlowHandler(config_entry) + return HomeAssistantYellowOptionsFlowHandler(hass, config_entry) async def async_step_system( self, data: dict[str, Any] | None = None @@ -107,6 +108,11 @@ class BaseHomeAssistantYellowOptionsFlow(OptionsFlow, ABC): _hw_settings: dict[str, bool] | None = None + def __init__(self, hass: HomeAssistant, *args: Any, **kwargs: Any) -> None: + """Instantiate options flow.""" + super().__init__(*args, **kwargs) + self._supervisor_client = get_supervisor_client(hass) + @abstractmethod async def async_step_main_menu(self, _: None = None) -> ConfigFlowResult: """Show the main menu.""" @@ -172,7 +178,7 @@ class BaseHomeAssistantYellowOptionsFlow(OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Reboot now.""" - await async_reboot_host(self.hass) + await self._supervisor_client.host.reboot() return self.async_create_entry(data={}) async def async_step_reboot_later( @@ -251,9 +257,9 @@ class HomeAssistantYellowOptionsFlowHandler( ): """Handle a firmware options flow for Home Assistant Yellow.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__(self, hass: HomeAssistant, *args: Any, **kwargs: Any) -> None: """Instantiate options flow.""" - super().__init__(*args, **kwargs) + super().__init__(hass, *args, **kwargs) self._hardware_name = BOARD_NAME self._device = RADIO_DEVICE diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py new file mode 100644 index 00000000000..1ec09e09694 --- /dev/null +++ b/homeassistant/components/homee/__init__.py @@ -0,0 +1,85 @@ +"""The Homee integration.""" + +import logging + +from pyHomee import Homee, HomeeAuthFailedException, HomeeConnectionFailedException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.COVER, Platform.SENSOR] + +type HomeeConfigEntry = ConfigEntry[Homee] + + +async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool: + """Set up homee from a config entry.""" + # Create the Homee api object using host, user, + # password & pyHomee instance from the config + homee = Homee( + host=entry.data[CONF_HOST], + user=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + device="HA_" + hass.config.location_name, + reconnect_interval=10, + max_retries=100, + ) + + # Start the homee websocket connection as a new task + # and wait until we are connected + try: + await homee.get_access_token() + except HomeeConnectionFailedException as exc: + raise ConfigEntryNotReady( + f"Connection to Homee failed: {exc.__cause__}" + ) from exc + except HomeeAuthFailedException as exc: + raise ConfigEntryNotReady( + f"Authentication to Homee failed: {exc.__cause__}" + ) from exc + + hass.loop.create_task(homee.run()) + await homee.wait_until_connected() + + entry.runtime_data = homee + entry.async_on_unload(homee.disconnect) + + async def _connection_update_callback(connected: bool) -> None: + """Call when the device is notified of changes.""" + if connected: + _LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST]) + else: + _LOGGER.warning("Disconnected from Homee at %s", entry.data[CONF_HOST]) + + await homee.add_connection_listener(_connection_update_callback) + + # create device register entry + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={ + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(homee.settings.mac_address)) + }, + identifiers={(DOMAIN, homee.settings.uid)}, + manufacturer="homee", + name=homee.settings.homee_name, + model="homee", + sw_version=homee.settings.version, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool: + """Unload a homee config entry.""" + # Unload platforms + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py new file mode 100644 index 00000000000..61d2a3f25a5 --- /dev/null +++ b/homeassistant/components/homee/config_flow.py @@ -0,0 +1,85 @@ +"""Config flow for homee integration.""" + +import logging +from typing import Any + +from pyHomee import ( + Homee, + HomeeAuthFailedException as HomeeAuthenticationFailedException, + HomeeConnectionFailedException, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +AUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for homee.""" + + VERSION = 1 + + homee: Homee + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial user step.""" + + errors = {} + if user_input is not None: + self.homee = Homee( + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + try: + await self.homee.get_access_token() + except HomeeConnectionFailedException: + errors["base"] = "cannot_connect" + except HomeeAuthenticationFailedException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + _LOGGER.info("Got access token for homee") + self.hass.loop.create_task(self.homee.run()) + _LOGGER.debug("Homee task created") + await self.homee.wait_until_connected() + _LOGGER.info("Homee connected") + self.homee.disconnect() + _LOGGER.debug("Homee disconnecting") + await self.homee.wait_until_disconnected() + _LOGGER.info("Homee config successfully tested") + + await self.async_set_unique_id(self.homee.settings.uid) + + self._abort_if_unique_id_configured() + + _LOGGER.info( + "Created new homee entry with ID %s", self.homee.settings.uid + ) + + return self.async_create_entry( + title=f"{self.homee.settings.homee_name} ({self.homee.host})", + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=AUTH_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py new file mode 100644 index 00000000000..d1d5be97ef7 --- /dev/null +++ b/homeassistant/components/homee/const.py @@ -0,0 +1,62 @@ +"""Constants for the homee integration.""" + +from homeassistant.const import ( + LIGHT_LUX, + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfSpeed, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, +) + +# General +DOMAIN = "homee" + +# Sensor mappings +HOMEE_UNIT_TO_HA_UNIT = { + "": None, + "n/a": None, + "text": None, + "%": PERCENTAGE, + "lx": LIGHT_LUX, + "klx": LIGHT_LUX, + "1/min": REVOLUTIONS_PER_MINUTE, + "A": UnitOfElectricCurrent.AMPERE, + "V": UnitOfElectricPotential.VOLT, + "kWh": UnitOfEnergy.KILO_WATT_HOUR, + "W": UnitOfPower.WATT, + "m/s": UnitOfSpeed.METERS_PER_SECOND, + "km/h": UnitOfSpeed.KILOMETERS_PER_HOUR, + "°F": UnitOfTemperature.FAHRENHEIT, + "°C": UnitOfTemperature.CELSIUS, + "K": UnitOfTemperature.KELVIN, + "s": UnitOfTime.SECONDS, + "min": UnitOfTime.MINUTES, + "h": UnitOfTime.HOURS, + "L": UnitOfVolume.LITERS, +} +OPEN_CLOSE_MAP = { + 0.0: "open", + 1.0: "closed", + 2.0: "partial", + 3.0: "opening", + 4.0: "closing", +} +OPEN_CLOSE_MAP_REVERSED = { + 0.0: "closed", + 1.0: "open", + 2.0: "partial", + 3.0: "cosing", + 4.0: "opening", +} +WINDOW_MAP = { + 0.0: "closed", + 1.0: "open", + 2.0: "tilted", +} +WINDOW_MAP_REVERSED = {0.0: "open", 1.0: "closed", 2.0: "tilted"} diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py new file mode 100644 index 00000000000..b4a853f7c35 --- /dev/null +++ b/homeassistant/components/homee/cover.py @@ -0,0 +1,277 @@ +"""The homee cover platform.""" + +import logging +from typing import Any, cast + +from pyHomee.const import AttributeType, NodeProfile +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeNodeEntity + +_LOGGER = logging.getLogger(__name__) + +OPEN_CLOSE_ATTRIBUTES = [ + AttributeType.OPEN_CLOSE, + AttributeType.SLAT_ROTATION_IMPULSE, + AttributeType.UP_DOWN, +] +POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION] + + +def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None: + """Return the attribute used for opening/closing the cover.""" + # We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them. + if (open_close := node.get_attribute_by_type(AttributeType.UP_DOWN)) is None: + open_close = node.get_attribute_by_type(AttributeType.OPEN_CLOSE) + + return open_close + + +def get_cover_features( + node: HomeeNode, open_close_attribute: HomeeAttribute | None +) -> CoverEntityFeature: + """Determine the supported cover features of a homee node based on the available attributes.""" + features = CoverEntityFeature(0) + + if (open_close_attribute is not None) and open_close_attribute.editable: + features |= ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + # Check for up/down position settable. + attribute = node.get_attribute_by_type(AttributeType.POSITION) + if attribute is not None: + if attribute.editable: + features |= CoverEntityFeature.SET_POSITION + + if node.get_attribute_by_type(AttributeType.SLAT_ROTATION_IMPULSE) is not None: + features |= CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT + + if node.get_attribute_by_type(AttributeType.SHUTTER_SLAT_POSITION) is not None: + features |= CoverEntityFeature.SET_TILT_POSITION + + return features + + +def get_device_class(node: HomeeNode) -> CoverDeviceClass | None: + """Determine the device class a homee node based on the node profile.""" + COVER_DEVICE_PROFILES = { + NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE, + NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER, + } + + return COVER_DEVICE_PROFILES.get(node.profile) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Add the homee platform for the cover integration.""" + + async_add_devices( + HomeeCover(node, config_entry) + for node in config_entry.runtime_data.nodes + if is_cover_node(node) + ) + + +def is_cover_node(node: HomeeNode) -> bool: + """Determine if a node is controllable as a homee cover based on its profile and attributes.""" + return node.profile in [ + NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH, + NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION, + NodeProfile.GARAGE_DOOR_OPERATOR, + NodeProfile.SHUTTER_POSITION_SWITCH, + ] + + +class HomeeCover(HomeeNodeEntity, CoverEntity): + """Representation of a homee cover device.""" + + _attr_name = None + + def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + """Initialize a homee cover entity.""" + super().__init__(node, entry) + self._open_close_attribute = get_open_close_attribute(node) + self._attr_supported_features = get_cover_features( + node, self._open_close_attribute + ) + self._attr_device_class = get_device_class(node) + self._attr_unique_id = ( + f"{self._attr_unique_id}-{self._open_close_attribute.id}" + if self._open_close_attribute is not None + else f"{self._attr_unique_id}-0" + ) + + @property + def current_cover_position(self) -> int | None: + """Return the cover's position.""" + # Translate the homee position values to HA's 0-100 scale + if ( + attribute := self._node.get_attribute_by_type(AttributeType.POSITION) + ) is not None: + homee_min = attribute.minimum + homee_max = attribute.maximum + homee_position = attribute.current_value + position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100 + + return int(100 - position) + + return None + + @property + def current_cover_tilt_position(self) -> int | None: + """Return the cover's tilt position.""" + # Translate the homee position values to HA's 0-100 scale + if ( + attribute := self._node.get_attribute_by_type( + AttributeType.SHUTTER_SLAT_POSITION + ) + ) is not None: + homee_min = attribute.minimum + homee_max = attribute.maximum + homee_position = attribute.current_value + position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100 + + return int(100 - position) + + return None + + @property + def is_opening(self) -> bool | None: + """Return the opening status of the cover.""" + if self._open_close_attribute is not None: + return ( + self._open_close_attribute.get_value() == 3 + if not self._open_close_attribute.is_reversed + else self._open_close_attribute.get_value() == 4 + ) + + return None + + @property + def is_closing(self) -> bool | None: + """Return the closing status of the cover.""" + if self._open_close_attribute is not None: + return ( + self._open_close_attribute.get_value() == 4 + if not self._open_close_attribute.is_reversed + else self._open_close_attribute.get_value() == 3 + ) + + return None + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + if ( + attribute := self._node.get_attribute_by_type(AttributeType.POSITION) + ) is not None: + return attribute.get_value() == attribute.maximum + + if self._open_close_attribute is not None: + if not self._open_close_attribute.is_reversed: + return self._open_close_attribute.get_value() == 1 + + return self._open_close_attribute.get_value() == 0 + + # If none of the above is present, it might be a slat only cover. + if ( + attribute := self._node.get_attribute_by_type( + AttributeType.SHUTTER_SLAT_POSITION + ) + ) is not None: + return attribute.get_value() == attribute.minimum + + return None + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + assert self._open_close_attribute is not None + if not self._open_close_attribute.is_reversed: + await self.async_set_value(self._open_close_attribute, 0) + else: + await self.async_set_value(self._open_close_attribute, 1) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + assert self._open_close_attribute is not None + if not self._open_close_attribute.is_reversed: + await self.async_set_value(self._open_close_attribute, 1) + else: + await self.async_set_value(self._open_close_attribute, 0) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + if CoverEntityFeature.SET_POSITION in self.supported_features: + position = 100 - cast(int, kwargs[ATTR_POSITION]) + + # Convert position to range of our entity. + if ( + attribute := self._node.get_attribute_by_type(AttributeType.POSITION) + ) is not None: + homee_min = attribute.minimum + homee_max = attribute.maximum + homee_position = (position / 100) * (homee_max - homee_min) + homee_min + + await self.async_set_value(attribute, homee_position) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + if self._open_close_attribute is not None: + await self.async_set_value(self._open_close_attribute, 2) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + if ( + slat_attribute := self._node.get_attribute_by_type( + AttributeType.SLAT_ROTATION_IMPULSE + ) + ) is not None: + if not slat_attribute.is_reversed: + await self.async_set_value(slat_attribute, 2) + else: + await self.async_set_value(slat_attribute, 1) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + if ( + slat_attribute := self._node.get_attribute_by_type( + AttributeType.SLAT_ROTATION_IMPULSE + ) + ) is not None: + if not slat_attribute.is_reversed: + await self.async_set_value(slat_attribute, 1) + else: + await self.async_set_value(slat_attribute, 2) + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + if CoverEntityFeature.SET_TILT_POSITION in self.supported_features: + position = 100 - cast(int, kwargs[ATTR_TILT_POSITION]) + + # Convert position to range of our entity. + if ( + attribute := self._node.get_attribute_by_type( + AttributeType.SHUTTER_SLAT_POSITION + ) + ) is not None: + homee_min = attribute.minimum + homee_max = attribute.maximum + homee_position = (position / 100) * (homee_max - homee_min) + homee_min + + await self.async_set_value(attribute, homee_position) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py new file mode 100644 index 00000000000..a6cd54354bf --- /dev/null +++ b/homeassistant/components/homee/entity.py @@ -0,0 +1,147 @@ +"""Base Entities for Homee integration.""" + +from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from . import HomeeConfigEntry +from .const import DOMAIN +from .helpers import get_name_for_enum + + +class HomeeEntity(Entity): + """Represents a Homee entity consisting of a single HomeeAttribute.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None: + """Initialize the wrapper using a HomeeAttribute and target entity.""" + self._attribute = attribute + self._attr_unique_id = ( + f"{entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}" + ) + self._entry = entry + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}") + } + ) + + self._host_connected = entry.runtime_data.connected + + async def async_added_to_hass(self) -> None: + """Add the homee attribute entity to home assistant.""" + self.async_on_remove( + self._attribute.add_on_changed_listener(self._on_node_updated) + ) + self.async_on_remove( + await self._entry.runtime_data.add_connection_listener( + self._on_connection_changed + ) + ) + + @property + def available(self) -> bool: + """Return the availability of the underlying node.""" + return (self._attribute.state == AttributeState.NORMAL) and self._host_connected + + async def async_update(self) -> None: + """Update entity from homee.""" + homee = self._entry.runtime_data + await homee.update_attribute(self._attribute.node_id, self._attribute.id) + + def _on_node_updated(self, attribute: HomeeAttribute) -> None: + self.schedule_update_ha_state() + + async def _on_connection_changed(self, connected: bool) -> None: + self._host_connected = connected + self.schedule_update_ha_state() + + +class HomeeNodeEntity(Entity): + """Representation of an Entity that uses more than one HomeeAttribute.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + """Initialize the wrapper using a HomeeNode and target entity.""" + self._node = node + self._attr_unique_id = f"{entry.unique_id}-{node.id}" + self._entry = entry + + ## Homee hub itself has node-id -1 + if node.id == -1: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.runtime_data.settings.uid)}, + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.unique_id}-{node.id}")}, + name=node.name, + model=get_name_for_enum(NodeProfile, node.profile), + sw_version=self._get_software_version(), + via_device=(DOMAIN, entry.runtime_data.settings.uid), + ) + + self._host_connected = entry.runtime_data.connected + + async def async_added_to_hass(self) -> None: + """Add the homee binary sensor device to home assistant.""" + self.async_on_remove(self._node.add_on_changed_listener(self._on_node_updated)) + self.async_on_remove( + await self._entry.runtime_data.add_connection_listener( + self._on_connection_changed + ) + ) + + @property + def available(self) -> bool: + """Return the availability of the underlying node.""" + return self._node.state == NodeState.AVAILABLE and self._host_connected + + async def async_update(self) -> None: + """Fetch new state data for this node.""" + # Base class requests the whole node, if only a single attribute is needed + # the platform will overwrite this method. + homee = self._entry.runtime_data + await homee.update_node(self._node.id) + + def _get_software_version(self) -> str | None: + """Return the software version of the node.""" + if ( + attribute := self._node.get_attribute_by_type( + AttributeType.FIRMWARE_REVISION + ) + ) is not None: + return str(attribute.get_value()) + if ( + attribute := self._node.get_attribute_by_type( + AttributeType.SOFTWARE_REVISION + ) + ) is not None: + return str(attribute.get_value()) + + return None + + def has_attribute(self, attribute_type: AttributeType) -> bool: + """Check if an attribute of the given type exists.""" + if self._node.attribute_map is None: + return False + + return attribute_type in self._node.attribute_map + + async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None: + """Set an attribute value on the homee node.""" + homee = self._entry.runtime_data + await homee.set_value(attribute.node_id, attribute.id, value) + + def _on_node_updated(self, node: HomeeNode) -> None: + self.schedule_update_ha_state() + + async def _on_connection_changed(self, connected: bool) -> None: + self._host_connected = connected + self.schedule_update_ha_state() diff --git a/homeassistant/components/homee/helpers.py b/homeassistant/components/homee/helpers.py new file mode 100644 index 00000000000..b73b1ae2bc9 --- /dev/null +++ b/homeassistant/components/homee/helpers.py @@ -0,0 +1,16 @@ +"""Helper functions for the homee custom component.""" + +from enum import IntEnum +import logging + +_LOGGER = logging.getLogger(__name__) + + +def get_name_for_enum(att_class: type[IntEnum], att_id: int) -> str | None: + """Return the enum item name for a given integer.""" + try: + item = att_class(att_id) + except ValueError: + _LOGGER.warning("Value %s does not exist in %s", att_id, att_class.__name__) + return None + return item.name.lower() diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json new file mode 100644 index 00000000000..3b1ee17b89c --- /dev/null +++ b/homeassistant/components/homee/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "link_quality": { + "default": "mdi:signal" + }, + "window_position": { + "default": "mdi:window-closed" + } + } + } +} diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json new file mode 100644 index 00000000000..6d03547efc9 --- /dev/null +++ b/homeassistant/components/homee/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "homee", + "name": "Homee", + "codeowners": ["@Taraman17"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/homee", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["homee"], + "quality_scale": "bronze", + "requirements": ["pyHomee==1.2.3"] +} diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml new file mode 100644 index 00000000000..ff99d177018 --- /dev/null +++ b/homeassistant/components/homee/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: + status: exempt + comment: Integration is push based. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py new file mode 100644 index 00000000000..9b8fb0f6fe1 --- /dev/null +++ b/homeassistant/components/homee/sensor.py @@ -0,0 +1,339 @@ +"""The homee sensor platform.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from pyHomee.const import AttributeType, NodeState +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeeConfigEntry +from .const import ( + HOMEE_UNIT_TO_HA_UNIT, + OPEN_CLOSE_MAP, + OPEN_CLOSE_MAP_REVERSED, + WINDOW_MAP, + WINDOW_MAP_REVERSED, +) +from .entity import HomeeEntity, HomeeNodeEntity +from .helpers import get_name_for_enum + + +def get_open_close_value(attribute: HomeeAttribute) -> str | None: + """Return the open/close value.""" + vals = OPEN_CLOSE_MAP if not attribute.is_reversed else OPEN_CLOSE_MAP_REVERSED + return vals.get(attribute.current_value) + + +def get_window_value(attribute: HomeeAttribute) -> str | None: + """Return the states of a window open sensor.""" + vals = WINDOW_MAP if not attribute.is_reversed else WINDOW_MAP_REVERSED + return vals.get(attribute.current_value) + + +@dataclass(frozen=True, kw_only=True) +class HomeeSensorEntityDescription(SensorEntityDescription): + """A class that describes Homee sensor entities.""" + + value_fn: Callable[[HomeeAttribute], str | float | None] = ( + lambda value: value.current_value + ) + native_unit_of_measurement_fn: Callable[[str], str | None] = ( + lambda homee_unit: HOMEE_UNIT_TO_HA_UNIT[homee_unit] + ) + + +SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = { + AttributeType.ACCUMULATED_ENERGY_USE: HomeeSensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + AttributeType.BATTERY_LEVEL: HomeeSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.BRIGHTNESS: HomeeSensorEntityDescription( + key="brightness", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda attribute: attribute.current_value * 1000 + if attribute.unit == "klx" + else attribute.current_value + ), + ), + AttributeType.CURRENT: HomeeSensorEntityDescription( + key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.CURRENT_ENERGY_USE: HomeeSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.CURRENT_VALVE_POSITION: HomeeSensorEntityDescription( + key="valve_position", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.DAWN: HomeeSensorEntityDescription( + key="dawn", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.DEVICE_TEMPERATURE: HomeeSensorEntityDescription( + key="device_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.EXHAUST_MOTOR_REVS: HomeeSensorEntityDescription( + key="exhaust_motor_revs", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.INDOOR_RELATIVE_HUMIDITY: HomeeSensorEntityDescription( + key="indoor_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.INDOOR_TEMPERATURE: HomeeSensorEntityDescription( + key="indoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.INTAKE_MOTOR_REVS: HomeeSensorEntityDescription( + key="intake_motor_revs", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.LEVEL: HomeeSensorEntityDescription( + key="level", + device_class=SensorDeviceClass.VOLUME_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.LINK_QUALITY: HomeeSensorEntityDescription( + key="link_quality", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.OPERATING_HOURS: HomeeSensorEntityDescription( + key="operating_hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.OUTDOOR_RELATIVE_HUMIDITY: HomeeSensorEntityDescription( + key="outdoor_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.OUTDOOR_TEMPERATURE: HomeeSensorEntityDescription( + key="outdoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.POSITION: HomeeSensorEntityDescription( + key="position", + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.RAIN_FALL_LAST_HOUR: HomeeSensorEntityDescription( + key="rainfall_hour", + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.RAIN_FALL_TODAY: HomeeSensorEntityDescription( + key="rainfall_day", + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.RELATIVE_HUMIDITY: HomeeSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.TEMPERATURE: HomeeSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.TOTAL_ACCUMULATED_ENERGY_USE: HomeeSensorEntityDescription( + key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + AttributeType.TOTAL_CURRENT: HomeeSensorEntityDescription( + key="total_current", + device_class=SensorDeviceClass.CURRENT, + ), + AttributeType.TOTAL_CURRENT_ENERGY_USE: HomeeSensorEntityDescription( + key="total_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.TOTAL_VOLTAGE: HomeeSensorEntityDescription( + key="total_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.UP_DOWN: HomeeSensorEntityDescription( + key="up_down", + device_class=SensorDeviceClass.ENUM, + options=[ + "open", + "closed", + "partial", + "opening", + "closing", + ], + value_fn=get_open_close_value, + ), + AttributeType.UV: HomeeSensorEntityDescription( + key="uv", + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.VOLTAGE: HomeeSensorEntityDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.WIND_SPEED: HomeeSensorEntityDescription( + key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), + AttributeType.WINDOW_POSITION: HomeeSensorEntityDescription( + key="window_position", + device_class=SensorDeviceClass.ENUM, + options=["closed", "open", "tilted"], + value_fn=get_window_value, + ), +} + + +@dataclass(frozen=True, kw_only=True) +class HomeeNodeSensorEntityDescription(SensorEntityDescription): + """Describes Homee node sensor entities.""" + + value_fn: Callable[[HomeeNode], str | None] + + +NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = ( + HomeeNodeSensorEntityDescription( + key="state", + device_class=SensorDeviceClass.ENUM, + options=[ + "available", + "unavailable", + "update_in_progress", + "waiting_for_attributes", + "initializing", + "user_interaction_required", + "password_required", + "host_unavailable", + "delete_in_progress", + "cosi_connected", + "blocked", + "waiting_for_wakeup", + "remote_node_deleted", + "firmware_update_in_progress", + ], + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + translation_key="node_sensor_state", + value_fn=lambda node: get_name_for_enum(NodeState, node.state), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Add the homee platform for the sensor components.""" + + devices: list[HomeeSensor | HomeeNodeSensor] = [] + for node in config_entry.runtime_data.nodes: + # Node properties that are sensors. + devices.extend( + HomeeNodeSensor(node, config_entry, description) + for description in NODE_SENSOR_DESCRIPTIONS + ) + + # Node attributes that are sensors. + devices.extend( + HomeeSensor(attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]) + for attribute in node.attributes + if attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable + ) + + if devices: + async_add_devices(devices) + + +class HomeeSensor(HomeeEntity, SensorEntity): + """Representation of a homee sensor.""" + + entity_description: HomeeSensorEntityDescription + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: HomeeSensorEntityDescription, + ) -> None: + """Initialize a homee sensor entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + if attribute.instance > 0: + self._attr_translation_key = f"{self._attr_translation_key}_instance" + self._attr_translation_placeholders = {"instance": str(attribute.instance)} + + @property + def native_value(self) -> float | str | None: + """Return the native value of the sensor.""" + return self.entity_description.value_fn(self._attribute) + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of the sensor.""" + return self.entity_description.native_unit_of_measurement_fn( + self._attribute.unit + ) + + +class HomeeNodeSensor(HomeeNodeEntity, SensorEntity): + """Represents a sensor based on a node's property.""" + + entity_description: HomeeNodeSensorEntityDescription + + def __init__( + self, + node: HomeeNode, + entry: HomeeConfigEntry, + description: HomeeNodeSensorEntityDescription, + ) -> None: + """Initialize a homee node sensor entity.""" + super().__init__(node, entry) + self.entity_description = description + self._node = node + self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" + + @property + def native_value(self) -> str | None: + """Return the sensors value.""" + return self.entity_description.value_fn(self._node) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json new file mode 100644 index 00000000000..401996622f2 --- /dev/null +++ b/homeassistant/components/homee/strings.json @@ -0,0 +1,140 @@ +{ + "config": { + "flow_title": "Homee {name} ({host})", + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "title": "Configure homee", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The IP address of your Homee.", + "username": "The username for your Homee.", + "password": "The password for your Homee." + } + } + } + }, + "entity": { + "sensor": { + "brightness_instance": { + "name": "Illuminance {instance}" + }, + "current_instance": { + "name": "Current {instance}" + }, + "dawn": { + "name": "Dawn" + }, + "device_temperature": { + "name": "Device temperature" + }, + "energy_instance": { + "name": "Energy {instance}" + }, + "exhaust_motor_revs": { + "name": "Exhaust motor speed" + }, + "indoor_humidity": { + "name": "Indoor humidity" + }, + "indoor_humidity_instance": { + "name": "Indoor humidity {instance}" + }, + "indoor_temperature": { + "name": "Indoor temperature" + }, + "indoor_temperature_instance": { + "name": "Indoor temperature {instance}" + }, + "intake_motor_revs": { + "name": "Intake motor speed" + }, + "level": { + "name": "Level" + }, + "link_quality": { + "name": "Link quality" + }, + "node_state": { + "name": "Node state" + }, + "operating_hours": { + "name": "Operating hours" + }, + "outdoor_humidity": { + "name": "Outdoor humidity" + }, + "outdoor_humidity_instance": { + "name": "Outdoor humidity {instance}" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "outdoor_temperature_instance": { + "name": "Outdoor temperature {instance}" + }, + "position": { + "name": "Position" + }, + "power_instance": { + "name": "Power {instance}" + }, + "rainfall_hour": { + "name": "Rainfall last hour" + }, + "rainfall_day": { + "name": "Rainfall today" + }, + "total_current": { + "name": "Total current" + }, + "total_energy": { + "name": "Total energy" + }, + "total_power": { + "name": "Total power" + }, + "total_voltage": { + "name": "Total voltage" + }, + "up_down": { + "name": "State", + "state": { + "open": "[%key:common::state::open%]", + "closed": "[%key:common::state::closed%]", + "partial": "Partially open", + "opening": "Opening", + "closing": "Closing" + } + }, + "uv": { + "name": "Ultraviolet" + }, + "valve_position": { + "name": "Valve position" + }, + "voltage_instance": { + "name": "Voltage {instance}" + }, + "window_position": { + "name": "Window position", + "state": { + "closed": "[%key:common::state::closed%]", + "open": "[%key:common::state::open%]", + "tilted": "Tilted" + } + } + } + } +} diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index b85308ffd66..97fb17d7db5 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -33,6 +33,7 @@ from homeassistant.components.device_automation.trigger import ( from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -1133,6 +1134,8 @@ class HomeKit: config[entity_id].setdefault( CONF_LINKED_MOTION_SENSOR, motion_binary_sensor_entity_id ) + + if domain in (CAMERA_DOMAIN, LOCK_DOMAIN): if doorbell_event_entity_id := lookup.get(DOORBELL_EVENT_SENSOR): config[entity_id].setdefault( CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id diff --git a/homeassistant/components/homekit/doorbell.py b/homeassistant/components/homekit/doorbell.py new file mode 100644 index 00000000000..45bbb2ea0ca --- /dev/null +++ b/homeassistant/components/homekit/doorbell.py @@ -0,0 +1,121 @@ +"""Extend the doorbell functions.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pyhap.util import callback as pyhap_callback + +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import ( + Event, + EventStateChangedData, + HassJobType, + State, + callback as ha_callback, +) +from homeassistant.helpers.event import async_track_state_change_event + +from .accessories import HomeAccessory +from .const import ( + CHAR_MUTE, + CHAR_PROGRAMMABLE_SWITCH_EVENT, + CONF_LINKED_DOORBELL_SENSOR, + SERV_DOORBELL, + SERV_SPEAKER, + SERV_STATELESS_PROGRAMMABLE_SWITCH, +) +from .util import state_changed_event_is_same_state + +_LOGGER = logging.getLogger(__name__) + +DOORBELL_SINGLE_PRESS = 0 +DOORBELL_DOUBLE_PRESS = 1 +DOORBELL_LONG_PRESS = 2 + + +class HomeDoorbellAccessory(HomeAccessory): + """Accessory with optional doorbell.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize an Accessory object with optional attached doorbell.""" + super().__init__(*args, **kwargs) + self._char_doorbell_detected = None + self._char_doorbell_detected_switch = None + linked_doorbell_sensor: str | None + linked_doorbell_sensor = self.config.get(CONF_LINKED_DOORBELL_SENSOR) + self.linked_doorbell_sensor = linked_doorbell_sensor + self.doorbell_is_event = False + if not linked_doorbell_sensor: + return + self.doorbell_is_event = linked_doorbell_sensor.startswith("event.") + if not (state := self.hass.states.get(linked_doorbell_sensor)): + return + serv_doorbell = self.add_preload_service(SERV_DOORBELL) + self.set_primary_service(serv_doorbell) + self._char_doorbell_detected = serv_doorbell.configure_char( + CHAR_PROGRAMMABLE_SWITCH_EVENT, + value=0, + ) + serv_stateless_switch = self.add_preload_service( + SERV_STATELESS_PROGRAMMABLE_SWITCH + ) + self._char_doorbell_detected_switch = serv_stateless_switch.configure_char( + CHAR_PROGRAMMABLE_SWITCH_EVENT, + value=0, + valid_values={"SinglePress": DOORBELL_SINGLE_PRESS}, + ) + serv_speaker = self.add_preload_service(SERV_SPEAKER) + serv_speaker.configure_char(CHAR_MUTE, value=0) + self.async_update_doorbell_state(None, state) + + @ha_callback + @pyhap_callback # type: ignore[misc] + def run(self) -> None: + """Handle doorbell event.""" + if self._char_doorbell_detected: + assert self.linked_doorbell_sensor + self._subscriptions.append( + async_track_state_change_event( + self.hass, + self.linked_doorbell_sensor, + self.async_update_doorbell_state_event, + job_type=HassJobType.Callback, + ) + ) + + super().run() + + @ha_callback + def async_update_doorbell_state_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + if not state_changed_event_is_same_state(event) and ( + new_state := event.data["new_state"] + ): + self.async_update_doorbell_state(event.data["old_state"], new_state) + + @ha_callback + def async_update_doorbell_state( + self, old_state: State | None, new_state: State + ) -> None: + """Handle link doorbell sensor state change to update HomeKit value.""" + assert self._char_doorbell_detected + assert self._char_doorbell_detected_switch + state = new_state.state + if state == STATE_ON or ( + self.doorbell_is_event + and old_state is not None + and old_state.state != STATE_UNAVAILABLE + and state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + ): + self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS) + self._char_doorbell_detected_switch.set_value(DOORBELL_SINGLE_PRESS) + _LOGGER.debug( + "%s: Set linked doorbell %s sensor to %d", + self.entity_id, + self.linked_doorbell_sensor, + DOORBELL_SINGLE_PRESS, + ) diff --git a/homeassistant/components/homekit/iidmanager.py b/homeassistant/components/homekit/iidmanager.py index d6daeb49f82..a477dde9c9d 100644 --- a/homeassistant/components/homekit/iidmanager.py +++ b/homeassistant/components/homekit/iidmanager.py @@ -102,8 +102,8 @@ class AccessoryIIDStorage: char_hap_type: str | None = uuid_to_hap_type(char_uuid) if char_uuid else None # Allocation key must be a string since we are saving it to JSON allocation_key = ( - f'{service_hap_type}_{service_unique_id or ""}_' - f'{char_hap_type or ""}_{char_unique_id or ""}' + f"{service_hap_type}_{service_unique_id or ''}_" + f"{char_hap_type or ''}_{char_unique_id or ''}" ) # AID must be a string since JSON keys cannot be int aid_str = str(aid) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index cf74bcc7d67..d7ea293b5dc 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.9.2", - "fnv-hash-fast==1.0.2", + "fnv-hash-fast==1.2.2", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 9e076f7d4d7..0fb2c2e7922 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -31,15 +31,12 @@ from homeassistant.helpers.event import ( ) from homeassistant.util.async_ import create_eager_task -from .accessories import TYPES, HomeAccessory, HomeDriver +from .accessories import TYPES, HomeDriver from .const import ( CHAR_MOTION_DETECTED, - CHAR_MUTE, - CHAR_PROGRAMMABLE_SWITCH_EVENT, CONF_AUDIO_CODEC, CONF_AUDIO_MAP, CONF_AUDIO_PACKET_SIZE, - CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -64,18 +61,13 @@ from .const import ( DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, DEFAULT_VIDEO_PROFILE_NAMES, - SERV_DOORBELL, SERV_MOTION_SENSOR, - SERV_SPEAKER, - SERV_STATELESS_PROGRAMMABLE_SWITCH, ) +from .doorbell import HomeDoorbellAccessory from .util import pid_is_alive, state_changed_event_is_same_state _LOGGER = logging.getLogger(__name__) -DOORBELL_SINGLE_PRESS = 0 -DOORBELL_DOUBLE_PRESS = 1 -DOORBELL_LONG_PRESS = 2 VIDEO_OUTPUT = ( "-map {v_map} -an " @@ -149,7 +141,7 @@ CONFIG_DEFAULTS = { @TYPES.register("Camera") # False-positive on pylint, not a CameraEntity # pylint: disable-next=hass-enforce-class-module -class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] +class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc] """Generate a Camera accessory.""" def __init__( @@ -237,36 +229,6 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] ) self._async_update_motion_state(None, state) - self._char_doorbell_detected = None - self._char_doorbell_detected_switch = None - linked_doorbell_sensor: str | None = self.config.get( - CONF_LINKED_DOORBELL_SENSOR - ) - self.linked_doorbell_sensor = linked_doorbell_sensor - self.doorbell_is_event = False - if not linked_doorbell_sensor: - return - self.doorbell_is_event = linked_doorbell_sensor.startswith("event.") - if not (state := self.hass.states.get(linked_doorbell_sensor)): - return - serv_doorbell = self.add_preload_service(SERV_DOORBELL) - self.set_primary_service(serv_doorbell) - self._char_doorbell_detected = serv_doorbell.configure_char( - CHAR_PROGRAMMABLE_SWITCH_EVENT, - value=0, - ) - serv_stateless_switch = self.add_preload_service( - SERV_STATELESS_PROGRAMMABLE_SWITCH - ) - self._char_doorbell_detected_switch = serv_stateless_switch.configure_char( - CHAR_PROGRAMMABLE_SWITCH_EVENT, - value=0, - valid_values={"SinglePress": DOORBELL_SINGLE_PRESS}, - ) - serv_speaker = self.add_preload_service(SERV_SPEAKER) - serv_speaker.configure_char(CHAR_MUTE, value=0) - self._async_update_doorbell_state(None, state) - @pyhap_callback # type: ignore[misc] @callback def run(self) -> None: @@ -285,17 +247,6 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] ) ) - if self._char_doorbell_detected: - assert self.linked_doorbell_sensor - self._subscriptions.append( - async_track_state_change_event( - self.hass, - self.linked_doorbell_sensor, - self._async_update_doorbell_state_event, - job_type=HassJobType.Callback, - ) - ) - super().run() @callback @@ -344,39 +295,6 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] detected, ) - @callback - def _async_update_doorbell_state_event( - self, event: Event[EventStateChangedData] - ) -> None: - """Handle state change event listener callback.""" - if not state_changed_event_is_same_state(event) and ( - new_state := event.data["new_state"] - ): - self._async_update_doorbell_state(event.data["old_state"], new_state) - - @callback - def _async_update_doorbell_state( - self, old_state: State | None, new_state: State - ) -> None: - """Handle link doorbell sensor state change to update HomeKit value.""" - assert self._char_doorbell_detected - assert self._char_doorbell_detected_switch - state = new_state.state - if state == STATE_ON or ( - self.doorbell_is_event - and old_state is not None - and old_state.state != STATE_UNAVAILABLE - and state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): - self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS) - self._char_doorbell_detected_switch.set_value(DOORBELL_SINGLE_PRESS) - _LOGGER.debug( - "%s: Set linked doorbell %s sensor to %d", - self.entity_id, - self.linked_doorbell_sensor, - DOORBELL_SINGLE_PRESS, - ) - @callback def async_update_state(self, new_state: State | None) -> None: """Handle state change to update HomeKit value.""" diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 6752633f3d2..651033682cf 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -409,11 +409,8 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): """Move cover to value if call came from HomeKit.""" _LOGGER.debug("%s: Set position to %d", self.entity_id, value) - if ( - self._supports_stop - and value > 70 - or not self._supports_stop - and value >= 50 + if (self._supports_stop and value > 70) or ( + not self._supports_stop and value >= 50 ): service, position = (SERVICE_OPEN_COVER, 100) elif value < 30 or not self._supports_stop: diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index cde80178c5e..212b3228154 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -52,6 +52,7 @@ from .const import ( PROP_MIN_VALUE, SERV_LIGHTBULB, ) +from .util import get_min_max _LOGGER = logging.getLogger(__name__) @@ -120,12 +121,14 @@ class Light(HomeAccessory): self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) if CHAR_COLOR_TEMPERATURE in self.chars: - self.min_mireds = color_temperature_kelvin_to_mired( + min_mireds = color_temperature_kelvin_to_mired( attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, DEFAULT_MAX_COLOR_TEMP) ) - self.max_mireds = color_temperature_kelvin_to_mired( + max_mireds = color_temperature_kelvin_to_mired( attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, DEFAULT_MIN_COLOR_TEMP) ) + # Ensure min is less than max + self.min_mireds, self.max_mireds = get_min_max(min_mireds, max_mireds) if not self.color_temp_supported and not self.rgbww_supported: self.max_mireds = self.min_mireds self.char_color_temp = serv_light.configure_char( @@ -282,7 +285,11 @@ class Light(HomeAccessory): hue, saturation = color_temperature_to_hs(color_temp) elif color_mode == ColorMode.WHITE: hue, saturation = 0, 0 - elif hue_sat := attributes.get(ATTR_HS_COLOR): + elif ( + (hue_sat := attributes.get(ATTR_HS_COLOR)) + and isinstance(hue_sat, (list, tuple)) + and len(hue_sat) == 2 + ): hue, saturation = hue_sat else: hue = None diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 70570a8fca5..59da802b8b7 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -9,8 +9,9 @@ from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import State, callback -from .accessories import TYPES, HomeAccessory +from .accessories import TYPES from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK +from .doorbell import HomeDoorbellAccessory _LOGGER = logging.getLogger(__name__) @@ -53,7 +54,7 @@ STATE_TO_SERVICE = { @TYPES.register("Lock") -class Lock(HomeAccessory): +class Lock(HomeDoorbellAccessory): """Generate a Lock accessory for a lock entity. The lock entity must support: unlock and lock. diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 68df6c38ad6..0482a5956ac 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -21,7 +21,7 @@ from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START, - STATE_CLEANING, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.const import ( @@ -213,7 +213,7 @@ class Vacuum(Switch): @callback def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" - current_state = new_state.state in (STATE_CLEANING, STATE_ON) + current_state = new_state.state in (VacuumActivity.CLEANING, STATE_ON) _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) self.char_on.set_value(current_state) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 91bab2d470a..4dda495ce77 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -14,6 +14,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + ATTR_MAX_HUMIDITY, ATTR_MAX_TEMP, ATTR_MIN_HUMIDITY, ATTR_MIN_TEMP, @@ -21,6 +22,7 @@ from homeassistant.components.climate import ( ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_HUMIDITY, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_TEMP, @@ -90,7 +92,7 @@ from .const import ( SERV_FANV2, SERV_THERMOSTAT, ) -from .util import temperature_to_homekit, temperature_to_states +from .util import get_min_max, temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) @@ -208,7 +210,10 @@ class Thermostat(HomeAccessory): self.fan_chars: list[str] = [] attributes = state.attributes - min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) + min_humidity, _ = get_min_max( + attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY), + attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY), + ) features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) if features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: @@ -839,6 +844,9 @@ def _get_temperature_range_from_state( else: max_temp = default_max + # Handle reversed temperature range + min_temp, max_temp = get_min_max(min_temp, max_temp) + # Homekit only supports 10-38, overwriting # the max to appears to work, but less than 0 causes # a crash on the home app diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index b958817bbac..f32c4f55a0f 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -48,7 +48,7 @@ class DeviceTriggerAccessory(HomeAccessory): for idx, trigger in enumerate(device_triggers): type_: str = trigger["type"] subtype: str | None = trigger.get("subtype") - unique_id = f'{type_}-{subtype or ""}' + unique_id = f"{type_}-{subtype or ''}" entity_id: str | None = None if (entity_id_or_uuid := trigger.get("entity_id")) and ( entry := ent_reg.async_get(entity_id_or_uuid) @@ -122,7 +122,7 @@ class DeviceTriggerAccessory(HomeAccessory): """ reason = "" if "trigger" in run_variables and "description" in run_variables["trigger"]: - reason = f' by {run_variables["trigger"]["description"]}' + reason = f" by {run_variables['trigger']['description']}" _LOGGER.debug("Button triggered%s - %s", reason, run_variables) idx = int(run_variables["trigger"]["idx"]) self.triggers[idx].set_value(0) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index ae7e35030be..a0dfcea7616 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -114,7 +114,7 @@ _LOGGER = logging.getLogger(__name__) NUMBERS_ONLY_RE = re.compile(r"[^\d.]+") VERSION_RE = re.compile(r"([0-9]+)(\.[0-9]+)?(\.[0-9]+)?") -INVALID_END_CHARS = "-_" +INVALID_END_CHARS = "-_ " MAX_VERSION_PART = 2**32 - 1 @@ -182,7 +182,6 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend( {vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)} ) - COVER_SCHEMA = BASIC_INFO_SCHEMA.extend( { vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain( @@ -195,6 +194,14 @@ CODE_SCHEMA = BASIC_INFO_SCHEMA.extend( {vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)} ) +LOCK_SCHEMA = CODE_SCHEMA.extend( + { + vol.Optional(CONF_LINKED_DOORBELL_SENSOR): cv.entity_domain( + [binary_sensor.DOMAIN, EVENT_DOMAIN] + ), + } +) + MEDIA_PLAYER_SCHEMA = vol.Schema( { vol.Required(CONF_FEATURE): vol.All( @@ -284,7 +291,7 @@ def validate_entity_config(values: dict) -> dict[str, dict]: if not isinstance(config, dict): raise vol.Invalid(f"The configuration for {entity} must be a dictionary.") - if domain in ("alarm_control_panel", "lock"): + if domain == "alarm_control_panel": config = CODE_SCHEMA(config) elif domain == media_player.const.DOMAIN: @@ -301,6 +308,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "camera": config = CAMERA_SCHEMA(config) + elif domain == "lock": + config = LOCK_SCHEMA(config) + elif domain == "switch": config = SWITCH_TYPE_SCHEMA(config) @@ -424,20 +434,12 @@ def cleanup_name_for_homekit(name: str | None) -> str: def temperature_to_homekit(temperature: float, unit: str) -> float: """Convert temperature to Celsius for HomeKit.""" - return round( - TemperatureConverter.convert(temperature, unit, UnitOfTemperature.CELSIUS), 1 - ) + return TemperatureConverter.convert(temperature, unit, UnitOfTemperature.CELSIUS) def temperature_to_states(temperature: float, unit: str) -> float: """Convert temperature back from Celsius to Home Assistant unit.""" - return ( - round( - TemperatureConverter.convert(temperature, UnitOfTemperature.CELSIUS, unit) - * 2 - ) - / 2 - ) + return TemperatureConverter.convert(temperature, UnitOfTemperature.CELSIUS, unit) def density_to_air_quality(density: float) -> int: @@ -641,7 +643,8 @@ def state_needs_accessory_mode(state: State) -> bool: state.domain == MEDIA_PLAYER_DOMAIN and state.attributes.get(ATTR_DEVICE_CLASS) in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER) - or state.domain == REMOTE_DOMAIN + ) or ( + state.domain == REMOTE_DOMAIN and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & RemoteEntityFeature.ACTIVITY ) @@ -653,3 +656,14 @@ def state_changed_event_is_same_state(event: Event[EventStateChangedData]) -> bo old_state = event_data["old_state"] new_state = event_data["new_state"] return bool(new_state and old_state and new_state.state == old_state.state) + + +def get_min_max(value1: float, value2: float) -> tuple[float, float]: + """Return the minimum and maximum of two values. + + HomeKit will go unavailable if the min and max are reversed + so we make sure the min is always the min and the max is always the max + as any mistakes made in integrations will cause the entire + bridge to go unavailable. + """ + return min(value1, value2), max(value1, value2) diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index 3cb80f2c817..b17f122dfa5 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -69,6 +69,7 @@ class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) + _attr_code_arm_required = False def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 4e55c8212be..cbf4ad61c2f 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -17,7 +17,7 @@ from aiohomekit.model.characteristics import ( ) from aiohomekit.model.services import Service, ServicesTypes from aiohomekit.utils import clamp_enum_to_char -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -136,7 +136,6 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): """The base HomeKit Controller climate entity.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False @callback def _async_reconfigure(self) -> None: diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 9e67d618079..0acf57fe55b 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -19,11 +19,14 @@ from aiohomekit.model.status_flags import StatusFlags from aiohomekit.utils import domain_supported, domain_to_name, serialize_broadcast_key import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import DOMAIN, KNOWN_DEVICES @@ -189,7 +192,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): return False async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered HomeKit accessory. @@ -202,7 +205,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): key.lower(): value for (key, value) in discovery_info.properties.items() } - if zeroconf.ATTR_PROPERTIES_ID not in properties: + if ATTR_PROPERTIES_ID not in properties: # This can happen if the TXT record is received after the PTR record # we will wait for the next update in this case _LOGGER.debug( @@ -216,7 +219,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): # The hkid is a unique random number that looks like a pairing code. # It changes if a device is factory reset. - hkid: str = properties[zeroconf.ATTR_PROPERTIES_ID] + hkid: str = properties[ATTR_PROPERTIES_ID] normalized_hkid = normalize_hkid(hkid) upper_case_hkid = hkid.upper() status_flags = int(properties["sf"]) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 52f22bcc9f4..211aec2c2d5 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -323,8 +323,7 @@ class HKDevice: self.hass, self.async_update_available_state, timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL), - name=f"HomeKit Device {self.unique_id} BLE availability " - "check poll", + name=f"HomeKit Device {self.unique_id} BLE availability check poll", ) ) # BLE devices always get an RSSI sensor as well diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index d7480a40a93..4fff32002e2 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -6,7 +6,7 @@ from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.cover import ( ATTR_POSITION, diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 63de146a024..b7f1842392b 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -6,7 +6,7 @@ from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -42,7 +42,6 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): # This must be set in subclasses to the name of a boolean characteristic # that controls whether the fan is on or off. on_characteristic: str - _enable_turn_on_off_backwards_compatibility = False @callback def _async_reconfigure(self) -> None: diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index f82baab5df7..b2b0e0b1026 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -6,7 +6,7 @@ from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.humidifier import ( DEFAULT_MAX_HUMIDITY, diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index 472ccfbd550..b306c440d7b 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -6,12 +6,14 @@ from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ColorMode, LightEntity, ) @@ -53,11 +55,19 @@ async def async_setup_entry( class HomeKitLight(HomeKitEntity, LightEntity): """Representation of a Homekit light.""" + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN + @callback def _async_reconfigure(self) -> None: """Reconfigure entity.""" self._async_clear_property_cache( - ("supported_features", "min_mireds", "max_mireds", "supported_color_modes") + ( + "supported_features", + "min_color_temp_kelvin", + "max_color_temp_kelvin", + "supported_color_modes", + ) ) super()._async_reconfigure() @@ -90,25 +100,35 @@ class HomeKitLight(HomeKitEntity, LightEntity): ) @cached_property - def min_mireds(self) -> int: - """Return minimum supported color temperature.""" + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): - return super().min_mireds - min_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].minValue - return int(min_value) if min_value else super().min_mireds + return DEFAULT_MAX_KELVIN + min_value_mireds = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].minValue + return ( + color_util.color_temperature_mired_to_kelvin(min_value_mireds) + if min_value_mireds + else DEFAULT_MAX_KELVIN + ) @cached_property - def max_mireds(self) -> int: - """Return the maximum color temperature.""" + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): - return super().max_mireds - max_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].maxValue - return int(max_value) if max_value else super().max_mireds + return DEFAULT_MIN_KELVIN + max_value_mireds = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].maxValue + return ( + color_util.color_temperature_mired_to_kelvin(max_value_mireds) + if max_value_mireds + else DEFAULT_MIN_KELVIN + ) @property - def color_temp(self) -> int: - """Return the color temperature.""" - return self.service.value(CharacteristicsTypes.COLOR_TEMPERATURE) + def color_temp_kelvin(self) -> int: + """Return the color temperature value in Kelvin.""" + return color_util.color_temperature_mired_to_kelvin( + self.service.value(CharacteristicsTypes.COLOR_TEMPERATURE) + ) @property def color_mode(self) -> str: @@ -153,7 +173,7 @@ class HomeKitLight(HomeKitEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the specified light on.""" hs_color = kwargs.get(ATTR_HS_COLOR) - temperature = kwargs.get(ATTR_COLOR_TEMP) + temperature_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) brightness = kwargs.get(ATTR_BRIGHTNESS) characteristics: dict[str, Any] = {} @@ -167,19 +187,18 @@ class HomeKitLight(HomeKitEntity, LightEntity): # does not support both, temperature will win. This is not # expected to happen in the UI, but it is possible via a manual # service call. - if temperature is not None: + if temperature_kelvin is not None: if self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): - characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int( - temperature + characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = ( + color_util.color_temperature_kelvin_to_mired(temperature_kelvin) ) + elif hs_color is None: # Some HomeKit devices implement color temperature with HS # since the spec "technically" does not permit the COLOR_TEMPERATURE # characteristic and the HUE and SATURATION characteristics to be # present at the same time. - hue_sat = color_util.color_temperature_to_hs( - color_util.color_temperature_mired_to_kelvin(temperature) - ) + hue_sat = color_util.color_temperature_to_hs(temperature_kelvin) characteristics[CharacteristicsTypes.HUE] = hue_sat[0] characteristics[CharacteristicsTypes.SATURATION] = hue_sat[1] diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index cddd61a12c1..b7c82b9fd51 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.6"], + "requirements": ["aiohomekit==3.2.7"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py index 2be28487cbb..6e16e16ba99 100644 --- a/homeassistant/components/homematic/climate.py +++ b/homeassistant/components/homematic/climate.py @@ -63,7 +63,6 @@ class HMThermostat(HMDevice, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/homematic/light.py b/homeassistant/components/homematic/light.py index b05cc6a46d6..838cdc9c3c3 100644 --- a/homeassistant/components/homematic/light.py +++ b/homeassistant/components/homematic/light.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -17,10 +17,14 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import color as color_util from .const import ATTR_DISCOVER_DEVICES from .entity import HMDevice +MAX_MIREDS = 500 # 2000 K +MIN_MIREDS = 153 # 6500 K + def setup_platform( hass: HomeAssistant, @@ -43,6 +47,9 @@ def setup_platform( class HMLight(HMDevice, LightEntity): """Representation of a Homematic light.""" + _attr_min_color_temp_kelvin = 2000 # 500 Mireds + _attr_max_color_temp_kelvin = 6500 # 153 Mireds + @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -99,12 +106,14 @@ class HMLight(HMDevice, LightEntity): return hue * 360.0, sat * 100.0 @property - def color_temp(self): - """Return the color temp in mireds [int].""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" if ColorMode.COLOR_TEMP not in self.supported_color_modes: return None hm_color_temp = self._hmdevice.get_color_temp(self._channel) - return self.max_mireds - (self.max_mireds - self.min_mireds) * hm_color_temp + return color_util.color_temperature_mired_to_kelvin( + MAX_MIREDS - (MAX_MIREDS - MIN_MIREDS) * hm_color_temp + ) @property def effect_list(self): @@ -130,7 +139,7 @@ class HMLight(HMDevice, LightEntity): self._hmdevice.set_level(percent_bright, self._channel) elif ( ATTR_HS_COLOR not in kwargs - and ATTR_COLOR_TEMP not in kwargs + and ATTR_COLOR_TEMP_KELVIN not in kwargs and ATTR_EFFECT not in kwargs ): self._hmdevice.on(self._channel) @@ -141,10 +150,11 @@ class HMLight(HMDevice, LightEntity): saturation=kwargs[ATTR_HS_COLOR][1] / 100.0, channel=self._channel, ) - if ATTR_COLOR_TEMP in kwargs: - hm_temp = (self.max_mireds - kwargs[ATTR_COLOR_TEMP]) / ( - self.max_mireds - self.min_mireds + if ATTR_COLOR_TEMP_KELVIN in kwargs: + mireds = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] ) + hm_temp = (MAX_MIREDS - mireds) / (MAX_MIREDS - MIN_MIREDS) self._hmdevice.set_color_temp(hm_temp) if ATTR_EFFECT in kwargs: self._hmdevice.set_effect(kwargs[ATTR_EFFECT]) diff --git a/homeassistant/components/homematic/strings.json b/homeassistant/components/homematic/strings.json index 48ebbe5d345..d962a218a4f 100644 --- a/homeassistant/components/homematic/strings.json +++ b/homeassistant/components/homematic/strings.json @@ -6,7 +6,7 @@ "fields": { "address": { "name": "Address", - "description": "Address of homematic device or BidCoS-RF for virtual remote." + "description": "Address of Homematic device or BidCoS-RF for virtual remote." }, "channel": { "name": "Channel", @@ -28,7 +28,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Name(s) of homematic central to set value." + "description": "Name(s) of Homematic central to set value." }, "name": { "name": "[%key:common::config_flow::data::name%]", @@ -72,11 +72,11 @@ }, "reconnect": { "name": "Reconnect", - "description": "Reconnects to all Homematic Hubs." + "description": "Reconnects to all Homematic hubs." }, "set_install_mode": { "name": "Set install mode", - "description": "Set a RPC XML interface into installation mode.", + "description": "Sets a RPC XML interface into installation mode.", "fields": { "interface": { "name": "Interface", @@ -92,7 +92,7 @@ }, "address": { "name": "Address", - "description": "Address of homematic device or BidCoS-RF to learn." + "description": "Address of Homematic device or BidCoS-RF to learn." } } }, diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index f6a69f50770..e7132fac83c 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -81,7 +81,6 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: """Initialize heating group.""" diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index bba67e10d4c..2b72794b323 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -14,6 +14,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CLIMATE, Platform.COVER, + Platform.EVENT, Platform.LIGHT, Platform.LOCK, Platform.SENSOR, diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py new file mode 100644 index 00000000000..8fb558b2b34 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/event.py @@ -0,0 +1,94 @@ +"""Support for HomematicIP Cloud events.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from homematicip.aio.device import Device + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import HomematicipGenericEntity +from .hap import HomematicipHAP + + +@dataclass(frozen=True, kw_only=True) +class HmipEventEntityDescription(EventEntityDescription): + """Description of a HomematicIP Cloud event.""" + + +EVENT_DESCRIPTIONS = { + "doorbell": HmipEventEntityDescription( + key="doorbell", + translation_key="doorbell", + device_class=EventDeviceClass.DOORBELL, + event_types=["ring"], + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the HomematicIP cover from a config entry.""" + hap = hass.data[DOMAIN][config_entry.unique_id] + + async_add_entities( + HomematicipDoorBellEvent( + hap, + device, + channel.index, + EVENT_DESCRIPTIONS["doorbell"], + ) + for device in hap.home.devices + for channel in device.functionalChannels + if channel.channelRole == "DOOR_BELL_INPUT" + ) + + +class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity): + """Event class for HomematicIP doorbell events.""" + + _attr_device_class = EventDeviceClass.DOORBELL + + def __init__( + self, + hap: HomematicipHAP, + device: Device, + channel: int, + description: HmipEventEntityDescription, + ) -> None: + """Initialize the event.""" + super().__init__( + hap, + device, + post=description.key, + channel=channel, + is_multi_channel=False, + ) + + self.entity_description = description + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + self.functional_channel.add_on_channel_event_handler(self._async_handle_event) + + @callback + def _async_handle_event(self, *args, **kwargs) -> None: + """Handle the event fired by the functional channel.""" + event_types = self.entity_description.event_types + if TYPE_CHECKING: + assert event_types is not None + + self._trigger_event(event_type=event_types[0]) + self.async_write_ha_state() diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 7878a8b4e0a..6fc422498ab 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==1.1.3"] + "requirements": ["homematicip==1.1.6"] } diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index c44d280c190..9ed9b33d7c7 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -93,7 +93,7 @@ ILLUMINATION_DEVICE_ATTRIBUTES = { } -async def async_setup_entry( # noqa: C901 +async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index ac7b184e513..37deace7ebf 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "init": { - "title": "Pick HomematicIP Access point", + "title": "Pick Homematic IP access point", "data": { "hapid": "Access point ID (SGTIN)", "pin": "[%key:common::config_flow::data::pin%]", @@ -10,8 +10,8 @@ } }, "link": { - "title": "Link Access point", - "description": "Press the blue button on the access point and the **Submit** button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" + "title": "Link access point", + "description": "Press the blue button on the access point and the **Submit** button to register Homematic IP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" } }, "error": { @@ -28,7 +28,7 @@ }, "exceptions": { "access_point_not_found": { - "message": "No matching access point found for access point id {id}" + "message": "No matching access point found for access point ID {id}" } }, "services": { @@ -41,8 +41,8 @@ "description": "The duration of eco mode in minutes." }, "accesspoint_id": { - "name": "Accesspoint ID", - "description": "The ID of the Homematic IP Access Point." + "name": "Access point ID", + "description": "The ID of the Homematic IP access point." } } }, @@ -113,20 +113,20 @@ } }, "dump_hap_config": { - "name": "Dump hap config", - "description": "Dumps the configuration of the Homematic IP Access Point(s).", + "name": "Dump HAP config", + "description": "Dumps the configuration of the Homematic IP access point(s).", "fields": { "config_output_path": { "name": "Config output path", - "description": "(Default is 'Your home-assistant config directory') Path where to store the config." + "description": "Path where to store the config. Default is 'Your Home Assistant config directory'." }, "config_output_file_prefix": { "name": "Config output file prefix", - "description": "Name of the config file. The SGTIN of the AP will always be appended." + "description": "Name of the config file. The SGTIN of the HAP will always be appended." }, "anonymize": { "name": "Anonymize", - "description": "Should the Configuration be anonymized?" + "description": "Should the configuration be anonymized?" } } }, @@ -142,7 +142,7 @@ }, "set_home_cooling_mode": { "name": "Set home cooling mode", - "description": "Set the heating/cooling mode for the entire home", + "description": "Sets the heating/cooling mode for the entire home", "fields": { "accesspoint_id": { "name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::name%]", diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 4733bc67073..a911f5398da 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,8 +1,12 @@ """The Homewizard integration.""" +from homewizard_energy import HomeWizardEnergy, HomeWizardEnergyV1, HomeWizardEnergyV2 + from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -12,7 +16,22 @@ type HomeWizardConfigEntry = ConfigEntry[HWEnergyDeviceUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool: """Set up Homewizard from a config entry.""" - coordinator = HWEnergyDeviceUpdateCoordinator(hass) + + api: HomeWizardEnergy + + if token := entry.data.get(CONF_TOKEN): + api = HomeWizardEnergyV2( + entry.data[CONF_IP_ADDRESS], + token=token, + clientsession=async_get_clientsession(hass), + ) + else: + api = HomeWizardEnergyV1( + entry.data[CONF_IP_ADDRESS], + clientsession=async_get_clientsession(hass), + ) + + coordinator = HWEnergyDeviceUpdateCoordinator(hass, api) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 7b05cb95271..b86f797ec2d 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -19,7 +19,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Identify button.""" - if entry.runtime_data.supports_identify(): + if entry.runtime_data.data.device.supports_identify(): async_add_entities([HomeWizardIdentifyButton(entry.runtime_data)]) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index aab6ce055a2..fe78385381c 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -3,19 +3,21 @@ from __future__ import annotations from collections.abc import Mapping -import logging -from typing import Any, NamedTuple +from typing import Any from homewizard_energy import HomeWizardEnergyV1 from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError -from homewizard_energy.v1.models import Device -from voluptuous import Required, Schema +from homewizard_energy.models import Device +import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_API_ENABLED, @@ -23,26 +25,19 @@ from .const import ( CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, + LOGGER, ) -_LOGGER = logging.getLogger(__name__) - - -class DiscoveryData(NamedTuple): - """User metadata.""" - - ip: str - product_name: str - product_type: str - serial: str - class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for P1 meter.""" VERSION = 1 - discovery: DiscoveryData + ip_address: str | None = None + product_name: str | None = None + product_type: str | None = None + serial: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -53,7 +48,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): try: device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.async_set_unique_id( @@ -68,18 +63,18 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): user_input = user_input or {} return self.async_show_form( step_id="user", - data_schema=Schema( + data_schema=vol.Schema( { - Required( + vol.Required( CONF_IP_ADDRESS, default=user_input.get(CONF_IP_ADDRESS) - ): str, + ): TextSelector(), } ), errors=errors, ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if ( @@ -94,54 +89,81 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): if (discovery_info.properties[CONF_PATH]) != "/api/v1": return self.async_abort(reason="unsupported_api_version") - self.discovery = DiscoveryData( - ip=discovery_info.host, - product_type=discovery_info.properties[CONF_PRODUCT_TYPE], - product_name=discovery_info.properties[CONF_PRODUCT_NAME], - serial=discovery_info.properties[CONF_SERIAL], - ) + self.ip_address = discovery_info.host + self.product_type = discovery_info.properties[CONF_PRODUCT_TYPE] + self.product_name = discovery_info.properties[CONF_PRODUCT_NAME] + self.serial = discovery_info.properties[CONF_SERIAL] - await self.async_set_unique_id( - f"{self.discovery.product_type}_{self.discovery.serial}" - ) + await self.async_set_unique_id(f"{self.product_type}_{self.serial}") self._abort_if_unique_id_configured( updates={CONF_IP_ADDRESS: discovery_info.host} ) return await self.async_step_discovery_confirm() + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle dhcp discovery to update existing entries. + + This flow is triggered only by DHCP discovery of known devices. + """ + try: + device = await self._async_try_connect(discovery_info.ip) + except RecoverableError as ex: + LOGGER.error(ex) + return self.async_abort(reason="unknown") + + await self.async_set_unique_id( + f"{device.product_type}_{discovery_info.macaddress}" + ) + + self._abort_if_unique_id_configured( + updates={CONF_IP_ADDRESS: discovery_info.ip} + ) + + # This situation should never happen, as Home Assistant will only + # send updates for existing entries. In case it does, we'll just + # abort the flow with an unknown error. + return self.async_abort(reason="unknown") + async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm discovery.""" + assert self.ip_address + assert self.product_name + assert self.product_type + assert self.serial + errors: dict[str, str] | None = None if user_input is not None or not onboarding.async_is_onboarded(self.hass): try: - await self._async_try_connect(self.discovery.ip) + await self._async_try_connect(self.ip_address) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) errors = {"base": ex.error_code} else: return self.async_create_entry( - title=self.discovery.product_name, - data={CONF_IP_ADDRESS: self.discovery.ip}, + title=self.product_name, + data={CONF_IP_ADDRESS: self.ip_address}, ) self._set_confirm_only() # We won't be adding mac/serial to the title for devices # that users generally don't have multiple of. - name = self.discovery.product_name - if self.discovery.product_type not in ["HWE-P1", "HWE-WTR"]: - name = f"{name} ({self.discovery.serial})" + name = self.product_name + if self.product_type not in ["HWE-P1", "HWE-WTR"]: + name = f"{name} ({self.serial})" self.context["title_placeholders"] = {"name": name} return self.async_show_form( step_id="discovery_confirm", description_placeholders={ - CONF_PRODUCT_TYPE: self.discovery.product_type, - CONF_SERIAL: self.discovery.serial, - CONF_IP_ADDRESS: self.discovery.ip, + CONF_PRODUCT_TYPE: self.product_type, + CONF_SERIAL: self.serial, + CONF_IP_ADDRESS: self.ip_address, }, errors=errors, ) @@ -162,7 +184,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): try: await self._async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) except RecoverableError as ex: - _LOGGER.error(ex) + LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.hass.config_entries.async_reload(reauth_entry.entry_id) @@ -170,6 +192,44 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="reauth_confirm", errors=errors) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + if user_input: + try: + device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + + except RecoverableError as ex: + LOGGER.error(ex) + errors = {"base": ex.error_code} + else: + await self.async_set_unique_id( + f"{device_info.product_type}_{device_info.serial}" + ) + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + ) + reconfigure_entry = self._get_reconfigure_entry() + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_IP_ADDRESS, + default=reconfigure_entry.data.get(CONF_IP_ADDRESS), + ): TextSelector(), + } + ), + description_placeholders={ + "title": reconfigure_entry.title, + }, + errors=errors, + ) + @staticmethod async def _async_try_connect(ip_address: str) -> Device: """Try to connect. @@ -187,7 +247,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) from ex except UnsupportedError as ex: - _LOGGER.error("API version unsuppored") + LOGGER.error("API version unsuppored") raise AbortFlow("unsupported_api_version") from ex except RequestError as ex: @@ -196,7 +256,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) from ex except Exception as ex: - _LOGGER.exception("Unexpected exception") + LOGGER.exception("Unexpected exception") raise AbortFlow("unknown_error") from ex finally: diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index 809ecc1416b..4bed4675833 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -2,12 +2,9 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from homewizard_energy.v1.models import Data, Device, State, System - from homeassistant.const import Platform DOMAIN = "homewizard" @@ -23,13 +20,3 @@ CONF_PRODUCT_TYPE = "product_type" CONF_SERIAL = "serial" UPDATE_INTERVAL = timedelta(seconds=5) - - -@dataclass -class DeviceResponseEntry: - """Dict describing a single response entry.""" - - device: Device - data: Data - state: State | None = None - system: System | None = None diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index 8f5045d3b94..7024c760b93 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -2,68 +2,34 @@ from __future__ import annotations -import logging - -from homewizard_energy import HomeWizardEnergyV1 -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError -from homewizard_energy.v1.const import SUPPORTS_IDENTIFY, SUPPORTS_STATE -from homewizard_energy.v1.models import Device +from homewizard_energy import HomeWizardEnergy +from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.models import CombinedModels as DeviceResponseEntry from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, UPDATE_INTERVAL, DeviceResponseEntry - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER, UPDATE_INTERVAL class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]): """Gather data for the energy device.""" - api: HomeWizardEnergyV1 + api: HomeWizardEnergy api_disabled: bool = False - _unsupported_error: bool = False - config_entry: ConfigEntry - def __init__( - self, - hass: HomeAssistant, - ) -> None: + def __init__(self, hass: HomeAssistant, api: HomeWizardEnergy) -> None: """Initialize update coordinator.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) - self.api = HomeWizardEnergyV1( - self.config_entry.data[CONF_IP_ADDRESS], - clientsession=async_get_clientsession(hass), - ) + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + self.api = api async def _async_update_data(self) -> DeviceResponseEntry: """Fetch all device and sensor data from api.""" try: - data = DeviceResponseEntry( - device=await self.api.device(), - data=await self.api.data(), - ) - - try: - if self.supports_state(data.device): - data.state = await self.api.state() - - data.system = await self.api.system() - - except UnsupportedError as ex: - # Old firmware, ignore - if not self._unsupported_error: - self._unsupported_error = True - _LOGGER.warning( - "%s is running an outdated firmware version (%s). Contact HomeWizard support to update your device", - self.config_entry.title, - ex, - ) + data = await self.api.combined() except RequestError as ex: raise UpdateFailed( @@ -89,18 +55,3 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] self.data = data return data - - def supports_state(self, device: Device | None = None) -> bool: - """Return True if the device supports state.""" - - if device is None: - device = self.data.device - - return device.product_type in SUPPORTS_STATE - - def supports_identify(self, device: Device | None = None) -> bool: - """Return True if the device supports identify.""" - if device is None: - device = self.data.device - - return device.product_type in SUPPORTS_IDENTIFY diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index 128e70d276a..c776cdb18f2 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -13,11 +13,12 @@ from . import HomeWizardConfigEntry TO_REDACT = { CONF_IP_ADDRESS, - "serial", - "wifi_ssid", - "unique_meter_id", - "unique_id", "gas_unique_id", + "id", + "serial", + "unique_id", + "unique_meter_id", + "wifi_ssid", } @@ -27,23 +28,10 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" data = entry.runtime_data.data - state: dict[str, Any] | None = None - if data.state: - state = asdict(data.state) - - system: dict[str, Any] | None = None - if data.system: - system = asdict(data.system) - return async_redact_data( { "entry": async_redact_data(entry.data, TO_REDACT), - "data": { - "device": asdict(data.device), - "data": asdict(data.data), - "state": state, - "system": system, - }, + "data": asdict(data), }, TO_REDACT, ) diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 0aea899c044..1090f561838 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -22,9 +22,7 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): manufacturer="HomeWizard", sw_version=coordinator.data.device.firmware_version, model_id=coordinator.data.device.product_type, - model=coordinator.data.device.product.name - if coordinator.data.device.product - else None, + model=coordinator.data.device.model_name, ) if (serial_number := coordinator.data.device.serial) is not None: diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 0ba2ac0eea7..4cc94d09d74 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -3,9 +3,15 @@ "name": "HomeWizard Energy", "codeowners": ["@DCSBL"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/homewizard", "iot_class": "local_polling", "loggers": ["homewizard_energy"], - "requirements": ["python-homewizard-energy==v7.0.0"], + "quality_scale": "platinum", + "requirements": ["python-homewizard-energy==v8.1.1"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 1b4a0643dbe..5806295fc81 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -6,7 +6,6 @@ from homeassistant.components.number import NumberEntity from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import brightness_to_value, value_to_brightness from . import HomeWizardConfigEntry from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -22,7 +21,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up numbers for device.""" - if entry.runtime_data.supports_state(): + if entry.runtime_data.data.device.supports_state(): async_add_entities([HWEnergyNumberEntity(entry.runtime_data)]) @@ -46,22 +45,21 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: """Set a new value.""" - await self.coordinator.api.state_set( - brightness=value_to_brightness((0, 100), value) - ) + await self.coordinator.api.system(status_led_brightness_pct=int(value)) await self.coordinator.async_refresh() @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self.coordinator.data.state is not None + return super().available and self.coordinator.data.system is not None @property def native_value(self) -> float | None: """Return the current value.""" if ( - not self.coordinator.data.state - or (brightness := self.coordinator.data.state.brightness) is None + not self.coordinator.data.system + or (brightness := self.coordinator.data.system.status_led_brightness_pct) + is None ): return None - return brightness_to_value((0, 100), brightness) + return round(brightness) diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index 1dbdba8212d..423bc4dea49 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -46,11 +46,7 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: - status: todo - comment: | - The integration doesn't update the device info based on DHCP discovery - of known existing devices. + discovery-update-info: done discovery: done docs-data-update: done docs-examples: done @@ -69,7 +65,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 24ed5933d06..8a9738e7ae7 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Final -from homewizard_energy.v1.models import Data, ExternalDevice +from homewizard_energy.models import ExternalDevice, Measurement from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS, @@ -27,6 +27,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfReactivePower, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -45,9 +46,9 @@ PARALLEL_UPDATES = 1 class HomeWizardSensorEntityDescription(SensorEntityDescription): """Class describing HomeWizard sensor entities.""" - enabled_fn: Callable[[Data], bool] = lambda data: True - has_fn: Callable[[Data], bool] - value_fn: Callable[[Data], StateType] + enabled_fn: Callable[[Measurement], bool] = lambda x: True + has_fn: Callable[[Measurement], bool] + value_fn: Callable[[Measurement], StateType] @dataclass(frozen=True, kw_only=True) @@ -68,8 +69,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( key="smr_version", translation_key="dsmr_version", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.smr_version is not None, - value_fn=lambda data: data.smr_version, + has_fn=lambda data: data.protocol_version is not None, + value_fn=lambda data: data.protocol_version, ), HomeWizardSensorEntityDescription( key="meter_model", @@ -82,8 +83,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( key="unique_meter_id", translation_key="unique_meter_id", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.unique_meter_id is not None, - value_fn=lambda data: data.unique_meter_id, + has_fn=lambda data: data.unique_id is not None, + value_fn=lambda data: data.unique_id, ), HomeWizardSensorEntityDescription( key="wifi_ssid", @@ -95,10 +96,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="active_tariff", translation_key="active_tariff", - has_fn=lambda data: data.active_tariff is not None, - value_fn=lambda data: ( - None if data.active_tariff is None else str(data.active_tariff) - ), + has_fn=lambda data: data.tariff is not None, + value_fn=lambda data: None if data.tariff is None else str(data.tariff), device_class=SensorDeviceClass.ENUM, options=["1", "2", "3", "4"], ), @@ -118,8 +117,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_kwh is not None, - value_fn=lambda data: data.total_energy_import_kwh, + has_fn=lambda data: data.energy_import_kwh is not None, + value_fn=lambda data: data.energy_import_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", @@ -130,10 +129,10 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.total_energy_import_t1_kwh is not None - and data.total_energy_export_t2_kwh is not None + data.energy_import_t1_kwh is not None + and data.energy_export_t2_kwh is not None ), - value_fn=lambda data: data.total_energy_import_t1_kwh, + value_fn=lambda data: data.energy_import_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", @@ -142,8 +141,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_t2_kwh is not None, - value_fn=lambda data: data.total_energy_import_t2_kwh, + has_fn=lambda data: data.energy_import_t2_kwh is not None, + value_fn=lambda data: data.energy_import_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", @@ -152,8 +151,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_t3_kwh is not None, - value_fn=lambda data: data.total_energy_import_t3_kwh, + has_fn=lambda data: data.energy_import_t3_kwh is not None, + value_fn=lambda data: data.energy_import_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", @@ -162,8 +161,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_import_t4_kwh is not None, - value_fn=lambda data: data.total_energy_import_t4_kwh, + has_fn=lambda data: data.energy_import_t4_kwh is not None, + value_fn=lambda data: data.energy_import_t4_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", @@ -171,9 +170,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_kwh != 0, - value_fn=lambda data: data.total_energy_export_kwh, + has_fn=lambda data: data.energy_export_kwh is not None, + enabled_fn=lambda data: data.energy_export_kwh != 0, + value_fn=lambda data: data.energy_export_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", @@ -184,11 +183,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.total_energy_export_t1_kwh is not None - and data.total_energy_export_t2_kwh is not None + data.energy_export_t1_kwh is not None + and data.energy_export_t2_kwh is not None ), - enabled_fn=lambda data: data.total_energy_export_t1_kwh != 0, - value_fn=lambda data: data.total_energy_export_t1_kwh, + enabled_fn=lambda data: data.energy_export_t1_kwh != 0, + value_fn=lambda data: data.energy_export_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", @@ -197,9 +196,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_t2_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_t2_kwh != 0, - value_fn=lambda data: data.total_energy_export_t2_kwh, + has_fn=lambda data: data.energy_export_t2_kwh is not None, + enabled_fn=lambda data: data.energy_export_t2_kwh != 0, + value_fn=lambda data: data.energy_export_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", @@ -208,9 +207,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_t3_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_t3_kwh != 0, - value_fn=lambda data: data.total_energy_export_t3_kwh, + has_fn=lambda data: data.energy_export_t3_kwh is not None, + enabled_fn=lambda data: data.energy_export_t3_kwh != 0, + value_fn=lambda data: data.energy_export_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", @@ -219,9 +218,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_energy_export_t4_kwh is not None, - enabled_fn=lambda data: data.total_energy_export_t4_kwh != 0, - value_fn=lambda data: data.total_energy_export_t4_kwh, + has_fn=lambda data: data.energy_export_t4_kwh is not None, + enabled_fn=lambda data: data.energy_export_t4_kwh != 0, + value_fn=lambda data: data.energy_export_t4_kwh, ), HomeWizardSensorEntityDescription( key="active_power_w", @@ -229,8 +228,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_w is not None, - value_fn=lambda data: data.active_power_w, + has_fn=lambda data: data.power_w is not None, + value_fn=lambda data: data.power_w, ), HomeWizardSensorEntityDescription( key="active_power_l1_w", @@ -240,8 +239,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_l1_w is not None, - value_fn=lambda data: data.active_power_l1_w, + has_fn=lambda data: data.power_l1_w is not None, + value_fn=lambda data: data.power_l1_w, ), HomeWizardSensorEntityDescription( key="active_power_l2_w", @@ -251,8 +250,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_l2_w is not None, - value_fn=lambda data: data.active_power_l2_w, + has_fn=lambda data: data.power_l2_w is not None, + value_fn=lambda data: data.power_l2_w, ), HomeWizardSensorEntityDescription( key="active_power_l3_w", @@ -262,8 +261,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.active_power_l3_w is not None, - value_fn=lambda data: data.active_power_l3_w, + has_fn=lambda data: data.power_l3_w is not None, + value_fn=lambda data: data.power_l3_w, ), HomeWizardSensorEntityDescription( key="active_voltage_v", @@ -271,8 +270,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_v is not None, - value_fn=lambda data: data.active_voltage_v, + has_fn=lambda data: data.voltage_v is not None, + value_fn=lambda data: data.voltage_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l1_v", @@ -282,8 +281,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_l1_v is not None, - value_fn=lambda data: data.active_voltage_l1_v, + has_fn=lambda data: data.voltage_l1_v is not None, + value_fn=lambda data: data.voltage_l1_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l2_v", @@ -293,8 +292,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_l2_v is not None, - value_fn=lambda data: data.active_voltage_l2_v, + has_fn=lambda data: data.voltage_l2_v is not None, + value_fn=lambda data: data.voltage_l2_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l3_v", @@ -304,8 +303,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_voltage_l3_v is not None, - value_fn=lambda data: data.active_voltage_l3_v, + has_fn=lambda data: data.voltage_l3_v is not None, + value_fn=lambda data: data.voltage_l3_v, ), HomeWizardSensorEntityDescription( key="active_current_a", @@ -313,8 +312,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_a is not None, - value_fn=lambda data: data.active_current_a, + has_fn=lambda data: data.current_a is not None, + value_fn=lambda data: data.current_a, ), HomeWizardSensorEntityDescription( key="active_current_l1_a", @@ -324,8 +323,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_l1_a is not None, - value_fn=lambda data: data.active_current_l1_a, + has_fn=lambda data: data.current_l1_a is not None, + value_fn=lambda data: data.current_l1_a, ), HomeWizardSensorEntityDescription( key="active_current_l2_a", @@ -335,8 +334,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_l2_a is not None, - value_fn=lambda data: data.active_current_l2_a, + has_fn=lambda data: data.current_l2_a is not None, + value_fn=lambda data: data.current_l2_a, ), HomeWizardSensorEntityDescription( key="active_current_l3_a", @@ -346,8 +345,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_current_l3_a is not None, - value_fn=lambda data: data.active_current_l3_a, + has_fn=lambda data: data.current_l3_a is not None, + value_fn=lambda data: data.current_l3_a, ), HomeWizardSensorEntityDescription( key="active_frequency_hz", @@ -355,8 +354,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_frequency_hz is not None, - value_fn=lambda data: data.active_frequency_hz, + has_fn=lambda data: data.frequency_hz is not None, + value_fn=lambda data: data.frequency_hz, ), HomeWizardSensorEntityDescription( key="active_apparent_power_va", @@ -364,8 +363,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_va is not None, - value_fn=lambda data: data.active_apparent_power_va, + has_fn=lambda data: data.apparent_power_va is not None, + value_fn=lambda data: data.apparent_power_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l1_va", @@ -375,8 +374,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_l1_va is not None, - value_fn=lambda data: data.active_apparent_power_l1_va, + has_fn=lambda data: data.apparent_power_l1_va is not None, + value_fn=lambda data: data.apparent_power_l1_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l2_va", @@ -386,8 +385,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_l2_va is not None, - value_fn=lambda data: data.active_apparent_power_l2_va, + has_fn=lambda data: data.apparent_power_l2_va is not None, + value_fn=lambda data: data.apparent_power_l2_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l3_va", @@ -397,8 +396,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_apparent_power_l3_va is not None, - value_fn=lambda data: data.active_apparent_power_l3_va, + has_fn=lambda data: data.apparent_power_l3_va is not None, + value_fn=lambda data: data.apparent_power_l3_va, ), HomeWizardSensorEntityDescription( key="active_reactive_power_var", @@ -406,8 +405,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_var is not None, - value_fn=lambda data: data.active_reactive_power_var, + has_fn=lambda data: data.reactive_power_var is not None, + value_fn=lambda data: data.reactive_power_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l1_var", @@ -417,8 +416,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_l1_var is not None, - value_fn=lambda data: data.active_reactive_power_l1_var, + has_fn=lambda data: data.reactive_power_l1_var is not None, + value_fn=lambda data: data.reactive_power_l1_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l2_var", @@ -428,8 +427,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_l2_var is not None, - value_fn=lambda data: data.active_reactive_power_l2_var, + has_fn=lambda data: data.reactive_power_l2_var is not None, + value_fn=lambda data: data.reactive_power_l2_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l3_var", @@ -439,8 +438,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_reactive_power_l3_var is not None, - value_fn=lambda data: data.active_reactive_power_l3_var, + has_fn=lambda data: data.reactive_power_l3_var is not None, + value_fn=lambda data: data.reactive_power_l3_var, ), HomeWizardSensorEntityDescription( key="active_power_factor", @@ -448,8 +447,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor is not None, - value_fn=lambda data: to_percentage(data.active_power_factor), + has_fn=lambda data: data.power_factor is not None, + value_fn=lambda data: to_percentage(data.power_factor), ), HomeWizardSensorEntityDescription( key="active_power_factor_l1", @@ -459,8 +458,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor_l1 is not None, - value_fn=lambda data: to_percentage(data.active_power_factor_l1), + has_fn=lambda data: data.power_factor_l1 is not None, + value_fn=lambda data: to_percentage(data.power_factor_l1), ), HomeWizardSensorEntityDescription( key="active_power_factor_l2", @@ -470,8 +469,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor_l2 is not None, - value_fn=lambda data: to_percentage(data.active_power_factor_l2), + has_fn=lambda data: data.power_factor_l2 is not None, + value_fn=lambda data: to_percentage(data.power_factor_l2), ), HomeWizardSensorEntityDescription( key="active_power_factor_l3", @@ -481,8 +480,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.active_power_factor_l3 is not None, - value_fn=lambda data: to_percentage(data.active_power_factor_l3), + has_fn=lambda data: data.power_factor_l3 is not None, + value_fn=lambda data: to_percentage(data.power_factor_l3), ), HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", @@ -551,8 +550,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( translation_key="active_power_average_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - has_fn=lambda data: data.active_power_average_w is not None, - value_fn=lambda data: data.active_power_average_w, + has_fn=lambda data: data.average_power_15m_w is not None, + value_fn=lambda data: data.average_power_15m_w, ), HomeWizardSensorEntityDescription( key="monthly_power_peak_w", @@ -565,7 +564,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="active_liter_lpm", translation_key="active_liter_lpm", - native_unit_of_measurement="l/min", + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, has_fn=lambda data: data.active_liter_lpm is not None, value_fn=lambda data: data.active_liter_lpm, @@ -623,19 +622,21 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" - data = entry.runtime_data.data.data + measurement = entry.runtime_data.data.measurement # Initialize default sensors entities: list = [ HomeWizardSensorEntity(entry.runtime_data, description) for description in SENSORS - if description.has_fn(data) + if description.has_fn(measurement) ] # Initialize external devices - if data.external_devices is not None: - for unique_id, device in data.external_devices.items(): - if description := EXTERNAL_SENSORS.get(device.meter_type): + if measurement.external_devices is not None: + for unique_id, device in measurement.external_devices.items(): + if device.type is not None and ( + description := EXTERNAL_SENSORS.get(device.type) + ): # Add external device entities.append( HomeWizardExternalSensorEntity( @@ -660,13 +661,13 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" - if not description.enabled_fn(self.coordinator.data.data): + if not description.enabled_fn(self.coordinator.data.measurement): self._attr_entity_registry_enabled_default = False @property def native_value(self) -> StateType: """Return the sensor value.""" - return self.entity_description.value_fn(self.coordinator.data.data) + return self.entity_description.value_fn(self.coordinator.data.measurement) @property def available(self) -> bool: @@ -711,8 +712,8 @@ class HomeWizardExternalSensorEntity(HomeWizardEntity, SensorEntity): def device(self) -> ExternalDevice | None: """Return ExternalDevice object.""" return ( - self.coordinator.data.data.external_devices[self._device_id] - if self.coordinator.data.data.external_devices is not None + self.coordinator.data.measurement.external_devices[self._device_id] + if self.coordinator.data.measurement.external_devices is not None else None ) diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index b3fd5a1fef2..4309664c4c8 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -17,6 +17,15 @@ }, "reauth_confirm": { "description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings." + }, + "reconfigure": { + "description": "Update configuration for {title}.", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "ip_address": "[%key:component::homewizard::config::step::user::data_description::ip_address%]" + } } }, "error": { @@ -29,7 +38,9 @@ "device_not_supported": "This device is not supported", "unknown_error": "[%key:common::config_flow::error::unknown%]", "unsupported_api_version": "Detected unsupported API version", - "reauth_successful": "Enabling API was successful" + "reauth_successful": "Enabling API was successful", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "The configured device is not the same found on this IP address." } }, "entity": { diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index aa0af17f578..0878703e4d5 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -6,7 +6,8 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from homewizard_energy import HomeWizardEnergyV1 +from homewizard_energy import HomeWizardEnergy +from homewizard_energy.models import CombinedModels as DeviceResponseEntry from homeassistant.components.switch import ( SwitchDeviceClass, @@ -18,7 +19,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeWizardConfigEntry -from .const import DeviceResponseEntry from .coordinator import HWEnergyDeviceUpdateCoordinator from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler @@ -31,9 +31,9 @@ class HomeWizardSwitchEntityDescription(SwitchEntityDescription): """Class describing HomeWizard switch entities.""" available_fn: Callable[[DeviceResponseEntry], bool] - create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool] + create_fn: Callable[[DeviceResponseEntry], bool] is_on_fn: Callable[[DeviceResponseEntry], bool | None] - set_fn: Callable[[HomeWizardEnergyV1, bool], Awaitable[Any]] + set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]] SWITCHES = [ @@ -41,28 +41,28 @@ SWITCHES = [ key="power_on", name=None, device_class=SwitchDeviceClass.OUTLET, - create_fn=lambda coordinator: coordinator.supports_state(), - available_fn=lambda data: data.state is not None and not data.state.switch_lock, - is_on_fn=lambda data: data.state.power_on if data.state else None, - set_fn=lambda api, active: api.state_set(power_on=active), + create_fn=lambda x: x.device.supports_state(), + available_fn=lambda x: x.state is not None and not x.state.switch_lock, + is_on_fn=lambda x: x.state.power_on if x.state else None, + set_fn=lambda api, active: api.state(power_on=active), ), HomeWizardSwitchEntityDescription( key="switch_lock", translation_key="switch_lock", entity_category=EntityCategory.CONFIG, - create_fn=lambda coordinator: coordinator.supports_state(), - available_fn=lambda data: data.state is not None, - is_on_fn=lambda data: data.state.switch_lock if data.state else None, - set_fn=lambda api, active: api.state_set(switch_lock=active), + create_fn=lambda x: x.device.supports_state(), + available_fn=lambda x: x.state is not None, + is_on_fn=lambda x: x.state.switch_lock if x.state else None, + set_fn=lambda api, active: api.state(switch_lock=active), ), HomeWizardSwitchEntityDescription( key="cloud_connection", translation_key="cloud_connection", entity_category=EntityCategory.CONFIG, create_fn=lambda _: True, - available_fn=lambda data: data.system is not None, - is_on_fn=lambda data: data.system.cloud_enabled if data.system else None, - set_fn=lambda api, active: api.system_set(cloud_enabled=active), + available_fn=lambda x: x.system is not None, + is_on_fn=lambda x: x.system.cloud_enabled if x.system else None, + set_fn=lambda api, active: api.system(cloud_enabled=active), ), ] @@ -76,7 +76,7 @@ async def async_setup_entry( async_add_entities( HomeWizardSwitchEntity(entry.runtime_data, description) for description in SWITCHES - if description.create_fn(entry.runtime_data) + if description.create_fn(entry.runtime_data.data) ) diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index 977e6be8afd..10cc2e61fb9 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -167,15 +167,15 @@ "services": { "send_command": { "name": "Send command", - "description": "Send custom command to a controller", + "description": "Sends a custom command to a controller", "fields": { "command": { "name": "Command", - "description": "Command to send to the controller. This can either be a single command or a list of commands." + "description": "The command to send to the controller. This can either be a single command or a list of commands." }, "controller_id": { "name": "Controller ID", - "description": "The controller to which to send command." + "description": "The controller to which to send the command." } } } diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 5a4d6374304..eb89ba2a681 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -22,14 +22,16 @@ from .const import ( ) UPDATE_LOOP_SLEEP_TIME = 5 -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.CLIMATE, Platform.HUMIDIFIER, Platform.SENSOR, Platform.SWITCH] MIGRATE_OPTIONS_KEYS = {CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE} +type HoneywellConfigEntry = ConfigEntry[HoneywellData] + @callback def _async_migrate_data_to_options( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: HoneywellConfigEntry ) -> None: if not MIGRATE_OPTIONS_KEYS.intersection(config_entry.data): return @@ -45,7 +47,9 @@ def _async_migrate_data_to_options( ) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: HoneywellConfigEntry +) -> bool: """Set up the Honeywell thermostat.""" _async_migrate_data_to_options(hass, config_entry) @@ -84,8 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if len(devices) == 0: _LOGGER.debug("No devices found") return False - data = HoneywellData(config_entry.entry_id, client, devices) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = data + config_entry.runtime_data = HoneywellData(config_entry.entry_id, client, devices) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) @@ -93,19 +96,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: HoneywellConfigEntry +) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: HoneywellConfigEntry +) -> bool: """Unload the config and platforms.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) @dataclass diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index d4e5ee10a6b..1df5eb9601b 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -31,7 +31,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -40,7 +39,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter -from . import HoneywellData +from . import HoneywellConfigEntry, HoneywellData from .const import ( _LOGGER, CONF_COOL_AWAY_TEMPERATURE, @@ -97,13 +96,15 @@ SCAN_INTERVAL = datetime.timedelta(seconds=30) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: HoneywellConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Honeywell thermostat.""" cool_away_temp = entry.options.get(CONF_COOL_AWAY_TEMPERATURE) heat_away_temp = entry.options.get(CONF_HEAT_AWAY_TEMPERATURE) - data: HoneywellData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data _async_migrate_unique_id(hass, data.devices) async_add_entities( [ @@ -131,7 +132,7 @@ def _async_migrate_unique_id( def remove_stale_devices( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HoneywellConfigEntry, devices: dict[str, SomeComfortDevice], ) -> None: """Remove stale devices from device registry.""" @@ -164,7 +165,6 @@ class HoneywellUSThermostat(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_translation_key = "honeywell" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -294,7 +294,7 @@ class HoneywellUSThermostat(ClimateEntity): def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" if self.hvac_mode == HVACMode.OFF: - return None + return HVACAction.OFF return HW_MODE_TO_HA_HVAC_ACTION.get(self._device.equipment_output_status) @property diff --git a/homeassistant/components/honeywell/diagnostics.py b/homeassistant/components/honeywell/diagnostics.py index 35624c8fc39..b266e06d110 100644 --- a/homeassistant/components/honeywell/diagnostics.py +++ b/homeassistant/components/honeywell/diagnostics.py @@ -4,19 +4,17 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import HoneywellData -from .const import DOMAIN +from . import HoneywellConfigEntry async def async_get_config_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HoneywellConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - honeywell: HoneywellData = hass.data[DOMAIN][config_entry.entry_id] + honeywell = config_entry.runtime_data return { f"Device {device}": { diff --git a/homeassistant/components/honeywell/humidifier.py b/homeassistant/components/honeywell/humidifier.py new file mode 100644 index 00000000000..e94ba465c30 --- /dev/null +++ b/homeassistant/components/honeywell/humidifier.py @@ -0,0 +1,136 @@ +"""Support for Honeywell (de)humidifiers.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from aiosomecomfort.device import Device + +from homeassistant.components.humidifier import ( + HumidifierDeviceClass, + HumidifierEntity, + HumidifierEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HoneywellConfigEntry +from .const import DOMAIN + +HUMIDIFIER_KEY = "humidifier" +DEHUMIDIFIER_KEY = "dehumidifier" + + +@dataclass(frozen=True, kw_only=True) +class HoneywellHumidifierEntityDescription(HumidifierEntityDescription): + """Describes a Honeywell humidifier entity.""" + + current_humidity: Callable[[Device], Any] + current_set_humidity: Callable[[Device], Any] + max_humidity: Callable[[Device], Any] + min_humidity: Callable[[Device], Any] + set_humidity: Callable[[Device, Any], Any] + mode: Callable[[Device], Any] + off: Callable[[Device], Any] + on: Callable[[Device], Any] + + +HUMIDIFIERS: dict[str, HoneywellHumidifierEntityDescription] = { + "Humidifier": HoneywellHumidifierEntityDescription( + key=HUMIDIFIER_KEY, + translation_key=HUMIDIFIER_KEY, + current_humidity=lambda device: device.current_humidity, + set_humidity=lambda device, humidity: device.set_humidifier_setpoint(humidity), + min_humidity=lambda device: device.humidifier_lower_limit, + max_humidity=lambda device: device.humidifier_upper_limit, + current_set_humidity=lambda device: device.humidifier_setpoint, + mode=lambda device: device.humidifier_mode, + off=lambda device: device.set_humidifier_off(), + on=lambda device: device.set_humidifier_auto(), + device_class=HumidifierDeviceClass.HUMIDIFIER, + ), + "Dehumidifier": HoneywellHumidifierEntityDescription( + key=DEHUMIDIFIER_KEY, + translation_key=DEHUMIDIFIER_KEY, + current_humidity=lambda device: device.current_humidity, + set_humidity=lambda device, humidity: device.set_dehumidifier_setpoint( + humidity + ), + min_humidity=lambda device: device.dehumidifier_lower_limit, + max_humidity=lambda device: device.dehumidifier_upper_limit, + current_set_humidity=lambda device: device.dehumidifier_setpoint, + mode=lambda device: device.dehumidifier_mode, + off=lambda device: device.set_dehumidifier_off(), + on=lambda device: device.set_dehumidifier_auto(), + device_class=HumidifierDeviceClass.DEHUMIDIFIER, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HoneywellConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Honeywell (de)humidifier dynamically.""" + data = config_entry.runtime_data + entities: list = [] + for device in data.devices.values(): + if device.has_humidifier: + entities.append(HoneywellHumidifier(device, HUMIDIFIERS["Humidifier"])) + if device.has_dehumidifier: + entities.append(HoneywellHumidifier(device, HUMIDIFIERS["Dehumidifier"])) + + async_add_entities(entities) + + +class HoneywellHumidifier(HumidifierEntity): + """Representation of a Honeywell US (De)Humidifier.""" + + entity_description: HoneywellHumidifierEntityDescription + _attr_has_entity_name = True + + def __init__( + self, device: Device, description: HoneywellHumidifierEntityDescription + ) -> None: + """Initialize the (De)Humidifier.""" + self._device = device + self.entity_description = description + self._attr_unique_id = f"{device.deviceid}_{description.key}" + self._attr_min_humidity = description.min_humidity(device) + self._attr_max_humidity = description.max_humidity(device) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.deviceid)}, + name=device.name, + manufacturer="Honeywell", + ) + + @property + def is_on(self) -> bool: + """Return the device is on or off.""" + return self.entity_description.mode(self._device) != 0 + + @property + def target_humidity(self) -> int | None: + """Return the humidity we try to reach.""" + return self.entity_description.current_set_humidity(self._device) + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self.entity_description.current_humidity(self._device) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.entity_description.on(self._device) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.entity_description.off(self._device) + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self.entity_description.set_humidity(self._device, humidity) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index d0f0c8281f7..36a4f497601 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.25"] + "requirements": ["AIOSomecomfort==0.0.30"] } diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py index 31ed8d646c5..a9109d5d557 100644 --- a/homeassistant/components/honeywell/sensor.py +++ b/homeassistant/components/honeywell/sensor.py @@ -14,14 +14,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import HoneywellData +from . import HoneywellConfigEntry from .const import DOMAIN OUTDOOR_TEMPERATURE_STATUS_KEY = "outdoor_temperature" @@ -81,11 +80,11 @@ SENSOR_TYPES: tuple[HoneywellSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HoneywellConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Honeywell thermostat.""" - data: HoneywellData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data async_add_entities( HoneywellSensor(device, description) diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index a64f1a6fce0..2538e7101a1 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -61,6 +61,14 @@ } } } + }, + "humidifier": { + "humidifier": { + "name": "[%key:component::humidifier::title%]" + }, + "dehumidifier": { + "name": "[%key:component::humidifier::entity_component::dehumidifier::name%]" + } } }, "exceptions": { diff --git a/homeassistant/components/honeywell/switch.py b/homeassistant/components/honeywell/switch.py index b90dd339593..3602dd1ba10 100644 --- a/homeassistant/components/honeywell/switch.py +++ b/homeassistant/components/honeywell/switch.py @@ -12,13 +12,12 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HoneywellData +from . import HoneywellConfigEntry, HoneywellData from .const import DOMAIN EMERGENCY_HEAT_KEY = "emergency_heat" @@ -34,11 +33,11 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HoneywellConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Honeywell switches.""" - data: HoneywellData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data async_add_entities( HoneywellSwitch(data, device, description) for device in data.devices.values() diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 48cc0598479..e9ebdb9da67 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -154,6 +154,7 @@ HTML5_SHOWNOTIFICATION_PARAMETERS = ( "tag", "timestamp", "vibrate", + "silent", ) diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index 40bdbb36261..2c68223581a 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -7,7 +7,7 @@ "vapid_prv_key": "VAPID private key" }, "data_description": { - "vapid_email": "Email to use for html5 push notifications.", + "vapid_email": "This contact address will be included in the metadata of each notification.", "vapid_prv_key": "If not specified, one will be automatically generated." } } diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 3b18b44862a..95cdee9ab9e 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -326,7 +326,8 @@ class HomeAssistantApplication(web.Application): protocol, writer, task, - loop=self._loop, + # loop will never be None when called from aiohttp + loop=self._loop, # type: ignore[arg-type] client_max_size=self._client_max_size, ) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index c8fc8ffb11b..b5093999836 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio -from homeassistant.util import dt as dt_util, yaml +from homeassistant.util import dt as dt_util, yaml as yaml_util from .const import KEY_HASS from .view import HomeAssistantView @@ -244,7 +244,7 @@ class IpBanManager: str(ip_ban.ip_address): {ATTR_BANNED_AT: ip_ban.banned_at.isoformat()} } # Write in a single write call to avoid interleaved writes - out.write("\n" + yaml.dump(ip_)) + out.write("\n" + yaml_util.dump(ip_)) async def async_add_ban(self, remote_addr: IPv4Address | IPv6Address) -> None: """Add a new IP address to the banned list.""" diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 29c5840a4bf..99877eaf0be 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -17,6 +17,8 @@ CACHE_HEADER = f"public, max-age={CACHE_TIME}" CACHE_HEADERS: Mapping[str, str] = {CACHE_CONTROL: CACHE_HEADER} RESPONSE_CACHE: LRU[tuple[str, Path], tuple[Path, str]] = LRU(512) +_GUESSER = CONTENT_TYPES.guess_file_type + class CachingStaticResource(StaticResource): """Static Resource handler that will add cache headers.""" @@ -37,9 +39,7 @@ class CachingStaticResource(StaticResource): # Must be directory index; ignore caching return response file_path = response._path # noqa: SLF001 - response.content_type = ( - CONTENT_TYPES.guess_type(file_path)[0] or FALLBACK_CONTENT_TYPE - ) + response.content_type = _GUESSER(file_path)[0] or FALLBACK_CONTENT_TYPE # Cache actual header after setter construction. content_type = response.headers[CONTENT_TYPE] RESPONSE_CACHE[key] = (file_path, content_type) diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 4ca39eaab0c..f633433c9e4 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -22,7 +22,7 @@ class HomeAssistantTCPSite(web.BaseSite): is merged. """ - __slots__ = ("_host", "_port", "_reuse_address", "_reuse_port", "_hosturl") + __slots__ = ("_host", "_hosturl", "_port", "_reuse_address", "_reuse_port") def __init__( self, diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 08fdae50c51..96e160ece7b 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -21,7 +21,6 @@ from requests.exceptions import SSLError, Timeout from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -38,6 +37,14 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import ( CONF_MANUFACTURER, @@ -262,7 +269,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=user_input) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle SSDP initiated config flow.""" @@ -270,13 +277,13 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): assert discovery_info.ssdp_location url = url_normalize( discovery_info.upnp.get( - ssdp.ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_PRESENTATION_URL, f"http://{urlparse(discovery_info.ssdp_location).hostname}/", ) ) unique_id = discovery_info.upnp.get( - ssdp.ATTR_UPNP_SERIAL, discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + ATTR_UPNP_SERIAL, discovery_info.upnp[ATTR_UPNP_UDN] ) await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured(updates={CONF_URL: url}) @@ -301,12 +308,12 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - CONF_NAME: discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) + CONF_NAME: discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) or "Huawei LTE" } } ) - self.manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) + self.manufacturer = discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) self.url = url return await self._async_show_user_form() diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index e044413f296..879c7215562 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -361,7 +361,7 @@ }, "suspend_integration": { "name": "Suspend integration", - "description": "Suspends integration. Suspending logs the integration out from the router, and stops accessing it. Useful e.g. if accessing the router web interface from another source such as a web browser is temporarily required. Invoke the resume_integration action to resume.\n.", + "description": "Suspends integration. Suspending logs the integration out from the router, and stops accessing it. Useful e.g. if accessing the router web interface from another source such as a web browser is temporarily required. Invoke the 'Resume integration' action to resume.", "fields": { "url": { "name": "[%key:common::config_flow::data::url%]", diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 8d17f810461..db025922ef8 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -13,7 +13,6 @@ from aiohue.util import normalize_bridge_id import slugify as unicode_slug import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -27,6 +26,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_ALLOW_HUE_GROUPS, @@ -214,7 +214,7 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Hue bridge. @@ -243,7 +243,7 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_link() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Hue bridge on HomeKit. diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index 76dd0fce12b..e9669d226f0 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -12,11 +12,13 @@ import aiohue from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, EFFECT_COLORLOOP, EFFECT_RANDOM, FLASH_LONG, @@ -35,7 +37,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, UpdateFailed, ) -from homeassistant.util import color +from homeassistant.util import color as color_util from ..bridge import HueBridge from ..const import ( @@ -362,7 +364,7 @@ class HueLight(CoordinatorEntity, LightEntity): "bulb in the Philips Hue App." ) LOGGER.warning(err, self.name) - if self.gamut and not color.check_valid_gamut(self.gamut): + if self.gamut and not color_util.check_valid_gamut(self.gamut): err = "Color gamut of %s: %s, not valid, setting gamut to None." LOGGER.debug(err, self.name, str(self.gamut)) self.gamut_typ = GAMUT_TYPE_UNAVAILABLE @@ -427,49 +429,50 @@ class HueLight(CoordinatorEntity, LightEntity): source = self.light.action if self.is_group else self.light.state if mode in ("xy", "hs") and "xy" in source: - return color.color_xy_to_hs(*source["xy"], self.gamut) + return color_util.color_xy_to_hs(*source["xy"], self.gamut) return None @property - def color_temp(self): - """Return the CT color value.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" # Don't return color temperature unless in color temperature mode if self._color_mode != "ct": return None - if self.is_group: - return self.light.action.get("ct") - return self.light.state.get("ct") + ct = ( + self.light.action.get("ct") if self.is_group else self.light.state.get("ct") + ) + return color_util.color_temperature_mired_to_kelvin(ct) if ct else None @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" if self.is_group: - return super().min_mireds + return DEFAULT_MAX_KELVIN min_mireds = self.light.controlcapabilities.get("ct", {}).get("min") # We filter out '0' too, which can be incorrectly reported by 3rd party buls if not min_mireds: - return super().min_mireds + return DEFAULT_MAX_KELVIN - return min_mireds + return color_util.color_temperature_mired_to_kelvin(min_mireds) @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" if self.is_group: - return super().max_mireds + return DEFAULT_MIN_KELVIN if self.is_livarno: - return 500 + return 2000 # 500 mireds max_mireds = self.light.controlcapabilities.get("ct", {}).get("max") if not max_mireds: - return super().max_mireds + return DEFAULT_MIN_KELVIN - return max_mireds + return color_util.color_temperature_mired_to_kelvin(max_mireds) @property def is_on(self): @@ -541,11 +544,14 @@ class HueLight(CoordinatorEntity, LightEntity): # Philips hue bulb models respond differently to hue/sat # requests, so we convert to XY first to ensure a consistent # color. - xy_color = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], self.gamut) + xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], self.gamut) command["xy"] = xy_color - elif ATTR_COLOR_TEMP in kwargs: - temp = kwargs[ATTR_COLOR_TEMP] - command["ct"] = max(self.min_mireds, min(temp, self.max_mireds)) + elif ATTR_COLOR_TEMP_KELVIN in kwargs: + temp_k = max( + self.min_color_temp_kelvin, + min(self.max_color_temp_kelvin, kwargs[ATTR_COLOR_TEMP_KELVIN]), + ) + command["ct"] = color_util.color_temperature_kelvin_to_mired(temp_k) if ATTR_BRIGHTNESS in kwargs: command["bri"] = hass_to_hue_brightness(kwargs[ATTR_BRIGHTNESS]) diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 97ff6feffa5..c7f966ce9f2 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -12,7 +12,7 @@ from aiohue.v2.models.feature import DynamicStatus from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_FLASH, ATTR_TRANSITION, ATTR_XY_COLOR, @@ -27,6 +27,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.entity_registry as er +from homeassistant.util import color as color_util from ..bridge import HueBridge from ..const import DOMAIN @@ -157,7 +158,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity): """Turn the grouped_light on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP)) + color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN)) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) flash = kwargs.get(ATTR_FLASH) @@ -235,9 +236,21 @@ class GroupedHueLight(HueBaseEntity, LightEntity): if color_temp := light.color_temperature: lights_with_color_temp_support += 1 # we assume mired values from the first capable light - self._attr_color_temp = color_temp.mirek - self._attr_max_mireds = color_temp.mirek_schema.mirek_maximum - self._attr_min_mireds = color_temp.mirek_schema.mirek_minimum + self._attr_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(color_temp.mirek) + if color_temp.mirek + else None + ) + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin( + color_temp.mirek_schema.mirek_maximum + ) + ) + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin( + color_temp.mirek_schema.mirek_minimum + ) + ) if color_temp.mirek is not None and color_temp.mirek_valid: lights_in_colortemp_mode += 1 if color := light.color: diff --git a/homeassistant/components/hue/v2/helpers.py b/homeassistant/components/hue/v2/helpers.py index 480296760e7..384d2a30596 100644 --- a/homeassistant/components/hue/v2/helpers.py +++ b/homeassistant/components/hue/v2/helpers.py @@ -2,6 +2,8 @@ from __future__ import annotations +from homeassistant.util import color as color_util + def normalize_hue_brightness(brightness: float | None) -> float | None: """Return calculated brightness values.""" @@ -21,10 +23,11 @@ def normalize_hue_transition(transition: float | None) -> float | None: return transition -def normalize_hue_colortemp(colortemp: int | None) -> int | None: +def normalize_hue_colortemp(colortemp_k: int | None) -> int | None: """Return color temperature within Hue's ranges.""" - if colortemp is not None: - # Hue only accepts a range between 153..500 - colortemp = min(colortemp, 500) - colortemp = max(colortemp, 153) - return colortemp + if colortemp_k is None: + return None + colortemp = color_util.color_temperature_kelvin_to_mired(colortemp_k) + # Hue only accepts a range between 153..500 + colortemp = min(colortemp, 500) + return max(colortemp, 153) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 053b3c19c2d..86d8cc93e54 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -13,7 +13,7 @@ from aiohue.v2.models.light import Light from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_TRANSITION, @@ -28,6 +28,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from ..bridge import HueBridge from ..const import DOMAIN @@ -39,9 +40,9 @@ from .helpers import ( ) EFFECT_NONE = "None" -FALLBACK_MIN_MIREDS = 153 # 6500 K -FALLBACK_MAX_MIREDS = 500 # 2000 K -FALLBACK_MIREDS = 173 # halfway +FALLBACK_MIN_KELVIN = 6500 +FALLBACK_MAX_KELVIN = 2000 +FALLBACK_KELVIN = 5800 # halfway async def async_setup_entry( @@ -164,28 +165,32 @@ class HueLight(HueBaseEntity, LightEntity): return None @property - def color_temp(self) -> int: - """Return the color temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" if color_temp := self.resource.color_temperature: - return color_temp.mirek + return color_util.color_temperature_mired_to_kelvin(color_temp.mirek) # return a fallback value to prevent issues with mired->kelvin conversions - return FALLBACK_MIREDS + return FALLBACK_KELVIN @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" if color_temp := self.resource.color_temperature: - return color_temp.mirek_schema.mirek_minimum + return color_util.color_temperature_mired_to_kelvin( + color_temp.mirek_schema.mirek_minimum + ) # return a fallback value to prevent issues with mired->kelvin conversions - return FALLBACK_MIN_MIREDS + return FALLBACK_MAX_KELVIN @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" if color_temp := self.resource.color_temperature: - return color_temp.mirek_schema.mirek_maximum + return color_util.color_temperature_mired_to_kelvin( + color_temp.mirek_schema.mirek_maximum + ) # return a fallback value to prevent issues with mired->kelvin conversions - return FALLBACK_MAX_MIREDS + return FALLBACK_MIN_KELVIN @property def extra_state_attributes(self) -> dict[str, str] | None: @@ -210,7 +215,7 @@ class HueLight(HueBaseEntity, LightEntity): """Turn the device on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP)) + color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN)) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) if self._last_brightness and brightness is None: # The Hue bridge sets the brightness to 1% when turning on a bulb diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index b556a6961bb..de9384edda6 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -4,11 +4,10 @@ from __future__ import annotations from datetime import timedelta from enum import StrEnum -from functools import partial import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -22,11 +21,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.deprecation import ( - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -34,9 +28,6 @@ from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 - _DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER, - _DEPRECATED_DEVICE_CLASS_HUMIDIFIER, - _DEPRECATED_SUPPORT_MODES, ATTR_ACTION, ATTR_AVAILABLE_MODES, ATTR_CURRENT_HUMIDITY, @@ -179,7 +170,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT ATTR_MAX_HUMIDITY: self.max_humidity, } - if HumidifierEntityFeature.MODES in self.supported_features_compat: + if HumidifierEntityFeature.MODES in self.supported_features: data[ATTR_AVAILABLE_MODES] = self.available_modes return data @@ -208,7 +199,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT if self.target_humidity is not None: data[ATTR_HUMIDITY] = self.target_humidity - if HumidifierEntityFeature.MODES in self.supported_features_compat: + if HumidifierEntityFeature.MODES in self.supported_features: data[ATTR_MODE] = self.mode return data @@ -275,19 +266,6 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT """Return the list of supported features.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> HumidifierEntityFeature: - """Return the supported features as HumidifierEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = HumidifierEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - async def async_service_humidity_set( entity: HumidifierEntity, service_call: ServiceCall @@ -314,13 +292,3 @@ async def async_service_humidity_set( ) await entity.async_set_humidity(humidity) - - -# As we import deprecated constants from the const module, we need to add these two functions -# otherwise this module will be logged for using deprecated constants and not the custom component -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index 03ff0774ca0..ceef0c5a890 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -1,15 +1,6 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum -from functools import partial - -from homeassistant.helpers.deprecation import ( - DeprecatedConstant, - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) MODE_NORMAL = "normal" MODE_ECO = "eco" @@ -43,15 +34,6 @@ DEFAULT_MAX_HUMIDITY = 100 DOMAIN = "humidifier" -# DEVICE_CLASS_* below are deprecated as of 2021.12 -# use the HumidifierDeviceClass enum instead. -_DEPRECATED_DEVICE_CLASS_HUMIDIFIER = DeprecatedConstant( - "humidifier", "HumidifierDeviceClass.HUMIDIFIER", "2025.1" -) -_DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER = DeprecatedConstant( - "dehumidifier", "HumidifierDeviceClass.DEHUMIDIFIER", "2025.1" -) - SERVICE_SET_MODE = "set_mode" SERVICE_SET_HUMIDITY = "set_humidity" @@ -60,17 +42,3 @@ class HumidifierEntityFeature(IntFlag): """Supported features of the humidifier entity.""" MODES = 1 - - -# The SUPPORT_MODES constant is deprecated as of Home Assistant 2022.5. -# Please use the HumidifierEntityFeature enum instead. -_DEPRECATED_SUPPORT_MODES = DeprecatedConstantEnum( - HumidifierEntityFeature.MODES, "2025.1" -) - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index debb9710dbd..c53c08c8ac7 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -7,11 +7,12 @@ from typing import TYPE_CHECKING, Any, Self import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, HUB_EXCEPTIONS from .util import async_connect_hub @@ -110,7 +111,7 @@ class PowerviewConfigFlow(ConfigFlow, domain=DOMAIN): return info, None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" self.discovered_ip = discovery_info.ip @@ -118,7 +119,7 @@ class PowerviewConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.discovered_ip = discovery_info.host @@ -128,7 +129,7 @@ class PowerviewConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self.discovered_ip = discovery_info.host diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 822f81f5f75..a08256fb0b5 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -9,16 +9,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import ( - aiohttp_client, - config_entry_oauth2_flow, - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.util import dt as dt_util from . import api -from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -62,10 +56,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> raise ConfigEntryAuthFailed from err raise ConfigEntryNotReady from err - coordinator = AutomowerDataUpdateCoordinator(hass, automower_api, entry) + if "amc:api" not in entry.data["token"]["scope"]: + # We raise ConfigEntryAuthFailed here because the websocket can't be used + # without the scope. So only polling would be possible. + raise ConfigEntryAuthFailed + + coordinator = AutomowerDataUpdateCoordinator(hass, automower_api) await coordinator.async_config_entry_first_refresh() - available_devices = list(coordinator.data) - cleanup_removed_devices(hass, coordinator.config_entry, available_devices) entry.runtime_data = coordinator entry.async_create_background_task( @@ -74,11 +71,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> "websocket_task", ) - if "amc:api" not in entry.data["token"]["scope"]: - # We raise ConfigEntryAuthFailed here because the websocket can't be used - # without the scope. So only polling would be possible. - raise ConfigEntryAuthFailed - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -86,34 +78,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> bool: """Handle unload of an entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def cleanup_removed_devices( - hass: HomeAssistant, config_entry: ConfigEntry, available_devices: list[str] -) -> None: - """Cleanup entity and device registry from removed devices.""" - device_reg = dr.async_get(hass) - identifiers = {(DOMAIN, mower_id) for mower_id in available_devices} - for device in dr.async_entries_for_config_entry(device_reg, config_entry.entry_id): - if not set(device.identifiers) & identifiers: - _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) - - -def remove_work_area_entities( - hass: HomeAssistant, - config_entry: ConfigEntry, - removed_work_areas: set[int], - mower_id: str, -) -> None: - """Remove all unused work area entities for the specified mower.""" - entity_reg = er.async_get(hass) - for entity_entry in er.async_entries_for_config_entry( - entity_reg, config_entry.entry_id - ): - for work_area_id in removed_work_areas: - if entity_entry.unique_id.startswith(f"{mower_id}_{work_area_id}_"): - _LOGGER.info("Deleting: %s", entity_entry.entity_id) - entity_reg.async_remove(entity_entry.entity_id) diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py index f8b8f155458..907d34e812a 100644 --- a/homeassistant/components/husqvarna_automower/binary_sensor.py +++ b/homeassistant/components/husqvarna_automower/binary_sensor.py @@ -30,6 +30,8 @@ from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: @@ -73,11 +75,16 @@ async def async_setup_entry( ) -> None: """Set up binary sensor platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerBinarySensorEntity(mower_id, coordinator, description) - for mower_id in coordinator.data - for description in MOWER_BINARY_SENSOR_TYPES - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerBinarySensorEntity(mower_id, coordinator, description) + for mower_id in mower_ids + for description in MOWER_BINARY_SENSOR_TYPES + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerBinarySensorEntity(AutomowerBaseEntity, BinarySensorEntity): diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 22a732ec54c..7e6e581cdf1 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -22,6 +22,8 @@ from .entity import ( _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class AutomowerButtonEntityDescription(ButtonEntityDescription): @@ -56,12 +58,17 @@ async def async_setup_entry( ) -> None: """Set up button platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerButtonEntity(mower_id, coordinator, description) - for mower_id in coordinator.data - for description in MOWER_BUTTON_TYPES - if description.exists_fn(coordinator.data[mower_id]) - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerButtonEntity(mower_id, coordinator, description) + for mower_id in mower_ids + for description in MOWER_BUTTON_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index d4162af0c5c..9e2ea037afb 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -15,6 +15,8 @@ from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 async def async_setup_entry( @@ -24,9 +26,14 @@ async def async_setup_entry( ) -> None: """Set up lawn mower platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerCalendarEntity(mower_id, coordinator) for mower_id in coordinator.data - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerCalendarEntity(mower_id, coordinator) for mower_id in mower_ids + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py index 4da3bd14089..7efed529453 100644 --- a/homeassistant/components/husqvarna_automower/config_flow.py +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -53,10 +53,10 @@ class HusqvarnaConfigFlowHandler( tz = await dt_util.async_get_time_zone(str(dt_util.DEFAULT_TIME_ZONE)) automower_api = AutomowerSession(AsyncConfigFlowAuth(websession, token), tz) try: - data = await automower_api.get_status() + status_data = await automower_api.get_status() except Exception: # noqa: BLE001 return self.async_abort(reason="unknown") - if data == {}: + if status_data == {}: return self.async_abort(reason="no_mower_connected") structured_token = structure_token(token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index c19f37a040d..a587b4f3821 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -1,25 +1,32 @@ """Data UpdateCoordinator for the Husqvarna Automower integration.""" +from __future__ import annotations + import asyncio +from collections.abc import Callable from datetime import timedelta import logging +from typing import TYPE_CHECKING from aioautomower.exceptions import ( - ApiException, - AuthException, + ApiError, + AuthError, + HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, - TimeoutException, ) from aioautomower.model import MowerAttributes from aioautomower.session import AutomowerSession -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN +if TYPE_CHECKING: + from . import AutomowerConfigEntry + _LOGGER = logging.getLogger(__name__) MAX_WS_RECONNECT_TIME = 600 SCAN_INTERVAL = timedelta(minutes=8) @@ -29,11 +36,9 @@ DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): """Class to manage fetching Husqvarna data.""" - config_entry: ConfigEntry + config_entry: AutomowerConfigEntry - def __init__( - self, hass: HomeAssistant, api: AutomowerSession, entry: ConfigEntry - ) -> None: + def __init__(self, hass: HomeAssistant, api: AutomowerSession) -> None: """Initialize data updater.""" super().__init__( hass, @@ -44,6 +49,12 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib self.api = api self.ws_connected: bool = False self.reconnect_time = DEFAULT_RECONNECT_TIME + self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] + self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] + self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] + self._devices_last_update: set[str] = set() + self._zones_last_update: dict[str, set[str]] = {} + self._areas_last_update: dict[str, set[int]] = {} async def _async_update_data(self) -> dict[str, MowerAttributes]: """Subscribe for websocket and poll data from the API.""" @@ -52,12 +63,21 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib self.api.register_data_callback(self.callback) self.ws_connected = True try: - return await self.api.get_status() - except ApiException as err: + data = await self.api.get_status() + except ApiError as err: raise UpdateFailed(err) from err - except AuthException as err: + except AuthError as err: raise ConfigEntryAuthFailed(err) from err + self._async_add_remove_devices(data) + for mower_id in data: + if data[mower_id].capabilities.stay_out_zones: + self._async_add_remove_stay_out_zones(data) + for mower_id in data: + if data[mower_id].capabilities.work_areas: + self._async_add_remove_work_areas(data) + return data + @callback def callback(self, ws_data: dict[str, MowerAttributes]) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" @@ -66,7 +86,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib async def client_listen( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: AutomowerConfigEntry, automower_client: AutomowerSession, ) -> None: """Listen with the client.""" @@ -80,7 +100,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib "Failed to connect to websocket. Trying to reconnect: %s", err, ) - except TimeoutException as err: + except HusqvarnaTimeoutError as err: _LOGGER.debug( "Failed to listen to websocket. Trying to reconnect: %s", err, @@ -93,3 +113,136 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib self.client_listen(hass, entry, automower_client), "reconnect_task", ) + + def _async_add_remove_devices(self, data: dict[str, MowerAttributes]) -> None: + """Add new device, remove non-existing device.""" + current_devices = set(data) + + # Skip update if no changes + if current_devices == self._devices_last_update: + return + + # Process removed devices + removed_devices = self._devices_last_update - current_devices + if removed_devices: + _LOGGER.debug("Removed devices: %s", ", ".join(map(str, removed_devices))) + self._remove_device(removed_devices) + + # Process new device + new_devices = current_devices - self._devices_last_update + if new_devices: + _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) + self._add_new_devices(new_devices) + + # Update device state + self._devices_last_update = current_devices + + def _remove_device(self, removed_devices: set[str]) -> None: + """Remove device from the registry.""" + device_registry = dr.async_get(self.hass) + for mower_id in removed_devices: + if device := device_registry.async_get_device( + identifiers={(DOMAIN, str(mower_id))} + ): + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + def _add_new_devices(self, new_devices: set[str]) -> None: + """Add new device and trigger callbacks.""" + for mower_callback in self.new_devices_callbacks: + mower_callback(new_devices) + + def _async_add_remove_stay_out_zones( + self, data: dict[str, MowerAttributes] + ) -> None: + """Add new stay-out zones, remove non-existing stay-out zones.""" + current_zones = { + mower_id: set(mower_data.stay_out_zones.zones) + for mower_id, mower_data in data.items() + if mower_data.capabilities.stay_out_zones + and mower_data.stay_out_zones is not None + } + + if not self._zones_last_update: + self._zones_last_update = current_zones + return + + if current_zones == self._zones_last_update: + return + + self._zones_last_update = self._update_stay_out_zones(current_zones) + + def _update_stay_out_zones( + self, current_zones: dict[str, set[str]] + ) -> dict[str, set[str]]: + """Update stay-out zones by adding and removing as needed.""" + new_zones = { + mower_id: zones - self._zones_last_update.get(mower_id, set()) + for mower_id, zones in current_zones.items() + } + removed_zones = { + mower_id: self._zones_last_update.get(mower_id, set()) - zones + for mower_id, zones in current_zones.items() + } + + for mower_id, zones in new_zones.items(): + for zone_callback in self.new_zones_callbacks: + zone_callback(mower_id, set(zones)) + + entity_registry = er.async_get(self.hass) + for mower_id, zones in removed_zones.items(): + for entity_entry in er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ): + for zone in zones: + if entity_entry.unique_id.startswith(f"{mower_id}_{zone}"): + entity_registry.async_remove(entity_entry.entity_id) + + return current_zones + + def _async_add_remove_work_areas(self, data: dict[str, MowerAttributes]) -> None: + """Add new work areas, remove non-existing work areas.""" + current_areas = { + mower_id: set(mower_data.work_areas) + for mower_id, mower_data in data.items() + if mower_data.capabilities.work_areas and mower_data.work_areas is not None + } + + if not self._areas_last_update: + self._areas_last_update = current_areas + return + + if current_areas == self._areas_last_update: + return + + self._areas_last_update = self._update_work_areas(current_areas) + + def _update_work_areas( + self, current_areas: dict[str, set[int]] + ) -> dict[str, set[int]]: + """Update work areas by adding and removing as needed.""" + new_areas = { + mower_id: areas - self._areas_last_update.get(mower_id, set()) + for mower_id, areas in current_areas.items() + } + removed_areas = { + mower_id: self._areas_last_update.get(mower_id, set()) - areas + for mower_id, areas in current_areas.items() + } + + for mower_id, areas in new_areas.items(): + for area_callback in self.new_areas_callbacks: + area_callback(mower_id, set(areas)) + + entity_registry = er.async_get(self.hass) + for mower_id, areas in removed_areas.items(): + for entity_entry in er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ): + for area in areas: + if entity_entry.unique_id.startswith(f"{mower_id}_{area}_"): + entity_registry.async_remove(entity_entry.entity_id) + + return current_areas diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py index 5e84b7cc67d..2fd59b63014 100644 --- a/homeassistant/components/husqvarna_automower/device_tracker.py +++ b/homeassistant/components/husqvarna_automower/device_tracker.py @@ -8,6 +8,9 @@ from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -16,11 +19,16 @@ async def async_setup_entry( ) -> None: """Set up device tracker platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerDeviceTrackerEntity(mower_id, coordinator) - for mower_id in coordinator.data - if coordinator.data[mower_id].capabilities.position - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerDeviceTrackerEntity(mower_id, coordinator) + for mower_id in mower_ids + if coordinator.data[mower_id].capabilities.position + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + _async_add_new_devices(set(coordinator.data)) class AutomowerDeviceTrackerEntity(AutomowerBaseEntity, TrackerEntity): diff --git a/homeassistant/components/husqvarna_automower/diagnostics.py b/homeassistant/components/husqvarna_automower/diagnostics.py index 658f6f94445..ceeec0f3e0d 100644 --- a/homeassistant/components/husqvarna_automower/diagnostics.py +++ b/homeassistant/components/husqvarna_automower/diagnostics.py @@ -6,7 +6,6 @@ import logging from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -26,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: AutomowerConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return async_redact_data(entry.as_dict(), TO_REDACT) diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index da6c0ae59ce..150a3d18d87 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -1,12 +1,14 @@ """Platform for Husqvarna Automower base entity.""" +from __future__ import annotations + import asyncio -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine import functools import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Concatenate -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerActivities, MowerAttributes, MowerStates, WorkArea from homeassistant.core import callback @@ -52,21 +54,20 @@ def _work_area_translation_key(work_area_id: int, key: str) -> str: return f"work_area_{key}" -def handle_sending_exception( +type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]] + + +def handle_sending_exception[_Entity: AutomowerBaseEntity, **_P]( poll_after_sending: bool = False, -) -> Callable[ - [Callable[..., Awaitable[Any]]], Callable[..., Coroutine[Any, Any, None]] -]: +) -> Callable[[_FuncType[_Entity, _P, Any]], _FuncType[_Entity, _P, None]]: """Handle exceptions while sending a command and optionally refresh coordinator.""" - def decorator( - func: Callable[..., Awaitable[Any]], - ) -> Callable[..., Coroutine[Any, Any, None]]: + def decorator(func: _FuncType[_Entity, _P, Any]) -> _FuncType[_Entity, _P, None]: @functools.wraps(func) - async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + async def wrapper(self: _Entity, *args: _P.args, **kwargs: _P.kwargs) -> None: try: await func(self, *args, **kwargs) - except ApiException as exception: + except ApiError as exception: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_send_failed", @@ -133,7 +134,7 @@ class AutomowerControlEntity(AutomowerAvailableEntity): class WorkAreaAvailableEntity(AutomowerAvailableEntity): - """Base entity for work work areas.""" + """Base entity for work areas.""" def __init__( self, @@ -164,4 +165,4 @@ class WorkAreaAvailableEntity(AutomowerAvailableEntity): class WorkAreaControlEntity(WorkAreaAvailableEntity, AutomowerControlEntity): - """Base entity work work areas with control function.""" + """Base entity for work areas with control function.""" diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index eeabaa09f79..dd75a8b9bc4 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -22,6 +22,10 @@ from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerAvailableEntity, handle_sending_exception +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING) MOWING_ACTIVITIES = ( MowerActivities.MOWING, @@ -42,9 +46,6 @@ PARK = "park" OVERRIDE_MODES = [MOW, PARK] -_LOGGER = logging.getLogger(__name__) - - async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, @@ -52,10 +53,15 @@ async def async_setup_entry( ) -> None: """Set up lawn mower platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in coordinator.data - ) + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + [AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in mower_ids] + ) + + _async_add_new_devices(set(coordinator.data)) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( "override_schedule", diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index d22d23583ba..0eabf5ec0d6 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", "iot_class": "cloud_push", "loggers": ["aioautomower"], - "requirements": ["aioautomower==2024.10.3"] + "quality_scale": "silver", + "requirements": ["aioautomower==2025.1.1"] } diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index d6d794f2d83..d3666494646 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -13,7 +13,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AutomowerConfigEntry, remove_work_area_entities +from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator from .entity import ( AutomowerControlEntity, @@ -24,6 +24,8 @@ from .entity import ( _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + @callback def _async_get_cutting_height(data: MowerAttributes) -> int: @@ -109,44 +111,47 @@ async def async_setup_entry( ) -> None: """Set up number platform.""" coordinator = entry.runtime_data - current_work_areas: dict[str, set[int]] = {} - - async_add_entities( - AutomowerNumberEntity(mower_id, coordinator, description) - for mower_id in coordinator.data - for description in MOWER_NUMBER_TYPES - if description.exists_fn(coordinator.data[mower_id]) - ) - - def _async_work_area_listener() -> None: - """Listen for new work areas and add/remove entities as needed.""" - for mower_id in coordinator.data: - if ( - coordinator.data[mower_id].capabilities.work_areas - and (_work_areas := coordinator.data[mower_id].work_areas) is not None - ): - received_work_areas = set(_work_areas.keys()) - current_work_area_set = current_work_areas.setdefault(mower_id, set()) - - new_work_areas = received_work_areas - current_work_area_set - removed_work_areas = current_work_area_set - received_work_areas - - if new_work_areas: - current_work_area_set.update(new_work_areas) - async_add_entities( - WorkAreaNumberEntity( - mower_id, coordinator, description, work_area_id - ) - for description in WORK_AREA_NUMBER_TYPES - for work_area_id in new_work_areas + entities: list[NumberEntity] = [] + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.work_areas: + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + entities.extend( + WorkAreaNumberEntity( + mower_id, coordinator, description, work_area_id ) + for description in WORK_AREA_NUMBER_TYPES + for work_area_id in _work_areas + ) + entities.extend( + AutomowerNumberEntity(mower_id, coordinator, description) + for description in MOWER_NUMBER_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + async_add_entities(entities) - if removed_work_areas: - remove_work_area_entities(hass, entry, removed_work_areas, mower_id) - current_work_area_set.difference_update(removed_work_areas) + def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None: + async_add_entities( + WorkAreaNumberEntity(mower_id, coordinator, description, work_area_id) + for description in WORK_AREA_NUMBER_TYPES + for work_area_id in work_area_ids + ) - coordinator.async_add_listener(_async_work_area_listener) - _async_work_area_listener() + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerNumberEntity(mower_id, coordinator, description) + for description in MOWER_NUMBER_TYPES + for mower_id in mower_ids + if description.exists_fn(coordinator.data[mower_id]) + ) + for mower_id in mower_ids: + mower_data = coordinator.data[mower_id] + if mower_data.capabilities.work_areas and mower_data.work_areas is not None: + work_area_ids = set(mower_data.work_areas.keys()) + _async_add_new_work_areas(mower_id, work_area_ids) + + coordinator.new_areas_callbacks.append(_async_add_new_work_areas) + coordinator.new_devices_callbacks.append(_async_add_new_devices) class AutomowerNumberEntity(AutomowerControlEntity, NumberEntity): diff --git a/homeassistant/components/husqvarna_automower/quality_scale.yaml b/homeassistant/components/husqvarna_automower/quality_scale.yaml new file mode 100644 index 00000000000..2fa41c02a4c --- /dev/null +++ b/homeassistant/components/husqvarna_automower/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + dependency-transparency: done + action-setup: + status: done + comment: | + The integration only has an entity service, registered in the platform. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: no configuration options + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: todo + comment: Discovery not implemented, yet. + discovery: + status: todo + comment: | + Most of the mowers are connected with a SIM card, some of the also have a + Wifi connection. Check, if discovery with Wifi is possible + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: no configuration possible + repair-issues: done + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py index a9431acaae3..03b1ac02587 100644 --- a/homeassistant/components/husqvarna_automower/select.py +++ b/homeassistant/components/husqvarna_automower/select.py @@ -16,6 +16,7 @@ from .entity import AutomowerControlEntity, handle_sending_exception _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 HEADLIGHT_MODES: list = [ HeadlightModes.ALWAYS_OFF.lower(), @@ -32,11 +33,17 @@ async def async_setup_entry( ) -> None: """Set up select platform.""" coordinator = entry.runtime_data - async_add_entities( - AutomowerSelectEntity(mower_id, coordinator) - for mower_id in coordinator.data - if coordinator.data[mower_id].capabilities.headlights - ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerSelectEntity(mower_id, coordinator) + for mower_id in mower_ids + if coordinator.data[mower_id].capabilities.headlights + ) + + _async_add_new_devices(set(coordinator.data)) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity): diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index ebb68033918..a2f4b5f4bab 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -35,6 +35,8 @@ from .entity import ( ) _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment" @@ -349,6 +351,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( key="number_of_collisions", translation_key="number_of_collisions", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL, exists_fn=lambda data: data.statistics.number_of_collisions is not None, value_fn=attrgetter("statistics.number_of_collisions"), @@ -431,44 +434,56 @@ async def async_setup_entry( ) -> None: """Set up sensor platform.""" coordinator = entry.runtime_data - current_work_areas: dict[str, set[int]] = {} - - async_add_entities( - AutomowerSensorEntity(mower_id, coordinator, description) - for mower_id, data in coordinator.data.items() - for description in MOWER_SENSOR_TYPES - if description.exists_fn(data) - ) - - def _async_work_area_listener() -> None: - """Listen for new work areas and add sensor entities if they did not exist. - - Listening for deletable work areas is managed in the number platform. - """ - for mower_id in coordinator.data: - if ( - coordinator.data[mower_id].capabilities.work_areas - and (_work_areas := coordinator.data[mower_id].work_areas) is not None - ): - received_work_areas = set(_work_areas.keys()) - new_work_areas = received_work_areas - current_work_areas.get( - mower_id, set() + entities: list[SensorEntity] = [] + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.work_areas: + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + entities.extend( + WorkAreaSensorEntity( + mower_id, coordinator, description, work_area_id + ) + for description in WORK_AREA_SENSOR_TYPES + for work_area_id in _work_areas + if description.exists_fn(_work_areas[work_area_id]) ) - if new_work_areas: - current_work_areas.setdefault(mower_id, set()).update( - new_work_areas - ) - async_add_entities( - WorkAreaSensorEntity( - mower_id, coordinator, description, work_area_id - ) - for description in WORK_AREA_SENSOR_TYPES - for work_area_id in new_work_areas - if description.exists_fn(_work_areas[work_area_id]) - ) + entities.extend( + AutomowerSensorEntity(mower_id, coordinator, description) + for description in MOWER_SENSOR_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + async_add_entities(entities) - coordinator.async_add_listener(_async_work_area_listener) - _async_work_area_listener() + def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None: + mower_data = coordinator.data[mower_id] + if mower_data.work_areas is None: + return + + async_add_entities( + WorkAreaSensorEntity(mower_id, coordinator, description, work_area_id) + for description in WORK_AREA_SENSOR_TYPES + for work_area_id in work_area_ids + if work_area_id in mower_data.work_areas + and description.exists_fn(mower_data.work_areas[work_area_id]) + ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerSensorEntity(mower_id, coordinator, description) + for mower_id in mower_ids + for description in MOWER_SENSOR_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + for mower_id in mower_ids: + mower_data = coordinator.data[mower_id] + if mower_data.capabilities.work_areas and mower_data.work_areas is not None: + _async_add_new_work_areas( + mower_id, + set(mower_data.work_areas.keys()), + ) + + coordinator.new_devices_callbacks.append(_async_add_new_devices) + coordinator.new_areas_callbacks.append(_async_add_new_work_areas) class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity): diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index d4c91e29f7d..9bd0bb06b3e 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -322,7 +322,7 @@ "services": { "override_schedule": { "name": "Override schedule", - "description": "Override the schedule to either mow or park for a duration of time.", + "description": "Lets the mower either mow or park for a given duration, overriding all schedules.", "fields": { "duration": { "name": "Duration", @@ -336,7 +336,7 @@ }, "override_schedule_work_area": { "name": "Override schedule work area", - "description": "Override the schedule of the mower for a duration of time in the selected work area.", + "description": "Lets the mower mow for a given duration in a specified work area, overriding all schedules.", "fields": { "duration": { "name": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::name%]", diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py index 2bbe5c87624..d55d51b42fe 100644 --- a/homeassistant/components/husqvarna_automower/switch.py +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -7,7 +7,6 @@ from aioautomower.model import MowerModes, StayOutZones, Zone from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AutomowerConfigEntry @@ -19,6 +18,8 @@ from .entity import ( handle_sending_exception, ) +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) @@ -29,82 +30,63 @@ async def async_setup_entry( ) -> None: """Set up switch platform.""" coordinator = entry.runtime_data - current_work_areas: dict[str, set[int]] = {} - current_stay_out_zones: dict[str, set[str]] = {} - - async_add_entities( + entities: list[SwitchEntity] = [] + entities.extend( AutomowerScheduleSwitchEntity(mower_id, coordinator) for mower_id in coordinator.data ) - - def _async_work_area_listener() -> None: - """Listen for new work areas and add switch entities if they did not exist. - - Listening for deletable work areas is managed in the number platform. - """ - for mower_id in coordinator.data: - if ( - coordinator.data[mower_id].capabilities.work_areas - and (_work_areas := coordinator.data[mower_id].work_areas) is not None - ): - received_work_areas = set(_work_areas.keys()) - new_work_areas = received_work_areas - current_work_areas.get( - mower_id, set() + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.stay_out_zones: + _stay_out_zones = coordinator.data[mower_id].stay_out_zones + if _stay_out_zones is not None: + entities.extend( + StayOutZoneSwitchEntity(coordinator, mower_id, stay_out_zone_uid) + for stay_out_zone_uid in _stay_out_zones.zones ) - if new_work_areas: - current_work_areas.setdefault(mower_id, set()).update( - new_work_areas - ) - async_add_entities( - WorkAreaSwitchEntity(coordinator, mower_id, work_area_id) - for work_area_id in new_work_areas - ) + if coordinator.data[mower_id].capabilities.work_areas: + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + entities.extend( + WorkAreaSwitchEntity(coordinator, mower_id, work_area_id) + for work_area_id in _work_areas + ) + async_add_entities(entities) - def _remove_stay_out_zone_entities( - removed_stay_out_zones: set, mower_id: str + def _async_add_new_stay_out_zones( + mower_id: str, stay_out_zone_uids: set[str] ) -> None: - """Remove all unused stay-out zones for all platforms.""" - entity_reg = er.async_get(hass) - for entity_entry in er.async_entries_for_config_entry( - entity_reg, entry.entry_id - ): - for stay_out_zone_uid in removed_stay_out_zones: - if entity_entry.unique_id.startswith(f"{mower_id}_{stay_out_zone_uid}"): - entity_reg.async_remove(entity_entry.entity_id) + async_add_entities( + StayOutZoneSwitchEntity(coordinator, mower_id, zone_uid) + for zone_uid in stay_out_zone_uids + ) - def _async_stay_out_zone_listener() -> None: - """Listen for new stay-out zones and add/remove switch entities if they did not exist.""" - for mower_id in coordinator.data: + def _async_add_new_work_areas(mower_id: str, work_area_ids: set[int]) -> None: + async_add_entities( + WorkAreaSwitchEntity(coordinator, mower_id, work_area_id) + for work_area_id in work_area_ids + ) + + def _async_add_new_devices(mower_ids: set[str]) -> None: + async_add_entities( + AutomowerScheduleSwitchEntity(mower_id, coordinator) + for mower_id in mower_ids + ) + for mower_id in mower_ids: + mower_data = coordinator.data[mower_id] if ( - coordinator.data[mower_id].capabilities.stay_out_zones - and (_stay_out_zones := coordinator.data[mower_id].stay_out_zones) - is not None + mower_data.capabilities.stay_out_zones + and mower_data.stay_out_zones is not None + and mower_data.stay_out_zones.zones is not None ): - received_stay_out_zones = set(_stay_out_zones.zones) - current_stay_out_zones_set = current_stay_out_zones.get(mower_id, set()) - new_stay_out_zones = ( - received_stay_out_zones - current_stay_out_zones_set + _async_add_new_stay_out_zones( + mower_id, set(mower_data.stay_out_zones.zones.keys()) ) - removed_stay_out_zones = ( - current_stay_out_zones_set - received_stay_out_zones - ) - if new_stay_out_zones: - current_stay_out_zones.setdefault(mower_id, set()).update( - new_stay_out_zones - ) - async_add_entities( - StayOutZoneSwitchEntity( - coordinator, mower_id, stay_out_zone_uid - ) - for stay_out_zone_uid in new_stay_out_zones - ) - if removed_stay_out_zones: - _remove_stay_out_zone_entities(removed_stay_out_zones, mower_id) + if mower_data.capabilities.work_areas and mower_data.work_areas is not None: + _async_add_new_work_areas(mower_id, set(mower_data.work_areas.keys())) - coordinator.async_add_listener(_async_work_area_listener) - coordinator.async_add_listener(_async_stay_out_zone_listener) - _async_work_area_listener() - _async_stay_out_zone_listener() + coordinator.new_devices_callbacks.append(_async_add_new_devices) + coordinator.new_zones_callbacks.append(_async_add_new_stay_out_zones) + coordinator.new_areas_callbacks.append(_async_add_new_work_areas) class AutomowerScheduleSwitchEntity(AutomowerControlEntity, SwitchEntity): @@ -183,14 +165,14 @@ class StayOutZoneSwitchEntity(AutomowerControlEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.coordinator.api.commands.switch_stay_out_zone( - self.mower_id, self.stay_out_zone_uid, False + self.mower_id, self.stay_out_zone_uid, switch=False ) @handle_sending_exception(poll_after_sending=True) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.coordinator.api.commands.switch_stay_out_zone( - self.mower_id, self.stay_out_zone_uid, True + self.mower_id, self.stay_out_zone_uid, switch=True ) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index df740aea3d1..7e0e4ce5ef1 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -56,7 +56,6 @@ class HuumDevice(ClimateEntity): _target_temperature: int | None = None _status: HuumStatusResponse | None = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, huum_handler: Huum, unique_id: str) -> None: """Initialize the heater.""" diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 9e402cd4932..ea5a5801e69 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -7,7 +7,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import DOMAIN +from .const import APP_ID, DOMAIN from .coordinator import ( HydrawiseMainDataUpdateCoordinator, HydrawiseUpdateCoordinators, @@ -30,7 +30,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b raise ConfigEntryAuthFailed hydrawise = client.Hydrawise( - auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]) + auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]), + app_id=APP_ID, ) main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, hydrawise) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 34c31d3ad16..83e8a8325f9 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -68,7 +68,7 @@ ZONE_BINARY_SENSORS: tuple[HydrawiseBinarySensorEntityDescription, ...] = ( ) SCHEMA_START_WATERING: VolDictType = { - vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=90)), + vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1440)), } SCHEMA_SUSPEND: VolDictType = { vol.Required("until"): cv.datetime, diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index 242763e81e3..ed21e96cd0b 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -2,18 +2,23 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Mapping from typing import Any from aiohttp import ClientError -from pydrawise import auth, client +from pydrawise import auth as pydrawise_auth, client from pydrawise.exceptions import NotAuthorizedError import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import DOMAIN, LOGGER +from .const import APP_ID, DOMAIN, LOGGER + +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): @@ -21,69 +26,88 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _create_or_update_entry( - self, - username: str, - password: str, - *, - on_failure: Callable[[str], ConfigFlowResult], - ) -> ConfigFlowResult: - """Create the config entry.""" - - # Verify that the provided credentials work.""" - api = client.Hydrawise(auth.Auth(username, password)) - try: - # Don't fetch zones because we don't need them yet. - user = await api.get_user(fetch_zones=False) - except NotAuthorizedError: - return on_failure("invalid_auth") - except TimeoutError: - return on_failure("timeout_connect") - except ClientError as ex: - LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex) - return on_failure("cannot_connect") - - await self.async_set_unique_id(f"hydrawise-{user.customer_id}") - - if self.source != SOURCE_REAUTH: - self._abort_if_unique_id_configured() - return self.async_create_entry( - title="Hydrawise", - data={CONF_USERNAME: username, CONF_PASSWORD: password}, - ) - - return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data_updates={CONF_USERNAME: username, CONF_PASSWORD: password}, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial setup.""" - if user_input is not None: - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] + if user_input is None: + return self._show_user_form({}) + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + unique_id, errors = await _authenticate(username, password) + if errors: + return self._show_user_form(errors) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=username, + data={CONF_USERNAME: username, CONF_PASSWORD: password}, + ) - return await self._create_or_update_entry( - username=username, password=password, on_failure=self._show_form - ) - return self._show_form() - - def _show_form(self, error_type: str | None = None) -> ConfigFlowResult: - errors = {} - if error_type is not None: - errors["base"] = error_type + def _show_user_form(self, errors: dict[str, str]) -> ConfigFlowResult: return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ), - errors=errors, + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: - """Perform reauth after updating config to username/password.""" - return await self.async_step_user() + """Handle reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self._show_reauth_form({}) + + reauth_entry = self._get_reauth_entry() + username = reauth_entry.data[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + user_id, errors = await _authenticate(username, password) + if user_id is None: + return self._show_reauth_form(errors) + + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + reauth_entry, data={CONF_USERNAME: username, CONF_PASSWORD: password} + ) + + def _show_reauth_form(self, errors: dict[str, str]) -> ConfigFlowResult: + return self.async_show_form( + step_id="reauth_confirm", data_schema=STEP_REAUTH_DATA_SCHEMA, errors=errors + ) + + +async def _authenticate( + username: str, password: str +) -> tuple[str | None, dict[str, str]]: + """Authenticate with the Hydrawise API.""" + unique_id = None + errors: dict[str, str] = {} + auth = pydrawise_auth.Auth(username, password) + try: + await auth.token() + except NotAuthorizedError: + errors["base"] = "invalid_auth" + except TimeoutError: + errors["base"] = "timeout_connect" + + if errors: + return unique_id, errors + + try: + api = client.Hydrawise(auth, app_id=APP_ID) + # Don't fetch zones because we don't need them yet. + user = await api.get_user(fetch_zones=False) + except TimeoutError: + errors["base"] = "timeout_connect" + except ClientError as ex: + LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex) + errors["base"] = "cannot_connect" + else: + unique_id = f"hydrawise-{user.customer_id}" + + return unique_id, errors diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index 6d846dd6127..beaf450a586 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -3,8 +3,12 @@ from datetime import timedelta import logging +from homeassistant.const import __version__ as HA_VERSION + LOGGER = logging.getLogger(__package__) +APP_ID = f"homeassistant-{HA_VERSION}" + DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = timedelta(minutes=15) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 9678dc83e5f..de45eb061d5 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2024.9.0"] + "requirements": ["pydrawise==2025.1.0"] } diff --git a/homeassistant/components/hydrawise/services.yaml b/homeassistant/components/hydrawise/services.yaml index 64c04901816..bf90a8e23b3 100644 --- a/homeassistant/components/hydrawise/services.yaml +++ b/homeassistant/components/hydrawise/services.yaml @@ -10,7 +10,7 @@ start_watering: selector: number: min: 0 - max: 90 + max: 1440 unit_of_measurement: min mode: box suspend: diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index 4d50f10bcb2..74c63cbe758 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -8,6 +8,13 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Hydrawise integration needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index b2b7dbdf531..045fbd986cc 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -12,7 +12,6 @@ from urllib.parse import urlparse from hyperion import client, const import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -30,6 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from . import create_hyperion_client from .const import ( @@ -155,7 +155,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return await self._advance_to_auth_step_if_necessary(hyperion_client) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initiated by SSDP.""" # Sample data provided by SSDP: { @@ -210,7 +210,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): except ValueError: self._data[CONF_PORT] = const.DEFAULT_PORT_JSON - if not (hyperion_id := discovery_info.upnp.get(ssdp.ATTR_UPNP_SERIAL)): + if not (hyperion_id := discovery_info.upnp.get(ATTR_UPNP_SERIAL)): return self.async_abort(reason="no_id") # For discovery mechanisms, we set the unique_id as early as possible to diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index 01682648277..ea7bc9e39fa 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -18,7 +18,7 @@ } }, "create_token": { - "description": "Choose **Submit** below to request a new authentication token. You will be redirected to the Hyperion UI to approve the request. Please verify the shown id is \"{auth_id}\"", + "description": "Choose **Submit** below to request a new authentication token. You will be redirected to the Hyperion UI to approve the request. Please verify the shown ID is \"{auth_id}\"", "title": "Automatically create new authentication token" }, "create_token_external": { @@ -40,7 +40,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "auth_new_token_not_granted_error": "Newly created token was not approved on Hyperion UI", "auth_new_token_not_work_error": "Failed to authenticate using newly created token", - "no_id": "The Hyperion Ambilight instance did not report its id", + "no_id": "The Hyperion Ambilight instance did not report its ID", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 78da1eff071..53d1bce80de 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -54,7 +54,6 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, dev: AqualinkThermostat) -> None: """Initialize AquaLink thermostat.""" diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py index 2cb1ba4b5d7..a307c1af98d 100644 --- a/homeassistant/components/iaqualink/config_flow.py +++ b/homeassistant/components/iaqualink/config_flow.py @@ -14,6 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.httpx_client import get_async_client from .const import DOMAIN @@ -34,7 +35,9 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN): password = user_input[CONF_PASSWORD] try: - async with AqualinkClient(username, password): + async with AqualinkClient( + username, password, httpx_client=get_async_client(self.hass) + ): pass except AqualinkServiceUnauthorizedException: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/ibeacon/manifest.json b/homeassistant/components/ibeacon/manifest.json index 8bd7e3ab9cc..bdbdaea49d2 100644 --- a/homeassistant/components/ibeacon/manifest.json +++ b/homeassistant/components/ibeacon/manifest.json @@ -7,7 +7,7 @@ "manufacturer_data_start": [2, 21] } ], - "codeowners": ["@bdraco"], + "codeowners": [], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/ibeacon", diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index 22c711e919a..adc96043d66 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -56,7 +56,7 @@ }, "play_sound": { "name": "Play sound", - "description": "Plays sound on an Apple device.", + "description": "Plays the Lost device sound on an Apple device.", "fields": { "account": { "name": "Account", @@ -64,7 +64,7 @@ }, "device_name": { "name": "Device name", - "description": "The name of the Apple device to play a sound." + "description": "The name of the Apple device to play the sound." } } }, @@ -92,7 +92,7 @@ }, "lost_device": { "name": "Lost device", - "description": "Makes an Apple device in lost state.", + "description": "Puts an Apple device in lost state.", "fields": { "account": { "name": "Account", diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 56a377ac2df..671319e46eb 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -4,53 +4,31 @@ from __future__ import annotations import logging -from attr import dataclass from bleak.exc import BleakError from idasen_ha.errors import AuthFailedError from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_NAME, - CONF_ADDRESS, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from .const import DOMAIN from .coordinator import IdasenDeskCoordinator PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) - -@dataclass -class DeskData: - """Data for the Idasen Desk integration.""" - - address: str - device_info: DeviceInfo - coordinator: IdasenDeskCoordinator +type IdasenDeskConfigEntry = ConfigEntry[IdasenDeskCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IdasenDeskConfigEntry) -> bool: """Set up IKEA Idasen from a config entry.""" address: str = entry.data[CONF_ADDRESS].upper() - coordinator = IdasenDeskCoordinator(hass, _LOGGER, entry.title, address) - device_info = DeviceInfo( - name=entry.title, - connections={(dr.CONNECTION_BLUETOOTH, address)}, - ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DeskData( - address, device_info, coordinator - ) + coordinator = IdasenDeskCoordinator(hass, entry.title, address) + entry.runtime_data = coordinator try: if not await coordinator.async_connect(): @@ -89,18 +67,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: IdasenDeskConfigEntry +) -> None: """Handle options update.""" - data: DeskData = hass.data[DOMAIN][entry.entry_id] - if entry.title != data.device_info[ATTR_NAME]: - await hass.config_entries.async_reload(entry.entry_id) + await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IdasenDeskConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: DeskData = hass.data[DOMAIN].pop(entry.entry_id) - await data.coordinator.async_disconnect() - bluetooth.async_rediscover_address(hass, data.address) + coordinator = entry.runtime_data + await coordinator.async_disconnect() + bluetooth.async_rediscover_address(hass, coordinator.address) return unload_ok diff --git a/homeassistant/components/idasen_desk/button.py b/homeassistant/components/idasen_desk/button.py index 0de3125576d..cd7553da1ac 100644 --- a/homeassistant/components/idasen_desk/button.py +++ b/homeassistant/components/idasen_desk/button.py @@ -6,14 +6,12 @@ import logging from typing import Any, Final from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DeskData, IdasenDeskCoordinator -from .const import DOMAIN +from . import IdasenDeskConfigEntry, IdasenDeskCoordinator +from .entity import IdasenDeskEntity _LOGGER = logging.getLogger(__name__) @@ -45,43 +43,38 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IdasenDeskConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set buttons for device.""" - data: DeskData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - IdasenDeskButton(data.address, data.device_info, data.coordinator, button) - for button in BUTTONS - ) + coordinator = entry.runtime_data + async_add_entities(IdasenDeskButton(coordinator, button) for button in BUTTONS) -class IdasenDeskButton(ButtonEntity): +class IdasenDeskButton(IdasenDeskEntity, ButtonEntity): """Defines a IdasenDesk button.""" entity_description: IdasenDeskButtonDescription - _attr_has_entity_name = True def __init__( self, - address: str, - device_info: DeviceInfo, coordinator: IdasenDeskCoordinator, description: IdasenDeskButtonDescription, ) -> None: """Initialize the IdasenDesk button entity.""" + super().__init__(f"{description.key}-{coordinator.address}", coordinator) self.entity_description = description - self._attr_unique_id = f"{description.key}-{address}" - self._attr_device_info = device_info - self._address = address - self._coordinator = coordinator - async def async_press(self) -> None: """Triggers the IdasenDesk button press service.""" _LOGGER.debug( "Trigger %s for %s", self.entity_description.key, - self._address, + self.coordinator.address, ) - await self.entity_description.press_action(self._coordinator)() + await self.entity_description.press_action(self.coordinator)() + + @property + def available(self) -> bool: + """Connect/disconnect buttons should always be available.""" + return True diff --git a/homeassistant/components/idasen_desk/coordinator.py b/homeassistant/components/idasen_desk/coordinator.py index 0661f2dede1..d9e90cfe5ea 100644 --- a/homeassistant/components/idasen_desk/coordinator.py +++ b/homeassistant/components/idasen_desk/coordinator.py @@ -19,27 +19,26 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): def __init__( self, hass: HomeAssistant, - logger: logging.Logger, name: str, address: str, ) -> None: """Init IdasenDeskCoordinator.""" - super().__init__(hass, logger, name=name) - self._address = address + super().__init__(hass, _LOGGER, name=name) + self.address = address self._expected_connected = False self.desk = Desk(self.async_set_updated_data) async def async_connect(self) -> bool: """Connect to desk.""" - _LOGGER.debug("Trying to connect %s", self._address) + _LOGGER.debug("Trying to connect %s", self.address) self._expected_connected = True ble_device = bluetooth.async_ble_device_from_address( - self.hass, self._address, connectable=True + self.hass, self.address, connectable=True ) if ble_device is None: - _LOGGER.debug("No BLEDevice for %s", self._address) + _LOGGER.debug("No BLEDevice for %s", self.address) return False await self.desk.connect(ble_device) return True @@ -47,7 +46,7 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): async def async_disconnect(self) -> None: """Disconnect from desk.""" self._expected_connected = False - _LOGGER.debug("Disconnecting from %s", self._address) + _LOGGER.debug("Disconnecting from %s", self.address) await self.desk.disconnect() async def async_connect_if_expected(self) -> None: diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py index eb6bf5523de..a8ba0983e99 100644 --- a/homeassistant/components/idasen_desk/cover.py +++ b/homeassistant/components/idasen_desk/cover.py @@ -12,30 +12,25 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DeskData, IdasenDeskCoordinator -from .const import DOMAIN +from . import IdasenDeskConfigEntry, IdasenDeskCoordinator +from .entity import IdasenDeskEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IdasenDeskConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the cover platform for Idasen Desk.""" - data: DeskData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [IdasenDeskCover(data.address, data.device_info, data.coordinator)] - ) + coordinator = entry.runtime_data + async_add_entities([IdasenDeskCover(coordinator)]) -class IdasenDeskCover(CoordinatorEntity[IdasenDeskCoordinator], CoverEntity): +class IdasenDeskCover(IdasenDeskEntity, CoverEntity): """Representation of Idasen Desk device.""" _attr_device_class = CoverDeviceClass.DAMPER @@ -45,28 +40,12 @@ class IdasenDeskCover(CoordinatorEntity[IdasenDeskCoordinator], CoverEntity): | CoverEntityFeature.STOP | CoverEntityFeature.SET_POSITION ) - _attr_has_entity_name = True _attr_name = None _attr_translation_key = "desk" - def __init__( - self, - address: str, - device_info: DeviceInfo, - coordinator: IdasenDeskCoordinator, - ) -> None: + def __init__(self, coordinator: IdasenDeskCoordinator) -> None: """Initialize an Idasen Desk cover.""" - super().__init__(coordinator) - self._desk = coordinator.desk - self._attr_unique_id = address - self._attr_device_info = device_info - - self._attr_current_cover_position = self._desk.height_percent - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self._desk.is_connected is True + super().__init__(coordinator.address, coordinator) @property def is_closed(self) -> bool: @@ -103,8 +82,7 @@ class IdasenDeskCover(CoordinatorEntity[IdasenDeskCoordinator], CoverEntity): "Failed to move to specified position: Bluetooth error" ) from err - @callback - def _handle_coordinator_update(self, *args: Any) -> None: - """Handle data update.""" - self._attr_current_cover_position = self._desk.height_percent - self.async_write_ha_state() + @property + def current_cover_position(self) -> int | None: + """Return the current cover position.""" + return self._desk.height_percent diff --git a/homeassistant/components/idasen_desk/entity.py b/homeassistant/components/idasen_desk/entity.py new file mode 100644 index 00000000000..bda7afd528c --- /dev/null +++ b/homeassistant/components/idasen_desk/entity.py @@ -0,0 +1,34 @@ +"""Base entity for Idasen Desk.""" + +from __future__ import annotations + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import IdasenDeskCoordinator + + +class IdasenDeskEntity(CoordinatorEntity[IdasenDeskCoordinator]): + """IdasenDesk sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + unique_id: str, + coordinator: IdasenDeskCoordinator, + ) -> None: + """Initialize the IdasenDesk sensor entity.""" + super().__init__(coordinator) + + self._attr_unique_id = unique_id + self._attr_device_info = dr.DeviceInfo( + manufacturer="LINAK", + connections={(dr.CONNECTION_BLUETOOTH, coordinator.address)}, + ) + self._desk = coordinator.desk + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._desk.is_connected is True diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 0f8c9eaafc9..9e83347f098 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -10,6 +10,8 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/idasen_desk", + "integration_type": "device", "iot_class": "local_push", - "requirements": ["idasen-ha==2.6.2"] + "quality_scale": "bronze", + "requirements": ["idasen-ha==2.6.3"] } diff --git a/homeassistant/components/idasen_desk/quality_scale.yaml b/homeassistant/components/idasen_desk/quality_scale.yaml new file mode 100644 index 00000000000..34bf97d9496 --- /dev/null +++ b/homeassistant/components/idasen_desk/quality_scale.yaml @@ -0,0 +1,101 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not use polling. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide configuration parameters. + docs-installation-parameters: + status: exempt + comment: | + This integration does not provide installation parameters. + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: + status: todo + comment: | + - remove the await hass.async_block_till_done() after service calls with blocking=True + - use constants (like SERVICE_PRESS and ATTR_ENTITY_ID) in the tests calling services + - rename test_buttons.py -> test_button.py + - rename test_sensors.py -> test_sensor.py + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration uses Bluetooth and addresses don't change. + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration has one device per config entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + This integration doesn't have any cases where a reconfiguration is needed. + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: | + This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration doesn't use websession. + strict-typing: todo diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py index 8ed85d21a34..4613d316a52 100644 --- a/homeassistant/components/idasen_desk/sensor.py +++ b/homeassistant/components/idasen_desk/sensor.py @@ -4,9 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -14,13 +12,11 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import UnitOfLength -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DeskData, IdasenDeskCoordinator -from .const import DOMAIN +from . import IdasenDeskConfigEntry, IdasenDeskCoordinator +from .entity import IdasenDeskEntity @dataclass(frozen=True, kw_only=True) @@ -46,57 +42,32 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: IdasenDeskConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Idasen Desk sensors.""" - data: DeskData = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( - IdasenDeskSensor( - data.address, data.device_info, data.coordinator, sensor_description - ) + IdasenDeskSensor(coordinator, sensor_description) for sensor_description in SENSORS ) -class IdasenDeskSensor(CoordinatorEntity[IdasenDeskCoordinator], SensorEntity): +class IdasenDeskSensor(IdasenDeskEntity, SensorEntity): """IdasenDesk sensor.""" entity_description: IdasenDeskSensorDescription - _attr_has_entity_name = True def __init__( self, - address: str, - device_info: DeviceInfo, coordinator: IdasenDeskCoordinator, description: IdasenDeskSensorDescription, ) -> None: """Initialize the IdasenDesk sensor entity.""" - super().__init__(coordinator) + super().__init__(f"{description.key}-{coordinator.address}", coordinator) self.entity_description = description - self._attr_unique_id = f"{description.key}-{address}" - self._attr_device_info = device_info - self._address = address - self._desk = coordinator.desk - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - self._update_native_value() - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self._desk.is_connected is True - - @callback - def _handle_coordinator_update(self, *args: Any) -> None: - """Handle data update.""" - self._update_native_value() - super()._handle_coordinator_update() - - def _update_native_value(self) -> None: - """Update the native value attribute.""" - self._attr_native_value = self.entity_description.value_fn(self.coordinator) + def native_value(self) -> float | None: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json index 70e08976925..7486973638b 100644 --- a/homeassistant/components/idasen_desk/strings.json +++ b/homeassistant/components/idasen_desk/strings.json @@ -4,7 +4,10 @@ "step": { "user": { "data": { - "address": "Bluetooth address" + "address": "Device" + }, + "data_description": { + "address": "The bluetooth device for the desk." } } }, diff --git a/homeassistant/components/iglo/light.py b/homeassistant/components/iglo/light.py index a31183f4489..0d20761c6e5 100644 --- a/homeassistant/components/iglo/light.py +++ b/homeassistant/components/iglo/light.py @@ -2,7 +2,6 @@ from __future__ import annotations -import math from typing import Any from iglo import Lamp @@ -11,7 +10,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, @@ -83,23 +82,19 @@ class IGloLamp(LightEntity): return ColorMode.HS @property - def color_temp(self): - """Return the color temperature.""" - return color_util.color_temperature_kelvin_to_mired(self._lamp.state()["white"]) + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + return self._lamp.state()["white"] @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return math.ceil( - color_util.color_temperature_kelvin_to_mired(self._lamp.max_kelvin) - ) + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" + return self._lamp.max_kelvin @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return math.ceil( - color_util.color_temperature_kelvin_to_mired(self._lamp.min_kelvin) - ) + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" + return self._lamp.min_kelvin @property def hs_color(self): @@ -135,11 +130,8 @@ class IGloLamp(LightEntity): self._lamp.rgb(*rgb) return - if ATTR_COLOR_TEMP in kwargs: - kelvin = int( - color_util.color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) - ) - self._lamp.white(kelvin) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + self._lamp.white(kwargs[ATTR_COLOR_TEMP_KELVIN]) return if ATTR_EFFECT in kwargs: diff --git a/homeassistant/components/igloohome/__init__.py b/homeassistant/components/igloohome/__init__.py new file mode 100644 index 00000000000..5e5e21452cf --- /dev/null +++ b/homeassistant/components/igloohome/__init__.py @@ -0,0 +1,61 @@ +"""The igloohome integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from aiohttp import ClientError +from igloohome_api import ( + Api as IgloohomeApi, + ApiException, + Auth as IgloohomeAuth, + AuthException, + GetDeviceInfoResponse, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +@dataclass +class IgloohomeRuntimeData: + """Holding class for runtime data.""" + + api: IgloohomeApi + devices: list[GetDeviceInfoResponse] + + +type IgloohomeConfigEntry = ConfigEntry[IgloohomeRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool: + """Set up igloohome from a config entry.""" + + authentication = IgloohomeAuth( + session=async_get_clientsession(hass), + client_id=entry.data[CONF_CLIENT_ID], + client_secret=entry.data[CONF_CLIENT_SECRET], + ) + + api = IgloohomeApi(auth=authentication) + try: + devices = (await api.get_devices()).payload + except AuthException as e: + raise ConfigEntryError from e + except (ApiException, ClientError) as e: + raise ConfigEntryNotReady from e + + entry.runtime_data = IgloohomeRuntimeData(api, devices) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/igloohome/config_flow.py b/homeassistant/components/igloohome/config_flow.py new file mode 100644 index 00000000000..a1d84900a03 --- /dev/null +++ b/homeassistant/components/igloohome/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for igloohome integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import ClientError +from igloohome_api import Auth as IgloohomeAuth, AuthException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CLIENT_ID): str, + vol.Required(CONF_CLIENT_SECRET): str, + } +) + + +class IgloohomeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for igloohome.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the config flow step.""" + + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_CLIENT_ID: user_input[CONF_CLIENT_ID], + } + ) + auth = IgloohomeAuth( + session=async_get_clientsession(self.hass), + client_id=user_input[CONF_CLIENT_ID], + client_secret=user_input[CONF_CLIENT_SECRET], + ) + try: + await auth.async_get_access_token() + except AuthException: + errors["base"] = "invalid_auth" + except ClientError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title="Client Credentials", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/igloohome/const.py b/homeassistant/components/igloohome/const.py new file mode 100644 index 00000000000..379c3bfbc1a --- /dev/null +++ b/homeassistant/components/igloohome/const.py @@ -0,0 +1,3 @@ +"""Constants for the igloohome integration.""" + +DOMAIN = "igloohome" diff --git a/homeassistant/components/igloohome/entity.py b/homeassistant/components/igloohome/entity.py new file mode 100644 index 00000000000..151cfbb3d2a --- /dev/null +++ b/homeassistant/components/igloohome/entity.py @@ -0,0 +1,32 @@ +"""Implementation of a base entity that belongs to all igloohome devices.""" + +from igloohome_api import Api as IgloohomeApi, GetDeviceInfoResponse + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class IgloohomeBaseEntity(Entity): + """A base entity that is a part of all igloohome devices.""" + + _attr_has_entity_name = True + + def __init__( + self, api_device_info: GetDeviceInfoResponse, api: IgloohomeApi, unique_key: str + ) -> None: + """Initialize the base device class.""" + self.api = api + self.api_device_info = api_device_info + # Register the entity as part of a device. + self._attr_device_info = dr.DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, api_device_info.deviceId) + }, + name=api_device_info.deviceName, + model=api_device_info.type, + ) + # Set the unique ID of the entity. + self._attr_unique_id = f"{unique_key}_{api_device_info.deviceId}" diff --git a/homeassistant/components/igloohome/manifest.json b/homeassistant/components/igloohome/manifest.json new file mode 100644 index 00000000000..28e287db2ab --- /dev/null +++ b/homeassistant/components/igloohome/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "igloohome", + "name": "igloohome", + "codeowners": ["@keithle888"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/igloohome", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["igloohome-api==0.0.6"] +} diff --git a/homeassistant/components/igloohome/quality_scale.yaml b/homeassistant/components/igloohome/quality_scale.yaml new file mode 100644 index 00000000000..432777cb729 --- /dev/null +++ b/homeassistant/components/igloohome/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + action-exceptions: + status: exempt + comment: | + Integration has no actions and is a read-only platform. + docs-configuration-parameters: todo + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No issues requiring a repair at the moment. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/igloohome/sensor.py b/homeassistant/components/igloohome/sensor.py new file mode 100644 index 00000000000..7f25798e454 --- /dev/null +++ b/homeassistant/components/igloohome/sensor.py @@ -0,0 +1,68 @@ +"""Implementation of the sensor platform.""" + +from datetime import timedelta +import logging + +from aiohttp import ClientError +from igloohome_api import Api as IgloohomeApi, ApiException, GetDeviceInfoResponse + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IgloohomeConfigEntry +from .entity import IgloohomeBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +SCAN_INTERVAL = timedelta(hours=1) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IgloohomeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensor entities.""" + + async_add_entities( + ( + IgloohomeBatteryEntity( + api_device_info=device, + api=entry.runtime_data.api, + ) + for device in entry.runtime_data.devices + if device.batteryLevel is not None + ), + update_before_add=True, + ) + + +class IgloohomeBatteryEntity(IgloohomeBaseEntity, SensorEntity): + """Implementation of a device that has a battery.""" + + _attr_native_unit_of_measurement = "%" + _attr_device_class = SensorDeviceClass.BATTERY + + def __init__( + self, api_device_info: GetDeviceInfoResponse, api: IgloohomeApi + ) -> None: + """Initialize the class.""" + super().__init__( + api_device_info=api_device_info, + api=api, + unique_key="battery", + ) + + async def async_update(self) -> None: + """Update the battery level.""" + try: + response = await self.api.get_device_info( + deviceId=self.api_device_info.deviceId + ) + except (ApiException, ClientError): + self._attr_available = False + else: + self._attr_available = True + self._attr_native_value = response.batteryLevel diff --git a/homeassistant/components/igloohome/strings.json b/homeassistant/components/igloohome/strings.json new file mode 100644 index 00000000000..463964c58ed --- /dev/null +++ b/homeassistant/components/igloohome/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "description": "Copy & paste your [API access credentials](https://access.igloocompany.co/api-access) to give Home Assistant access to your account.", + "data": { + "client_id": "Client ID", + "client_secret": "Client secret" + }, + "data_description": { + "client_id": "Client ID provided by your iglooaccess account.", + "client_secret": "Client Secret provided by your iglooaccess account." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} diff --git a/homeassistant/components/ihc/entity.py b/homeassistant/components/ihc/entity.py index f73c3079867..f90b2ee943c 100644 --- a/homeassistant/components/ihc/entity.py +++ b/homeassistant/components/ihc/entity.py @@ -43,7 +43,7 @@ class IHCEntity(Entity): self.suggested_area = product.get("group") if "id" in product: product_id = product["id"] - self.device_id = f"{controller_id}_{product_id }" + self.device_id = f"{controller_id}_{product_id}" # this will name the device the same way as the IHC visual application: Product name + position self.device_name = product["name"] if self.ihc_position: diff --git a/homeassistant/components/ihc/strings.json b/homeassistant/components/ihc/strings.json index af2152a88bb..04daef83c9d 100644 --- a/homeassistant/components/ihc/strings.json +++ b/homeassistant/components/ihc/strings.json @@ -6,7 +6,7 @@ "fields": { "controller_id": { "name": "Controller ID", - "description": "If you have multiple controller, this is the index of you controller\nstarting with 0.\n." + "description": "If you have multiple controllers, this is the index of your controller, starting with 0." }, "ihc_id": { "name": "IHC ID", diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index dbb5962eabf..1cf2de278d1 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -14,7 +14,7 @@ from typing import Final, final from aiohttp import hdrs, web import httpx -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index bb8c33ba749..e43377a3230 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==11.0.0"] + "requirements": ["Pillow==11.1.0"] } diff --git a/homeassistant/components/image_upload/media_source.py b/homeassistant/components/image_upload/media_source.py new file mode 100644 index 00000000000..ee9511e2c36 --- /dev/null +++ b/homeassistant/components/image_upload/media_source.py @@ -0,0 +1,76 @@ +"""Expose image_upload as media sources.""" + +from __future__ import annotations + +from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_source import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, + Unresolvable, +) +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_get_media_source(hass: HomeAssistant) -> ImageUploadMediaSource: + """Set up image media source.""" + return ImageUploadMediaSource(hass) + + +class ImageUploadMediaSource(MediaSource): + """Provide images as media sources.""" + + name: str = "Image Upload" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize ImageMediaSource.""" + super().__init__(DOMAIN) + self.hass = hass + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + image = self.hass.data[DOMAIN].data.get(item.identifier) + + if not image: + raise Unresolvable(f"Could not resolve media item: {item.identifier}") + + return PlayMedia( + f"/api/image/serve/{image['id']}/original", image["content_type"] + ) + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if item.identifier: + raise BrowseError("Unknown item") + + children = [ + BrowseMediaSource( + domain=DOMAIN, + identifier=image["id"], + media_class=MediaClass.IMAGE, + media_content_type=image["content_type"], + title=image["name"], + thumbnail=f"/api/image/serve/{image['id']}/256x256", + can_play=True, + can_expand=False, + ) + for image in self.hass.data[DOMAIN].data.values() + ] + + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.APP, + media_content_type="", + title="Image Upload", + can_play=False, + can_expand=True, + children_media_class=MediaClass.IMAGE, + children=children, + ) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 994c53b5b3e..df0e63e200a 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -9,12 +9,7 @@ from typing import Any from aioimaplib import AioImapException import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -35,6 +30,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.util.ssl import SSLCipherList +from . import ImapConfigEntry from .const import ( CONF_CHARSET, CONF_CUSTOM_EVENT_DATA_TEMPLATE, @@ -212,7 +208,7 @@ class IMAPConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: ImapConfigEntry, ) -> ImapOptionsFlow: """Get the options flow for this handler.""" return ImapOptionsFlow() diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index a9d0fdfbd48..1df107196ff 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -14,7 +14,6 @@ from typing import TYPE_CHECKING, Any from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, @@ -53,6 +52,9 @@ from .const import ( ) from .errors import InvalidAuth, InvalidFolder +if TYPE_CHECKING: + from . import ImapConfigEntry + _LOGGER = logging.getLogger(__name__) BACKOFF_TIME = 10 @@ -210,14 +212,14 @@ class ImapMessage: class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Base class for imap client.""" - config_entry: ConfigEntry + config_entry: ImapConfigEntry custom_event_template: Template | None def __init__( self, hass: HomeAssistant, imap_client: IMAP4_SSL, - entry: ConfigEntry, + entry: ImapConfigEntry, update_interval: timedelta | None, ) -> None: """Initiate imap client.""" @@ -332,7 +334,17 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - if not (count := len(message_ids := lines[0].split())): + # Check we do have returned items. + # + # In rare cases, when no UID's are returned, + # only the status line is returned, and not an empty line. + # See: https://github.com/home-assistant/core/issues/132042 + # + # Strictly the RfC notes that 0 or more numbers should be returned + # delimited by a space. + # + # See: https://datatracker.ietf.org/doc/html/rfc3501#section-7.2.5 + if len(lines) == 1 or not (count := len(message_ids := lines[0].split())): self._last_message_uid = None return 0 last_message_uid = ( @@ -391,7 +403,7 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): """Class for imap client.""" def __init__( - self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry + self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ImapConfigEntry ) -> None: """Initiate imap client.""" _LOGGER.debug( @@ -437,7 +449,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): """Class for imap client.""" def __init__( - self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry + self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ImapConfigEntry ) -> None: """Initiate imap client.""" _LOGGER.debug("Connected to server %s using IMAP push", entry.data[CONF_SERVER]) diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index b058a3d50f4..a3370de94ca 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/imap", "iot_class": "cloud_push", "loggers": ["aioimaplib"], - "requirements": ["aioimaplib==1.1.0"] + "requirements": ["aioimaplib==2.0.0"] } diff --git a/homeassistant/components/imap/quality_scale.yaml b/homeassistant/components/imap/quality_scale.yaml index 180aef93f91..1c75b527882 100644 --- a/homeassistant/components/imap/quality_scale.yaml +++ b/homeassistant/components/imap/quality_scale.yaml @@ -94,4 +94,7 @@ rules: status: exempt comment: | This integration does not use web sessions. - strict-typing: done + strict-typing: + status: todo + comment: | + Requirement 'aioimaplib==1.1.0' appears untyped diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index b484586e057..60892388252 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -17,6 +17,9 @@ from . import ImapConfigEntry from .const import DOMAIN from .coordinator import ImapDataUpdateCoordinator +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription( key="imap_mail_count", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py index caf4e058e06..eb12e1a2bb4 100644 --- a/homeassistant/components/imgw_pib/__init__.py +++ b/homeassistant/components/imgw_pib/__init__.py @@ -9,16 +9,18 @@ from aiohttp import ClientError from imgw_pib import ImgwPib from imgw_pib.exceptions import ApiError +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_STATION_ID +from .const import CONF_STATION_ID, DOMAIN from .coordinator import ImgwPibDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -42,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b try: imgwpib = await ImgwPib.create( - client_session, hydrological_station_id=station_id + client_session, + hydrological_station_id=station_id, + hydrological_details=False, ) except (ClientError, TimeoutError, ApiError) as err: raise ConfigEntryNotReady from err @@ -50,6 +54,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b coordinator = ImgwPibDataUpdateCoordinator(hass, imgwpib, station_id) await coordinator.async_config_entry_first_refresh() + # Remove binary_sensor entities for which the endpoint has been blocked by IMGW-PIB API + entity_reg = er.async_get(hass) + for key in ("flood_warning", "flood_alarm"): + if entity_id := entity_reg.async_get_entity_id( + BINARY_SENSOR_PLATFORM, DOMAIN, f"{coordinator.station_id}_{key}" + ): + entity_reg.async_remove(entity_id) + entry.runtime_data = ImgwPibData(coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/imgw_pib/binary_sensor.py b/homeassistant/components/imgw_pib/binary_sensor.py deleted file mode 100644 index 1c4cc738f8f..00000000000 --- a/homeassistant/components/imgw_pib/binary_sensor.py +++ /dev/null @@ -1,82 +0,0 @@ -"""IMGW-PIB binary sensor platform.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass - -from imgw_pib.model import HydrologicalData - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import ImgwPibConfigEntry -from .coordinator import ImgwPibDataUpdateCoordinator -from .entity import ImgwPibEntity - -PARALLEL_UPDATES = 1 - - -@dataclass(frozen=True, kw_only=True) -class ImgwPibBinarySensorEntityDescription(BinarySensorEntityDescription): - """IMGW-PIB sensor entity description.""" - - value: Callable[[HydrologicalData], bool | None] - - -BINARY_SENSOR_TYPES: tuple[ImgwPibBinarySensorEntityDescription, ...] = ( - ImgwPibBinarySensorEntityDescription( - key="flood_warning", - translation_key="flood_warning", - device_class=BinarySensorDeviceClass.SAFETY, - value=lambda data: data.flood_warning, - ), - ImgwPibBinarySensorEntityDescription( - key="flood_alarm", - translation_key="flood_alarm", - device_class=BinarySensorDeviceClass.SAFETY, - value=lambda data: data.flood_alarm, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ImgwPibConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Add a IMGW-PIB binary sensor entity from a config_entry.""" - coordinator = entry.runtime_data.coordinator - - async_add_entities( - ImgwPibBinarySensorEntity(coordinator, description) - for description in BINARY_SENSOR_TYPES - if getattr(coordinator.data, description.key) is not None - ) - - -class ImgwPibBinarySensorEntity(ImgwPibEntity, BinarySensorEntity): - """Define IMGW-PIB binary sensor entity.""" - - entity_description: ImgwPibBinarySensorEntityDescription - - def __init__( - self, - coordinator: ImgwPibDataUpdateCoordinator, - description: ImgwPibBinarySensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - - self._attr_unique_id = f"{coordinator.station_id}_{description.key}" - self.entity_description = description - - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index bf8608ae21b..29aa19a4b56 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -1,26 +1,6 @@ { "entity": { - "binary_sensor": { - "flood_warning": { - "default": "mdi:check-circle", - "state": { - "on": "mdi:home-flood" - } - }, - "flood_alarm": { - "default": "mdi:check-circle", - "state": { - "on": "mdi:home-flood" - } - } - }, "sensor": { - "flood_warning_level": { - "default": "mdi:alert-outline" - }, - "flood_alarm_level": { - "default": "mdi:alert" - }, "water_level": { "default": "mdi:waves" }, diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index b5c35f3f1eb..0ecc1b4b7d0 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", - "requirements": ["imgw_pib==1.0.6"] + "requirements": ["imgw_pib==1.0.9"] } diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index f000222b31b..15043af2015 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -8,17 +8,20 @@ from dataclasses import dataclass from imgw_pib.model import HydrologicalData from homeassistant.components.sensor import ( + DOMAIN as SENSOR_PLATFORM, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import EntityCategory, UnitOfLength, UnitOfTemperature +from homeassistant.const import UnitOfLength, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import ImgwPibConfigEntry +from .const import DOMAIN from .coordinator import ImgwPibDataUpdateCoordinator from .entity import ImgwPibEntity @@ -33,26 +36,6 @@ class ImgwPibSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( - ImgwPibSensorEntityDescription( - key="flood_alarm_level", - translation_key="flood_alarm_level", - native_unit_of_measurement=UnitOfLength.CENTIMETERS, - device_class=SensorDeviceClass.DISTANCE, - entity_category=EntityCategory.DIAGNOSTIC, - suggested_display_precision=0, - entity_registry_enabled_default=False, - value=lambda data: data.flood_alarm_level.value, - ), - ImgwPibSensorEntityDescription( - key="flood_warning_level", - translation_key="flood_warning_level", - native_unit_of_measurement=UnitOfLength.CENTIMETERS, - device_class=SensorDeviceClass.DISTANCE, - entity_category=EntityCategory.DIAGNOSTIC, - suggested_display_precision=0, - entity_registry_enabled_default=False, - value=lambda data: data.flood_warning_level.value, - ), ImgwPibSensorEntityDescription( key="water_level", translation_key="water_level", @@ -82,6 +65,14 @@ async def async_setup_entry( """Add a IMGW-PIB sensor entity from a config_entry.""" coordinator = entry.runtime_data.coordinator + # Remove entities for which the endpoint has been blocked by IMGW-PIB API + entity_reg = er.async_get(hass) + for key in ("flood_warning_level", "flood_alarm_level"): + if entity_id := entity_reg.async_get_entity_id( + SENSOR_PLATFORM, DOMAIN, f"{coordinator.station_id}_{key}" + ): + entity_reg.async_remove(entity_id) + async_add_entities( ImgwPibSensorEntity(coordinator, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 6bc337d5720..9a17dcf7087 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -17,21 +17,7 @@ } }, "entity": { - "binary_sensor": { - "flood_alarm": { - "name": "Flood alarm" - }, - "flood_warning": { - "name": "Flood warning" - } - }, "sensor": { - "flood_alarm_level": { - "name": "Flood alarm level" - }, - "flood_warning_level": { - "name": "Flood warning level" - }, "water_level": { "name": "Water level" }, diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 05dd1de449a..22f2bf3623c 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -126,15 +126,23 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): ) except improv_ble_errors.InvalidCommand as err: _LOGGER.warning( - "Aborting improv flow, device %s sent invalid improv data: '%s'", - self._discovery_info.address, + ( + "Received invalid improv via BLE data '%s' from device with " + "bluetooth address '%s'; if the device is a self-configured " + "ESPHome device, either correct or disable the 'esp32_improv' " + "configuration; if it's a commercial device, contact the vendor" + ), service_data[SERVICE_DATA_UUID].hex(), + self._discovery_info.address, ) raise AbortFlow("invalid_improv_data") from err if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): _LOGGER.debug( - "Aborting improv flow, device %s is already provisioned: %s", + ( + "Aborting improv flow, device with bluetooth address '%s' is " + "already provisioned: %s" + ), self._discovery_info.address, improv_service_data.state, ) diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 39e471b7614..249a0ae9085 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -3,33 +3,17 @@ from __future__ import annotations from aiohttp import ClientResponseError -from incomfortclient import IncomfortError, InvalidHeaterList -import voluptuous as vol +from incomfortclient import InvalidGateway, InvalidHeaterList -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers import device_registry as dr from .const import DOMAIN from .coordinator import InComfortDataCoordinator, async_connect_gateway -from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Inclusive(CONF_USERNAME, "credentials"): cv.string, - vol.Inclusive(CONF_PASSWORD, "credentials"): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) +from .errors import InComfortTimeout, InComfortUnknownError, NoHeaters, NotFound PLATFORMS = ( Platform.WATER_HEATER, @@ -43,54 +27,7 @@ INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway" type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] -async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: - """Import config entry from configuration.yaml.""" - if not hass.config_entries.async_entries(DOMAIN): - # Start import flow - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - if result["type"] == FlowResultType.ABORT: - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - - -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Create an Intergas InComfort/Intouch system.""" - if config := hass_config.get(DOMAIN): - hass.async_create_task(_async_import(hass, config)) - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool: """Set up a config entry.""" try: data = await async_connect_gateway(hass, dict(entry.data)) @@ -98,17 +35,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await heater.update() except InvalidHeaterList as exc: raise NoHeaters from exc - except IncomfortError as exc: - if isinstance(exc.message, ClientResponseError): - if exc.message.status == 401: - raise ConfigEntryAuthFailed("Incorrect credentials") from exc - if exc.message.status == 404: - raise NotFound from exc - raise InConfortUnknownError from exc + except InvalidGateway as exc: + raise ConfigEntryAuthFailed("Incorrect credentials") from exc + except ClientResponseError as exc: + if exc.status == 404: + raise NotFound from exc + raise InComfortUnknownError from exc except TimeoutError as exc: - raise InConfortTimeout from exc + raise InComfortTimeout from exc - coordinator = InComfortDataCoordinator(hass, data) + # Register discovered gateway device + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + if entry.unique_id is not None + else set(), + manufacturer="Intergas", + name="RFGateway", + ) + coordinator = InComfortDataCoordinator(hass, data, entry.entry_id) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() @@ -116,6 +63,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool: """Unload config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index a94e1fac504..e4353e457a5 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,6 +21,8 @@ from . import InComfortConfigEntry from .coordinator import InComfortDataCoordinator from .entity import IncomfortBoilerEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class IncomfortBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -27,6 +30,7 @@ class IncomfortBinarySensorEntityDescription(BinarySensorEntityDescription): value_key: str extra_state_attributes_fn: Callable[[dict[str, Any]], dict[str, Any]] | None = None + entity_category: EntityCategory = EntityCategory.DIAGNOSTIC SENSOR_TYPES: tuple[IncomfortBinarySensorEntityDescription, ...] = ( @@ -38,24 +42,28 @@ SENSOR_TYPES: tuple[IncomfortBinarySensorEntityDescription, ...] = ( extra_state_attributes_fn=lambda status: { "fault_code": status["fault_code"] or "none", }, + entity_registry_enabled_default=False, ), IncomfortBinarySensorEntityDescription( key="is_pumping", translation_key="is_pumping", device_class=BinarySensorDeviceClass.RUNNING, value_key="is_pumping", + entity_registry_enabled_default=False, ), IncomfortBinarySensorEntityDescription( key="is_burning", translation_key="is_burning", device_class=BinarySensorDeviceClass.RUNNING, value_key="is_burning", + entity_registry_enabled_default=False, ), IncomfortBinarySensorEntityDescription( key="is_tapping", translation_key="is_tapping", device_class=BinarySensorDeviceClass.RUNNING, value_key="is_tapping", + entity_registry_enabled_default=False, ), ) @@ -94,7 +102,7 @@ class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return the status of the sensor.""" - return self._heater.status[self.entity_description.value_key] + return bool(self._heater.status[self.entity_description.value_key]) @property def extra_state_attributes(self) -> dict[str, Any] | None: diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index eccf03588dc..f814b1fb1f5 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -12,16 +12,18 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import InComfortConfigEntry -from .const import DOMAIN +from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import InComfortDataCoordinator from .entity import IncomfortEntity +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, @@ -30,15 +32,19 @@ async def async_setup_entry( ) -> None: """Set up InComfort/InTouch climate devices.""" incomfort_coordinator = entry.runtime_data + legacy_setpoint_status = entry.options.get(CONF_LEGACY_SETPOINT_STATUS, False) heaters = incomfort_coordinator.data.heaters async_add_entities( - InComfortClimate(incomfort_coordinator, h, r) for h in heaters for r in h.rooms + InComfortClimate(incomfort_coordinator, h, r, legacy_setpoint_status) + for h in heaters + for r in h.rooms ) class InComfortClimate(IncomfortEntity, ClimateEntity): """Representation of an InComfort/InTouch climate device.""" + _attr_entity_category = EntityCategory.CONFIG _attr_min_temp = 5.0 _attr_max_temp = 30.0 _attr_name = None @@ -46,19 +52,20 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: InComfortDataCoordinator, heater: InComfortHeater, room: InComfortRoom, + legacy_setpoint_status: bool, ) -> None: """Initialize the climate device.""" super().__init__(coordinator) self._heater = heater self._room = room + self._legacy_setpoint_status = legacy_setpoint_status self._attr_unique_id = f"{heater.serial_no}_{room.room_no}" self._attr_device_info = DeviceInfo( @@ -66,6 +73,8 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): manufacturer="Intergas", name=f"Thermostat {room.room_no}", ) + if coordinator.unique_id: + self._attr_device_info["via_device"] = (DOMAIN, coordinator.unique_id) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -90,14 +99,16 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): As we set the override, we report back the override. The actual set point is is returned at a later time. - Some older thermostats return 0.0 as override, in that case we fallback to - the actual setpoint. + Some older thermostats do not clear the override setting in that case, in that case + we fallback to the returning actual setpoint. """ + if self._legacy_setpoint_status: + return self._room.setpoint return self._room.override or self._room.setpoint async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature for this zone.""" - temperature = kwargs.get(ATTR_TEMPERATURE) + temperature: float = kwargs[ATTR_TEMPERATURE] await self._room.set_override(temperature) await self.coordinator.async_refresh() diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index e905f0d743d..8e4a5f72619 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -1,21 +1,35 @@ """Config flow support for Intergas InComfort integration.""" +from __future__ import annotations + +from collections.abc import Mapping from typing import Any -from aiohttp import ClientResponseError -from incomfortclient import IncomfortError, InvalidHeaterList +from incomfortclient import InvalidGateway, InvalidHeaterList import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( + BooleanSelector, + BooleanSelectorConfig, TextSelector, TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import DOMAIN +from . import InComfortConfigEntry +from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import async_connect_gateway TITLE = "Intergas InComfort/Intouch Lan2RF gateway" @@ -34,10 +48,33 @@ CONFIG_SCHEMA = vol.Schema( } ) -ERROR_STATUS_MAPPING: dict[int, tuple[str, str]] = { - 401: (CONF_PASSWORD, "auth_error"), - 404: ("base", "not_found"), -} +DHCP_CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="admin") + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + +REAUTH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_LEGACY_SETPOINT_STATUS, default=False): BooleanSelector( + BooleanSelectorConfig() + ) + } +) async def async_try_connect_gateway( @@ -46,15 +83,10 @@ async def async_try_connect_gateway( """Try to connect to the Lan2RF gateway.""" try: await async_connect_gateway(hass, config) + except InvalidGateway: + return {"base": "auth_error"} except InvalidHeaterList: return {"base": "no_heaters"} - except IncomfortError as exc: - if isinstance(exc.message, ClientResponseError): - scope, error = ERROR_STATUS_MAPPING.get( - exc.message.status, ("base", "unknown") - ) - return {scope: error} - return {"base": "unknown"} except TimeoutError: return {"base": "timeout_error"} except Exception: # noqa: BLE001 @@ -66,26 +98,165 @@ async def async_try_connect_gateway( class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow to set up an Intergas InComfort boyler and thermostats.""" + _discovered_host: str + + @staticmethod + @callback + def async_get_options_flow( + config_entry: InComfortConfigEntry, + ) -> InComfortOptionsFlowHandler: + """Get the options flow for this handler.""" + return InComfortOptionsFlowHandler() + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Prepare configuration for a DHCP discovered Intergas Gateway device.""" + self._discovered_host = discovery_info.ip + # In case we have an existing entry with the same host + # we update the entry with the unique_id for the gateway, and abort the flow + unique_id = format_mac(discovery_info.macaddress) + existing_entries_without_unique_id = [ + entry + for entry in self._async_current_entries(include_ignore=False) + if entry.unique_id is None + and entry.data.get(CONF_HOST) == self._discovered_host + and entry.state is ConfigEntryState.LOADED + ] + if existing_entries_without_unique_id: + self.hass.config_entries.async_update_entry( + existing_entries_without_unique_id[0], unique_id=unique_id + ) + self.hass.config_entries.async_schedule_reload( + existing_entries_without_unique_id[0].entry_id + ) + raise AbortFlow("already_configured") + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._discovered_host}) + + return await self.async_step_dhcp_confirm() + + async def async_step_dhcp_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm setup from discovery.""" + if user_input is not None: + return await self.async_step_dhcp_auth({CONF_HOST: self._discovered_host}) + return self.async_show_form( + step_id="dhcp_confirm", + description_placeholders={CONF_HOST: self._discovered_host}, + ) + + async def async_step_dhcp_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial set up via DHCP.""" + errors: dict[str, str] | None = None + data_schema: vol.Schema = DHCP_CONFIG_SCHEMA + if user_input is not None: + user_input[CONF_HOST] = self._discovered_host + if ( + errors := await async_try_connect_gateway(self.hass, user_input) + ) is None: + return self.async_create_entry(title=TITLE, data=user_input) + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + + return self.async_show_form( + step_id="dhcp_auth", + data_schema=data_schema, + errors=errors, + description_placeholders={CONF_HOST: self._discovered_host}, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] | None = None + data_schema: vol.Schema = CONFIG_SCHEMA + if is_reconfigure := (self.source == SOURCE_RECONFIGURE): + reconfigure_entry = self._get_reconfigure_entry() + data_schema = self.add_suggested_values_to_schema( + data_schema, reconfigure_entry.data + ) if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) if ( - errors := await async_try_connect_gateway(self.hass, user_input) + errors := await async_try_connect_gateway( + self.hass, + (reconfigure_entry.data | user_input) + if is_reconfigure + else user_input, + ) ) is None: + if is_reconfigure: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) return self.async_create_entry(title=TITLE, data=user_input) + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) return self.async_show_form( - step_id="user", data_schema=CONFIG_SCHEMA, errors=errors + step_id="user", data_schema=data_schema, errors=errors ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import `incomfort` config entry from configuration.yaml.""" + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication and confirmation.""" errors: dict[str, str] | None = None - if (errors := await async_try_connect_gateway(self.hass, import_data)) is None: - return self.async_create_entry(title=TITLE, data=import_data) - reason = next(iter(errors.items()))[1] - return self.async_abort(reason=reason) + + if user_input: + password: str = user_input[CONF_PASSWORD] + + reauth_entry = self._get_reauth_entry() + errors = await async_try_connect_gateway( + self.hass, reauth_entry.data | {CONF_PASSWORD: password} + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, data_updates={CONF_PASSWORD: password} + ) + + return self.async_show_form( + step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, errors=errors + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration flow.""" + return await self.async_step_user() + + +class InComfortOptionsFlowHandler(OptionsFlow): + """Handle InComfort Lan2RF gateway options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + errors: dict[str, str] | None = None + if user_input is not None: + new_options: dict[str, Any] = self.config_entry.options | user_input + self.hass.config_entries.async_update_entry( + self.config_entry, options=new_options + ) + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) + return self.async_create_entry(data=new_options) + + data_schema = self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, self.config_entry.options + ) + return self.async_show_form( + step_id="init", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/incomfort/const.py b/homeassistant/components/incomfort/const.py index 721dd8591b0..b3b9312acd6 100644 --- a/homeassistant/components/incomfort/const.py +++ b/homeassistant/components/incomfort/const.py @@ -1,3 +1,5 @@ """Constants for Intergas InComfort integration.""" DOMAIN = "incomfort" + +CONF_LEGACY_SETPOINT_STATUS = "legacy_setpoint_status" diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py index a5c8da0c208..3436d40298a 100644 --- a/homeassistant/components/incomfort/coordinator.py +++ b/homeassistant/components/incomfort/coordinator.py @@ -9,7 +9,7 @@ from aiohttp import ClientResponseError from incomfortclient import ( Gateway as InComfortGateway, Heater as InComfortHeater, - IncomfortError, + InvalidHeaterList, ) from homeassistant.const import CONF_HOST @@ -50,8 +50,11 @@ async def async_connect_gateway( class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): """Data coordinator for InComfort entities.""" - def __init__(self, hass: HomeAssistant, incomfort_data: InComfortData) -> None: + def __init__( + self, hass: HomeAssistant, incomfort_data: InComfortData, unique_id: str | None + ) -> None: """Initialize coordinator.""" + self.unique_id = unique_id super().__init__( hass, _LOGGER, @@ -66,10 +69,11 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): for heater in self.incomfort_data.heaters: await heater.update() except TimeoutError as exc: - raise UpdateFailed from exc - except IncomfortError as exc: - if isinstance(exc.message, ClientResponseError): - if exc.message.status == 401: - raise ConfigEntryError("Incorrect credentials") from exc - raise UpdateFailed from exc + raise UpdateFailed("Timeout error") from exc + except ClientResponseError as exc: + if exc.status == 401: + raise ConfigEntryError("Incorrect credentials") from exc + raise UpdateFailed(exc.message) from exc + except InvalidHeaterList as exc: + raise UpdateFailed(exc.message) from exc return self.incomfort_data diff --git a/homeassistant/components/incomfort/diagnostics.py b/homeassistant/components/incomfort/diagnostics.py new file mode 100644 index 00000000000..a2f89a94f58 --- /dev/null +++ b/homeassistant/components/incomfort/diagnostics.py @@ -0,0 +1,45 @@ +"""Diagnostics support for InComfort integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant, callback + +from . import InComfortConfigEntry + +REDACT_CONFIG = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: InComfortConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return _async_get_diagnostics(hass, entry) + + +@callback +def _async_get_diagnostics( + hass: HomeAssistant, + entry: InComfortConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + redacted_config = async_redact_data(entry.data | entry.options, REDACT_CONFIG) + coordinator = entry.runtime_data + + nr_heaters = len(coordinator.incomfort_data.heaters) + status: dict[str, Any] = { + f"heater_{n}": coordinator.incomfort_data.heaters[n].status + for n in range(nr_heaters) + } + for n in range(nr_heaters): + status[f"heater_{n}"]["rooms"] = { + m: dict(coordinator.incomfort_data.heaters[n].rooms[m].status) + for m in range(len(coordinator.incomfort_data.heaters[n].rooms)) + } + return { + "config": redacted_config, + "gateway": status, + } diff --git a/homeassistant/components/incomfort/entity.py b/homeassistant/components/incomfort/entity.py index 33037a78edf..1924c91376b 100644 --- a/homeassistant/components/incomfort/entity.py +++ b/homeassistant/components/incomfort/entity.py @@ -26,4 +26,7 @@ class IncomfortBoilerEntity(IncomfortEntity): identifiers={(DOMAIN, heater.serial_no)}, manufacturer="Intergas", name="Boiler", + serial_number=heater.serial_no, ) + if coordinator.unique_id: + self._attr_device_info["via_device"] = (DOMAIN, coordinator.unique_id) diff --git a/homeassistant/components/incomfort/errors.py b/homeassistant/components/incomfort/errors.py index 93a29d05bb8..c367916d6c7 100644 --- a/homeassistant/components/incomfort/errors.py +++ b/homeassistant/components/incomfort/errors.py @@ -1,32 +1,33 @@ """Exceptions raised by Intergas InComfort integration.""" -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from .const import DOMAIN + class NotFound(HomeAssistantError): """Raise exception if no Lan2RF Gateway was found.""" - translation_domain = HOMEASSISTANT_DOMAIN + translation_domain = DOMAIN translation_key = "not_found" class NoHeaters(ConfigEntryNotReady): """Raise exception if no heaters are found.""" - translation_domain = HOMEASSISTANT_DOMAIN + translation_domain = DOMAIN translation_key = "no_heaters" -class InConfortTimeout(ConfigEntryNotReady): +class InComfortTimeout(ConfigEntryNotReady): """Raise exception if no heaters are found.""" - translation_domain = HOMEASSISTANT_DOMAIN + translation_domain = DOMAIN translation_key = "timeout_error" -class InConfortUnknownError(ConfigEntryNotReady): +class InComfortUnknownError(ConfigEntryNotReady): """Raise exception if no heaters are found.""" - translation_domain = HOMEASSISTANT_DOMAIN + translation_domain = DOMAIN translation_key = "unknown" diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index 40c93012eef..f4d752bfa48 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -3,8 +3,12 @@ "name": "Intergas InComfort/Intouch Lan2RF gateway", "codeowners": ["@jbouwh"], "config_flow": true, + "dhcp": [ + { "hostname": "rfgateway", "macaddress": "0004A3*" }, + { "registered_devices": true } + ], "documentation": "https://www.home-assistant.io/integrations/incomfort", "iot_class": "local_polling", "loggers": ["incomfortclient"], - "requirements": ["incomfort-client==0.6.3-1"] + "requirements": ["incomfort-client==0.6.7"] } diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index e0d6740f1d4..e3f3fc785b2 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfPressure, UnitOfTemperature +from homeassistant.const import EntityCategory, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -22,6 +22,8 @@ from . import InComfortConfigEntry from .coordinator import InComfortDataCoordinator from .entity import IncomfortBoilerEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class IncomfortSensorEntityDescription(SensorEntityDescription): @@ -29,6 +31,7 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): value_key: str extra_key: str | None = None + entity_category = EntityCategory.DIAGNOSTIC SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( @@ -38,6 +41,7 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, value_key="pressure", + entity_registry_enabled_default=False, ), IncomfortSensorEntityDescription( key="cv_temp", @@ -46,6 +50,7 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, extra_key="is_pumping", value_key="heater_temp", + entity_registry_enabled_default=False, ), IncomfortSensorEntityDescription( key="tap_temp", @@ -55,6 +60,7 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, extra_key="is_tapping", value_key="tap_temp", + entity_registry_enabled_default=False, ), ) @@ -93,7 +99,7 @@ class IncomfortSensor(IncomfortBoilerEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._heater.status[self.entity_description.value_key] + return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return] @property def extra_state_attributes(self) -> dict[str, Any] | None: diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index a2bb874142b..4c47d4c57ad 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -13,44 +13,71 @@ "username": "The username to log into the gateway. This is `admin` in most cases.", "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." } + }, + "dhcp_auth": { + "title": "Set up Intergas InComfort Lan2RF Gateway", + "description": "Please enter authentication details for gateway {host}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The username to log into the gateway. This is `admin` in most cases.", + "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + } + }, + "dhcp_confirm": { + "title": "Set up Intergas InComfort Lan2RF Gateway", + "description": "Do you want to set up the discovered Intergas InComfort Lan2RF Gateway ({host})?" + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Correct the gateway password." + }, + "description": "Re-authenticate to the gateway." } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { "auth_error": "Invalid credentials.", "no_heaters": "No heaters found.", "not_found": "No Lan2RF gateway found.", - "timeout_error": "Time out when connection to Lan2RF gateway.", - "unknown": "Unknown error when connection to Lan2RF gateway." - }, - "error": { - "auth_error": "[%key:component::incomfort::config::abort::auth_error%]", - "no_heaters": "[%key:component::incomfort::config::abort::no_heaters%]", - "not_found": "[%key:component::incomfort::config::abort::not_found%]", - "timeout_error": "[%key:component::incomfort::config::abort::timeout_error%]", - "unknown": "[%key:component::incomfort::config::abort::unknown%]" + "timeout_error": "Time out when connecting to Lan2RF gateway.", + "unknown": "Unknown error when connecting to Lan2RF gateway." } }, - "issues": { - "deprecated_yaml_import_issue_unknown": { - "title": "YAML import failed with unknown error", - "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + "exceptions": { + "no_heaters": { + "message": "[%key:component::incomfort::config::error::no_heaters%]" }, - "deprecated_yaml_import_issue_auth_error": { - "title": "YAML import failed due to an authentication error", - "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + "not_found": { + "message": "[%key:component::incomfort::config::error::not_found%]" }, - "deprecated_yaml_import_issue_no_heaters": { - "title": "YAML import failed because no heaters were found", - "description": "Configuring {integration_title} using YAML is being removed but no heaters were found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + "timeout_error": { + "message": "[%key:component::incomfort::config::error::timeout_error%]" }, - "deprecated_yaml_import_issue_not_found": { - "title": "YAML import failed because no gateway was found", - "description": "Configuring {integration_title} using YAML is being removed but no Lan2RF gateway was found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "deprecated_yaml_import_issue_timeout_error": { - "title": "YAML import failed because of timeout issues", - "description": "Configuring {integration_title} using YAML is being removed but there was a timeout while connecting to your {integration_title} while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + "unknown": { + "message": "[%key:component::incomfort::config::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "title": "Intergas InComfort Lan2RF Gateway options", + "data": { + "legacy_setpoint_status": "Legacy setpoint handling" + }, + "data_description": { + "legacy_setpoint_status": "Some older gateway models with an older firmware versions might not update the thermostat setpoint and override settings correctly. Enable this option if you experience issues in updating the setpoint for your thermostat. It will use the actual setpoint of the thermostat instead of the override. As side effect is that it might take a few minutes before the setpoint is updated." + } + } } }, "entity": { diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index e7620ac2a1a..0ab4a6a06b8 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -8,7 +8,7 @@ from typing import Any from incomfortclient import Heater as InComfortHeater from homeassistant.components.water_heater import WaterHeaterEntity -from homeassistant.const import UnitOfTemperature +from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,6 +20,8 @@ _LOGGER = logging.getLogger(__name__) HEATER_ATTRS = ["display_code", "display_text", "is_burning"] +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -35,6 +37,7 @@ async def async_setup_entry( class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity): """Representation of an InComfort/Intouch water_heater device.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_min_temp = 30.0 _attr_max_temp = 80.0 _attr_name = None diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index dcc2865acad..428ffccb7c1 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -385,7 +385,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity): @callback def async_set_datetime(self, date=None, time=None, datetime=None, timestamp=None): """Set a new date / time.""" - if timestamp: + if timestamp is not None: datetime = dt_util.as_local(dt_util.utc_from_timestamp(timestamp)) if datetime: diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index 3db8edbf1c9..506841e7efb 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -94,7 +94,6 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): _attr_hvac_modes = list(HVAC_MODES.values()) _attr_fan_modes = list(FAN_MODES.values()) _attr_min_humidity = 1 - _enable_turn_on_off_backwards_compatibility = False @property def temperature_unit(self) -> str: diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 143a9e2a5e2..54756397211 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -7,7 +7,7 @@ from typing import Any from pyinsteon import async_connect -from homeassistant.components import dhcp, usb +from homeassistant.components import usb from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, ConfigFlow, @@ -15,6 +15,8 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_NAME from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import CONF_HUB_VERSION, DOMAIN from .schemas import build_hub_schema, build_plm_manual_schema, build_plm_schema @@ -129,9 +131,7 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): step_id=step_id, data_schema=data_schema, errors=errors ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB discovery.""" self._device_path = discovery_info.device self._device_name = usb.human_readable_device_name( @@ -162,7 +162,7 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a DHCP discovery.""" self.discovered_conf = {CONF_HOST: discovery_info.ip} diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index c13e22bf8c5..0f1c70b9ea8 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -56,7 +56,6 @@ class InsteonFanEntity(InsteonEntity, FanEntity): | FanEntityFeature.TURN_ON ) _attr_speed_count = 3 - _enable_turn_on_off_backwards_compatibility = False @property def percentage(self) -> int | None: diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 4df997ac939..4a8aadb70db 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -164,7 +164,7 @@ }, "x10_all_units_off": { "name": "X10 all units off", - "description": "[%key:component::insteon::services::add_all_link::description%]", + "description": "Sends X10 'All units off' command.", "fields": { "housecode": { "name": "Housecode", @@ -174,7 +174,7 @@ }, "x10_all_lights_on": { "name": "X10 all lights on", - "description": "Sends X10 All Lights On command.", + "description": "Sends X10 'All lights on' command.", "fields": { "housecode": { "name": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::name%]", @@ -184,7 +184,7 @@ }, "x10_all_lights_off": { "name": "X10 all lights off", - "description": "Sends X10 All Lights Off command.", + "description": "Sends X10 'All lights off' command.", "fields": { "housecode": { "name": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::name%]", diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index a053e5cea5c..27aa74d0785 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -576,7 +576,7 @@ class IntegrationSensor(RestoreSensor): if ( self._max_sub_interval is not None and source_state is not None - and (source_state_dec := _decimal_state(source_state.state)) + and (source_state_dec := _decimal_state(source_state.state)) is not None ): @callback diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index 7609398673b..ce78f1a6fa3 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -149,7 +149,7 @@ async def _async_wait_for_initialization( while ( fireplace.data.ipv4_address == "127.0.0.1" and fireplace.data.serial == "unset" ): - LOGGER.debug(f"Waiting for fireplace to initialize [{fireplace.read_mode}]") + LOGGER.debug("Waiting for fireplace to initialize [%s]", fireplace.read_mode) await asyncio.sleep(INIT_WAIT_TIME_SECONDS) diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index 4eddde5ff10..f72df254424 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -58,7 +58,6 @@ class IntellifireClimate(IntellifireEntity, ClimateEntity): _attr_target_temperature_step = 1.0 _attr_temperature_unit = UnitOfTemperature.CELSIUS last_temp = DEFAULT_THERMOSTAT_TEMP - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index a6b63f3b3e8..35c3bc09010 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -13,7 +13,6 @@ from intellifire4py.local_api import IntelliFireAPILocal from intellifire4py.model import IntelliFireCommonFireplaceData import voluptuous as vol -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, @@ -22,6 +21,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( API_MODE_LOCAL, @@ -145,13 +145,13 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN): """ errors: dict[str, str] = {} LOGGER.debug( - f"STEP: pick_cloud_device: {user_input} - DHCP_MODE[{self._dhcp_mode}" + "STEP: pick_cloud_device: %s - DHCP_MODE[%s]", user_input, self._dhcp_mode ) if self._dhcp_mode or user_input is not None: if self._dhcp_mode: serial = self._dhcp_discovered_serial - LOGGER.debug(f"DHCP Mode detected for serial [{serial}]") + LOGGER.debug("DHCP Mode detected for serial [%s]", serial) if user_input is not None: serial = user_input[CONF_SERIAL] diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index dc2fc279a5d..c5bec07faaa 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -81,7 +81,6 @@ class IntellifireFan(IntellifireEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False @property def is_on(self) -> bool: diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 30aaa933072..a1451f8fcca 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -45,6 +45,7 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN, TIMER_DATA from .timers import ( + CancelAllTimersIntentHandler, CancelTimerIntentHandler, DecreaseTimerIntentHandler, IncreaseTimerIntentHandler, @@ -64,11 +65,11 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) __all__ = [ - "async_register_timer_handler", - "async_device_supports_timers", - "TimerInfo", - "TimerEventType", "DOMAIN", + "TimerEventType", + "TimerInfo", + "async_device_supports_timers", + "async_register_timer_handler", ] ONOFF_DEVICE_CLASSES = { @@ -130,6 +131,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, SetPositionIntentHandler()) intent.async_register(hass, StartTimerIntentHandler()) intent.async_register(hass, CancelTimerIntentHandler()) + intent.async_register(hass, CancelAllTimersIntentHandler()) intent.async_register(hass, IncreaseTimerIntentHandler()) intent.async_register(hass, DecreaseTimerIntentHandler()) intent.async_register(hass, PauseTimerIntentHandler()) @@ -137,7 +139,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, TimerStatusIntentHandler()) intent.async_register(hass, GetCurrentDateIntentHandler()) intent.async_register(hass, GetCurrentTimeIntentHandler()) - intent.async_register(hass, HelloIntentHandler()) + intent.async_register(hass, RespondIntentHandler()) return True @@ -421,15 +423,25 @@ class GetCurrentTimeIntentHandler(intent.IntentHandler): return response -class HelloIntentHandler(intent.IntentHandler): +class RespondIntentHandler(intent.IntentHandler): """Responds with no action.""" intent_type = intent.INTENT_RESPOND description = "Returns the provided response with no action." + slot_schema = { + vol.Optional("response"): cv.string, + } + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Return the provided response, but take no action.""" - return intent_obj.create_response() + slots = self.async_validate_slots(intent_obj.slots) + response = intent_obj.create_response() + + if "response" in slots: + response.async_set_speech(slots["response"]["value"]) + + return response async def _async_process_intent( diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 639744abc66..d641f8dc6b5 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -10,7 +10,7 @@ import logging import time from typing import Any -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME @@ -21,7 +21,7 @@ from homeassistant.helpers import ( device_registry as dr, intent, ) -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from .const import TIMER_DATA @@ -261,7 +261,7 @@ class TimerManager: if seconds is not None: total_seconds += seconds - timer_id = ulid.ulid_now() + timer_id = ulid_util.ulid_now() created_at = time.monotonic_ns() timer = TimerInfo( id=timer_id, @@ -887,6 +887,36 @@ class CancelTimerIntentHandler(intent.IntentHandler): return intent_obj.create_response() +class CancelAllTimersIntentHandler(intent.IntentHandler): + """Intent handler for cancelling all timers.""" + + intent_type = intent.INTENT_CANCEL_ALL_TIMERS + description = "Cancels all timers" + slot_schema = { + vol.Optional("area"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + canceled = 0 + + for timer in _find_timers(hass, intent_obj.device_id, slots): + timer_manager.cancel_timer(timer.id) + canceled += 1 + + response = intent_obj.create_response() + speech_slots = {"canceled": canceled} + if "area" in slots: + speech_slots["area"] = slots["area"]["value"] + + response.async_set_speech_slots(speech_slots) + + return response + + class IncreaseTimerIntentHandler(intent.IntentHandler): """Intent handler for increasing the time of a timer.""" diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 6f47cadb04f..a4f84f6ff9e 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -148,6 +148,8 @@ class ScriptIntentHandler(intent.IntentHandler): vol.Any("name", "area", "floor"): cv.string, vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]), vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, } def __init__(self, intent_type: str, config: ConfigType) -> None: @@ -205,7 +207,14 @@ class ScriptIntentHandler(intent.IntentHandler): ) if match_constraints.has_constraints: - match_result = intent.async_match_targets(hass, match_constraints) + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id"), + floor_id=slots.get("preferred_floor_id"), + ) + + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences + ) if match_result.is_match: targets = {} diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 82b653a34c7..1a1f58a6b80 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -147,7 +147,6 @@ class IntesisAC(ClimateEntity): _attr_should_poll = False _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, ih_device_id, ih_device, controller): """Initialize the thermostat.""" diff --git a/homeassistant/components/iotty/manifest.json b/homeassistant/components/iotty/manifest.json index 1c0d5cc3df2..5425ce3b480 100644 --- a/homeassistant/components/iotty/manifest.json +++ b/homeassistant/components/iotty/manifest.json @@ -1,11 +1,11 @@ { "domain": "iotty", "name": "iotty", - "codeowners": ["@pburgio", "@shapournemati-iotty"], + "codeowners": ["@shapournemati-iotty"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/iotty", "integration_type": "device", "iot_class": "cloud_polling", - "requirements": ["iottycloud==0.2.1"] + "requirements": ["iottycloud==0.3.0"] } diff --git a/homeassistant/components/iotty/switch.py b/homeassistant/components/iotty/switch.py index 1e2bdffcf79..b06e3ea308d 100644 --- a/homeassistant/components/iotty/switch.py +++ b/homeassistant/components/iotty/switch.py @@ -3,13 +3,22 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from iottycloud.device import Device from iottycloud.lightswitch import LightSwitch -from iottycloud.verbs import LS_DEVICE_TYPE_UID +from iottycloud.outlet import Outlet +from iottycloud.verbs import ( + COMMAND_TURNOFF, + COMMAND_TURNON, + LS_DEVICE_TYPE_UID, + OU_DEVICE_TYPE_UID, +) -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,31 +29,62 @@ from .entity import IottyEntity _LOGGER = logging.getLogger(__name__) +ENTITIES: dict[str, SwitchEntityDescription] = { + LS_DEVICE_TYPE_UID: SwitchEntityDescription( + key="light", + name=None, + device_class=SwitchDeviceClass.SWITCH, + ), + OU_DEVICE_TYPE_UID: SwitchEntityDescription( + key="outlet", + name=None, + device_class=SwitchDeviceClass.OUTLET, + ), +} + async def async_setup_entry( hass: HomeAssistant, config_entry: IottyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Activate the iotty LightSwitch component.""" + """Activate the iotty Switch component.""" _LOGGER.debug("Setup SWITCH entry id is %s", config_entry.entry_id) coordinator = config_entry.runtime_data.coordinator - entities = [ - IottyLightSwitch( - coordinator=coordinator, iotty_cloud=coordinator.iotty, iotty_device=d + lightswitch_entities = [ + IottySwitch( + coordinator=coordinator, + iotty_cloud=coordinator.iotty, + iotty_device=d, + entity_description=ENTITIES[LS_DEVICE_TYPE_UID], ) for d in coordinator.data.devices if d.device_type == LS_DEVICE_TYPE_UID if (isinstance(d, LightSwitch)) ] - _LOGGER.debug("Found %d LightSwitches", len(entities)) + _LOGGER.debug("Found %d LightSwitches", len(lightswitch_entities)) + + outlet_entities = [ + IottySwitch( + coordinator=coordinator, + iotty_cloud=coordinator.iotty, + iotty_device=d, + entity_description=ENTITIES[OU_DEVICE_TYPE_UID], + ) + for d in coordinator.data.devices + if d.device_type == OU_DEVICE_TYPE_UID + if (isinstance(d, Outlet)) + ] + _LOGGER.debug("Found %d Outlets", len(outlet_entities)) + + entities = lightswitch_entities + outlet_entities async_add_entities(entities) known_devices: set = config_entry.runtime_data.known_devices for known_device in coordinator.data.devices: - if known_device.device_type == LS_DEVICE_TYPE_UID: + if known_device.device_type in {LS_DEVICE_TYPE_UID, OU_DEVICE_TYPE_UID}: known_devices.add(known_device) @callback @@ -59,21 +99,37 @@ async def async_setup_entry( # Add entities for devices which we've not yet seen for device in devices: - if ( - any(d.device_id == device.device_id for d in known_devices) - or device.device_type != LS_DEVICE_TYPE_UID + if any(d.device_id == device.device_id for d in known_devices) or ( + device.device_type not in {LS_DEVICE_TYPE_UID, OU_DEVICE_TYPE_UID} ): continue - iotty_entity = IottyLightSwitch( - coordinator=coordinator, - iotty_cloud=coordinator.iotty, - iotty_device=LightSwitch( + iotty_entity: SwitchEntity + iotty_device: LightSwitch | Outlet + if device.device_type == LS_DEVICE_TYPE_UID: + if TYPE_CHECKING: + assert isinstance(device, LightSwitch) + iotty_device = LightSwitch( device.device_id, device.serial_number, device.device_type, device.device_name, - ), + ) + else: + if TYPE_CHECKING: + assert isinstance(device, Outlet) + iotty_device = Outlet( + device.device_id, + device.serial_number, + device.device_type, + device.device_name, + ) + + iotty_entity = IottySwitch( + coordinator=coordinator, + iotty_cloud=coordinator.iotty, + iotty_device=iotty_device, + entity_description=ENTITIES[device.device_type], ) entities.extend([iotty_entity]) @@ -85,24 +141,27 @@ async def async_setup_entry( coordinator.async_add_listener(async_update_data) -class IottyLightSwitch(IottyEntity, SwitchEntity): - """Haas entity class for iotty LightSwitch.""" +class IottySwitch(IottyEntity, SwitchEntity): + """Haas entity class for iotty switch.""" - _attr_device_class = SwitchDeviceClass.SWITCH - _iotty_device: LightSwitch + _attr_device_class: SwitchDeviceClass | None + _iotty_device: LightSwitch | Outlet def __init__( self, coordinator: IottyDataUpdateCoordinator, iotty_cloud: IottyProxy, - iotty_device: LightSwitch, + iotty_device: LightSwitch | Outlet, + entity_description: SwitchEntityDescription, ) -> None: - """Initialize the LightSwitch device.""" + """Initialize the Switch device.""" super().__init__(coordinator, iotty_cloud, iotty_device) + self.entity_description = entity_description + self._attr_device_class = entity_description.device_class @property def is_on(self) -> bool: - """Return true if the LightSwitch is on.""" + """Return true if the Switch is on.""" _LOGGER.debug( "Retrieve device status for %s ? %s", self._iotty_device.device_id, @@ -111,30 +170,25 @@ class IottyLightSwitch(IottyEntity, SwitchEntity): return self._iotty_device.is_on async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the LightSwitch on.""" + """Turn the Switch on.""" _LOGGER.debug("[%s] Turning on", self._iotty_device.device_id) - await self._iotty_cloud.command( - self._iotty_device.device_id, self._iotty_device.cmd_turn_on() - ) + await self._iotty_cloud.command(self._iotty_device.device_id, COMMAND_TURNON) await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the LightSwitch off.""" + """Turn the Switch off.""" _LOGGER.debug("[%s] Turning off", self._iotty_device.device_id) - await self._iotty_cloud.command( - self._iotty_device.device_id, self._iotty_device.cmd_turn_off() - ) + await self._iotty_cloud.command(self._iotty_device.device_id, COMMAND_TURNOFF) await self.coordinator.async_request_refresh() @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - device: Device = next( + device: LightSwitch | Outlet = next( # type: ignore[assignment] device for device in self.coordinator.data.devices if device.device_id == self._iotty_device.device_id ) - if isinstance(device, LightSwitch): - self._iotty_device.is_on = device.is_on + self._iotty_device.is_on = device.is_on self.async_write_ha_state() diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 0d7df3fcf92..1abd7807213 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ipma", "iot_class": "cloud_polling", "loggers": ["geopy", "pyipma"], - "requirements": ["pyipma==3.0.7"] + "requirements": ["pyipma==3.0.8"] } diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index 5f2cb98646b..2a921cdbb04 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -4,8 +4,9 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine -from dataclasses import dataclass +from dataclasses import asdict, dataclass import logging +from typing import Any from pyipma.api import IPMA_API from pyipma.location import Location @@ -28,23 +29,41 @@ _LOGGER = logging.getLogger(__name__) class IPMASensorEntityDescription(SensorEntityDescription): """Describes a IPMA sensor entity.""" - value_fn: Callable[[Location, IPMA_API], Coroutine[Location, IPMA_API, int | None]] + value_fn: Callable[ + [Location, IPMA_API], Coroutine[Location, IPMA_API, tuple[Any, dict[str, Any]]] + ] -async def async_retrieve_rcm(location: Location, api: IPMA_API) -> int | None: +async def async_retrieve_rcm( + location: Location, api: IPMA_API +) -> tuple[int, dict[str, Any]] | tuple[None, dict[str, Any]]: """Retrieve RCM.""" fire_risk: RCM = await location.fire_risk(api) if fire_risk: - return fire_risk.rcm - return None + return fire_risk.rcm, {} + return None, {} -async def async_retrieve_uvi(location: Location, api: IPMA_API) -> int | None: +async def async_retrieve_uvi( + location: Location, api: IPMA_API +) -> tuple[int, dict[str, Any]] | tuple[None, dict[str, Any]]: """Retrieve UV.""" uv_risk: UV = await location.uv_risk(api) if uv_risk: - return round(uv_risk.iUv) - return None + return round(uv_risk.iUv), {} + return None, {} + + +async def async_retrieve_warning( + location: Location, api: IPMA_API +) -> tuple[Any, dict[str, str]]: + """Retrieve Warning.""" + warnings = await location.warnings(api) + if len(warnings): + return warnings[0].awarenessLevelID, { + k: str(v) for k, v in asdict(warnings[0]).items() + } + return "green", {} SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( @@ -58,6 +77,11 @@ SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( translation_key="uv_index", value_fn=async_retrieve_uvi, ), + IPMASensorEntityDescription( + key="alert", + translation_key="weather_alert", + value_fn=async_retrieve_warning, + ), ) @@ -94,6 +118,8 @@ class IPMASensor(SensorEntity, IPMADevice): async def async_update(self) -> None: """Update sensors.""" async with asyncio.timeout(10): - self._attr_native_value = await self.entity_description.value_fn( + state, attrs = await self.entity_description.value_fn( self._location, self._api ) + self._attr_native_value = state + self._attr_extra_state_attributes = attrs diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json index ea5e5ff4759..ff9c23dd7ca 100644 --- a/homeassistant/components/ipma/strings.json +++ b/homeassistant/components/ipma/strings.json @@ -31,6 +31,15 @@ }, "uv_index": { "name": "UV index" + }, + "weather_alert": { + "name": "Weather Alert", + "state": { + "red": "Red", + "yellow": "Yellow", + "orange": "Orange", + "green": "Green" + } } } } diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index ecd4d1af9f6..4d0c43242e4 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -16,7 +16,6 @@ from pyipp import ( ) import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -28,6 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_BASE_PATH, CONF_SERIAL, DOMAIN @@ -103,7 +103,7 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.host diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 11c99a7428f..a738036b3ee 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==2.1.3", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.2.2", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 39bf39bcbe0..2765a14b7a3 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -194,9 +194,9 @@ class IrishRailTransportData: ATTR_STATION: self.station, ATTR_ORIGIN: "", ATTR_DESTINATION: dest, - ATTR_DUE_IN: "n/a", - ATTR_DUE_AT: "n/a", - ATTR_EXPECT_AT: "n/a", + ATTR_DUE_IN: None, + ATTR_DUE_AT: None, + ATTR_EXPECT_AT: None, ATTR_DIRECTION: direction, ATTR_STOPS_AT: stops_at, ATTR_TRAIN_TYPE: "", diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index 56a83117e68..6af6abb1436 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -5,8 +5,7 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING -from aiogithubapi import GitHubAPI -from pynecil import Pynecil +from pynecil import IronOSUpdate, Pynecil from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -19,16 +18,31 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from .const import DOMAIN -from .coordinator import IronOSFirmwareUpdateCoordinator, IronOSLiveDataCoordinator +from .coordinator import ( + IronOSCoordinators, + IronOSFirmwareUpdateCoordinator, + IronOSLiveDataCoordinator, + IronOSSettingsCoordinator, +) -PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.UPDATE] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] -type IronOSConfigEntry = ConfigEntry[IronOSLiveDataCoordinator] -IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN) - CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +type IronOSConfigEntry = ConfigEntry[IronOSCoordinators] +IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN) + + _LOGGER = logging.getLogger(__name__) @@ -36,7 +50,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up IronOS firmware update coordinator.""" session = async_get_clientsession(hass) - github = GitHubAPI(session=session) + github = IronOSUpdate(session) hass.data[IRON_OS_KEY] = IronOSFirmwareUpdateCoordinator(hass, github) await hass.data[IRON_OS_KEY].async_request_refresh() @@ -59,10 +73,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo device = Pynecil(ble_device) - coordinator = IronOSLiveDataCoordinator(hass, device) - await coordinator.async_config_entry_first_refresh() + live_data = IronOSLiveDataCoordinator(hass, device) + await live_data.async_config_entry_first_refresh() - entry.runtime_data = coordinator + settings = IronOSSettingsCoordinator(hass, device) + await settings.async_config_entry_first_refresh() + + entry.runtime_data = IronOSCoordinators( + live_data=live_data, + settings=settings, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/iron_os/binary_sensor.py b/homeassistant/components/iron_os/binary_sensor.py new file mode 100644 index 00000000000..81ba0e08c95 --- /dev/null +++ b/homeassistant/components/iron_os/binary_sensor.py @@ -0,0 +1,54 @@ +"""Binary sensor platform for IronOS integration.""" + +from __future__ import annotations + +from enum import StrEnum + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IronOSConfigEntry +from .coordinator import IronOSLiveDataCoordinator +from .entity import IronOSBaseEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class PinecilBinarySensor(StrEnum): + """Pinecil Binary Sensors.""" + + TIP_CONNECTED = "tip_connected" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IronOSConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensors from a config entry.""" + coordinator = entry.runtime_data.live_data + + entity_description = BinarySensorEntityDescription( + key=PinecilBinarySensor.TIP_CONNECTED, + translation_key=PinecilBinarySensor.TIP_CONNECTED, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ) + + async_add_entities([IronOSBinarySensorEntity(coordinator, entity_description)]) + + +class IronOSBinarySensorEntity(IronOSBaseEntity, BinarySensorEntity): + """Representation of a IronOS binary sensor entity.""" + + coordinator: IronOSLiveDataCoordinator + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.coordinator.has_tip diff --git a/homeassistant/components/iron_os/button.py b/homeassistant/components/iron_os/button.py new file mode 100644 index 00000000000..be16148a656 --- /dev/null +++ b/homeassistant/components/iron_os/button.py @@ -0,0 +1,85 @@ +"""Button platform for IronOS integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from pynecil import CharSetting + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IronOSConfigEntry +from .coordinator import IronOSCoordinators +from .entity import IronOSBaseEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class IronOSButtonEntityDescription(ButtonEntityDescription): + """Describes IronOS button entity.""" + + characteristic: CharSetting + + +class IronOSButton(StrEnum): + """Button controls for IronOS device.""" + + SETTINGS_RESET = "settings_reset" + SETTINGS_SAVE = "settings_save" + + +BUTTON_DESCRIPTIONS: tuple[IronOSButtonEntityDescription, ...] = ( + IronOSButtonEntityDescription( + key=IronOSButton.SETTINGS_RESET, + translation_key=IronOSButton.SETTINGS_RESET, + characteristic=CharSetting.SETTINGS_RESET, + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + IronOSButtonEntityDescription( + key=IronOSButton.SETTINGS_SAVE, + translation_key=IronOSButton.SETTINGS_SAVE, + characteristic=CharSetting.SETTINGS_SAVE, + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IronOSConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up button entities from a config entry.""" + coordinators = entry.runtime_data + + async_add_entities( + IronOSButtonEntity(coordinators, description) + for description in BUTTON_DESCRIPTIONS + ) + + +class IronOSButtonEntity(IronOSBaseEntity, ButtonEntity): + """Implementation of a IronOS button entity.""" + + entity_description: IronOSButtonEntityDescription + + def __init__( + self, + coordinators: IronOSCoordinators, + entity_description: IronOSButtonEntityDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__(coordinators.live_data, entity_description) + + self.settings = coordinators.settings + + async def async_press(self) -> None: + """Handle the button press.""" + + await self.settings.write(self.entity_description.characteristic, True) diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 699f5a01704..080fee20762 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -2,15 +2,28 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta +from enum import Enum import logging -from typing import TYPE_CHECKING +from typing import cast -from aiogithubapi import GitHubAPI, GitHubException, GitHubReleaseModel -from pynecil import CommunicationError, DeviceInfoResponse, LiveDataResponse, Pynecil +from pynecil import ( + CharSetting, + CommunicationError, + DeviceInfoResponse, + IronOSUpdate, + LatestRelease, + LiveDataResponse, + Pynecil, + SettingsDataResponse, + UpdateException, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -19,24 +32,58 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) SCAN_INTERVAL_GITHUB = timedelta(hours=3) +SCAN_INTERVAL_SETTINGS = timedelta(seconds=60) -class IronOSLiveDataCoordinator(DataUpdateCoordinator[LiveDataResponse]): - """IronOS live data coordinator.""" +@dataclass +class IronOSCoordinators: + """IronOS data class holding coordinators.""" + + live_data: IronOSLiveDataCoordinator + settings: IronOSSettingsCoordinator + + +class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """IronOS base coordinator.""" device_info: DeviceInfoResponse config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, device: Pynecil) -> None: + def __init__( + self, + hass: HomeAssistant, + device: Pynecil, + update_interval: timedelta, + ) -> None: """Initialize IronOS coordinator.""" + super().__init__( hass, _LOGGER, name=DOMAIN, - update_interval=SCAN_INTERVAL, + update_interval=update_interval, + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=3, immediate=False + ), ) self.device = device + async def _async_setup(self) -> None: + """Set up the coordinator.""" + try: + self.device_info = await self.device.get_device_info() + + except CommunicationError as e: + raise UpdateFailed("Cannot connect to device") from e + + +class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): + """IronOS coordinator.""" + + def __init__(self, hass: HomeAssistant, device: Pynecil) -> None: + """Initialize IronOS coordinator.""" + super().__init__(hass, device=device, update_interval=SCAN_INTERVAL) + async def _async_update_data(self) -> LiveDataResponse: """Fetch data from Device.""" @@ -50,11 +97,22 @@ class IronOSLiveDataCoordinator(DataUpdateCoordinator[LiveDataResponse]): except CommunicationError as e: raise UpdateFailed("Cannot connect to device") from e + @property + def has_tip(self) -> bool: + """Return True if the tip is connected.""" + if ( + self.data.max_tip_temp_ability is not None + and self.data.live_temp is not None + ): + threshold = self.data.max_tip_temp_ability - 5 + return self.data.live_temp <= threshold + return False -class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel]): + +class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): """IronOS coordinator for retrieving update information from github.""" - def __init__(self, hass: HomeAssistant, github: GitHubAPI) -> None: + def __init__(self, hass: HomeAssistant, github: IronOSUpdate) -> None: """Initialize IronOS coordinator.""" super().__init__( hass, @@ -65,18 +123,53 @@ class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel]) ) self.github = github - async def _async_update_data(self) -> GitHubReleaseModel: + async def _async_update_data(self) -> LatestRelease: """Fetch data from Github.""" try: - release = await self.github.repos.releases.latest("Ralim/IronOS") + return await self.github.latest_release() + except UpdateException as e: + raise UpdateFailed("Failed to check for latest IronOS update") from e - except GitHubException as e: - raise UpdateFailed( - "Failed to retrieve latest release data from Github" + +class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]): + """IronOS coordinator.""" + + def __init__(self, hass: HomeAssistant, device: Pynecil) -> None: + """Initialize IronOS coordinator.""" + super().__init__(hass, device=device, update_interval=SCAN_INTERVAL_SETTINGS) + + async def _async_update_data(self) -> SettingsDataResponse: + """Fetch data from Device.""" + + characteristics = set(self.async_contexts()) + + if self.device.is_connected and characteristics: + try: + return await self.device.get_settings(list(characteristics)) + except CommunicationError as e: + _LOGGER.debug("Failed to fetch settings", exc_info=e) + + return self.data or SettingsDataResponse() + + async def write( + self, + characteristic: CharSetting, + value: bool | Enum | float, + ) -> None: + """Write value to the settings characteristic.""" + + try: + await self.device.write(characteristic, value) + except CommunicationError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="submit_setting_failed", ) from e - if TYPE_CHECKING: - assert release.data - - return release.data + # prevent switch bouncing while waiting for coordinator to finish refresh + self.data.update( + cast(SettingsDataResponse, {characteristic.name.lower(): value}) + ) + self.async_update_listeners() + await self.async_request_refresh() diff --git a/homeassistant/components/iron_os/diagnostics.py b/homeassistant/components/iron_os/diagnostics.py new file mode 100644 index 00000000000..e9545c24dec --- /dev/null +++ b/homeassistant/components/iron_os/diagnostics.py @@ -0,0 +1,25 @@ +"""Diagnostics platform for IronOS integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from . import IronOSConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: IronOSConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "config_entry_data": { + CONF_ADDRESS: config_entry.unique_id, + }, + "device_info": config_entry.runtime_data.live_data.device_info, + "live_data": config_entry.runtime_data.live_data.data, + "settings_data": config_entry.runtime_data.settings.data, + } diff --git a/homeassistant/components/iron_os/entity.py b/homeassistant/components/iron_os/entity.py index 77bebda9390..190a9f33639 100644 --- a/homeassistant/components/iron_os/entity.py +++ b/homeassistant/components/iron_os/entity.py @@ -31,7 +31,8 @@ class IronOSBaseEntity(CoordinatorEntity[IronOSLiveDataCoordinator]): ) if TYPE_CHECKING: assert coordinator.config_entry.unique_id - self.device_info = DeviceInfo( + + self._attr_device_info = DeviceInfo( connections={(CONNECTION_BLUETOOTH, coordinator.config_entry.unique_id)}, manufacturer=MANUFACTURER, model=MODEL, diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index fa14b8134d0..6410c561b9d 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -1,8 +1,110 @@ { "entity": { + "binary_sensor": { + "tip_connected": { + "default": "mdi:pencil-outline", + "state": { + "off": "mdi:pencil-off-outline" + } + } + }, + "button": { + "settings_save": { + "default": "mdi:content-save-cog" + }, + "settings_reset": { + "default": "mdi:refresh" + } + }, "number": { "setpoint_temperature": { "default": "mdi:thermometer" + }, + "sleep_temperature": { + "default": "mdi:thermometer-low" + }, + "sleep_timeout": { + "default": "mdi:timer-sand" + }, + "qc_max_voltage": { + "default": "mdi:flash-alert-outline" + }, + "pd_timeout": { + "default": "mdi:timer-alert-outline" + }, + "boost_temp": { + "default": "mdi:thermometer-high" + }, + "shutdown_timeout": { + "default": "mdi:thermometer-off" + }, + "display_brightness": { + "default": "mdi:brightness-6" + }, + "voltage_div": { + "default": "mdi:call-split" + }, + "temp_increment_short": { + "default": "mdi:gesture-tap-button" + }, + "temp_increment_long": { + "default": "mdi:gesture-tap-button" + }, + "accel_sensitivity": { + "default": "mdi:motion" + }, + "calibration_offset": { + "default": "mdi:contrast" + }, + "hall_sensitivity": { + "default": "mdi:leak" + }, + "keep_awake_pulse_delay": { + "default": "mdi:clock-end" + }, + "keep_awake_pulse_duration": { + "default": "mdi:clock-start" + }, + "keep_awake_pulse_power": { + "default": "mdi:waves-arrow-up" + }, + "min_voltage_per_cell": { + "default": "mdi:fuel-cell" + }, + "power_limit": { + "default": "mdi:flash-alert" + } + }, + "select": { + "locking_mode": { + "default": "mdi:download-lock" + }, + "orientation_mode": { + "default": "mdi:screen-rotation" + }, + "autostart_mode": { + "default": "mdi:power-standby" + }, + "animation_speed": { + "default": "mdi:image-refresh" + }, + "min_dc_voltage_cells": { + "default": "mdi:fuel-cell" + }, + "temp_unit": { + "default": "mdi:temperature-celsius", + "state": { + "fahrenheit": "mdi:temperature-fahrenheit" + } + }, + "desc_scroll_speed": { + "default": "mdi:message-text-fast" + }, + "logo_duration": { + "default": "mdi:clock-digital" + }, + "usb_pd_mode": { + "default": "mdi:meter-electric-outline" } }, "sensor": { @@ -58,6 +160,41 @@ "estimated_power": { "default": "mdi:flash" } + }, + "switch": { + "animation_loop": { + "default": "mdi:play-box", + "state": { + "on": "mdi:animation-play" + } + }, + "calibrate_cjc": { + "default": "mdi:tune-vertical" + }, + "cooling_temp_blink": { + "default": "mdi:alarm-light-outline", + "state": { + "off": "mdi:alarm-light-off-outline" + } + }, + "display_invert": { + "default": "mdi:invert-colors" + }, + "invert_buttons": { + "default": "mdi:plus-minus-variant" + }, + "idle_screen_details": { + "default": "mdi:card-bulleted-outline", + "state": { + "off": "mdi:card-bulleted-off-outline" + } + }, + "solder_screen_details": { + "default": "mdi:card-bulleted-outline", + "state": { + "off": "mdi:card-bulleted-off-outline" + } + } } } } diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 4ec08a43b61..462e75c5b6e 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -12,6 +12,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/iron_os", "iot_class": "local_polling", - "loggers": ["pynecil", "aiogithubapi"], - "requirements": ["pynecil==0.2.1", "aiogithubapi==24.6.0"] + "loggers": ["pynecil"], + "requirements": ["pynecil==4.0.1"] } diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index 9230faec1f1..518c11372c4 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -6,37 +6,70 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from pynecil import CharSetting, CommunicationError, LiveDataResponse +from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse from homeassistant.components.number import ( + DEFAULT_MAX_VALUE, NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, ) -from homeassistant.const import UnitOfTemperature +from homeassistant.const import ( + EntityCategory, + UnitOfElectricPotential, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import IronOSConfigEntry -from .const import DOMAIN, MAX_TEMP, MIN_TEMP +from .const import MAX_TEMP, MIN_TEMP +from .coordinator import IronOSCoordinators from .entity import IronOSBaseEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class IronOSNumberEntityDescription(NumberEntityDescription): """Describes IronOS number entity.""" - value_fn: Callable[[LiveDataResponse], float | int | None] - max_value_fn: Callable[[LiveDataResponse], float | int] - set_key: CharSetting + value_fn: Callable[[LiveDataResponse, SettingsDataResponse], float | int | None] + max_value_fn: Callable[[LiveDataResponse], float | int] | None = None + characteristic: CharSetting + raw_value_fn: Callable[[float], float | int] | None = None class PinecilNumber(StrEnum): """Number controls for Pinecil device.""" SETPOINT_TEMP = "setpoint_temperature" + SLEEP_TEMP = "sleep_temperature" + SLEEP_TIMEOUT = "sleep_timeout" + QC_MAX_VOLTAGE = "qc_max_voltage" + PD_TIMEOUT = "pd_timeout" + BOOST_TEMP = "boost_temp" + SHUTDOWN_TIMEOUT = "shutdown_timeout" + DISPLAY_BRIGHTNESS = "display_brightness" + POWER_LIMIT = "power_limit" + CALIBRATION_OFFSET = "calibration_offset" + HALL_SENSITIVITY = "hall_sensitivity" + MIN_VOLTAGE_PER_CELL = "min_voltage_per_cell" + ACCEL_SENSITIVITY = "accel_sensitivity" + KEEP_AWAKE_PULSE_POWER = "keep_awake_pulse_power" + KEEP_AWAKE_PULSE_DELAY = "keep_awake_pulse_delay" + KEEP_AWAKE_PULSE_DURATION = "keep_awake_pulse_duration" + VOLTAGE_DIV = "voltage_div" + TEMP_INCREMENT_SHORT = "temp_increment_short" + TEMP_INCREMENT_LONG = "temp_increment_long" + + +def multiply(value: float | None, multiplier: float) -> float | None: + """Multiply if not None.""" + return value * multiplier if value is not None else None PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( @@ -45,13 +78,249 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( translation_key=PinecilNumber.SETPOINT_TEMP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, - value_fn=lambda data: data.setpoint_temp, - set_key=CharSetting.SETPOINT_TEMP, + value_fn=lambda data, _: data.setpoint_temp, + characteristic=CharSetting.SETPOINT_TEMP, mode=NumberMode.BOX, native_min_value=MIN_TEMP, native_step=5, max_value_fn=lambda data: min(data.max_tip_temp_ability or MAX_TEMP, MAX_TEMP), ), + IronOSNumberEntityDescription( + key=PinecilNumber.SLEEP_TEMP, + translation_key=PinecilNumber.SLEEP_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + value_fn=lambda _, settings: settings.get("sleep_temp"), + characteristic=CharSetting.SLEEP_TEMP, + mode=NumberMode.BOX, + native_min_value=MIN_TEMP, + native_max_value=MAX_TEMP, + native_step=10, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.BOOST_TEMP, + translation_key=PinecilNumber.BOOST_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + value_fn=lambda _, settings: settings.get("boost_temp"), + characteristic=CharSetting.BOOST_TEMP, + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=MAX_TEMP, + native_step=10, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.QC_MAX_VOLTAGE, + translation_key=PinecilNumber.QC_MAX_VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=NumberDeviceClass.VOLTAGE, + value_fn=lambda _, settings: settings.get("qc_ideal_voltage"), + characteristic=CharSetting.QC_IDEAL_VOLTAGE, + mode=NumberMode.BOX, + native_min_value=9.0, + native_max_value=22.0, + native_step=0.1, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.PD_TIMEOUT, + translation_key=PinecilNumber.PD_TIMEOUT, + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=NumberDeviceClass.DURATION, + value_fn=lambda _, settings: settings.get("pd_negotiation_timeout"), + characteristic=CharSetting.PD_NEGOTIATION_TIMEOUT, + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=5.0, + native_step=1, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.SHUTDOWN_TIMEOUT, + translation_key=PinecilNumber.SHUTDOWN_TIMEOUT, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=NumberDeviceClass.DURATION, + value_fn=lambda _, settings: settings.get("shutdown_time"), + characteristic=CharSetting.SHUTDOWN_TIME, + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=60, + native_step=1, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.DISPLAY_BRIGHTNESS, + translation_key=PinecilNumber.DISPLAY_BRIGHTNESS, + value_fn=lambda _, settings: settings.get("display_brightness"), + characteristic=CharSetting.DISPLAY_BRIGHTNESS, + mode=NumberMode.SLIDER, + native_min_value=1, + native_max_value=5, + native_step=1, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.SLEEP_TIMEOUT, + translation_key=PinecilNumber.SLEEP_TIMEOUT, + value_fn=lambda _, settings: settings.get("sleep_timeout"), + characteristic=CharSetting.SLEEP_TIMEOUT, + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=15, + native_step=1, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.POWER_LIMIT, + translation_key=PinecilNumber.POWER_LIMIT, + value_fn=lambda _, settings: settings.get("power_limit"), + characteristic=CharSetting.POWER_LIMIT, + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=120, + native_step=5, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfPower.WATT, + entity_registry_enabled_default=False, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.CALIBRATION_OFFSET, + translation_key=PinecilNumber.CALIBRATION_OFFSET, + value_fn=lambda _, settings: settings.get("calibration_offset"), + characteristic=CharSetting.CALIBRATION_OFFSET, + mode=NumberMode.BOX, + native_min_value=100, + native_max_value=2500, + native_step=1, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfElectricPotential.MICROVOLT, + entity_registry_enabled_default=False, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.HALL_SENSITIVITY, + translation_key=PinecilNumber.HALL_SENSITIVITY, + value_fn=lambda _, settings: settings.get("hall_sensitivity"), + characteristic=CharSetting.HALL_SENSITIVITY, + mode=NumberMode.SLIDER, + native_min_value=0, + native_max_value=9, + native_step=1, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.MIN_VOLTAGE_PER_CELL, + translation_key=PinecilNumber.MIN_VOLTAGE_PER_CELL, + value_fn=lambda _, settings: settings.get("min_voltage_per_cell"), + characteristic=CharSetting.MIN_VOLTAGE_PER_CELL, + mode=NumberMode.BOX, + native_min_value=2.4, + native_max_value=3.8, + native_step=0.1, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_registry_enabled_default=False, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.ACCEL_SENSITIVITY, + translation_key=PinecilNumber.ACCEL_SENSITIVITY, + value_fn=lambda _, settings: settings.get("accel_sensitivity"), + characteristic=CharSetting.ACCEL_SENSITIVITY, + mode=NumberMode.SLIDER, + native_min_value=0, + native_max_value=9, + native_step=1, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.KEEP_AWAKE_PULSE_POWER, + translation_key=PinecilNumber.KEEP_AWAKE_PULSE_POWER, + value_fn=lambda _, settings: settings.get("keep_awake_pulse_power"), + characteristic=CharSetting.KEEP_AWAKE_PULSE_POWER, + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=9.9, + native_step=0.1, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfPower.WATT, + entity_registry_enabled_default=False, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.KEEP_AWAKE_PULSE_DELAY, + translation_key=PinecilNumber.KEEP_AWAKE_PULSE_DELAY, + value_fn=( + lambda _, settings: multiply(settings.get("keep_awake_pulse_delay"), 2.5) + ), + characteristic=CharSetting.KEEP_AWAKE_PULSE_DELAY, + raw_value_fn=lambda value: value / 2.5, + mode=NumberMode.BOX, + native_min_value=2.5, + native_max_value=22.5, + native_step=2.5, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_registry_enabled_default=False, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.KEEP_AWAKE_PULSE_DURATION, + translation_key=PinecilNumber.KEEP_AWAKE_PULSE_DURATION, + value_fn=( + lambda _, settings: multiply(settings.get("keep_awake_pulse_duration"), 250) + ), + characteristic=CharSetting.KEEP_AWAKE_PULSE_DURATION, + raw_value_fn=lambda value: value / 250, + mode=NumberMode.BOX, + native_min_value=250, + native_max_value=2250, + native_step=250, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + entity_registry_enabled_default=False, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.VOLTAGE_DIV, + translation_key=PinecilNumber.VOLTAGE_DIV, + value_fn=(lambda _, settings: settings.get("voltage_div")), + characteristic=CharSetting.VOLTAGE_DIV, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=360, + native_max_value=900, + native_step=1, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.TEMP_INCREMENT_SHORT, + translation_key=PinecilNumber.TEMP_INCREMENT_SHORT, + value_fn=(lambda _, settings: settings.get("temp_increment_short")), + characteristic=CharSetting.TEMP_INCREMENT_SHORT, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=1, + native_max_value=50, + native_step=1, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.TEMP_INCREMENT_LONG, + translation_key=PinecilNumber.TEMP_INCREMENT_LONG, + value_fn=(lambda _, settings: settings.get("temp_increment_long")), + characteristic=CharSetting.TEMP_INCREMENT_LONG, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=5, + native_max_value=90, + native_step=5, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), ) @@ -61,10 +330,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up number entities from a config entry.""" - coordinator = entry.runtime_data + coordinators = entry.runtime_data async_add_entities( - IronOSNumberEntity(coordinator, description) + IronOSNumberEntity(coordinators, description) for description in PINECIL_NUMBER_DESCRIPTIONS ) @@ -74,23 +343,46 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): entity_description: IronOSNumberEntityDescription + def __init__( + self, + coordinators: IronOSCoordinators, + entity_description: IronOSNumberEntityDescription, + ) -> None: + """Initialize the number entity.""" + super().__init__(coordinators.live_data, entity_description) + + self.settings = coordinators.settings + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - try: - await self.coordinator.device.write(self.entity_description.set_key, value) - except CommunicationError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="submit_setting_failed", - ) from e - self.async_write_ha_state() + if raw_value_fn := self.entity_description.raw_value_fn: + value = raw_value_fn(value) + + await self.settings.write(self.entity_description.characteristic, value) @property def native_value(self) -> float | int | None: """Return sensor state.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn( + self.coordinator.data, self.settings.data + ) @property def native_max_value(self) -> float: """Return sensor state.""" - return self.entity_description.max_value_fn(self.coordinator.data) + + if self.entity_description.max_value_fn is not None: + return self.entity_description.max_value_fn(self.coordinator.data) + + return self.entity_description.native_max_value or DEFAULT_MAX_VALUE + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + await super().async_added_to_hass() + self.async_on_remove( + self.settings.async_add_listener( + self._handle_coordinator_update, self.entity_description.characteristic + ) + ) + await self.settings.async_request_refresh() diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml new file mode 100644 index 00000000000..c80b8b5adfe --- /dev/null +++ b/homeassistant/components/iron_os/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not have actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: done + comment: Integration does register actions aside from entity actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not register events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: todo + test-before-setup: todo + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Devices don't require authentication + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Device is not connected to an ip network. Other information from discovery is immutable and does not require updating. + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Only one device per config entry. New devices are set up as new entries. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: Reconfiguration would force a new config entry + repair-issues: + status: exempt + comment: no repairs/issues + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Device doesn't make http requests. + strict-typing: done diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py new file mode 100644 index 00000000000..e9c7f81c208 --- /dev/null +++ b/homeassistant/components/iron_os/select.py @@ -0,0 +1,206 @@ +"""Select platform for IronOS integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum, StrEnum + +from pynecil import ( + AnimationSpeed, + AutostartMode, + BatteryType, + CharSetting, + LockingMode, + LogoDuration, + ScreenOrientationMode, + ScrollSpeed, + SettingsDataResponse, + TempUnit, + USBPDMode, +) + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IronOSConfigEntry +from .coordinator import IronOSCoordinators +from .entity import IronOSBaseEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class IronOSSelectEntityDescription(SelectEntityDescription): + """Describes IronOS select entity.""" + + value_fn: Callable[[SettingsDataResponse], str | None] + characteristic: CharSetting + raw_value_fn: Callable[[str], Enum] + + +class PinecilSelect(StrEnum): + """Select controls for Pinecil device.""" + + MIN_DC_VOLTAGE_CELLS = "min_dc_voltage_cells" + ORIENTATION_MODE = "orientation_mode" + ANIMATION_SPEED = "animation_speed" + AUTOSTART_MODE = "autostart_mode" + TEMP_UNIT = "temp_unit" + DESC_SCROLL_SPEED = "desc_scroll_speed" + LOCKING_MODE = "locking_mode" + LOGO_DURATION = "logo_duration" + USB_PD_MODE = "usb_pd_mode" + + +def enum_to_str(enum: Enum | None) -> str | None: + """Convert enum name to lower-case string.""" + return enum.name.lower() if isinstance(enum, Enum) else None + + +PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = ( + IronOSSelectEntityDescription( + key=PinecilSelect.MIN_DC_VOLTAGE_CELLS, + translation_key=PinecilSelect.MIN_DC_VOLTAGE_CELLS, + characteristic=CharSetting.MIN_DC_VOLTAGE_CELLS, + value_fn=lambda x: enum_to_str(x.get("min_dc_voltage_cells")), + raw_value_fn=lambda value: BatteryType[value.upper()], + options=[x.name.lower() for x in BatteryType], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSSelectEntityDescription( + key=PinecilSelect.ORIENTATION_MODE, + translation_key=PinecilSelect.ORIENTATION_MODE, + characteristic=CharSetting.ORIENTATION_MODE, + value_fn=lambda x: enum_to_str(x.get("orientation_mode")), + raw_value_fn=lambda value: ScreenOrientationMode[value.upper()], + options=[x.name.lower() for x in ScreenOrientationMode], + entity_category=EntityCategory.CONFIG, + ), + IronOSSelectEntityDescription( + key=PinecilSelect.ANIMATION_SPEED, + translation_key=PinecilSelect.ANIMATION_SPEED, + characteristic=CharSetting.ANIMATION_SPEED, + value_fn=lambda x: enum_to_str(x.get("animation_speed")), + raw_value_fn=lambda value: AnimationSpeed[value.upper()], + options=[x.name.lower() for x in AnimationSpeed], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSSelectEntityDescription( + key=PinecilSelect.AUTOSTART_MODE, + translation_key=PinecilSelect.AUTOSTART_MODE, + characteristic=CharSetting.AUTOSTART_MODE, + value_fn=lambda x: enum_to_str(x.get("autostart_mode")), + raw_value_fn=lambda value: AutostartMode[value.upper()], + options=[x.name.lower() for x in AutostartMode], + entity_category=EntityCategory.CONFIG, + ), + IronOSSelectEntityDescription( + key=PinecilSelect.TEMP_UNIT, + translation_key=PinecilSelect.TEMP_UNIT, + characteristic=CharSetting.TEMP_UNIT, + value_fn=lambda x: enum_to_str(x.get("temp_unit")), + raw_value_fn=lambda value: TempUnit[value.upper()], + options=[x.name.lower() for x in TempUnit], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSSelectEntityDescription( + key=PinecilSelect.DESC_SCROLL_SPEED, + translation_key=PinecilSelect.DESC_SCROLL_SPEED, + characteristic=CharSetting.DESC_SCROLL_SPEED, + value_fn=lambda x: enum_to_str(x.get("desc_scroll_speed")), + raw_value_fn=lambda value: ScrollSpeed[value.upper()], + options=[x.name.lower() for x in ScrollSpeed], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSSelectEntityDescription( + key=PinecilSelect.LOCKING_MODE, + translation_key=PinecilSelect.LOCKING_MODE, + characteristic=CharSetting.LOCKING_MODE, + value_fn=lambda x: enum_to_str(x.get("locking_mode")), + raw_value_fn=lambda value: LockingMode[value.upper()], + options=[x.name.lower() for x in LockingMode], + entity_category=EntityCategory.CONFIG, + ), + IronOSSelectEntityDescription( + key=PinecilSelect.LOGO_DURATION, + translation_key=PinecilSelect.LOGO_DURATION, + characteristic=CharSetting.LOGO_DURATION, + value_fn=lambda x: enum_to_str(x.get("logo_duration")), + raw_value_fn=lambda value: LogoDuration[value.upper()], + options=[x.name.lower() for x in LogoDuration], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSSelectEntityDescription( + key=PinecilSelect.USB_PD_MODE, + translation_key=PinecilSelect.USB_PD_MODE, + characteristic=CharSetting.USB_PD_MODE, + value_fn=lambda x: enum_to_str(x.get("usb_pd_mode")), + raw_value_fn=lambda value: USBPDMode[value.upper()], + options=["off", "on"], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IronOSConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up select entities from a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + IronOSSelectEntity(coordinator, description) + for description in PINECIL_SELECT_DESCRIPTIONS + ) + + +class IronOSSelectEntity(IronOSBaseEntity, SelectEntity): + """Implementation of a IronOS select entity.""" + + entity_description: IronOSSelectEntityDescription + + def __init__( + self, + coordinators: IronOSCoordinators, + entity_description: IronOSSelectEntityDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__(coordinators.live_data, entity_description) + + self.settings = coordinators.settings + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + + return self.entity_description.value_fn(self.settings.data) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + + await self.settings.write( + self.entity_description.characteristic, + self.entity_description.raw_value_fn(option), + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + await super().async_added_to_hass() + self.async_on_remove( + self.settings.async_add_listener( + self._handle_coordinator_update, self.entity_description.characteristic + ) + ) + await self.settings.async_request_refresh() diff --git a/homeassistant/components/iron_os/sensor.py b/homeassistant/components/iron_os/sensor.py index 095ffd254df..d178b46723f 100644 --- a/homeassistant/components/iron_os/sensor.py +++ b/homeassistant/components/iron_os/sensor.py @@ -28,8 +28,12 @@ from homeassistant.helpers.typing import StateType from . import IronOSConfigEntry from .const import OHM +from .coordinator import IronOSLiveDataCoordinator from .entity import IronOSBaseEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + class PinecilSensor(StrEnum): """Pinecil Sensors.""" @@ -54,7 +58,7 @@ class PinecilSensor(StrEnum): class IronOSSensorEntityDescription(SensorEntityDescription): """IronOS sensor entity descriptions.""" - value_fn: Callable[[LiveDataResponse], StateType] + value_fn: Callable[[LiveDataResponse, bool], StateType] PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( @@ -64,7 +68,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.live_temp, + value_fn=lambda data, has_tip: data.live_temp if has_tip else None, ), IronOSSensorEntityDescription( key=PinecilSensor.DC_VOLTAGE, @@ -72,7 +76,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.dc_voltage, + value_fn=lambda data, _: data.dc_voltage, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -81,7 +85,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.handle_temp, + value_fn=lambda data, _: data.handle_temp, ), IronOSSensorEntityDescription( key=PinecilSensor.PWMLEVEL, @@ -90,7 +94,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( suggested_display_precision=0, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.pwm_level, + value_fn=lambda data, _: data.pwm_level, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -98,15 +102,18 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( translation_key=PinecilSensor.POWER_SRC, device_class=SensorDeviceClass.ENUM, options=[item.name.lower() for item in PowerSource], - value_fn=lambda data: data.power_src.name.lower() if data.power_src else None, + value_fn=( + lambda data, _: data.power_src.name.lower() if data.power_src else None + ), entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( key=PinecilSensor.TIP_RESISTANCE, translation_key=PinecilSensor.TIP_RESISTANCE, native_unit_of_measurement=OHM, - value_fn=lambda data: data.tip_resistance, + value_fn=lambda data, has_tip: data.tip_resistance if has_tip else None, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), IronOSSensorEntityDescription( key=PinecilSensor.UPTIME, @@ -114,7 +121,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.uptime, + value_fn=lambda data, _: data.uptime, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -123,7 +130,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.movement_time, + value_fn=lambda data, _: data.movement_time, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -131,17 +138,17 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( translation_key=PinecilSensor.MAX_TIP_TEMP_ABILITY, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - value_fn=lambda data: data.max_tip_temp_ability, + value_fn=lambda data, has_tip: data.max_tip_temp_ability if has_tip else None, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( key=PinecilSensor.TIP_VOLTAGE, translation_key=PinecilSensor.TIP_VOLTAGE, - native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + native_unit_of_measurement=UnitOfElectricPotential.MICROVOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=3, - value_fn=lambda data: data.tip_voltage, + suggested_display_precision=0, + value_fn=lambda data, has_tip: data.tip_voltage if has_tip else None, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -149,7 +156,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( translation_key=PinecilSensor.HALL_SENSOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - value_fn=lambda data: data.hall_sensor, + value_fn=lambda data, _: data.hall_sensor, entity_category=EntityCategory.DIAGNOSTIC, ), IronOSSensorEntityDescription( @@ -158,7 +165,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENUM, options=[item.name.lower() for item in OperatingMode], value_fn=( - lambda data: data.operating_mode.name.lower() + lambda data, _: data.operating_mode.name.lower() if data.operating_mode else None ), @@ -169,7 +176,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.estimated_power, + value_fn=lambda data, _: data.estimated_power, ), ) @@ -180,7 +187,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors from a config entry.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.live_data async_add_entities( IronOSSensorEntity(coordinator, description) @@ -192,8 +199,11 @@ class IronOSSensorEntity(IronOSBaseEntity, SensorEntity): """Representation of a IronOS sensor entity.""" entity_description: IronOSSensorEntityDescription + coordinator: IronOSLiveDataCoordinator @property def native_value(self) -> StateType: """Return sensor state.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn( + self.coordinator.data, self.coordinator.has_tip + ) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 75584fe191c..60168699427 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -1,14 +1,21 @@ { + "common": { + "slow": "Slow", + "fast": "Fast" + }, "config": { "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { "address": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "address": "Ensure your device is powered on and within Bluetooth range before continuing" } }, "bluetooth_confirm": { - "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + "description": "Do you want to set up {name}?\n\n*Ensure your device is powered on and within Bluetooth range before continuing*" } }, "abort": { @@ -17,9 +24,155 @@ } }, "entity": { + "binary_sensor": { + "tip_connected": { + "name": "Soldering tip" + } + }, + "button": { + "settings_save": { + "name": "Save settings" + }, + "settings_reset": { + "name": "Restore default settings" + } + }, "number": { "setpoint_temperature": { "name": "Setpoint temperature" + }, + "sleep_temperature": { + "name": "Sleep temperature" + }, + "sleep_timeout": { + "name": "Sleep timeout" + }, + "qc_max_voltage": { + "name": "Quick Charge voltage" + }, + "pd_timeout": { + "name": "Power Delivery timeout" + }, + "boost_temp": { + "name": "Boost temperature" + }, + "shutdown_timeout": { + "name": "Shutdown timeout" + }, + "display_brightness": { + "name": "Display brightness" + }, + "power_limit": { + "name": "Power limit" + }, + "calibration_offset": { + "name": "Calibration offset" + }, + "hall_sensitivity": { + "name": "Hall effect sensitivity" + }, + "min_voltage_per_cell": { + "name": "Min. voltage per cell" + }, + "accel_sensitivity": { + "name": "Motion sensitivity" + }, + "keep_awake_pulse_power": { + "name": "Keep-awake pulse intensity" + }, + "keep_awake_pulse_delay": { + "name": "Keep-awake pulse delay" + }, + "keep_awake_pulse_duration": { + "name": "Keep-awake pulse duration" + }, + "voltage_div": { + "name": "Voltage divider" + }, + "temp_increment_short": { + "name": "Short-press temperature step" + }, + "temp_increment_long": { + "name": "Long-press temperature step" + } + }, + "select": { + "min_dc_voltage_cells": { + "name": "Power source", + "state": { + "no_battery": "External power supply (DC)", + "battery_3s": "3S (3 cells)", + "battery_4s": "4S (4 cells)", + "battery_5s": "5S (5 cells)", + "battery_6s": "6S (6 cells)" + } + }, + "orientation_mode": { + "name": "Display orientation mode", + "state": { + "right_handed": "Right-handed", + "left_handed": "Left-handed", + "auto": "Auto" + } + }, + "animation_speed": { + "name": "Animation speed", + "state": { + "off": "[%key:common::state::off%]", + "slow": "[%key:component::iron_os::common::slow%]", + "medium": "Medium", + "fast": "[%key:component::iron_os::common::fast%]" + } + }, + "autostart_mode": { + "name": "Start-up behavior", + "state": { + "disabled": "[%key:common::state::disabled%]", + "soldering": "Soldering mode", + "sleeping": "Sleeping mode", + "idle": "Idle mode" + } + }, + "temp_unit": { + "name": "Temperature display unit", + "state": { + "celsius": "Celsius (°C)", + "fahrenheit": "Fahrenheit (°F)" + } + }, + "desc_scroll_speed": { + "name": "Scrolling speed", + "state": { + "slow": "[%key:component::iron_os::common::slow%]", + "fast": "[%key:component::iron_os::common::fast%]" + } + }, + "locking_mode": { + "name": "Button locking mode", + "state": { + "off": "[%key:common::state::off%]", + "boost_only": "Boost only", + "full_locking": "Full locking" + } + }, + "logo_duration": { + "name": "Boot logo duration", + "state": { + "off": "[%key:common::state::off%]", + "seconds_1": "1 second", + "seconds_2": "2 second", + "seconds_3": "3 second", + "seconds_4": "4 second", + "seconds_5": "5 second", + "loop": "Loop" + } + }, + "usb_pd_mode": { + "name": "Power Delivery 3.1 EPR", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } }, "sensor": { @@ -76,6 +229,29 @@ "estimated_power": { "name": "Estimated power" } + }, + "switch": { + "animation_loop": { + "name": "Animation loop" + }, + "cooling_temp_blink": { + "name": "Cool down screen flashing" + }, + "idle_screen_details": { + "name": "Detailed idle screen" + }, + "solder_screen_details": { + "name": "Detailed solder screen" + }, + "invert_buttons": { + "name": "Swap +/- buttons" + }, + "display_invert": { + "name": "Invert screen" + }, + "calibrate_cjc": { + "name": "Calibrate CJC" + } } }, "exceptions": { diff --git a/homeassistant/components/iron_os/switch.py b/homeassistant/components/iron_os/switch.py new file mode 100644 index 00000000000..d88e8cfdcb5 --- /dev/null +++ b/homeassistant/components/iron_os/switch.py @@ -0,0 +1,154 @@ +"""Switch platform for IronOS integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from pynecil import CharSetting, SettingsDataResponse + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IronOSConfigEntry +from .coordinator import IronOSCoordinators +from .entity import IronOSBaseEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class IronOSSwitchEntityDescription(SwitchEntityDescription): + """Describes IronOS switch entity.""" + + is_on_fn: Callable[[SettingsDataResponse], bool | None] + characteristic: CharSetting + + +class IronOSSwitch(StrEnum): + """Switch controls for IronOS device.""" + + ANIMATION_LOOP = "animation_loop" + COOLING_TEMP_BLINK = "cooling_temp_blink" + IDLE_SCREEN_DETAILS = "idle_screen_details" + SOLDER_SCREEN_DETAILS = "solder_screen_details" + INVERT_BUTTONS = "invert_buttons" + DISPLAY_INVERT = "display_invert" + CALIBRATE_CJC = "calibrate_cjc" + + +SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = ( + IronOSSwitchEntityDescription( + key=IronOSSwitch.ANIMATION_LOOP, + translation_key=IronOSSwitch.ANIMATION_LOOP, + characteristic=CharSetting.ANIMATION_LOOP, + is_on_fn=lambda x: x.get("animation_loop"), + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.COOLING_TEMP_BLINK, + translation_key=IronOSSwitch.COOLING_TEMP_BLINK, + characteristic=CharSetting.COOLING_TEMP_BLINK, + is_on_fn=lambda x: x.get("cooling_temp_blink"), + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.IDLE_SCREEN_DETAILS, + translation_key=IronOSSwitch.IDLE_SCREEN_DETAILS, + characteristic=CharSetting.IDLE_SCREEN_DETAILS, + is_on_fn=lambda x: x.get("idle_screen_details"), + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.SOLDER_SCREEN_DETAILS, + translation_key=IronOSSwitch.SOLDER_SCREEN_DETAILS, + characteristic=CharSetting.SOLDER_SCREEN_DETAILS, + is_on_fn=lambda x: x.get("solder_screen_details"), + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.INVERT_BUTTONS, + translation_key=IronOSSwitch.INVERT_BUTTONS, + characteristic=CharSetting.INVERT_BUTTONS, + is_on_fn=lambda x: x.get("invert_buttons"), + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.DISPLAY_INVERT, + translation_key=IronOSSwitch.DISPLAY_INVERT, + characteristic=CharSetting.DISPLAY_INVERT, + is_on_fn=lambda x: x.get("display_invert"), + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.CALIBRATE_CJC, + translation_key=IronOSSwitch.CALIBRATE_CJC, + characteristic=CharSetting.CALIBRATE_CJC, + is_on_fn=lambda x: x.get("calibrate_cjc"), + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IronOSConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches from a config entry.""" + + coordinators = entry.runtime_data + + async_add_entities( + IronOSSwitchEntity(coordinators, description) + for description in SWITCH_DESCRIPTIONS + ) + + +class IronOSSwitchEntity(IronOSBaseEntity, SwitchEntity): + """Representation of a IronOS Switch.""" + + entity_description: IronOSSwitchEntityDescription + + def __init__( + self, + coordinators: IronOSCoordinators, + entity_description: IronOSSwitchEntityDescription, + ) -> None: + """Initialize the switch entity.""" + super().__init__(coordinators.live_data, entity_description) + + self.settings = coordinators.settings + + @property + def is_on(self) -> bool | None: + """Return the state of the device.""" + return self.entity_description.is_on_fn( + self.settings.data, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.settings.write(self.entity_description.characteristic, True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.settings.write(self.entity_description.characteristic, False) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + await super().async_added_to_hass() + self.async_on_remove( + self.settings.async_add_listener( + self._handle_coordinator_update, self.entity_description.characteristic + ) + ) + await self.settings.async_request_refresh() diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index 786ba86f730..b431d321f24 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -15,6 +15,8 @@ from . import IRON_OS_KEY, IronOSConfigEntry, IronOSLiveDataCoordinator from .coordinator import IronOSFirmwareUpdateCoordinator from .entity import IronOSBaseEntity +PARALLEL_UPDATES = 0 + UPDATE_DESCRIPTION = UpdateEntityDescription( key="firmware", device_class=UpdateDeviceClass.FIRMWARE, @@ -28,7 +30,7 @@ async def async_setup_entry( ) -> None: """Set up IronOS update platform.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.live_data async_add_entities( [IronOSUpdate(coordinator, hass.data[IRON_OS_KEY], UPDATE_DESCRIPTION)] diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py index c11c43070df..1a3b2109d0c 100644 --- a/homeassistant/components/ista_ecotrend/config_flow.py +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -66,7 +66,7 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): else: if TYPE_CHECKING: assert info - title = f"{info["firstName"]} {info["lastName"]}".strip() + title = f"{info['firstName']} {info['lastName']}".strip() await self.async_set_unique_id(info["activeConsumptionUnit"]) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/ista_ecotrend/quality_scale.yaml b/homeassistant/components/ista_ecotrend/quality_scale.yaml new file mode 100644 index 00000000000..b942ecba487 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration registers no actions. + appropriate-polling: done + brands: done + common-modules: + status: todo + comment: Group the 3 different executor jobs as one executor job + config-flow-test-coverage: + status: todo + comment: test_form/docstrings outdated, test already_configuret, test abort conditions in reauth, + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration registers no actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: The integration registers no events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration registers no actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + discovery: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: + status: done + comment: The default category is appropriate. + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py index 7aa1adfe4c9..e96ac103741 100644 --- a/homeassistant/components/ista_ecotrend/sensor.py +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -40,6 +40,8 @@ from .coordinator import IstaCoordinator from .util import IstaConsumptionType, IstaValueType, get_native_value, get_statistics _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 @dataclass(kw_only=True, frozen=True) @@ -71,7 +73,6 @@ SENSOR_DESCRIPTIONS: tuple[IstaSensorEntityDescription, ...] = ( translation_key=IstaSensorEntity.HEATING, suggested_display_precision=0, consumption_type=IstaConsumptionType.HEATING, - native_unit_of_measurement="units", state_class=SensorStateClass.TOTAL, ), IstaSensorEntityDescription( @@ -183,12 +184,12 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity): self.consumption_unit = consumption_unit self.entity_description = entity_description self._attr_unique_id = f"{consumption_unit}_{entity_description.key}" + address = coordinator.details[consumption_unit]["address"] self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, manufacturer="ista SE", model="ista EcoTrend", - name=f"{coordinator.details[consumption_unit]["address"]["street"]} " - f"{coordinator.details[consumption_unit]["address"]["houseNumber"]}".strip(), + name=f"{address['street']} {address['houseNumber']}".strip(), configuration_url="https://ecotrend.ista.de/", identifiers={(DOMAIN, consumption_unit)}, ) diff --git a/homeassistant/components/ista_ecotrend/strings.json b/homeassistant/components/ista_ecotrend/strings.json index 0757977a8ea..e7c37461b19 100644 --- a/homeassistant/components/ista_ecotrend/strings.json +++ b/homeassistant/components/ista_ecotrend/strings.json @@ -38,7 +38,8 @@ "entity": { "sensor": { "heating": { - "name": "Heating" + "name": "Heating", + "unit_of_measurement": "units" }, "heating_cost": { "name": "Heating cost" diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index d4376b5a3b4..d5deba56284 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -88,7 +88,6 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): ) _attr_target_temperature_step = 1.0 _attr_fan_modes = [FAN_AUTO, FAN_ON] - _enable_turn_on_off_backwards_compatibility = False def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: """Initialize the ISY Thermostat entity.""" diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 3575fa99a55..b44096e2ccd 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -14,7 +14,6 @@ from pyisy.configuration import Configuration from pyisy.connection import Connection import voluptuous as vol -from homeassistant.components import dhcp, ssdp from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -27,6 +26,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import ( CONF_IGNORE_STRING, @@ -209,7 +214,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): raise AbortFlow("already_configured") async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a discovered ISY/IoX device via dhcp.""" friendly_name = discovery_info.hostname @@ -232,14 +237,14 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered ISY/IoX Device.""" - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] url = discovery_info.ssdp_location assert isinstance(url, str) parsed_url = urlparse(url) - mac = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + mac = discovery_info.upnp[ATTR_UPNP_UDN] mac = mac.removeprefix(UDN_UUID_PREFIX) url = url.removesuffix(ISY_URL_POSTFIX) diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index 1d8af78f83c..fc0406e2d5f 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -53,7 +53,6 @@ class ISYFanEntity(ISYNodeEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False @property def percentage(self) -> int | None: diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py index 41e5899504d..8befcf024d1 100644 --- a/homeassistant/components/isy994/select.py +++ b/homeassistant/components/isy994/select.py @@ -45,7 +45,7 @@ from .models import IsyData def time_string(i: int) -> str: """Return a formatted ramp rate time string.""" if i >= 60: - return f"{(float(i)/60):.1f} {UnitOfTime.MINUTES}" + return f"{(float(i) / 60):.1f} {UnitOfTime.MINUTES}" return f"{i} {UnitOfTime.SECONDS}" diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index f0e55881652..86a1f14ff91 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -37,7 +37,7 @@ "step": { "init": { "title": "ISY Options", - "description": "Set the options for the ISY Integration: \n • Node Sensor String: Any device or folder that contains 'Node Sensor String' in the name will be treated as a sensor or binary sensor. \n • Ignore String: Any device with 'Ignore String' in the name will be ignored. \n • Variable Sensor String: Any variable that contains 'Variable Sensor String' will be added as a sensor. \n • Restore Light Brightness: If enabled, the previous brightness will be restored when turning on a light instead of the device's built-in On-Level.", + "description": "Set the options for the ISY integration: \n • Node Sensor String: Any device or folder that contains 'Node Sensor String' in the name will be treated as a sensor or binary sensor. \n • Ignore String: Any device with 'Ignore String' in the name will be ignored. \n • Variable Sensor String: Any variable that contains 'Variable Sensor String' will be added as a sensor. \n • Restore Light Brightness: If enabled, the previous brightness will be restored when turning on a light instead of the device's built-in On-Level.", "data": { "sensor_string": "Node Sensor String", "ignore_string": "Ignore String", @@ -62,7 +62,7 @@ "fields": { "command": { "name": "Command", - "description": "The ISY REST Command to be sent to the device." + "description": "The ISY REST command to be sent to the device." }, "value": { "name": "Value", @@ -74,13 +74,13 @@ }, "unit_of_measurement": { "name": "Unit of measurement", - "description": "The ISY Unit of Measurement (UOM) to send with the command, if required." + "description": "The ISY unit of measurement (UOM) to send with the command, if required." } } }, "send_node_command": { "name": "Send node command", - "description": "Sends a command to an ISY Device using its Home Assistant entity ID. Valid commands are: beep, brighten, dim, disable, enable, fade_down, fade_stop, fade_up, fast_off, fast_on, and query.", + "description": "Sends a command to an ISY device using its Home Assistant entity ID. Valid commands are: beep, brighten, dim, disable, enable, fade_down, fade_stop, fade_up, fast_off, fast_on, and query.", "fields": { "command": { "name": "Command", @@ -90,7 +90,7 @@ }, "get_zwave_parameter": { "name": "Get Z-Wave Parameter", - "description": "Requests a Z-Wave Device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "Parameter", @@ -99,8 +99,8 @@ } }, "set_zwave_parameter": { - "name": "Set Z-Wave Parameter", - "description": "Updates a Z-Wave Device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "name": "Set Z-Wave parameter", + "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "[%key:component::isy994::services::get_zwave_parameter::fields::parameter::name%]", @@ -117,8 +117,8 @@ } }, "set_zwave_lock_user_code": { - "name": "Set Z-Wave Lock User Code", - "description": "Sets a Z-Wave Lock User Code via the ISY.", + "name": "Set Z-Wave lock user code", + "description": "Sets a user code for a Z-Wave lock via the ISY.", "fields": { "user_num": { "name": "User Number", @@ -131,8 +131,8 @@ } }, "delete_zwave_lock_user_code": { - "name": "Delete Z-Wave Lock User Code", - "description": "Delete a Z-Wave Lock User Code via the ISY.", + "name": "Delete Z-Wave lock user code", + "description": "Deletes a user code for a Z-Wave lock via the ISY.", "fields": { "user_num": { "name": "[%key:component::isy994::services::set_zwave_lock_user_code::fields::user_num::name%]", @@ -141,8 +141,8 @@ } }, "rename_node": { - "name": "Rename Node on ISY", - "description": "Renames a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant Entity Name or Entity ID to match. The entity name and ID will only be updated after reloading the integration or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.", + "name": "Rename node on ISY", + "description": "Renames a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant entity name or entity ID to match. The entity name and ID will only be updated after reloading the integration or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.", "fields": { "name": { "name": "New Name", diff --git a/homeassistant/components/ituran/__init__.py b/homeassistant/components/ituran/__init__.py new file mode 100644 index 00000000000..bf9cff238cd --- /dev/null +++ b/homeassistant/components/ituran/__init__.py @@ -0,0 +1,29 @@ +"""The Ituran integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import IturanConfigEntry, IturanDataUpdateCoordinator + +PLATFORMS: list[Platform] = [ + Platform.DEVICE_TRACKER, + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: IturanConfigEntry) -> bool: + """Set up Ituran from a config entry.""" + + coordinator = IturanDataUpdateCoordinator(hass, entry=entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: IturanConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ituran/config_flow.py b/homeassistant/components/ituran/config_flow.py new file mode 100644 index 00000000000..9709e471503 --- /dev/null +++ b/homeassistant/components/ituran/config_flow.py @@ -0,0 +1,137 @@ +"""Config flow for Ituran integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from pyituran import Ituran +from pyituran.exceptions import IturanApiError, IturanAuthError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult + +from .const import ( + CONF_ID_OR_PASSPORT, + CONF_MOBILE_ID, + CONF_OTP, + CONF_PHONE_NUMBER, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ID_OR_PASSPORT): str, + vol.Required(CONF_PHONE_NUMBER): str, + } +) + +STEP_OTP_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_OTP): str, + } +) + + +class IturanConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ituran.""" + + _user_info: dict[str, Any] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_ID_OR_PASSPORT]) + if self.source != SOURCE_REAUTH: + self._abort_if_unique_id_configured() + + ituran = Ituran( + user_input[CONF_ID_OR_PASSPORT], + user_input[CONF_PHONE_NUMBER], + ) + user_input[CONF_MOBILE_ID] = ituran.mobile_id + try: + authenticated = await ituran.is_authenticated() + if not authenticated: + await ituran.request_otp() + except IturanApiError: + errors["base"] = "cannot_connect" + except IturanAuthError: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if authenticated: + return self.async_create_entry( + title=f"Ituran {user_input[CONF_ID_OR_PASSPORT]}", + data=user_input, + ) + self._user_info = user_input + return await self.async_step_otp() + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_otp( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the OTP step.""" + errors: dict[str, str] = {} + if user_input is not None: + ituran = Ituran( + self._user_info[CONF_ID_OR_PASSPORT], + self._user_info[CONF_PHONE_NUMBER], + self._user_info[CONF_MOBILE_ID], + ) + try: + await ituran.authenticate(user_input[CONF_OTP]) + except IturanApiError: + errors["base"] = "cannot_connect" + except IturanAuthError: + errors["base"] = "invalid_otp" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=self._user_info + ) + return self.async_create_entry( + title=f"Ituran {self._user_info[CONF_ID_OR_PASSPORT]}", + data=self._user_info, + ) + + return self.async_show_form( + step_id="otp", data_schema=STEP_OTP_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + self._user_info = dict(entry_data) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth confirmation message.""" + if user_input is not None: + return await self.async_step_user(self._user_info) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({}), + description_placeholders={ + "phone_number": self._user_info[CONF_PHONE_NUMBER] + }, + ) diff --git a/homeassistant/components/ituran/const.py b/homeassistant/components/ituran/const.py new file mode 100644 index 00000000000..b17271490ee --- /dev/null +++ b/homeassistant/components/ituran/const.py @@ -0,0 +1,13 @@ +"""Constants for the Ituran integration.""" + +from datetime import timedelta +from typing import Final + +DOMAIN = "ituran" + +CONF_ID_OR_PASSPORT: Final = "id_or_passport" +CONF_PHONE_NUMBER: Final = "phone_number" +CONF_MOBILE_ID: Final = "mobile_id" +CONF_OTP: Final = "otp" + +UPDATE_INTERVAL = timedelta(seconds=300) diff --git a/homeassistant/components/ituran/coordinator.py b/homeassistant/components/ituran/coordinator.py new file mode 100644 index 00000000000..cd0949eb4c2 --- /dev/null +++ b/homeassistant/components/ituran/coordinator.py @@ -0,0 +1,76 @@ +"""Coordinator for Ituran.""" + +import logging + +from pyituran import Ituran, Vehicle +from pyituran.exceptions import IturanApiError, IturanAuthError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_ID_OR_PASSPORT, + CONF_MOBILE_ID, + CONF_PHONE_NUMBER, + DOMAIN, + UPDATE_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + +type IturanConfigEntry = ConfigEntry[IturanDataUpdateCoordinator] + + +class IturanDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]): + """Class to manage fetching Ituran data.""" + + config_entry: IturanConfigEntry + + def __init__(self, hass: HomeAssistant, entry: IturanConfigEntry) -> None: + """Initialize account-wide Ituran data updater.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}-{entry.data[CONF_ID_OR_PASSPORT]}", + update_interval=UPDATE_INTERVAL, + config_entry=entry, + ) + self.ituran = Ituran( + entry.data[CONF_ID_OR_PASSPORT], + entry.data[CONF_PHONE_NUMBER], + entry.data[CONF_MOBILE_ID], + ) + + async def _async_update_data(self) -> dict[str, Vehicle]: + """Fetch data from Ituran.""" + + try: + vehicles = await self.ituran.get_vehicles() + except IturanApiError as e: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="api_error" + ) from e + except IturanAuthError as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="auth_error" + ) from e + + updated_data = {vehicle.license_plate: vehicle for vehicle in vehicles} + self._cleanup_removed_vehicles(updated_data) + + return updated_data + + def _cleanup_removed_vehicles(self, data: dict[str, Vehicle]) -> None: + account_vehicles = {(DOMAIN, license_plate) for license_plate in data} + device_registry = dr.async_get(self.hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=self.config_entry.entry_id + ) + for device in device_entries: + if not device.identifiers.intersection(account_vehicles): + device_registry.async_update_device( + device.id, remove_config_entry_id=self.config_entry.entry_id + ) diff --git a/homeassistant/components/ituran/device_tracker.py b/homeassistant/components/ituran/device_tracker.py new file mode 100644 index 00000000000..37796570c61 --- /dev/null +++ b/homeassistant/components/ituran/device_tracker.py @@ -0,0 +1,49 @@ +"""Device tracker for Ituran vehicles.""" + +from __future__ import annotations + +from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IturanConfigEntry +from .coordinator import IturanDataUpdateCoordinator +from .entity import IturanBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IturanConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Ituran tracker from config entry.""" + coordinator = config_entry.runtime_data + async_add_entities( + IturanDeviceTracker(coordinator, license_plate) + for license_plate in coordinator.data + ) + + +class IturanDeviceTracker(IturanBaseEntity, TrackerEntity): + """Ituran device tracker.""" + + _attr_translation_key = "car" + _attr_name = None + + def __init__( + self, + coordinator: IturanDataUpdateCoordinator, + license_plate: str, + ) -> None: + """Initialize the device tracker.""" + super().__init__(coordinator, license_plate, "device_tracker") + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self.vehicle.gps_coordinates[0] + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self.vehicle.gps_coordinates[1] diff --git a/homeassistant/components/ituran/entity.py b/homeassistant/components/ituran/entity.py new file mode 100644 index 00000000000..597cdac9513 --- /dev/null +++ b/homeassistant/components/ituran/entity.py @@ -0,0 +1,47 @@ +"""Base for all turan entities.""" + +from __future__ import annotations + +from pyituran import Vehicle + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import IturanDataUpdateCoordinator + + +class IturanBaseEntity(CoordinatorEntity[IturanDataUpdateCoordinator]): + """Common base for Ituran entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: IturanDataUpdateCoordinator, + license_plate: str, + unique_key: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._license_plate = license_plate + self._attr_unique_id = f"{license_plate}-{unique_key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.vehicle.license_plate)}, + manufacturer=self.vehicle.make, + model=self.vehicle.model, + name=self.vehicle.model, + serial_number=self.vehicle.license_plate, + ) + + @property + def available(self) -> bool: + """Return True if vehicle is still included in the account.""" + return super().available and self._license_plate in self.coordinator.data + + @property + def vehicle(self) -> Vehicle: + """Return the vehicle information associated with this entity.""" + return self.coordinator.data[self._license_plate] diff --git a/homeassistant/components/ituran/icons.json b/homeassistant/components/ituran/icons.json new file mode 100644 index 00000000000..bd9182f1569 --- /dev/null +++ b/homeassistant/components/ituran/icons.json @@ -0,0 +1,20 @@ +{ + "entity": { + "device_tracker": { + "car": { + "default": "mdi:car" + } + }, + "sensor": { + "address": { + "default": "mdi:map-marker" + }, + "battery_voltage": { + "default": "mdi:car-battery" + }, + "heading": { + "default": "mdi:compass" + } + } + } +} diff --git a/homeassistant/components/ituran/manifest.json b/homeassistant/components/ituran/manifest.json new file mode 100644 index 00000000000..0cf20d3c6b2 --- /dev/null +++ b/homeassistant/components/ituran/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ituran", + "name": "Ituran", + "codeowners": ["@shmuelzon"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ituran", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "silver", + "requirements": ["pyituran==0.1.4"] +} diff --git a/homeassistant/components/ituran/quality_scale.yaml b/homeassistant/components/ituran/quality_scale.yaml new file mode 100644 index 00000000000..cd7e17c3b12 --- /dev/null +++ b/homeassistant/components/ituran/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + dependency-transparency: done + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + brands: done + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + reauthentication-flow: done + parallel-updates: + status: exempt + comment: | + Read only platforms and coordinator. + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + No options flow. + # Gold + entity-translations: done + entity-device-class: + status: exempt + comment: | + Only device_tracker platform. + devices: done + entity-category: todo + entity-disabled-by-default: done + discovery: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a service + provider, which uses the users credentials to get the data. + stale-devices: todo + diagnostics: todo + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + dynamic-devices: done + discovery-update-info: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a service + provider, which uses the users credentials to get the data. + repair-issues: + status: exempt + comment: | + No repairs/issues. + docs-use-cases: todo + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: todo + docs-examples: todo + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/ituran/sensor.py b/homeassistant/components/ituran/sensor.py new file mode 100644 index 00000000000..e962f5bd561 --- /dev/null +++ b/homeassistant/components/ituran/sensor.py @@ -0,0 +1,119 @@ +"""Sensors for Ituran vehicles.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from pyituran import Vehicle + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + DEGREE, + UnitOfElectricPotential, + UnitOfLength, + UnitOfSpeed, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import IturanConfigEntry +from .coordinator import IturanDataUpdateCoordinator +from .entity import IturanBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class IturanSensorEntityDescription(SensorEntityDescription): + """Describes Ituran sensor entity.""" + + value_fn: Callable[[Vehicle], StateType | datetime] + + +SENSOR_TYPES: list[IturanSensorEntityDescription] = [ + IturanSensorEntityDescription( + key="address", + translation_key="address", + entity_registry_enabled_default=False, + value_fn=lambda vehicle: vehicle.address, + ), + IturanSensorEntityDescription( + key="battery_voltage", + translation_key="battery_voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + entity_registry_enabled_default=False, + value_fn=lambda vehicle: vehicle.battery_voltage, + ), + IturanSensorEntityDescription( + key="heading", + translation_key="heading", + native_unit_of_measurement=DEGREE, + suggested_display_precision=0, + entity_registry_enabled_default=False, + value_fn=lambda vehicle: vehicle.heading, + ), + IturanSensorEntityDescription( + key="last_update_from_vehicle", + translation_key="last_update_from_vehicle", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + value_fn=lambda vehicle: vehicle.last_update, + ), + IturanSensorEntityDescription( + key="mileage", + translation_key="mileage", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + suggested_display_precision=2, + value_fn=lambda vehicle: vehicle.mileage, + ), + IturanSensorEntityDescription( + key="speed", + device_class=SensorDeviceClass.SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + suggested_display_precision=0, + value_fn=lambda vehicle: vehicle.speed, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IturanConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Ituran sensors from config entry.""" + coordinator = config_entry.runtime_data + async_add_entities( + IturanSensor(coordinator, license_plate, description) + for description in SENSOR_TYPES + for license_plate in coordinator.data + ) + + +class IturanSensor(IturanBaseEntity, SensorEntity): + """Ituran device tracker.""" + + entity_description: IturanSensorEntityDescription + + def __init__( + self, + coordinator: IturanDataUpdateCoordinator, + license_plate: str, + description: IturanSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, license_plate, description.key) + self.entity_description = description + + @property + def native_value(self) -> StateType | datetime: + """Return the state of the device.""" + return self.entity_description.value_fn(self.vehicle) diff --git a/homeassistant/components/ituran/strings.json b/homeassistant/components/ituran/strings.json new file mode 100644 index 00000000000..efc60ef454b --- /dev/null +++ b/homeassistant/components/ituran/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "step": { + "user": { + "data": { + "id_or_passport": "ID or passport number", + "phone_number": "Mobile phone number" + }, + "data_description": { + "id_or_passport": "The government ID or passport number provided when registering with Ituran.", + "phone_number": "The mobile phone number provided when registering with Ituran. A one-time password will be sent to this mobile number." + } + }, + "otp": { + "data": { + "otp": "OTP" + }, + "data_description": { + "otp": "A one-time-password sent as a text message to the mobile phone number provided before." + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "A new one-time password will be sent to {phone_number}." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_otp": "OTP invalid", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "entity": { + "sensor": { + "address": { + "name": "Address" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "heading": { + "name": "Heading" + }, + "last_update_from_vehicle": { + "name": "Last update from vehicle" + }, + "mileage": { + "name": "Mileage" + } + } + }, + "exceptions": { + "api_error": { + "message": "An error occurred while communicating with the Ituran service." + }, + "auth_error": { + "message": "Failed authenticating with the Ituran service, please reauthenticate the integration." + } + } +} diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 2a602939250..e61917c825b 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -141,7 +141,6 @@ class ControllerDevice(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_target_temperature_step = 0.5 - _enable_turn_on_off_backwards_compatibility = False def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index 24aeecab7e5..5c519f661ee 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -36,7 +36,6 @@ SENSOR_TYPES: tuple[JellyfinSensorEntityDescription, ...] = ( key="watching", translation_key="watching", value_fn=_count_now_playing, - native_unit_of_measurement="clients", ), ) diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json index f2afa0c8ad5..a9816b1fb78 100644 --- a/homeassistant/components/jellyfin/strings.json +++ b/homeassistant/components/jellyfin/strings.json @@ -29,7 +29,8 @@ "entity": { "sensor": { "watching": { - "name": "Active clients" + "name": "Active clients", + "unit_of_measurement": "clients" } } }, diff --git a/homeassistant/components/kaleidescape/config_flow.py b/homeassistant/components/kaleidescape/config_flow.py index e4a562dc00b..031709db9f2 100644 --- a/homeassistant/components/kaleidescape/config_flow.py +++ b/homeassistant/components/kaleidescape/config_flow.py @@ -7,9 +7,9 @@ from urllib.parse import urlparse import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from . import KaleidescapeDeviceInfo, UnsupportedError, validate_host from .const import DEFAULT_HOST, DOMAIN, NAME as KALEIDESCAPE_NAME @@ -61,11 +61,11 @@ class KaleidescapeConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle discovered device.""" host = cast(str, urlparse(discovery_info.ssdp_location).hostname) - serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) diff --git a/homeassistant/components/keba/manifest.json b/homeassistant/components/keba/manifest.json index d86ce053187..6427a30f000 100644 --- a/homeassistant/components/keba/manifest.json +++ b/homeassistant/components/keba/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["keba_kecontact"], "quality_scale": "legacy", - "requirements": ["keba-kecontact==1.1.0"] + "requirements": ["keba-kecontact==1.3.0"] } diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index d11fedac385..3dc4c8b1b77 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -8,7 +8,6 @@ from urllib.parse import urlparse from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -24,6 +23,11 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import VolDictType from .const import ( @@ -105,23 +109,23 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered device.""" - friendly_name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") + friendly_name = discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, "") # Filter out items not having "keenetic" in their name if "keenetic" not in friendly_name.lower(): return self.async_abort(reason="not_keenetic_ndms2") # Filters out items having no/empty UDN - if not discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): + if not discovery_info.upnp.get(ATTR_UPNP_UDN): return self.async_abort(reason="no_udn") # We can cast the hostname to str because the ssdp_location is not bytes and # not a relative url host = cast(str, urlparse(discovery_info.ssdp_location).hostname) - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json index 1bbce2ff35d..db331fe6874 100644 --- a/homeassistant/components/kef/manifest.json +++ b/homeassistant/components/kef/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["aiokef", "tenacity"], "quality_scale": "legacy", - "requirements": ["aiokef==0.2.16", "getmac==0.9.4"] + "requirements": ["aiokef==0.2.16", "getmac==0.9.5"] } diff --git a/homeassistant/components/kef/strings.json b/homeassistant/components/kef/strings.json index e5ffff68162..56fdeaa6704 100644 --- a/homeassistant/components/kef/strings.json +++ b/homeassistant/components/kef/strings.json @@ -22,14 +22,14 @@ }, "high_pass": { "name": "High pass", - "description": "High-pass mode\"." + "description": "High-pass mode." }, "sub_polarity": { "name": "Subwoofer polarity", "description": "Sub polarity." }, "bass_extension": { - "name": "Base extension", + "name": "Bass extension", "description": "Bass extension." } } @@ -39,7 +39,7 @@ "description": "Sets the \"Desk mode\" slider of the speaker in dB.", "fields": { "db_value": { - "name": "DB value", + "name": "dB value", "description": "Value of the slider." } } @@ -75,8 +75,8 @@ } }, "set_low_hz": { - "name": "Sets low Hertz", - "description": "Set the \"Sub out low-pass frequency\" slider of the speaker in Hz.", + "name": "Set low Hertz", + "description": "Sets the \"Sub out low-pass frequency\" slider of the speaker in Hz.", "fields": { "hz_value": { "name": "[%key:component::kef::services::set_high_hz::fields::hz_value::name%]", @@ -85,8 +85,8 @@ } }, "set_sub_db": { - "name": "Sets subwoofer dB", - "description": "Set the \"Sub gain\" slider of the speaker in dB.", + "name": "Set subwoofer dB", + "description": "Sets the \"Sub gain\" slider of the speaker in dB.", "fields": { "db_value": { "name": "[%key:component::kef::services::set_desk_db::fields::db_value::name%]", diff --git a/homeassistant/components/keymitt_ble/config_flow.py b/homeassistant/components/keymitt_ble/config_flow.py index 217ce3cc923..821fbc410f7 100644 --- a/homeassistant/components/keymitt_ble/config_flow.py +++ b/homeassistant/components/keymitt_ble/config_flow.py @@ -34,7 +34,7 @@ def short_address(address: str) -> str: def name_from_discovery(discovery: MicroBotAdvertisement) -> str: """Get the name from a discovery.""" - return f'{discovery.data["local_name"]} {short_address(discovery.address)}' + return f"{discovery.data['local_name']} {short_address(discovery.address)}" class MicroBotConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 2c3887bb383..88d0c868636 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -26,8 +26,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -DOMAIN = "kitchen_sink" - +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.BUTTON, @@ -88,9 +87,27 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # Start a reauth flow config_entry.async_start_reauth(hass) + # Notify backup listeners + hass.async_create_task(_notify_backup_listeners(hass), eager_start=False) + return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload config entry.""" + # Notify backup listeners + hass.async_create_task(_notify_backup_listeners(hass), eager_start=False) + + return await hass.config_entries.async_unload_platforms( + entry, COMPONENTS_WITH_DEMO_PLATFORM + ) + + +async def _notify_backup_listeners(hass: HomeAssistant) -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + def _create_issues(hass: HomeAssistant) -> None: """Create some issue registry issues.""" async_create_issue( diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py new file mode 100644 index 00000000000..c4a045aeefc --- /dev/null +++ b/homeassistant/components/kitchen_sink/backup.py @@ -0,0 +1,118 @@ +"""Backup platform for the kitchen_sink integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable, Coroutine +import logging +from typing import Any + +from homeassistant.components.backup import AddonInfo, AgentBackup, BackupAgent, Folder +from homeassistant.core import HomeAssistant, callback + +from . import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +LOGGER = logging.getLogger(__name__) + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Register the backup agents.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + LOGGER.info("No config entry found or entry is not loaded") + return [] + return [KitchenSinkBackupAgent("syncer")] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + + return remove_listener + + +class KitchenSinkBackupAgent(BackupAgent): + """Kitchen sink backup agent.""" + + domain = DOMAIN + + def __init__(self, name: str) -> None: + """Initialize the kitchen sink backup sync agent.""" + super().__init__() + self.name = name + self._uploads = [ + AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + database_included=False, + date="1970-01-01T00:00:00Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Kitchen sink syncer", + protected=False, + size=1234, + ) + ] + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + LOGGER.info("Downloading backup %s", backup_id) + reader = asyncio.StreamReader() + reader.feed_data(b"backup data") + reader.feed_eof() + return reader + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + LOGGER.info("Uploading backup %s %s", backup.backup_id, backup) + self._uploads.append(backup) + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + self._uploads = [ + upload for upload in self._uploads if upload.backup_id != backup_id + ] + LOGGER.info("Deleted backup %s", backup_id) + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List synced backups.""" + return self._uploads + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + for backup in self._uploads: + if backup.backup_id == backup_id: + return backup + return None diff --git a/homeassistant/components/kitchen_sink/const.py b/homeassistant/components/kitchen_sink/const.py new file mode 100644 index 00000000000..e6edaca46ce --- /dev/null +++ b/homeassistant/components/kitchen_sink/const.py @@ -0,0 +1,12 @@ +"""Constants for the Kitchen Sink integration.""" + +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.util.hass_dict import HassKey + +DOMAIN = "kitchen_sink" +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index 63e27e04637..c03f909e617 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -21,6 +21,9 @@ "bool": "Optional boolean", "int": "Numeric input" }, + "data_description": { + "int": "A longer description for the numeric input" + }, "description": "This section allows input of some extra data", "name": "Collapsible section" } @@ -77,8 +80,8 @@ }, "services": { "test_service_1": { - "name": "Test service 1", - "description": "Fake service for testing", + "name": "Test action 1", + "description": "Fake action for testing", "fields": { "field_1": { "name": "Field 1", diff --git a/homeassistant/components/knocki/__init__.py b/homeassistant/components/knocki/__init__.py index 42c3956bd68..dfdf060e3b5 100644 --- a/homeassistant/components/knocki/__init__.py +++ b/homeassistant/components/knocki/__init__.py @@ -41,13 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_create_background_task( - hass, client.start_websocket(), "knocki-websocket" - ) + await client.start_websocket() return True async def async_unload_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bool: """Unload a config entry.""" + await entry.runtime_data.client.close() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/knocki/manifest.json b/homeassistant/components/knocki/manifest.json index d9a45b18f0e..a91119ca831 100644 --- a/homeassistant/components/knocki/manifest.json +++ b/homeassistant/components/knocki/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["knocki"], - "requirements": ["knocki==0.3.5"] + "requirements": ["knocki==0.4.2"] } diff --git a/homeassistant/components/knocki/quality_scale.yaml b/homeassistant/components/knocki/quality_scale.yaml new file mode 100644 index 00000000000..45b3764d786 --- /dev/null +++ b/homeassistant/components/knocki/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration is push-based. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: data_descriptions are missing + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have any configuration parameters. + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: This is a cloud service and does not benefit from device updates. + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: + status: exempt + comment: | + The default ones are good. + entity-device-class: + status: exempt + comment: | + Knocki does not have a device class. + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have any entities that are disabled by default. + entity-translations: + status: exempt + comment: | + This integration does not have any translatable entities. + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 9180e287618..fa3439b02f4 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -10,6 +10,7 @@ from typing import Final import voluptuous as vol from xknx import XKNX from xknx.core import XknxConnectionState +from xknx.core.state_updater import StateTrackerType, TrackerOptions from xknx.core.telegram_queue import TelegramQueue from xknx.dpt import DPTBase from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException @@ -54,6 +55,7 @@ from .const import ( CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, CONF_KNX_TELEGRAM_LOG_SIZE, + CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, @@ -90,7 +92,7 @@ from .schema import ( WeatherSchema, ) from .services import register_knx_services -from .storage.config_store import KNXConfigStore +from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams from .websocket import register_panel @@ -225,6 +227,8 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: if knxkeys_filename is not None: with contextlib.suppress(FileNotFoundError): (storage_dir / knxkeys_filename).unlink() + with contextlib.suppress(FileNotFoundError): + (storage_dir / CONFIG_STORAGE_KEY).unlink() with contextlib.suppress(FileNotFoundError): (storage_dir / PROJECT_STORAGE_KEY).unlink() with contextlib.suppress(FileNotFoundError): @@ -270,11 +274,18 @@ class KNXModule: self.project = KNXProject(hass=hass, entry=entry) self.config_store = KNXConfigStore(hass=hass, config_entry=entry) + default_state_updater = ( + TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60) + if self.entry.data[CONF_KNX_STATE_UPDATER] + else TrackerOptions( + tracker_type=StateTrackerType.INIT, update_interval_min=60 + ) + ) self.xknx = XKNX( address_format=self.project.get_address_format(), connection_config=self.connection_config(), rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], - state_updater=self.entry.data[CONF_KNX_STATE_UPDATER], + state_updater=default_state_updater, ) self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_changed_cb @@ -352,6 +363,7 @@ class KNXModule: if _conn_type == CONF_KNX_TUNNELING_TCP: return ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP, + individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), gateway_ip=self.entry.data[CONF_HOST], gateway_port=self.entry.data[CONF_PORT], auto_reconnect=True, @@ -364,6 +376,7 @@ class KNXModule: if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE: return ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP_SECURE, + individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), gateway_ip=self.entry.data[CONF_HOST], gateway_port=self.entry.data[CONF_PORT], secure_config=SecureConfig( @@ -398,6 +411,9 @@ class KNXModule: ) return ConnectionConfig( auto_reconnect=True, + individual_address=self.entry.data.get( + CONF_KNX_TUNNEL_ENDPOINT_IA, # may be configured at knxkey upload + ), secure_config=SecureConfig( knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), knxkeys_file_path=_knxkeys_file, diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 0e0da4d5c0c..2c0153c5d2b 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -148,7 +148,6 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): _device: XknxClimate _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "knx_climate" - _enable_turn_on_off_backwards_compatibility = False def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: """Initialize of a KNX climate device.""" @@ -428,7 +427,7 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): self._device.mode.xknx.devices.async_remove(self._device.mode) await super().async_will_remove_from_hass() - def after_update_callback(self, _device: XknxDevice) -> None: + def after_update_callback(self, device: XknxDevice) -> None: """Call after device was updated.""" if self._device.mode is not None and self._device.mode.supports_controller_mode: hvac_mode = CONTROLLER_MODES.get( @@ -436,4 +435,4 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): ) if hvac_mode is not HVACMode.OFF: self._last_hvac_mode = hvac_mode - super().after_update_callback(_device) + super().after_update_callback(device) diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index feeb7626577..eda160cd1a6 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import AsyncGenerator -from typing import Any, Final +from typing import Any, Final, Literal import voluptuous as vol from xknx import XKNX @@ -121,6 +121,15 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): self._gatewayscanner: GatewayScanner | None = None self._async_scan_gen: AsyncGenerator[GatewayDescriptor] | None = None + @property + def _xknx(self) -> XKNX: + """Return XKNX instance.""" + if isinstance(self, OptionsFlow) and ( + knx_module := self.hass.data.get(KNX_MODULE_KEY) + ): + return knx_module.xknx + return XKNX() + @abstractmethod def finish_flow(self) -> ConfigFlowResult: """Finish the flow.""" @@ -183,14 +192,8 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): CONF_KNX_ROUTING: CONF_KNX_ROUTING.capitalize(), } - if isinstance(self, OptionsFlow) and ( - knx_module := self.hass.data.get(KNX_MODULE_KEY) - ): - xknx = knx_module.xknx - else: - xknx = XKNX() self._gatewayscanner = GatewayScanner( - xknx, stop_on_found=0, timeout_in_seconds=2 + self._xknx, stop_on_found=0, timeout_in_seconds=2 ) # keep a reference to the generator to scan in background until user selects a connection type self._async_scan_gen = self._gatewayscanner.async_scan() @@ -204,8 +207,25 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): CONF_KNX_AUTOMATIC: CONF_KNX_AUTOMATIC.capitalize() } | supported_connection_types + default_connection_type: Literal["automatic", "tunneling", "routing"] + _current_conn = self.initial_data.get(CONF_KNX_CONNECTION_TYPE) + if _current_conn in ( + CONF_KNX_TUNNELING, + CONF_KNX_TUNNELING_TCP, + CONF_KNX_TUNNELING_TCP_SECURE, + ): + default_connection_type = CONF_KNX_TUNNELING + elif _current_conn in (CONF_KNX_ROUTING, CONF_KNX_ROUTING_SECURE): + default_connection_type = CONF_KNX_ROUTING + elif CONF_KNX_AUTOMATIC in supported_connection_types: + default_connection_type = CONF_KNX_AUTOMATIC + else: + default_connection_type = CONF_KNX_TUNNELING + fields = { - vol.Required(CONF_KNX_CONNECTION_TYPE): vol.In(supported_connection_types) + vol.Required( + CONF_KNX_CONNECTION_TYPE, default=default_connection_type + ): vol.In(supported_connection_types) } return self.async_show_form( step_id="connection_type", data_schema=vol.Schema(fields) @@ -216,8 +236,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): ) -> ConfigFlowResult: """Select a tunnel from a list. - Will be skipped if the gateway scan was unsuccessful - or if only one gateway was found. + Will be skipped if the gateway scan was unsuccessful. """ if user_input is not None: if user_input[CONF_KNX_GATEWAY] == OPTION_MANUAL_TUNNEL: @@ -247,6 +266,8 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): user_password=None, tunnel_endpoint_ia=None, ) + if connection_type == CONF_KNX_TUNNELING_TCP: + return await self.async_step_tcp_tunnel_endpoint() if connection_type == CONF_KNX_TUNNELING_TCP_SECURE: return await self.async_step_secure_key_source_menu_tunnel() self.new_title = f"Tunneling @ {self._selected_tunnel}" @@ -255,16 +276,99 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): if not self._found_tunnels: return await self.async_step_manual_tunnel() - errors: dict = {} - tunnel_options = { - str(tunnel): f"{tunnel}{' 🔐' if tunnel.tunnelling_requires_secure else ''}" + tunnel_options = [ + selector.SelectOptionDict( + value=str(tunnel), + label=( + f"{tunnel}" + f"{' TCP' if tunnel.supports_tunnelling_tcp else ' UDP'}" + f"{' 🔐 Secure tunneling' if tunnel.tunnelling_requires_secure else ''}" + ), + ) for tunnel in self._found_tunnels + ] + tunnel_options.append( + selector.SelectOptionDict( + value=OPTION_MANUAL_TUNNEL, label=OPTION_MANUAL_TUNNEL + ) + ) + default_tunnel = next( + ( + str(tunnel) + for tunnel in self._found_tunnels + if tunnel.ip_addr == self.initial_data.get(CONF_HOST) + ), + vol.UNDEFINED, + ) + fields = { + vol.Required( + CONF_KNX_GATEWAY, default=default_tunnel + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=tunnel_options, + mode=selector.SelectSelectorMode.LIST, + ) + ) } - tunnel_options |= {OPTION_MANUAL_TUNNEL: OPTION_MANUAL_TUNNEL} - fields = {vol.Required(CONF_KNX_GATEWAY): vol.In(tunnel_options)} + return self.async_show_form(step_id="tunnel", data_schema=vol.Schema(fields)) + + async def async_step_tcp_tunnel_endpoint( + self, user_input: dict | None = None + ) -> ConfigFlowResult: + """Select specific tunnel endpoint for plain TCP connection.""" + if user_input is not None: + selected_tunnel_ia: str | None = ( + None + if user_input[CONF_KNX_TUNNEL_ENDPOINT_IA] == CONF_KNX_AUTOMATIC + else user_input[CONF_KNX_TUNNEL_ENDPOINT_IA] + ) + self.new_entry_data |= KNXConfigEntryData( + tunnel_endpoint_ia=selected_tunnel_ia, + ) + self.new_title = ( + f"{selected_tunnel_ia or 'Tunneling'} @ {self._selected_tunnel}" + ) + return self.finish_flow() + + # this step is only called from async_step_tunnel so self._selected_tunnel is always set + assert self._selected_tunnel + # skip if only one tunnel endpoint or no tunnelling slot infos + if len(self._selected_tunnel.tunnelling_slots) <= 1: + return self.finish_flow() + + tunnel_endpoint_options = [ + selector.SelectOptionDict( + value=CONF_KNX_AUTOMATIC, label=CONF_KNX_AUTOMATIC.capitalize() + ) + ] + _current_ia = self._xknx.current_address + tunnel_endpoint_options.extend( + selector.SelectOptionDict( + value=str(slot), + label=( + f"{slot} - {'current connection' if slot == _current_ia else 'occupied' if not slot_status.free else 'free'}" + ), + ) + for slot, slot_status in self._selected_tunnel.tunnelling_slots.items() + ) + default_endpoint = ( + self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA) or CONF_KNX_AUTOMATIC + ) return self.async_show_form( - step_id="tunnel", data_schema=vol.Schema(fields), errors=errors + step_id="tcp_tunnel_endpoint", + data_schema=vol.Schema( + { + vol.Required( + CONF_KNX_TUNNEL_ENDPOINT_IA, default=default_endpoint + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=tunnel_endpoint_options, + mode=selector.SelectSelectorMode.LIST, + ) + ), + } + ), ) async def async_step_manual_tunnel( @@ -612,12 +716,15 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): ) for endpoint in self._tunnel_endpoints ) + default_endpoint = ( + self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA) or CONF_KNX_AUTOMATIC + ) return self.async_show_form( step_id="knxkeys_tunnel_select", data_schema=vol.Schema( { vol.Required( - CONF_KNX_TUNNEL_ENDPOINT_IA, default=CONF_KNX_AUTOMATIC + CONF_KNX_TUNNEL_ENDPOINT_IA, default=default_endpoint ): selector.SelectSelector( selector.SelectSelectorConfig( options=tunnel_endpoint_options, diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 7a9dfc34546..3ef35479c4e 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -104,7 +104,7 @@ class KNXConfigEntryData(TypedDict, total=False): route_back: bool # not required host: str # only required for tunnelling port: int # only required for tunnelling - tunnel_endpoint_ia: str | None + tunnel_endpoint_ia: str | None # tunnelling only - not required (use get()) # KNX secure user_id: int | None # not required user_password: str | None # not required @@ -114,7 +114,7 @@ class KNXConfigEntryData(TypedDict, total=False): backbone_key: str | None # not required sync_latency_tolerance: int | None # not required # OptionsFlow only - state_updater: bool + state_updater: bool # default state updater: True -> expire 60; False -> init rate_limit: int # Integration only (not forwarded to xknx) telegram_log_size: int # not required diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index 6574e5d5860..a042c2b4c6b 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -69,7 +69,7 @@ class _KnxEntityBase(Entity): """Request a state update from KNX bus.""" await self._device.sync() - def after_update_callback(self, _device: XknxDevice) -> None: + def after_update_callback(self, device: XknxDevice) -> None: """Call after device was updated.""" self.async_write_ha_state() diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 82bee48ba69..6585b848d8a 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -88,7 +88,7 @@ class KNXExposeSensor: self._remove_listener: Callable[[], None] | None = None self.device: ExposeSensor = ExposeSensor( xknx=self.xknx, - name=f"{self.entity_id}__{self.expose_attribute or "state"}", + name=f"{self.entity_id}__{self.expose_attribute or 'state'}", group_address=config[KNX_ADDRESS], respond_to_read=config[CONF_RESPOND_TO_READ], value_type=self.expose_type, diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index ce17517b970..75d91e48048 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -43,7 +43,6 @@ class KNXFan(KnxYamlEntity, FanEntity): """Representation of a KNX fan.""" _device: XknxFan - _enable_turn_on_off_backwards_compatibility = False def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: """Initialize of KNX fan.""" diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 8e64b46c890..6115f8be128 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, cast -from propcache import cached_property +from propcache.api import cached_property from xknx import XKNX from xknx.devices.light import ColorTemperatureType, Light as XknxLight, XYYColor diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index aed7f3ed455..73a61be68ee 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -10,9 +10,9 @@ "iot_class": "local_push", "loggers": ["xknx", "xknxproject"], "requirements": [ - "xknx==3.3.0", + "xknx==3.4.0", "xknxproject==3.8.1", - "knx-frontend==2024.11.16.205004" + "knx-frontend==2025.1.18.164225" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/quality_scale.yaml b/homeassistant/components/knx/quality_scale.yaml new file mode 100644 index 00000000000..a6bbaf18bcb --- /dev/null +++ b/homeassistant/components/knx/quality_scale.yaml @@ -0,0 +1,117 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: exempt + comment: | + This integration is push-based. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: + status: exempt + comment: | + YAML entities don't support devices. UI entities do and use `has_entity_name`. + runtime-data: + status: exempt + comment: | + KNXModule is needed in places where no config_entry handle is available: + device_trigger, services, websocket + test-before-configure: + status: exempt + comment: | + For automatic connection modes, there has already been successful communication + with the KNX interface at the discovery process. + For manual tunneling, we avoid making short-lived connections since there seem to be + interfaces having troubles with that. + For routing, the protocol doesn't provide any means to test since it is connectionless multicast. + test-before-setup: done + unique-config-entry: + status: done + comment: Single config entry. + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: | + The library logs when the connection is lost / reconnected. Individual entities don't. + parallel-updates: + status: exempt + comment: | + Integration is push based. + reauthentication-flow: + status: exempt + comment: | + Integration has no authentication. + test-coverage: done + # Gold + devices: + status: exempt + comment: | + YAML entities don't support devices. UI entities support user-defined devices. + diagnostics: done + discovery-update-info: todo + discovery: + status: exempt + comment: | + KNX doesn't support any provided discovery method. + docs-data-update: todo + docs-examples: done + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: | + Devices aren't supported directly since communication is on group address level. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Devices aren't discoverable in KNX. + entity-category: + status: exempt + comment: | + Entity category can be configured by the user. + entity-device-class: + status: exempt + comment: | + Entity category can be configured by the user. Proper defaults are determined by configured DPT. + entity-disabled-by-default: + status: exempt + comment: | + Since all entities are configured manually, they are enabled by default. + entity-translations: + status: exempt + comment: | + Since all entities are configured manually, names are user-defined. + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: | + Devices aren't discoverable in KNX. Manual device removal is implemented. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + No HTTP is used. + strict-typing: done diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index ed265db4ac7..fa4911aa4b7 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -211,7 +211,7 @@ class KNXSystemSensor(SensorEntity): return True return self.knx.xknx.connection_manager.state is XknxConnectionState.CONNECTED - def after_update_callback(self, _: XknxConnectionState) -> None: + def after_update_callback(self, device: XknxConnectionState) -> None: """Call after device was updated.""" self.async_write_ha_state() diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 113be9709ee..6c392902737 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -2,7 +2,6 @@ from __future__ import annotations -from functools import partial import logging from typing import TYPE_CHECKING @@ -47,14 +46,14 @@ def register_knx_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, SERVICE_KNX_SEND, - partial(service_send_to_knx_bus, hass), + service_send_to_knx_bus, schema=SERVICE_KNX_SEND_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_KNX_READ, - partial(service_read_to_knx_bus, hass), + service_read_to_knx_bus, schema=SERVICE_KNX_READ_SCHEMA, ) @@ -62,7 +61,7 @@ def register_knx_services(hass: HomeAssistant) -> None: hass, DOMAIN, SERVICE_KNX_EVENT_REGISTER, - partial(service_event_register_modify, hass), + service_event_register_modify, schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA, ) @@ -70,7 +69,7 @@ def register_knx_services(hass: HomeAssistant) -> None: hass, DOMAIN, SERVICE_KNX_EXPOSURE_REGISTER, - partial(service_exposure_register_modify, hass), + service_exposure_register_modify, schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, ) @@ -78,7 +77,7 @@ def register_knx_services(hass: HomeAssistant) -> None: hass, DOMAIN, SERVICE_RELOAD, - partial(service_reload_integration, hass), + service_reload_integration, ) @@ -103,9 +102,9 @@ SERVICE_KNX_EVENT_REGISTER_SCHEMA = vol.Schema( ) -async def service_event_register_modify(hass: HomeAssistant, call: ServiceCall) -> None: +async def service_event_register_modify(call: ServiceCall) -> None: """Service for adding or removing a GroupAddress to the knx_event filter.""" - knx_module = get_knx_module(hass) + knx_module = get_knx_module(call.hass) attr_address = call.data[KNX_ADDRESS] group_addresses = list(map(parse_device_group_address, attr_address)) @@ -156,11 +155,9 @@ SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any( ) -async def service_exposure_register_modify( - hass: HomeAssistant, call: ServiceCall -) -> None: +async def service_exposure_register_modify(call: ServiceCall) -> None: """Service for adding or removing an exposure to KNX bus.""" - knx_module = get_knx_module(hass) + knx_module = get_knx_module(call.hass) group_address = call.data[KNX_ADDRESS] @@ -223,9 +220,9 @@ SERVICE_KNX_SEND_SCHEMA = vol.Any( ) -async def service_send_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> None: +async def service_send_to_knx_bus(call: ServiceCall) -> None: """Service for sending an arbitrary KNX message to the KNX bus.""" - knx_module = get_knx_module(hass) + knx_module = get_knx_module(call.hass) attr_address = call.data[KNX_ADDRESS] attr_payload = call.data[SERVICE_KNX_ATTR_PAYLOAD] @@ -271,9 +268,9 @@ SERVICE_KNX_READ_SCHEMA = vol.Schema( ) -async def service_read_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> None: +async def service_read_to_knx_bus(call: ServiceCall) -> None: """Service for sending a GroupValueRead telegram to the KNX bus.""" - knx_module = get_knx_module(hass) + knx_module = get_knx_module(call.hass) for address in call.data[KNX_ADDRESS]: telegram = Telegram( @@ -284,8 +281,8 @@ async def service_read_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> Non await knx_module.xknx.telegrams.put(telegram) -async def service_reload_integration(hass: HomeAssistant, call: ServiceCall) -> None: +async def service_reload_integration(call: ServiceCall) -> None: """Reload the integration.""" - knx_module = get_knx_module(hass) - await hass.config_entries.async_reload(knx_module.entry.entry_id) - hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) + knx_module = get_knx_module(call.hass) + await call.hass.config_entries.async_reload(knx_module.entry.entry_id) + call.hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 08b921f316b..dadc8e84796 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -3,41 +3,56 @@ "step": { "connection_type": { "title": "KNX connection", - "description": "Please enter the connection type we should use for your KNX connection. \n AUTOMATIC - The integration takes care of the connectivity to your KNX Bus by performing a gateway scan. \n TUNNELING - The integration will connect to your KNX bus via tunneling. \n ROUTING - The integration will connect to your KNX bus via routing.", + "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.)\n\n'Tunneling' will connect to a specific KNX IP interface over a tunnel.\n\n'Routing' will use Multicast to communicate with KNX IP routers.", "data": { - "connection_type": "KNX Connection Type" + "connection_type": "KNX connection type" + }, + "data_description": { + "connection_type": "Please select the connection type you want to use for your KNX connection." } }, "tunnel": { "title": "Tunnel", - "description": "Please select a gateway from the list.", "data": { - "gateway": "KNX Tunnel Connection" + "gateway": "Please select a gateway from the list." + }, + "data_description": { + "gateway": "Select a KNX tunneling interface you want use for the connection." + } + }, + "tcp_tunnel_endpoint": { + "title": "Tunnel endpoint", + "data": { + "tunnel_endpoint_ia": "Select the tunnel endpoint used for the connection." + }, + "data_description": { + "tunnel_endpoint_ia": "'Automatic' selects a free tunnel endpoint for you when connecting. If you're unsure, this is the best option." } }, "manual_tunnel": { "title": "Tunnel settings", "description": "Please enter the connection information of your tunneling device.", "data": { - "tunneling_type": "KNX Tunneling Type", - "port": "[%key:common::config_flow::data::port%]", + "tunneling_type": "KNX tunneling type", "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", "route_back": "Route back / NAT mode", "local_ip": "Local IP interface" }, "data_description": { - "port": "Port of the KNX/IP tunneling device.", + "tunneling_type": "Select the tunneling type of your KNX/IP tunneling device. Older interfaces may only support `UDP`.", "host": "IP address or hostname of the KNX/IP tunneling device.", + "port": "Port used by the KNX/IP tunneling device.", "route_back": "Enable if your KNXnet/IP tunneling server is behind NAT. Only applies for UDP connections.", "local_ip": "Local IP or interface name used for the connection from Home Assistant. Leave blank to use auto-discovery." } }, "secure_key_source_menu_tunnel": { - "title": "KNX IP-Secure", - "description": "Select how you want to configure KNX/IP Secure.", + "title": "KNX IP Secure", + "description": "How do you want to configure KNX/IP Secure?", "menu_options": { - "secure_knxkeys": "Use a `.knxkeys` file containing IP secure keys", - "secure_tunnel_manual": "Configure IP secure credentials manually" + "secure_knxkeys": "Use a `.knxkeys` file providing IP Secure keys", + "secure_tunnel_manual": "Configure IP Secure credentials manually" } }, "secure_key_source_menu_routing": { @@ -45,37 +60,40 @@ "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", "menu_options": { "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", - "secure_routing_manual": "Configure IP secure backbone key manually" + "secure_routing_manual": "Configure IP Secure backbone key manually" } }, "secure_knxkeys": { "title": "Import KNX Keyring", - "description": "Please select a `.knxkeys` file to import.", + "description": "The Keyring is used to encrypt and decrypt KNX IP Secure communication.", "data": { "knxkeys_file": "Keyring file", - "knxkeys_password": "The password to decrypt the `.knxkeys` file" + "knxkeys_password": "Keyring password" }, "data_description": { - "knxkeys_password": "This was set when exporting the file from ETS." + "knxkeys_file": "Select a `.knxkeys` file. This can be exported from ETS.", + "knxkeys_password": "The password to open the `.knxkeys` file was set when exporting." } }, "knxkeys_tunnel_select": { - "title": "Tunnel endpoint", - "description": "Select the tunnel used for connection.", + "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", "data": { - "user_id": "`Automatic` will use the first free tunnel endpoint." + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" + }, + "data_description": { + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" } }, "secure_tunnel_manual": { "title": "Secure tunnelling", - "description": "Please enter your IP secure information.", + "description": "Please enter your IP Secure information.", "data": { "user_id": "User ID", "user_password": "User password", "device_authentication": "Device authentication password" }, "data_description": { - "user_id": "This is often tunnel number +1. So 'Tunnel 2' would have User-ID '3'.", + "user_id": "This usually is tunnel number +1. So first tunnel in the list presented in ETS would have User-ID `2`.", "user_password": "Password for the specific tunnel connection set in the 'Properties' panel of the tunnel in ETS.", "device_authentication": "This is set in the 'IP' panel of the interface in ETS." } @@ -88,8 +106,8 @@ "sync_latency_tolerance": "Network latency tolerance" }, "data_description": { - "backbone_key": "Can be seen in the 'Security' report of an ETS project. Eg. '00112233445566778899AABBCCDDEEFF'", - "sync_latency_tolerance": "Default is 1000." + "backbone_key": "Can be seen in the 'Security' report of your ETS project. Eg. `00112233445566778899AABBCCDDEEFF`", + "sync_latency_tolerance": "Should be equal to the backbone configuration of your ETS project. Default is `1000`" } }, "routing": { @@ -97,13 +115,16 @@ "description": "Please configure the routing options.", "data": { "individual_address": "Individual address", - "routing_secure": "Use KNX IP Secure", + "routing_secure": "KNX IP Secure Routing", "multicast_group": "Multicast group", "multicast_port": "Multicast port", "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" }, "data_description": { "individual_address": "KNX address to be used by Home Assistant, e.g. `0.0.4`", + "routing_secure": "Select if your installation uses encrypted communication according to the KNX IP Secure standard. This setting requires compatible devices and configuration. You'll be prompted for credentials in the next step.", + "multicast_group": "Multicast group used by your installation. Default is `224.0.23.12`", + "multicast_port": "Multicast port used by your installation. Default is `3671`", "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" } } @@ -140,8 +161,8 @@ "telegram_log_size": "Telegram history limit" }, "data_description": { - "state_updater": "Set default for reading states from the KNX Bus. When disabled, Home Assistant will not actively retrieve entity states from the KNX Bus. Can be overridden by `sync_state` entity options.", - "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: 0 or 20 to 40", + "state_updater": "Sets the default behavior for reading state addresses from the KNX Bus.\nWhen enabled, Home Assistant will monitor each group address and read it from the bus if no value has been received for one hour.\nWhen disabled, state addresses will only be read once after a bus connection is established.\nThis behavior can be overridden for individual entities using the `sync_state` option.", + "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`", "telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}" } }, @@ -150,13 +171,27 @@ "description": "[%key:component::knx::config::step::connection_type::description%]", "data": { "connection_type": "[%key:component::knx::config::step::connection_type::data::connection_type%]" + }, + "data_description": { + "connection_type": "[%key:component::knx::config::step::connection_type::data_description::connection_type%]" } }, "tunnel": { "title": "[%key:component::knx::config::step::tunnel::title%]", - "description": "[%key:component::knx::config::step::tunnel::description%]", "data": { "gateway": "[%key:component::knx::config::step::tunnel::data::gateway%]" + }, + "data_description": { + "gateway": "[%key:component::knx::config::step::tunnel::data_description::gateway%]" + } + }, + "tcp_tunnel_endpoint": { + "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", + "data": { + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" + }, + "data_description": { + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" } }, "manual_tunnel": { @@ -170,6 +205,7 @@ "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" }, "data_description": { + "tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data_description::tunneling_type%]", "port": "[%key:component::knx::config::step::manual_tunnel::data_description::port%]", "host": "[%key:component::knx::config::step::manual_tunnel::data_description::host%]", "route_back": "[%key:component::knx::config::step::manual_tunnel::data_description::route_back%]", @@ -200,14 +236,17 @@ "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]" }, "data_description": { + "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]", "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]" } }, "knxkeys_tunnel_select": { - "title": "[%key:component::knx::config::step::knxkeys_tunnel_select::title%]", - "description": "[%key:component::knx::config::step::knxkeys_tunnel_select::description%]", + "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", "data": { - "user_id": "[%key:component::knx::config::step::knxkeys_tunnel_select::data::user_id%]" + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" + }, + "data_description": { + "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" } }, "secure_tunnel_manual": { @@ -248,6 +287,9 @@ }, "data_description": { "individual_address": "[%key:component::knx::config::step::routing::data_description::individual_address%]", + "routing_secure": "[%key:component::knx::config::step::routing::data_description::routing_secure%]", + "multicast_group": "[%key:component::knx::config::step::routing::data_description::multicast_group%]", + "multicast_port": "[%key:component::knx::config::step::routing::data_description::multicast_port%]", "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" } } @@ -360,8 +402,8 @@ } }, "read": { - "name": "Reads from KNX bus", - "description": "Send GroupValueRead requests to the KNX bus. Response can be used from `knx_event` and will be processed in KNX entities.", + "name": "Read from KNX bus", + "description": "Sends GroupValueRead requests to the KNX bus. Response can be used from `knx_event` and will be processed in KNX entities.", "fields": { "address": { "name": "[%key:component::knx::services::send::fields::address::name%]", @@ -370,8 +412,8 @@ } }, "event_register": { - "name": "Registers knx_event", - "description": "Add or remove group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this service can be removed.", + "name": "Register knx_event", + "description": "Adds or removes group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this action can be removed.", "fields": { "address": { "name": "[%key:component::knx::services::send::fields::address::name%]", @@ -389,7 +431,7 @@ }, "exposure_register": { "name": "Expose to KNX bus", - "description": "Adds or remove exposures to KNX bus. Only exposures added with this service can be removed.", + "description": "Adds or removes exposures to KNX bus. Only exposures added with this action can be removed.", "fields": { "address": { "name": "[%key:component::knx::services::send::fields::address::name%]", @@ -401,7 +443,7 @@ }, "entity_id": { "name": "Entity", - "description": "Entity id whose state or attribute shall be exposed." + "description": "Entity ID whose state or attribute shall be exposed." }, "attribute": { "name": "Entity attribute", diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py index f87b94b23fd..0bd51f27ab6 100644 --- a/homeassistant/components/kodi/config_flow.py +++ b/homeassistant/components/kodi/config_flow.py @@ -8,7 +8,6 @@ from typing import Any from pykodi import CannotConnectError, InvalidAuthError, Kodi, get_kodi_connection import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -22,6 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_WS_PORT, @@ -103,7 +103,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_name: str | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self._host = discovery_info.host diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 5b472e0c193..8d5e76df71e 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -41,7 +41,7 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "no_uuid": "Kodi instance does not have a unique id. This is most likely due to an old Kodi version (17.x or below). You can configure the integration manually or upgrade to a more recent Kodi version." + "no_uuid": "Kodi instance does not have a unique ID. This is most likely due to an old Kodi version (17.x or below). You can configure the integration manually or upgrade to a more recent Kodi version." } }, "device_automation": { @@ -57,15 +57,15 @@ "fields": { "media_type": { "name": "Media type", - "description": "Media type identifier. It must be one of SONG or ALBUM." + "description": "Media type identifier. It must be one of 'SONG' or 'ALBUM'." }, "media_id": { "name": "Media ID", - "description": "Unique Id of the media entry to add (`songid` or albumid`). If not defined, `media_name` and `artist_name` are needed to search the Kodi music library." + "description": "Unique ID of the media entry to add (`songid` or albumid`). If not defined, Media name and Artist name are needed to search the Kodi music library." }, "media_name": { "name": "Media name", - "description": "Optional media name for filtering media. Can be 'ALL' when `media_type` is 'ALBUM' and `artist_name` is specified, to add all songs from one artist." + "description": "Optional media name for filtering media. Can be 'ALL' when Media type is 'ALBUM' and Artist name is specified, to add all songs from one artist." }, "artist_name": { "name": "Artist name", diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index 65dd7cf39b3..7f5f4d8abd4 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -12,7 +12,6 @@ from urllib.parse import urlparse import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, BinarySensorDeviceClass, @@ -40,6 +39,11 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + SsdpServiceInfo, +) from .const import ( CONF_ACTIVATION, @@ -254,7 +258,7 @@ class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered konnected panel. @@ -264,16 +268,16 @@ class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.debug(discovery_info) try: - if discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER: + if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER: return self.async_abort(reason="not_konn_panel") if not any( - name in discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + name in discovery_info.upnp[ATTR_UPNP_MODEL_NAME] for name in KONN_PANEL_MODEL_NAMES ): _LOGGER.warning( "Discovered unrecognized Konnected device %s", - discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "Unknown"), + discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "Unknown"), ) return self.async_abort(reason="not_konn_panel") diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py index fa6aa92856b..5f4393146f0 100644 --- a/homeassistant/components/kostal_plenticore/coordinator.py +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -101,8 +101,8 @@ class Plenticore: model=f"{prod1} {prod2}", name=settings["scb:network"][hostname_id], sw_version=( - f'IOC: {device_local["Properties:VersionIOC"]}' - f' MC: {device_local["Properties:VersionMC"]}' + f"IOC: {device_local['Properties:VersionIOC']}" + f" MC: {device_local['Properties:VersionMC']}" ), ) diff --git a/homeassistant/components/lacrosse_view/config_flow.py b/homeassistant/components/lacrosse_view/config_flow.py index ecf30f9a197..75a5c737034 100644 --- a/homeassistant/components/lacrosse_view/config_flow.py +++ b/homeassistant/components/lacrosse_view/config_flow.py @@ -40,7 +40,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Loca raise InvalidAuth from error if not locations: - raise NoLocations(f'No locations found for account {data["username"]}') + raise NoLocations(f"No locations found for account {data['username']}") return locations diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index da513bc8cff..d20616e1940 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -3,14 +3,14 @@ import logging from packaging import version -from pylamarzocco.client_bluetooth import LaMarzoccoBluetoothClient -from pylamarzocco.client_cloud import LaMarzoccoCloudClient -from pylamarzocco.client_local import LaMarzoccoLocalClient +from pylamarzocco.clients.bluetooth import LaMarzoccoBluetoothClient +from pylamarzocco.clients.cloud import LaMarzoccoCloudClient +from pylamarzocco.clients.local import LaMarzoccoLocalClient from pylamarzocco.const import BT_MODEL_PREFIXES, FirmwareType +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from homeassistant.components.bluetooth import async_discovered_service_info -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -23,10 +23,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_USE_BLUETOOTH, DOMAIN -from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator +from .coordinator import ( + LaMarzoccoConfigEntry, + LaMarzoccoConfigUpdateCoordinator, + LaMarzoccoFirmwareUpdateCoordinator, + LaMarzoccoRuntimeData, + LaMarzoccoStatisticsUpdateCoordinator, +) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -48,10 +54,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - assert entry.unique_id serial = entry.unique_id + client = async_create_clientsession(hass) cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], - client=get_async_client(hass), + client=client, ) # initialize local API @@ -61,7 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - local_client = LaMarzoccoLocalClient( host=host, local_bearer=entry.data[CONF_TOKEN], - client=get_async_client(hass), + client=client, ) # initialize Bluetooth @@ -99,18 +106,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - address_or_ble_device=entry.data[CONF_MAC], ) - coordinator = LaMarzoccoUpdateCoordinator( - hass=hass, - entry=entry, - local_client=local_client, + device = LaMarzoccoMachine( + model=entry.data[CONF_MODEL], + serial_number=entry.unique_id, + name=entry.data[CONF_NAME], cloud_client=cloud_client, + local_client=local_client, bluetooth_client=bluetooth_client, ) - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + coordinators = LaMarzoccoRuntimeData( + LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client), + LaMarzoccoFirmwareUpdateCoordinator(hass, entry, device), + LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device), + ) - gateway_version = coordinator.device.firmware[FirmwareType.GATEWAY].current_version + # API does not like concurrent requests, so no asyncio.gather here + await coordinators.config_coordinator.async_config_entry_first_refresh() + await coordinators.firmware_coordinator.async_config_entry_first_refresh() + await coordinators.statistics_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinators + + gateway_version = device.firmware[FirmwareType.GATEWAY].current_version if version.parse(gateway_version) < version.parse("v3.4-rc5"): # incompatible gateway firmware, create an issue ir.async_create_issue( @@ -125,7 +143,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + async def update_listener( + hass: HomeAssistant, entry: LaMarzoccoConfigEntry + ) -> None: await hass.config_entries.async_reload(entry.entry_id) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -133,12 +153,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, entry: LaMarzoccoConfigEntry +) -> bool: """Migrate config entry.""" if entry.version > 2: # guard against downgrade from a future version diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index 444e4d0723b..e36b53bc993 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -3,6 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass +from pylamarzocco.const import MachineModel from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.binary_sensor import ( @@ -15,7 +16,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LaMarzoccoConfigEntry -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -25,7 +29,7 @@ class LaMarzoccoBinarySensorEntityDescription( ): """Description of a La Marzocco binary sensor.""" - is_on_fn: Callable[[LaMarzoccoMachineConfig], bool] + is_on_fn: Callable[[LaMarzoccoMachineConfig], bool | None] ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( @@ -54,6 +58,15 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( ), ) +SCALE_ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( + LaMarzoccoBinarySensorEntityDescription( + key="connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + is_on_fn=lambda config: config.scale.connected if config.scale else None, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -61,13 +74,32 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up binary sensor entities.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator - async_add_entities( + entities = [ LaMarzoccoBinarySensorEntity(coordinator, description) for description in ENTITIES if description.supported_fn(coordinator) - ) + ] + + if ( + coordinator.device.model == MachineModel.LINEA_MINI + and coordinator.device.config.scale + ): + entities.extend( + LaMarzoccoScaleBinarySensorEntity(coordinator, description) + for description in SCALE_ENTITIES + ) + + def _async_add_new_scale() -> None: + async_add_entities( + LaMarzoccoScaleBinarySensorEntity(coordinator, description) + for description in SCALE_ENTITIES + ) + + coordinator.new_device_callback.append(_async_add_new_scale) + + async_add_entities(entities) class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity): @@ -76,6 +108,14 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity): entity_description: LaMarzoccoBinarySensorEntityDescription @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self.entity_description.is_on_fn(self.coordinator.device.config) + + +class LaMarzoccoScaleBinarySensorEntity( + LaMarzoccoBinarySensorEntity, LaMarzoccScaleEntity +): + """Binary sensor for La Marzocco scales.""" + + entity_description: LaMarzoccoBinarySensorEntityDescription diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py index dabf01d817d..22e92f656ff 100644 --- a/homeassistant/components/lamarzocco/button.py +++ b/homeassistant/components/lamarzocco/button.py @@ -57,7 +57,7 @@ async def async_setup_entry( ) -> None: """Set up button entities.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator async_add_entities( LaMarzoccoButtonEntity(coordinator, description) for description in ENTITIES diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py index 0ec9b55a9a1..1dcc7c324ac 100644 --- a/homeassistant/components/lamarzocco/calendar.py +++ b/homeassistant/components/lamarzocco/calendar.py @@ -13,6 +13,9 @@ from homeassistant.util import dt as dt_util from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoBaseEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + CALENDAR_KEY = "auto_on_off_schedule" DAY_OF_WEEK = [ @@ -33,7 +36,7 @@ async def async_setup_entry( ) -> None: """Set up switch entities and services.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator async_add_entities( LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, wake_up_sleep_entry) for wake_up_sleep_entry in coordinator.device.config.wake_up_sleep_entries.values() diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 0f288e22c4a..87a9824423a 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -6,8 +6,9 @@ from collections.abc import Mapping import logging from typing import Any -from pylamarzocco.client_cloud import LaMarzoccoCloudClient -from pylamarzocco.client_local import LaMarzoccoLocalClient +from aiohttp import ClientSession +from pylamarzocco.clients.cloud import LaMarzoccoCloudClient +from pylamarzocco.clients.local import LaMarzoccoLocalClient from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.models import LaMarzoccoDeviceInfo import voluptuous as vol @@ -16,11 +17,9 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfo, async_discovered_service_info, ) -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -37,15 +36,20 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_USE_BLUETOOTH, DOMAIN +from .coordinator import LaMarzoccoConfigEntry CONF_MACHINE = "machine" @@ -57,6 +61,8 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 2 + _client: ClientSession + def __init__(self) -> None: """Initialize the config flow.""" self._config: dict[str, Any] = {} @@ -80,9 +86,11 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): **self._discovered, } + self._client = async_create_clientsession(self.hass) cloud_client = LaMarzoccoCloudClient( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], + client=self._client, ) try: self._fleet = await cloud_client.get_customer_fleet() @@ -136,8 +144,17 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, autocomplete="username" + ) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), } ), errors=errors, @@ -163,7 +180,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): # validate local connection if host is provided if user_input.get(CONF_HOST): if not await LaMarzoccoLocalClient.validate_connection( - client=get_async_client(self.hass), + client=self._client, host=user_input[CONF_HOST], token=selected_device.communication_key, ): @@ -291,6 +308,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): CONF_ADDRESS: discovery_info.macaddress, } ) + self._async_abort_entries_match({CONF_ADDRESS: discovery_info.macaddress}) _LOGGER.debug( "Discovered La Marzocco machine %s through DHCP at address %s", @@ -337,13 +355,20 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required( - CONF_USERNAME, - default=reconfigure_entry.data[CONF_USERNAME], - ): str, + CONF_USERNAME, default=reconfigure_entry.data[CONF_USERNAME] + ): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, autocomplete="username" + ), + ), vol.Required( - CONF_PASSWORD, - default=reconfigure_entry.data[CONF_PASSWORD], - ): str, + CONF_PASSWORD, default=reconfigure_entry.data[CONF_PASSWORD] + ): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), } ), ) @@ -353,7 +378,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: LaMarzoccoConfigEntry, ) -> LmOptionsFlowHandler: """Create the options flow.""" return LmOptionsFlowHandler() diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 46a8e05745e..2385039f53d 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -2,48 +2,56 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from abc import abstractmethod +from collections.abc import Callable +from dataclasses import dataclass from datetime import timedelta import logging -from time import time from typing import Any -from pylamarzocco.client_bluetooth import LaMarzoccoBluetoothClient -from pylamarzocco.client_cloud import LaMarzoccoCloudClient -from pylamarzocco.client_local import LaMarzoccoLocalClient +from pylamarzocco.clients.local import LaMarzoccoLocalClient +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful -from pylamarzocco.lm_machine import LaMarzoccoMachine -from websockets.protocol import State from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL, CONF_NAME, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN SCAN_INTERVAL = timedelta(seconds=30) -FIRMWARE_UPDATE_INTERVAL = 3600 -STATISTICS_UPDATE_INTERVAL = 300 - +FIRMWARE_UPDATE_INTERVAL = timedelta(hours=1) +STATISTICS_UPDATE_INTERVAL = timedelta(minutes=5) _LOGGER = logging.getLogger(__name__) -type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoUpdateCoordinator] + +@dataclass +class LaMarzoccoRuntimeData: + """Runtime data for La Marzocco.""" + + config_coordinator: LaMarzoccoConfigUpdateCoordinator + firmware_coordinator: LaMarzoccoFirmwareUpdateCoordinator + statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator + + +type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData] class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): - """Class to handle fetching data from the La Marzocco API centrally.""" + """Base class for La Marzocco coordinators.""" + _default_update_interval = SCAN_INTERVAL config_entry: LaMarzoccoConfigEntry def __init__( self, hass: HomeAssistant, entry: LaMarzoccoConfigEntry, - cloud_client: LaMarzoccoCloudClient, - local_client: LaMarzoccoLocalClient | None, - bluetooth_client: LaMarzoccoBluetoothClient | None, + device: LaMarzoccoMachine, + local_client: LaMarzoccoLocalClient | None = None, ) -> None: """Initialize coordinator.""" super().__init__( @@ -51,27 +59,43 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): _LOGGER, config_entry=entry, name=DOMAIN, - update_interval=SCAN_INTERVAL, + update_interval=self._default_update_interval, ) + self.device = device self.local_connection_configured = local_client is not None - - assert self.config_entry.unique_id - self.device = LaMarzoccoMachine( - model=self.config_entry.data[CONF_MODEL], - serial_number=self.config_entry.unique_id, - name=self.config_entry.data[CONF_NAME], - cloud_client=cloud_client, - local_client=local_client, - bluetooth_client=bluetooth_client, - ) - - self._last_firmware_data_update: float | None = None - self._last_statistics_data_update: float | None = None self._local_client = local_client + self.new_device_callback: list[Callable] = [] - async def _async_setup(self) -> None: + async def _async_update_data(self) -> None: + """Do the data update.""" + try: + await self._internal_async_update_data() + except AuthFail as ex: + _LOGGER.debug("Authentication failed", exc_info=True) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from ex + except RequestNotSuccessful as ex: + _LOGGER.debug(ex, exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="api_error" + ) from ex + + @abstractmethod + async def _internal_async_update_data(self) -> None: + """Actual data update logic.""" + + +class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator): + """Class to handle fetching data from the La Marzocco API centrally.""" + + _scale_address: str | None = None + + async def _async_connect_websocket(self) -> None: """Set up the coordinator.""" - if self._local_client is not None: + if self._local_client is not None and ( + self._local_client.websocket is None or self._local_client.websocket.closed + ): _LOGGER.debug("Init WebSocket in background task") self.config_entry.async_create_background_task( @@ -86,9 +110,8 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): if ( self._local_client is not None and self._local_client.websocket is not None - and self._local_client.websocket.state is State.OPEN + and not self._local_client.websocket.closed ): - self._local_client.terminating = True await self._local_client.websocket.close() self.config_entry.async_on_unload( @@ -98,41 +121,49 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): ) self.config_entry.async_on_unload(websocket_close) - async def _async_update_data(self) -> None: + async def _internal_async_update_data(self) -> None: """Fetch data from API endpoint.""" - await self._async_handle_request(self.device.get_config) - - if ( - self._last_firmware_data_update is None - or (self._last_firmware_data_update + FIRMWARE_UPDATE_INTERVAL) < time() - ): - await self._async_handle_request(self.device.get_firmware) - self._last_firmware_data_update = time() - - if ( - self._last_statistics_data_update is None - or (self._last_statistics_data_update + STATISTICS_UPDATE_INTERVAL) < time() - ): - await self._async_handle_request(self.device.get_statistics) - self._last_statistics_data_update = time() - + await self.device.get_config() _LOGGER.debug("Current status: %s", str(self.device.config)) + await self._async_connect_websocket() + self._async_add_remove_scale() - async def _async_handle_request[**_P]( - self, - func: Callable[_P, Coroutine[None, None, None]], - *args: _P.args, - **kwargs: _P.kwargs, - ) -> None: - try: - await func(*args, **kwargs) - except AuthFail as ex: - _LOGGER.debug("Authentication failed", exc_info=True) - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="authentication_failed" - ) from ex - except RequestNotSuccessful as ex: - _LOGGER.debug(ex, exc_info=True) - raise UpdateFailed( - translation_domain=DOMAIN, translation_key="api_error" - ) from ex + @callback + def _async_add_remove_scale(self) -> None: + """Add or remove a scale when added or removed.""" + if self.device.config.scale and not self._scale_address: + self._scale_address = self.device.config.scale.address + for scale_callback in self.new_device_callback: + scale_callback() + elif not self.device.config.scale and self._scale_address: + device_registry = dr.async_get(self.hass) + if device := device_registry.async_get_device( + identifiers={(DOMAIN, self._scale_address)} + ): + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + self._scale_address = None + + +class LaMarzoccoFirmwareUpdateCoordinator(LaMarzoccoUpdateCoordinator): + """Coordinator for La Marzocco firmware.""" + + _default_update_interval = FIRMWARE_UPDATE_INTERVAL + + async def _internal_async_update_data(self) -> None: + """Fetch data from API endpoint.""" + await self.device.get_firmware() + _LOGGER.debug("Current firmware: %s", str(self.device.firmware)) + + +class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator): + """Coordinator for La Marzocco statistics.""" + + _default_update_interval = STATISTICS_UPDATE_INTERVAL + + async def _internal_async_update_data(self) -> None: + """Fetch data from API endpoint.""" + await self.device.get_statistics() + _LOGGER.debug("Current statistics: %s", str(self.device.statistics)) diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py index 43ae51ee192..204a8b7142a 100644 --- a/homeassistant/components/lamarzocco/diagnostics.py +++ b/homeassistant/components/lamarzocco/diagnostics.py @@ -31,7 +31,7 @@ async def async_get_config_entry_diagnostics( entry: LaMarzoccoConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator device = coordinator.device # collect all data sources diagnostics_data = DiagnosticsData( diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index f0942f51ace..3e70ff1acdf 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -2,12 +2,17 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING from pylamarzocco.const import FirmwareType -from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.devices.machine import LaMarzoccoMachine -from homeassistant.const import CONF_ADDRESS -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.const import CONF_ADDRESS, CONF_MAC +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, +) from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -48,17 +53,17 @@ class LaMarzoccoBaseEntity( serial_number=device.serial_number, sw_version=device.firmware[FirmwareType.MACHINE].current_version, ) + connections: set[tuple[str, str]] = set() if coordinator.config_entry.data.get(CONF_ADDRESS): - self._attr_device_info.update( - DeviceInfo( - connections={ - ( - CONNECTION_NETWORK_MAC, - coordinator.config_entry.data[CONF_ADDRESS], - ) - } - ) + connections.add( + (CONNECTION_NETWORK_MAC, coordinator.config_entry.data[CONF_ADDRESS]) ) + if coordinator.config_entry.data.get(CONF_MAC): + connections.add( + (CONNECTION_BLUETOOTH, coordinator.config_entry.data[CONF_MAC]) + ) + if connections: + self._attr_device_info.update(DeviceInfo(connections=connections)) class LaMarzoccoEntity(LaMarzoccoBaseEntity): @@ -81,3 +86,26 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity): """Initialize the entity.""" super().__init__(coordinator, entity_description.key) self.entity_description = entity_description + + +class LaMarzoccScaleEntity(LaMarzoccoEntity): + """Common class for scale.""" + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + entity_description: LaMarzoccoEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, entity_description) + scale = coordinator.device.config.scale + if TYPE_CHECKING: + assert scale + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, scale.address)}, + name=scale.name, + manufacturer="Acaia", + model="Lunar", + model_id="Y.301", + via_device=(DOMAIN, coordinator.device.serial_number), + ) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index 860da12ddd9..79267b4abd4 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -43,6 +43,9 @@ "preinfusion_off": { "default": "mdi:water" }, + "scale_target": { + "default": "mdi:scale-balance" + }, "smart_standby_time": { "default": "mdi:timer" }, @@ -54,6 +57,13 @@ } }, "select": { + "active_bbw": { + "default": "mdi:alpha-u", + "state": { + "a": "mdi:alpha-a", + "b": "mdi:alpha-b" + } + }, "smart_standby_mode": { "default": "mdi:power", "state": { diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index a71da7c4754..afd367b0f6e 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -36,5 +36,6 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], - "requirements": ["pylamarzocco==1.2.11"] + "quality_scale": "platinum", + "requirements": ["pylamarzocco==1.4.6"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index f32607fd73b..44b582fbf1a 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -11,8 +11,8 @@ from pylamarzocco.const import ( PhysicalKey, PrebrewMode, ) +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.lm_machine import LaMarzoccoMachine from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.number import ( @@ -33,7 +33,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity PARALLEL_UPDATES = 1 @@ -56,7 +56,9 @@ class LaMarzoccoKeyNumberEntityDescription( ): """Description of an La Marzocco number entity with keys.""" - native_value_fn: Callable[[LaMarzoccoMachineConfig, PhysicalKey], float | int] + native_value_fn: Callable[ + [LaMarzoccoMachineConfig, PhysicalKey], float | int | None + ] set_value_fn: Callable[ [LaMarzoccoMachine, float | int, PhysicalKey], Coroutine[Any, Any, bool] ] @@ -203,6 +205,27 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( ), ) +SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( + LaMarzoccoKeyNumberEntityDescription( + key="scale_target", + translation_key="scale_target", + native_step=PRECISION_WHOLE, + native_min_value=1, + native_max_value=100, + entity_category=EntityCategory.CONFIG, + set_value_fn=lambda machine, weight, key: machine.set_bbw_recipe_target( + key, int(weight) + ), + native_value_fn=lambda config, key: ( + config.bbw_settings.doses[key] if config.bbw_settings else None + ), + supported_fn=( + lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI + and coordinator.device.config.scale is not None + ), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -210,7 +233,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up number entities.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator entities: list[NumberEntity] = [ LaMarzoccoNumberEntity(coordinator, description) for description in ENTITIES @@ -224,6 +247,27 @@ async def async_setup_entry( LaMarzoccoKeyNumberEntity(coordinator, description, key) for key in range(min(num_keys, 1), num_keys + 1) ) + + for description in SCALE_KEY_ENTITIES: + if description.supported_fn(coordinator): + if bbw_settings := coordinator.device.config.bbw_settings: + entities.extend( + LaMarzoccoScaleTargetNumberEntity( + coordinator, description, int(key) + ) + for key in bbw_settings.doses + ) + + def _async_add_new_scale() -> None: + if bbw_settings := coordinator.device.config.bbw_settings: + async_add_entities( + LaMarzoccoScaleTargetNumberEntity(coordinator, description, int(key)) + for description in SCALE_KEY_ENTITIES + for key in bbw_settings.doses + ) + + coordinator.new_device_callback.append(_async_add_new_scale) + async_add_entities(entities) @@ -281,7 +325,7 @@ class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity): self.pyhsical_key = pyhsical_key @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the current value.""" return self.entity_description.native_value_fn( self.coordinator.device.config, PhysicalKey(self.pyhsical_key) @@ -305,3 +349,11 @@ class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity): }, ) from exc self.async_write_ha_state() + + +class LaMarzoccoScaleTargetNumberEntity( + LaMarzoccoKeyNumberEntity, LaMarzoccScaleEntity +): + """Entity representing a key number on the scale.""" + + entity_description: LaMarzoccoKeyNumberEntityDescription diff --git a/homeassistant/components/lamarzocco/quality_scale.yaml b/homeassistant/components/lamarzocco/quality_scale.yaml index 3677bd8d6b8..b03f661c7b7 100644 --- a/homeassistant/components/lamarzocco/quality_scale.yaml +++ b/homeassistant/components/lamarzocco/quality_scale.yaml @@ -62,9 +62,9 @@ rules: docs-troubleshooting: done docs-use-cases: done dynamic-devices: - status: exempt + status: done comment: | - Device type integration. + Device type integration, only possible for addon scale entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -74,9 +74,9 @@ rules: reconfiguration-flow: done repair-issues: done stale-devices: - status: exempt + status: done comment: | - Device type integration. + Device type integration, only possible for addon scale # Platinum async-dependency: done diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index 637ef935979..7acb654f0d2 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -4,9 +4,15 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any -from pylamarzocco.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel +from pylamarzocco.const import ( + MachineModel, + PhysicalKey, + PrebrewMode, + SmartStandbyMode, + SteamLevel, +) +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.lm_machine import LaMarzoccoMachine from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -17,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import LaMarzoccoConfigEntry -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity PARALLEL_UPDATES = 1 @@ -52,7 +58,7 @@ class LaMarzoccoSelectEntityDescription( ): """Description of a La Marzocco select entity.""" - current_option_fn: Callable[[LaMarzoccoMachineConfig], str] + current_option_fn: Callable[[LaMarzoccoMachineConfig], str | None] select_option_fn: Callable[[LaMarzoccoMachine, str], Coroutine[Any, Any, bool]] @@ -100,6 +106,22 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( ), ) +SCALE_ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( + LaMarzoccoSelectEntityDescription( + key="active_bbw", + translation_key="active_bbw", + options=["a", "b"], + select_option_fn=lambda machine, option: machine.set_active_bbw_recipe( + PhysicalKey[option.upper()] + ), + current_option_fn=lambda config: ( + config.bbw_settings.active_dose.name.lower() + if config.bbw_settings + else None + ), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -107,13 +129,32 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up select entities.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator - async_add_entities( + entities = [ LaMarzoccoSelectEntity(coordinator, description) for description in ENTITIES if description.supported_fn(coordinator) - ) + ] + + if ( + coordinator.device.model == MachineModel.LINEA_MINI + and coordinator.device.config.scale + ): + entities.extend( + LaMarzoccoScaleSelectEntity(coordinator, description) + for description in SCALE_ENTITIES + ) + + def _async_add_new_scale() -> None: + async_add_entities( + LaMarzoccoScaleSelectEntity(coordinator, description) + for description in SCALE_ENTITIES + ) + + coordinator.new_device_callback.append(_async_add_new_scale) + + async_add_entities(entities) class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity): @@ -122,7 +163,7 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity): entity_description: LaMarzoccoSelectEntityDescription @property - def current_option(self) -> str: + def current_option(self) -> str | None: """Return the current selected option.""" return str( self.entity_description.current_option_fn(self.coordinator.device.config) @@ -145,3 +186,9 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity): }, ) from exc self.async_write_ha_state() + + +class LaMarzoccoScaleSelectEntity(LaMarzoccoSelectEntity, LaMarzoccScaleEntity): + """Select entity for La Marzocco scales.""" + + entity_description: LaMarzoccoSelectEntityDescription diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 04b095e798c..2acca879d52 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from pylamarzocco.const import BoilerType, MachineModel, PhysicalKey -from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.devices.machine import LaMarzoccoMachine from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,12 +12,20 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LaMarzoccoConfigEntry -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -30,24 +38,6 @@ class LaMarzoccoSensorEntityDescription( ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( - LaMarzoccoSensorEntityDescription( - key="drink_stats_coffee", - translation_key="drink_stats_coffee", - native_unit_of_measurement="drinks", - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.statistics.drink_stats.get(PhysicalKey.A, 0), - available_fn=lambda device: len(device.statistics.drink_stats) > 0, - entity_category=EntityCategory.DIAGNOSTIC, - ), - LaMarzoccoSensorEntityDescription( - key="drink_stats_flushing", - translation_key="drink_stats_flushing", - native_unit_of_measurement="drinks", - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.statistics.total_flushes, - available_fn=lambda device: len(device.statistics.drink_stats) > 0, - entity_category=EntityCategory.DIAGNOSTIC, - ), LaMarzoccoSensorEntityDescription( key="shot_timer", translation_key="shot_timer", @@ -85,6 +75,42 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ), ) +STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( + LaMarzoccoSensorEntityDescription( + key="drink_stats_coffee", + translation_key="drink_stats_coffee", + native_unit_of_measurement="drinks", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.statistics.drink_stats.get(PhysicalKey.A, 0), + available_fn=lambda device: len(device.statistics.drink_stats) > 0, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoSensorEntityDescription( + key="drink_stats_flushing", + translation_key="drink_stats_flushing", + native_unit_of_measurement="drinks", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.statistics.total_flushes, + available_fn=lambda device: len(device.statistics.drink_stats) > 0, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + +SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( + LaMarzoccoSensorEntityDescription( + key="scale_battery", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + value_fn=lambda device: ( + device.config.scale.battery if device.config.scale else 0 + ), + supported_fn=( + lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI + ), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -92,14 +118,40 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor entities.""" - coordinator = entry.runtime_data + config_coordinator = entry.runtime_data.config_coordinator - async_add_entities( - LaMarzoccoSensorEntity(coordinator, description) + entities = [ + LaMarzoccoSensorEntity(config_coordinator, description) for description in ENTITIES - if description.supported_fn(coordinator) + if description.supported_fn(config_coordinator) + ] + + if ( + config_coordinator.device.model == MachineModel.LINEA_MINI + and config_coordinator.device.config.scale + ): + entities.extend( + LaMarzoccoScaleSensorEntity(config_coordinator, description) + for description in SCALE_ENTITIES + ) + + statistics_coordinator = entry.runtime_data.statistics_coordinator + entities.extend( + LaMarzoccoSensorEntity(statistics_coordinator, description) + for description in STATISTIC_ENTITIES + if description.supported_fn(statistics_coordinator) ) + def _async_add_new_scale() -> None: + async_add_entities( + LaMarzoccoScaleSensorEntity(config_coordinator, description) + for description in SCALE_ENTITIES + ) + + config_coordinator.new_device_callback.append(_async_add_new_scale) + + async_add_entities(entities) + class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): """Sensor representing espresso machine temperature data.""" @@ -110,3 +162,9 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): def native_value(self) -> int | float: """State of the sensor.""" return self.entity_description.value_fn(self.coordinator.device) + + +class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity): + """Sensor for a La Marzocco scale.""" + + entity_description: LaMarzoccoSensorEntityDescription diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index f98d5c2a700..cc96e4615dc 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -67,8 +67,10 @@ "step": { "init": { "data": { - "title": "Update Configuration", "use_bluetooth": "Use Bluetooth" + }, + "data_description": { + "use_bluetooth": "Should the integration try to use Bluetooth to control the machine?" } } } @@ -120,6 +122,9 @@ "preinfusion_off_key": { "name": "Preinfusion time Key {key}" }, + "scale_target_key": { + "name": "Brew by weight target {key}" + }, "smart_standby_time": { "name": "Smart standby time" }, @@ -131,6 +136,13 @@ } }, "select": { + "active_bbw": { + "name": "Active brew by weight recipe", + "state": { + "a": "Recipe A", + "b": "Recipe B" + } + }, "prebrew_infusion_select": { "name": "Prebrew/-infusion mode", "state": { diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index 4dc701c4c29..54bd1ac2aed 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -5,8 +5,8 @@ from dataclasses import dataclass from typing import Any from pylamarzocco.const import BoilerType +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.lm_machine import LaMarzoccoMachine from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription @@ -68,7 +68,7 @@ async def async_setup_entry( ) -> None: """Set up switch entities and services.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.config_coordinator entities: list[SwitchEntity] = [] entities.extend( diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index ca182909042..0833ee6e249 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -59,7 +59,7 @@ async def async_setup_entry( ) -> None: """Create update entities.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.firmware_coordinator async_add_entities( LaMarzoccoUpdateEntity(coordinator, description) for description in ENTITIES diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index 36dcdf26ed6..23b0062fc82 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -23,12 +23,6 @@ from demetriek import ( import voluptuous as vol from yarl import URL -from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_SERIAL, - SsdpServiceInfo, -) from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC from homeassistant.data_entry_flow import AbortFlow @@ -44,6 +38,12 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from homeassistant.util.network import is_link_local from .const import DOMAIN, LOGGER @@ -249,7 +249,10 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): device = await lametric.device() if self.source != SOURCE_REAUTH: - await self.async_set_unique_id(device.serial_number) + await self.async_set_unique_id( + device.serial_number, + raise_on_progress=False, + ) self._abort_if_unique_id_configured( updates={CONF_HOST: lametric.host, CONF_API_KEY: lametric.api_key} ) diff --git a/homeassistant/components/lametric/diagnostics.py b/homeassistant/components/lametric/diagnostics.py index 69c681e911a..c14ed998ace 100644 --- a/homeassistant/components/lametric/diagnostics.py +++ b/homeassistant/components/lametric/diagnostics.py @@ -26,5 +26,5 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] # Round-trip via JSON to trigger serialization - data = json.loads(coordinator.data.json()) + data = json.loads(coordinator.data.to_json()) return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/lametric/entity.py b/homeassistant/components/lametric/entity.py index 2a952851712..eb331650870 100644 --- a/homeassistant/components/lametric/entity.py +++ b/homeassistant/components/lametric/entity.py @@ -30,4 +30,5 @@ class LaMetricEntity(CoordinatorEntity[LaMetricDataUpdateCoordinator]): model_id=coordinator.data.model, name=coordinator.data.name, sw_version=coordinator.data.os_version, + serial_number=coordinator.data.serial_number, ) diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json index b0c6f8fd96e..4c4359d0ddb 100644 --- a/homeassistant/components/lametric/manifest.json +++ b/homeassistant/components/lametric/manifest.json @@ -13,7 +13,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["demetriek"], - "requirements": ["demetriek==0.4.0"], + "requirements": ["demetriek==1.2.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1" diff --git a/homeassistant/components/lametric/notify.py b/homeassistant/components/lametric/notify.py index 7362f0ca402..195924e2da5 100644 --- a/homeassistant/components/lametric/notify.py +++ b/homeassistant/components/lametric/notify.py @@ -5,12 +5,14 @@ from __future__ import annotations from typing import Any from demetriek import ( + AlarmSound, LaMetricDevice, LaMetricError, Model, Notification, NotificationIconType, NotificationPriority, + NotificationSound, Simple, Sound, ) @@ -18,8 +20,9 @@ from demetriek import ( from homeassistant.components.notify import ATTR_DATA, BaseNotificationService from homeassistant.const import CONF_ICON from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.enum import try_parse_enum from .const import CONF_CYCLES, CONF_ICON_TYPE, CONF_PRIORITY, CONF_SOUND, DOMAIN from .coordinator import LaMetricDataUpdateCoordinator @@ -53,7 +56,12 @@ class LaMetricNotificationService(BaseNotificationService): sound = None if CONF_SOUND in data: - sound = Sound(sound=data[CONF_SOUND], category=None) + snd: AlarmSound | NotificationSound | None + if (snd := try_parse_enum(AlarmSound, data[CONF_SOUND])) is None and ( + snd := try_parse_enum(NotificationSound, data[CONF_SOUND]) + ) is None: + raise ServiceValidationError("Unknown sound provided") + sound = Sound(sound=snd, category=None) notification = Notification( icon_type=NotificationIconType(data.get(CONF_ICON_TYPE, "none")), diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index cea9debb04b..ccfd48a3abf 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from demetriek import Device, LaMetricDevice +from demetriek import Device, LaMetricDevice, Range from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry @@ -25,6 +25,8 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): """Class describing LaMetric number entities.""" value_fn: Callable[[Device], int | None] + range_fn: Callable[[Device], Range | None] + has_fn: Callable[[Device], bool] = lambda device: True set_value_fn: Callable[[LaMetricDevice, float], Awaitable[Any]] @@ -32,11 +34,9 @@ NUMBERS = [ LaMetricNumberEntityDescription( key="brightness", translation_key="brightness", - name="Brightness", entity_category=EntityCategory.CONFIG, native_step=1, - native_min_value=0, - native_max_value=100, + range_fn=lambda device: device.display.brightness_limit, native_unit_of_measurement=PERCENTAGE, value_fn=lambda device: device.display.brightness, set_value_fn=lambda device, bri: device.display(brightness=int(bri)), @@ -44,12 +44,12 @@ NUMBERS = [ LaMetricNumberEntityDescription( key="volume", translation_key="volume", - name="Volume", entity_category=EntityCategory.CONFIG, native_step=1, - native_min_value=0, - native_max_value=100, - value_fn=lambda device: device.audio.volume, + range_fn=lambda device: device.audio.volume_range if device.audio else None, + native_unit_of_measurement=PERCENTAGE, + has_fn=lambda device: bool(device.audio and device.audio.available), + value_fn=lambda device: device.audio.volume if device.audio else 0, set_value_fn=lambda api, volume: api.audio(volume=int(volume)), ), ] @@ -91,6 +91,20 @@ class LaMetricNumberEntity(LaMetricEntity, NumberEntity): """Return the number value.""" return self.entity_description.value_fn(self.coordinator.data) + @property + def native_min_value(self) -> int: + """Return the min range.""" + if limits := self.entity_description.range_fn(self.coordinator.data): + return limits.range_min + return 0 + + @property + def native_max_value(self) -> int: + """Return the max range.""" + if limits := self.entity_description.range_fn(self.coordinator.data): + return limits.range_max + return 100 + @lametric_exception_handler async def async_set_native_value(self, value: float) -> None: """Change to new number value.""" diff --git a/homeassistant/components/lametric/quality_scale.yaml b/homeassistant/components/lametric/quality_scale.yaml new file mode 100644 index 00000000000..a8982bb938b --- /dev/null +++ b/homeassistant/components/lametric/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: todo + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: todo + comment: | + Device are documented, but some are missing. For example, the their pro + strip is supported as well. + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration connects to a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not raise any repairable issues. + stale-devices: + status: exempt + comment: | + This integration connects to a single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/lametric/services.py b/homeassistant/components/lametric/services.py index d5191e0a434..2d9cd8f222d 100644 --- a/homeassistant/components/lametric/services.py +++ b/homeassistant/components/lametric/services.py @@ -19,8 +19,9 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE_ID, CONF_ICON from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv +from homeassistant.util.enum import try_parse_enum from .const import ( CONF_CYCLES, @@ -118,7 +119,12 @@ async def async_send_notification( """Send a notification to an LaMetric device.""" sound = None if CONF_SOUND in call.data: - sound = Sound(sound=call.data[CONF_SOUND], category=None) + snd: AlarmSound | NotificationSound | None + if (snd := try_parse_enum(AlarmSound, call.data[CONF_SOUND])) is None and ( + snd := try_parse_enum(NotificationSound, call.data[CONF_SOUND]) + ) is None: + raise ServiceValidationError("Unknown sound provided") + sound = Sound(sound=snd, category=None) notification = Notification( icon_type=NotificationIconType(call.data[CONF_ICON_TYPE]), diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 87bda01e305..3c2f05fa535 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -21,8 +21,11 @@ "api_key": "You can find this API key in [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)." } }, - "user_cloud_select_device": { + "cloud_select_device": { "data": { + "device": "Device" + }, + "data_description": { "device": "Select the LaMetric device to add" } } @@ -63,6 +66,14 @@ "name": "Dismiss all notifications" } }, + "number": { + "brightness": { + "name": "Brightness" + }, + "volume": { + "name": "Volume" + } + }, "sensor": { "rssi": { "name": "Wi-Fi signal" @@ -127,7 +138,7 @@ "description": "The message to display." }, "icon": { - "name": "Icon", + "name": "Icon ID", "description": "The ID number of the icon or animation to display. List of all icons and their IDs can be found at: https://developer.lametric.com/icons." }, "sound": { diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index 9689bb7b802..3aabfaf17e1 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -25,6 +25,7 @@ class LaMetricSwitchEntityDescription(SwitchEntityDescription): """Class describing LaMetric switch entities.""" available_fn: Callable[[Device], bool] = lambda device: True + has_fn: Callable[[Device], bool] = lambda device: True is_on_fn: Callable[[Device], bool] set_fn: Callable[[LaMetricDevice, bool], Awaitable[Any]] @@ -34,8 +35,11 @@ SWITCHES = [ key="bluetooth", translation_key="bluetooth", entity_category=EntityCategory.CONFIG, - available_fn=lambda device: device.bluetooth.available, - is_on_fn=lambda device: device.bluetooth.active, + available_fn=lambda device: bool( + device.bluetooth and device.bluetooth.available + ), + has_fn=lambda device: bool(device.bluetooth), + is_on_fn=lambda device: bool(device.bluetooth and device.bluetooth.active), set_fn=lambda api, active: api.bluetooth(active=active), ), ] @@ -54,6 +58,7 @@ async def async_setup_entry( description=description, ) for description in SWITCHES + if description.has_fn(coordinator.data) ) diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index a8c52b72a81..0680bfc9d71 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import final -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index eb26ef48e4e..58924413c56 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -6,7 +6,15 @@ from functools import partial import logging import pypck -from pypck.connection import PchkConnectionManager +from pypck.connection import ( + PchkAuthenticationError, + PchkConnectionFailedError, + PchkConnectionManager, + PchkConnectionRefusedError, + PchkLcnNotConnectedError, + PchkLicenseError, +) +from pypck.lcn_defs import LcnEvent from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -20,6 +28,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType @@ -31,6 +40,7 @@ from .const import ( CONF_SK_NUM_TRIES, CONF_TRANSITION, CONNECTION, + DEVICE_CONNECTIONS, DOMAIN, PLATFORMS, ) @@ -80,30 +90,29 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b settings=settings, connection_id=config_entry.entry_id, ) + try: # establish connection to PCHK server await lcn_connection.async_connect(timeout=15) - except pypck.connection.PchkAuthenticationError: - _LOGGER.warning('Authentication on PCHK "%s" failed', config_entry.title) - return False - except pypck.connection.PchkLicenseError: - _LOGGER.warning( - ( - 'Maximum number of connections on PCHK "%s" was ' - "reached. An additional license key is required" - ), - config_entry.title, - ) - return False - except TimeoutError: - _LOGGER.warning('Connection to PCHK "%s" failed', config_entry.title) - return False + except ( + PchkAuthenticationError, + PchkLicenseError, + PchkConnectionRefusedError, + PchkConnectionFailedError, + PchkLcnNotConnectedError, + ) as ex: + await lcn_connection.async_close() + raise ConfigEntryNotReady( + f"Unable to connect to {config_entry.title}: {ex}" + ) from ex _LOGGER.debug('LCN connected to "%s"', config_entry.title) hass.data[DOMAIN][config_entry.entry_id] = { CONNECTION: lcn_connection, + DEVICE_CONNECTIONS: {}, ADD_ENTITIES_CALLBACKS: {}, } + # Update config_entry with LCN device serials await async_update_config_entry(hass, config_entry) @@ -116,9 +125,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # register for LCN bus messages device_registry = dr.async_get(hass) + event_received = partial(async_host_event_received, hass, config_entry) input_received = partial( async_host_input_received, hass, config_entry, device_registry ) + + lcn_connection.register_for_events(event_received) lcn_connection.register_for_inputs(input_received) return True @@ -174,6 +186,31 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok +def async_host_event_received( + hass: HomeAssistant, config_entry: ConfigEntry, event: pypck.lcn_defs.LcnEvent +) -> None: + """Process received event from LCN.""" + lcn_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] + + async def reload_config_entry() -> None: + """Close connection and schedule config entry for reload.""" + await lcn_connection.async_close() + hass.config_entries.async_schedule_reload(config_entry.entry_id) + + if event in ( + LcnEvent.CONNECTION_LOST, + LcnEvent.PING_TIMEOUT, + ): + _LOGGER.info('The connection to host "%s" has been lost', config_entry.title) + hass.async_create_task(reload_config_entry()) + elif event == LcnEvent.BUS_DISCONNECTED: + _LOGGER.info( + 'The connection to the LCN bus via host "%s" has been disconnected', + config_entry.title, + ) + hass.async_create_task(reload_config_entry()) + + def async_host_input_received( hass: HomeAssistant, config_entry: ConfigEntry, @@ -193,8 +230,6 @@ def async_host_input_received( ) identifiers = {(DOMAIN, generate_unique_id(config_entry.entry_id, address))} device = device_registry.async_get_device(identifiers=identifiers) - if device is None: - return if isinstance(inp, pypck.inputs.ModStatusAccessControl): _async_fire_access_control_event(hass, device, address, inp) @@ -203,7 +238,10 @@ def async_host_input_received( def _async_fire_access_control_event( - hass: HomeAssistant, device: dr.DeviceEntry, address: AddressType, inp: InputType + hass: HomeAssistant, + device: dr.DeviceEntry | None, + address: AddressType, + inp: InputType, ) -> None: """Fire access control event (transponder, transmitter, fingerprint, codelock).""" event_data = { @@ -225,7 +263,10 @@ def _async_fire_access_control_event( def _async_fire_send_keys_event( - hass: HomeAssistant, device: dr.DeviceEntry, address: AddressType, inp: InputType + hass: HomeAssistant, + device: dr.DeviceEntry | None, + address: AddressType, + inp: InputType, ) -> None: """Fire send_keys event.""" for table, action in enumerate(inp.actions): diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 1c7472bc4e3..1dff15c4f22 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -32,6 +32,7 @@ from .const import ( CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_SETPOINT, + CONF_TARGET_VALUE_LOCKED, DOMAIN, ) from .entity import LcnEntity @@ -81,8 +82,6 @@ async def async_setup_entry( class LcnClimate(LcnEntity, ClimateEntity): """Representation of a LCN climate device.""" - _enable_turn_on_off_backwards_compatibility = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize of a LCN climate device.""" super().__init__(config, config_entry) @@ -95,6 +94,9 @@ class LcnClimate(LcnEntity, ClimateEntity): self.regulator_id = pypck.lcn_defs.Var.to_set_point_id(self.setpoint) self.is_lockable = config[CONF_DOMAIN_DATA][CONF_LOCKABLE] + self.target_value_locked = config[CONF_DOMAIN_DATA].get( + CONF_TARGET_VALUE_LOCKED, -1 + ) self._max_temp = config[CONF_DOMAIN_DATA][CONF_MAX_TEMP] self._min_temp = config[CONF_DOMAIN_DATA][CONF_MIN_TEMP] @@ -173,7 +175,9 @@ class LcnClimate(LcnEntity, ClimateEntity): self._is_on = True self.async_write_ha_state() elif hvac_mode == HVACMode.OFF: - if not await self.device_connection.lock_regulator(self.regulator_id, True): + if not await self.device_connection.lock_regulator( + self.regulator_id, True, self.target_value_locked + ): return self._is_on = False self._target_temperature = None diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index 008265e62ae..a1be32704f7 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -96,7 +96,10 @@ async def validate_connection(data: ConfigType) -> str | None: host_name, ) error = "license_error" - except (TimeoutError, ConnectionRefusedError): + except ( + pypck.connection.PchkConnectionFailedError, + pypck.connection.PchkConnectionRefusedError, + ): _LOGGER.warning('Connection to PCHK "%s" failed', host_name) error = "connection_refused" diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index 97aeeecd8b5..b443e05def7 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -20,6 +20,7 @@ DEFAULT_NAME = "pchk" ADD_ENTITIES_CALLBACKS = "add_entities_callbacks" CONNECTION = "connection" +DEVICE_CONNECTIONS = "device_connections" CONF_HARDWARE_SERIAL = "hardware_serial" CONF_SOFTWARE_SERIAL = "software_serial" CONF_HARDWARE_TYPE = "hardware_type" @@ -34,6 +35,7 @@ CONF_DIMMABLE = "dimmable" CONF_TRANSITION = "transition" CONF_MOTOR = "motor" CONF_LOCKABLE = "lockable" +CONF_TARGET_VALUE_LOCKED = "target_value_locked" CONF_VARIABLE = "variable" CONF_VALUE = "value" CONF_RELVARREF = "value_reference" diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 6a9c63ea212..b999c6f3770 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -38,6 +38,7 @@ from .const import ( CONF_SCENES, CONF_SOFTWARE_SERIAL, CONNECTION, + DEVICE_CONNECTIONS, DOMAIN, LED_PORTS, LOGICOP_PORTS, @@ -89,9 +90,9 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str: if domain_name == "cover": return cast(str, domain_data["motor"]) if domain_name == "climate": - return f'{domain_data["source"]}.{domain_data["setpoint"]}' + return f"{domain_data['source']}.{domain_data['setpoint']}" if domain_name == "scene": - return f'{domain_data["register"]}.{domain_data["scene"]}' + return f"{domain_data['register']}.{domain_data['scene']}" raise ValueError("Unknown domain") @@ -237,7 +238,7 @@ def register_lcn_address_devices( identifiers = {(DOMAIN, generate_unique_id(config_entry.entry_id, address))} if device_config[CONF_ADDRESS][2]: # is group - device_model = f"LCN group (g{address[0]:03d}{address[1]:03d})" + device_model = "LCN group" sw_version = None else: # is module hardware_type = device_config[CONF_HARDWARE_TYPE] @@ -245,10 +246,10 @@ def register_lcn_address_devices( hardware_name = pypck.lcn_defs.HARDWARE_DESCRIPTIONS[hardware_type] else: hardware_name = pypck.lcn_defs.HARDWARE_DESCRIPTIONS[-1] - device_model = f"{hardware_name} (m{address[0]:03d}{address[1]:03d})" + device_model = f"{hardware_name}" sw_version = f"{device_config[CONF_SOFTWARE_SERIAL]:06X}" - device_registry.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers=identifiers, via_device=host_identifiers, @@ -258,6 +259,10 @@ def register_lcn_address_devices( model=device_model, ) + hass.data[DOMAIN][config_entry.entry_id][DEVICE_CONNECTIONS][ + device_entry.id + ] = get_device_connection(hass, address, config_entry) + async def async_update_device_config( device_connection: DeviceConnectionType, device_config: ConfigType diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 695a35df871..2ac183dcc97 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.2"] + "requirements": ["pypck==0.8.3", "lcn-frontend==0.2.3"] } diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index c9c91b9843d..809701c680a 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -24,6 +24,7 @@ from .const import ( CONF_REGISTER, CONF_REVERSE_TIME, CONF_SETPOINT, + CONF_TARGET_VALUE_LOCKED, CONF_TRANSITION, KEYS, LED_PORTS, @@ -58,6 +59,7 @@ DOMAIN_DATA_CLIMATE: VolDictType = { vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_LOCKABLE, default=False): vol.Coerce(bool), + vol.Optional(CONF_TARGET_VALUE_LOCKED, default=-1): vol.Coerce(float), vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=UnitOfTemperature.CELSIUS): vol.In( UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT ), diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 92f5863c47e..a6c42de0487 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -8,12 +8,21 @@ import voluptuous as vol from homeassistant.const import ( CONF_ADDRESS, CONF_BRIGHTNESS, + CONF_DEVICE_ID, CONF_HOST, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import ( CONF_KEYS, @@ -30,6 +39,7 @@ from .const import ( CONF_TRANSITION, CONF_VALUE, CONF_VARIABLE, + DEVICE_CONNECTIONS, DOMAIN, LED_PORTS, LED_STATUS, @@ -53,7 +63,13 @@ from .helpers import ( class LcnServiceCall: """Parent class for all LCN service calls.""" - schema = vol.Schema({vol.Required(CONF_ADDRESS): is_address}) + schema = vol.Schema( + { + vol.Optional(CONF_DEVICE_ID): cv.string, + vol.Optional(CONF_ADDRESS): is_address, + } + ) + supports_response = SupportsResponse.NONE def __init__(self, hass: HomeAssistant) -> None: """Initialize service call.""" @@ -61,8 +77,37 @@ class LcnServiceCall: def get_device_connection(self, service: ServiceCall) -> DeviceConnectionType: """Get address connection object.""" - address, host_name = service.data[CONF_ADDRESS] + if CONF_DEVICE_ID not in service.data and CONF_ADDRESS not in service.data: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_device_identifier", + ) + if CONF_DEVICE_ID in service.data: + device_id = service.data[CONF_DEVICE_ID] + device_registry = dr.async_get(self.hass) + if not (device := device_registry.async_get(device_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, + ) + + return self.hass.data[DOMAIN][device.primary_config_entry][ + DEVICE_CONNECTIONS + ][device_id] + + async_create_issue( + self.hass, + DOMAIN, + "deprecated_address_parameter", + breaks_in_ha_version="2025.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_address_parameter", + ) + + address, host_name = service.data[CONF_ADDRESS] for config_entry in self.hass.config_entries.async_entries(DOMAIN): if config_entry.data[CONF_HOST] == host_name: device_connection = get_device_connection( @@ -73,7 +118,7 @@ class LcnServiceCall: return device_connection raise ValueError("Invalid host name.") - async def async_call_service(self, service: ServiceCall) -> None: + async def async_call_service(self, service: ServiceCall) -> ServiceResponse: """Execute service call.""" raise NotImplementedError diff --git a/homeassistant/components/lcn/services.yaml b/homeassistant/components/lcn/services.yaml index d62a1e72d45..f58e79b9f40 100644 --- a/homeassistant/components/lcn/services.yaml +++ b/homeassistant/components/lcn/services.yaml @@ -2,8 +2,76 @@ output_abs: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: &device_selector + device: + filter: + - integration: lcn + model: LCN group + - integration: lcn + model: UnknownModuleType + - integration: lcn + model: LCN-SW1.0 + - integration: lcn + model: LCN-SW1.1 + - integration: lcn + model: LCN-UP1.0 + - integration: lcn + model: LCN-UP2 + - integration: lcn + model: LCN-SW2 + - integration: lcn + model: LCN-UP-Profi1-Plus + - integration: lcn + model: LCN-DI12 + - integration: lcn + model: LCN-HU + - integration: lcn + model: LCN-SH + - integration: lcn + model: LCN-UP2 + - integration: lcn + model: LCN-UPP + - integration: lcn + model: LCN-SK + - integration: lcn + model: LCN-LD + - integration: lcn + model: LCN-SH-Plus + - integration: lcn + model: LCN-UPS + - integration: lcn + model: LCN_UPS24V + - integration: lcn + model: LCN-GTM + - integration: lcn + model: LCN-SHS + - integration: lcn + model: LCN-ESD + - integration: lcn + model: LCN-EB2 + - integration: lcn + model: LCN-MRS + - integration: lcn + model: LCN-EB11 + - integration: lcn + model: LCN-UMR + - integration: lcn + model: LCN-UPU + - integration: lcn + model: LCN-UMR24V + - integration: lcn + model: LCN-SHD + - integration: lcn + model: LCN-SHU + - integration: lcn + model: LCN-SR6 + - integration: lcn + model: LCN-UMF + - integration: lcn + model: LCN-WBH address: - required: true example: "myhome.s0.m7" selector: text: @@ -34,8 +102,10 @@ output_abs: output_rel: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: @@ -58,8 +128,10 @@ output_rel: output_toggle: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: @@ -83,8 +155,10 @@ output_toggle: relays: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: @@ -96,8 +170,10 @@ relays: led: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: @@ -130,8 +206,10 @@ led: var_abs: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: @@ -197,8 +275,10 @@ var_abs: var_reset: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: @@ -230,8 +310,10 @@ var_reset: var_rel: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: @@ -321,8 +403,10 @@ var_rel: lock_regulator: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: @@ -355,8 +439,10 @@ lock_regulator: send_keys: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: @@ -402,8 +488,10 @@ send_keys: lock_keys: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: @@ -445,8 +533,10 @@ lock_keys: dyn_text: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: @@ -464,8 +554,10 @@ dyn_text: pck: fields: + device_id: + example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" + selector: *device_selector address: - required: true example: "myhome.s0.m7" selector: text: diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 088a3654500..0bdd85a3678 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -17,7 +17,7 @@ "config": { "step": { "user": { - "title": "Setup LCN host", + "title": "Set up LCN host", "description": "Set up new connection to LCN host.", "data": { "host": "[%key:common::config_flow::data::name%]", @@ -30,8 +30,14 @@ "acknowledge": "Request acknowledgement from modules" }, "data_description": { - "dim_mode": "The number of steps used for dimming outputs.", - "acknowledge": "Retry sendig commands if no response is received (increases bus traffic)." + "host": "Name of the LCN integration entry.", + "ip_address": "IP address or hostname of the PCHK server.", + "port": "Port used by the PCHK server.", + "username": "Username for authorization on the PCHK server.", + "password": "Password for authorization on the PCHK server.", + "sk_num_tries": "If you have a segment coupler in your LCN installation, increase this number to at least 3, so all segment couplers are identified correctly.", + "dim_mode": "The number of steps used for dimming outputs of all LCN modules.", + "acknowledge": "Retry sendig commands if no expected response is received from modules (increases bus traffic)." } }, "reconfigure": { @@ -47,6 +53,11 @@ "acknowledge": "[%key:component::lcn::config::step::user::data::acknowledge%]" }, "data_description": { + "ip_address": "[%key:component::lcn::config::step::user::data_description::ip_address%]", + "port": "[%key:component::lcn::config::step::user::data_description::port%]", + "username": "[%key:component::lcn::config::step::user::data_description::username%]", + "password": "[%key:component::lcn::config::step::user::data_description::password%]", + "sk_num_tries": "[%key:component::lcn::config::step::user::data_description::sk_num_tries%]", "dim_mode": "[%key:component::lcn::config::step::user::data_description::dim_mode%]", "acknowledge": "[%key:component::lcn::config::step::user::data_description::acknowledge%]" } @@ -65,11 +76,15 @@ "issues": { "deprecated_regulatorlock_sensor": { "title": "Deprecated LCN regulator lock binary sensor", - "description": "Your LCN regulator lock binary sensor entity `{entity}` is beeing used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + "description": "Your LCN regulator lock binary sensor entity `{entity}` is being used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." }, "deprecated_keylock_sensor": { "title": "Deprecated LCN key lock binary sensor", - "description": "Your LCN key lock binary sensor entity `{entity}` is beeing used in automations or scripts. A key lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + "description": "Your LCN key lock binary sensor entity `{entity}` is being used in automations or scripts. A key lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + }, + "deprecated_address_parameter": { + "title": "Deprecated 'address' parameter", + "description": "The 'address' parameter in the LCN action calls is deprecated. The 'device ID' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." } }, "services": { @@ -77,6 +92,10 @@ "name": "Output absolute brightness", "description": "Sets absolute brightness of output port in percent.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "The device ID of the LCN module or group." + }, "address": { "name": "Address", "description": "Module address." @@ -99,6 +118,10 @@ "name": "Output relative brightness", "description": "Sets relative brightness of output port in percent.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" @@ -117,6 +140,10 @@ "name": "Toggle output", "description": "Toggles output port.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" @@ -135,6 +162,10 @@ "name": "Relays", "description": "Sets the relays status.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" @@ -147,19 +178,23 @@ }, "led": { "name": "LED", - "description": "Sets the led state.", + "description": "Sets the LED state.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" }, "led": { "name": "[%key:component::lcn::services::led::name%]", - "description": "Led." + "description": "The LED port of the device." }, "state": { "name": "State", - "description": "Led state." + "description": "The LED state to set." } } }, @@ -167,6 +202,10 @@ "name": "Set absolute variable", "description": "Sets absolute value of a variable or setpoint.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" @@ -189,6 +228,10 @@ "name": "Reset variable", "description": "Resets value of variable or setpoint.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" @@ -203,6 +246,10 @@ "name": "Shift variable", "description": "Shift value of a variable, setpoint or threshold.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" @@ -229,6 +276,10 @@ "name": "Lock regulator", "description": "Locks a regulator setpoint.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" @@ -247,6 +298,10 @@ "name": "Send keys", "description": "Sends keys (which executes bound commands).", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" @@ -273,6 +328,10 @@ "name": "Lock keys", "description": "Locks keys.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" @@ -299,6 +358,10 @@ "name": "Dynamic text", "description": "Sends dynamic text to LCN-GTxD displays.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" @@ -317,6 +380,10 @@ "name": "PCK", "description": "Sends arbitrary PCK command.", "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "[%key:component::lcn::services::output_abs::fields::device_id::description%]" + }, "address": { "name": "Address", "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" @@ -326,6 +393,39 @@ "description": "PCK command (without address header)." } } + }, + "address_to_device_id": { + "name": "Address to device ID", + "description": "Convert LCN address to device ID.", + "fields": { + "id": { + "name": "Module or group ID", + "description": "Target module or group ID." + }, + "segment_id": { + "name": "Segment ID", + "description": "Target segment ID." + }, + "type": { + "name": "Type", + "description": "Target type." + }, + "host": { + "name": "Host name", + "description": "Host name as given in the integration panel." + } + } + } + }, + "exceptions": { + "no_device_identifier": { + "message": "No device identifier provided. Please provide the device ID." + }, + "invalid_address": { + "message": "LCN device for given address has not been configured." + }, + "invalid_device_id": { + "message": "LCN device for given device ID has not been configured." } } } diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index d3268dfbf91..46df71d4235 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -423,25 +423,22 @@ async def async_create_or_update_device_in_config_entry( device_connection.is_group, ) - device_configs = [*config_entry.data[CONF_DEVICES]] - data = {**config_entry.data, CONF_DEVICES: device_configs} - for device_config in data[CONF_DEVICES]: - if tuple(device_config[CONF_ADDRESS]) == address: - break # device already in config_entry - else: - # create new device_entry - device_config = { - CONF_ADDRESS: address, - CONF_NAME: "", - CONF_HARDWARE_SERIAL: -1, - CONF_SOFTWARE_SERIAL: -1, - CONF_HARDWARE_TYPE: -1, - } - data[CONF_DEVICES].append(device_config) + device_config = { + CONF_ADDRESS: address, + CONF_NAME: "", + CONF_HARDWARE_SERIAL: -1, + CONF_SOFTWARE_SERIAL: -1, + CONF_HARDWARE_TYPE: -1, + } + + device_configs = [ + device + for device in config_entry.data[CONF_DEVICES] + if tuple(device[CONF_ADDRESS]) != address + ] + data = {**config_entry.data, CONF_DEVICES: [*device_configs, device_config]} - # update device_entry await async_update_device_config(device_connection, device_config) - hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index d3e21eeae90..2e64a590eaf 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.20.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.22.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/config_flow.py b/homeassistant/components/led_ble/config_flow.py index 90d86d44160..517fb3759de 100644 --- a/homeassistant/components/led_ble/config_flow.py +++ b/homeassistant/components/led_ble/config_flow.py @@ -6,7 +6,7 @@ import logging from typing import Any from bluetooth_data_tools import human_readable_name -from led_ble import BLEAK_EXCEPTIONS, LEDBLE +from led_ble import BLEAK_EXCEPTIONS, LEDBLE, CharacteristicMissingError import voluptuous as vol from homeassistant.components.bluetooth import ( @@ -66,6 +66,8 @@ class LedBleConfigFlow(ConfigFlow, domain=DOMAIN): led_ble = LEDBLE(discovery_info.device) try: await led_ble.update() + except CharacteristicMissingError: + return self.async_abort(reason="not_supported") except BLEAK_EXCEPTIONS: errors["base"] = "cannot_connect" except Exception: diff --git a/homeassistant/components/led_ble/const.py b/homeassistant/components/led_ble/const.py index 64c28d1ada5..bf4dadd441c 100644 --- a/homeassistant/components/led_ble/const.py +++ b/homeassistant/components/led_ble/const.py @@ -5,7 +5,7 @@ from typing import Final DOMAIN = "led_ble" DEVICE_TIMEOUT = 30 -LOCAL_NAMES = {"LEDnet", "BLE-LED", "LEDBLE", "Triones", "LEDBlue"} +LOCAL_NAMES = {"LEDnet", "BLE-LED", "LEDBLE", "Triones", "LEDBlue", "LD-0003"} UNSUPPORTED_SUB_MODEL = "LEDnetWF" diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 1d12e355a0d..7b07653e2db 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -27,7 +27,7 @@ "local_name": "AP-*" }, { - "local_name": "MELK-*" + "local_name": "LD-0003" } ], "codeowners": ["@bdraco"], @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.20.0", "led-ble==1.0.2"] + "requirements": ["bluetooth-data-tools==1.22.0", "led-ble==1.1.4"] } diff --git a/homeassistant/components/lektrico/config_flow.py b/homeassistant/components/lektrico/config_flow.py index 7091856f4fd..0641749a2b9 100644 --- a/homeassistant/components/lektrico/config_flow.py +++ b/homeassistant/components/lektrico/config_flow.py @@ -7,7 +7,6 @@ from typing import Any from lektricowifi import Device, DeviceConnectionError import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( ATTR_HW_VERSION, @@ -17,6 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -116,7 +116,7 @@ class LektricoFlowHandler(ConfigFlow, domain=DOMAIN): self._serial_number = str(settings["serial_number"]) self._device_type = settings["type"] self._board_revision = settings["board_revision"] - self._name = f"{settings["type"]}_{self._serial_number}" + self._name = f"{settings['type']}_{self._serial_number}" # Check if already configured # Set unique id diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py new file mode 100644 index 00000000000..905887463d7 --- /dev/null +++ b/homeassistant/components/letpot/__init__.py @@ -0,0 +1,94 @@ +"""The LetPot integration.""" + +from __future__ import annotations + +import asyncio + +from letpot.client import LetPotClient +from letpot.converters import CONVERTERS +from letpot.exceptions import LetPotAuthenticationException, LetPotException +from letpot.models import AuthenticationInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, +) +from .coordinator import LetPotDeviceCoordinator + +PLATFORMS: list[Platform] = [Platform.TIME] + +type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]] + + +async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: + """Set up LetPot from a config entry.""" + + auth = AuthenticationInfo( + access_token=entry.data[CONF_ACCESS_TOKEN], + access_token_expires=entry.data[CONF_ACCESS_TOKEN_EXPIRES], + refresh_token=entry.data[CONF_REFRESH_TOKEN], + refresh_token_expires=entry.data[CONF_REFRESH_TOKEN_EXPIRES], + user_id=entry.data[CONF_USER_ID], + email=entry.data[CONF_EMAIL], + ) + websession = async_get_clientsession(hass) + client = LetPotClient(websession, auth) + + if not auth.is_valid: + try: + auth = await client.refresh_token() + hass.config_entries.async_update_entry( + entry, + data={ + CONF_ACCESS_TOKEN: auth.access_token, + CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires, + CONF_REFRESH_TOKEN: auth.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires, + CONF_USER_ID: auth.user_id, + CONF_EMAIL: auth.email, + }, + ) + except LetPotAuthenticationException as exc: + raise ConfigEntryAuthFailed from exc + + try: + devices = await client.get_devices() + except LetPotAuthenticationException as exc: + raise ConfigEntryAuthFailed from exc + except LetPotException as exc: + raise ConfigEntryNotReady from exc + + coordinators: list[LetPotDeviceCoordinator] = [ + LetPotDeviceCoordinator(hass, auth, device) + for device in devices + if any(converter.supports_type(device.device_type) for converter in CONVERTERS) + ] + + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators + ] + ) + + entry.runtime_data = coordinators + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + for coordinator in entry.runtime_data: + coordinator.device_client.disconnect() + return unload_ok diff --git a/homeassistant/components/letpot/config_flow.py b/homeassistant/components/letpot/config_flow.py new file mode 100644 index 00000000000..bc710cd6aef --- /dev/null +++ b/homeassistant/components/letpot/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for the LetPot integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from letpot.client import LetPotClient +from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ), + ), + } +) +STEP_REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD), + ), + } +) + + +class LetPotConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LetPot.""" + + VERSION = 1 + + async def _async_validate_credentials( + self, email: str, password: str + ) -> tuple[dict[str, str], dict[str, Any] | None]: + """Try logging in to the LetPot account and returns credential info.""" + websession = async_get_clientsession(self.hass) + client = LetPotClient(websession) + try: + auth = await client.login(email, password) + except LetPotConnectionException: + return {"base": "cannot_connect"}, None + except LetPotAuthenticationException: + return {"base": "invalid_auth"}, None + except Exception: + _LOGGER.exception("Unexpected exception") + return {"base": "unknown"}, None + else: + return {}, { + CONF_ACCESS_TOKEN: auth.access_token, + CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires, + CONF_REFRESH_TOKEN: auth.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires, + CONF_USER_ID: auth.user_id, + CONF_EMAIL: auth.email, + } + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + errors, data_dict = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if not errors and data_dict is not None: + await self.async_set_unique_id(data_dict[CONF_USER_ID]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=data_dict[CONF_EMAIL], data=data_dict + ) + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + if user_input is not None: + errors, data_dict = await self._async_validate_credentials( + reauth_entry.data[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if not errors and data_dict is not None: + await self.async_set_unique_id(data_dict[CONF_USER_ID]) + if reauth_entry.unique_id != data_dict[CONF_USER_ID]: + # Abort if the received account is different and already added + self._abort_if_unique_id_configured() + return self.async_update_reload_and_abort( + reauth_entry, + unique_id=self.unique_id, + data_updates=data_dict, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_SCHEMA, + description_placeholders={"email": reauth_entry.title}, + errors=errors, + ) diff --git a/homeassistant/components/letpot/const.py b/homeassistant/components/letpot/const.py new file mode 100644 index 00000000000..af01bbfdffc --- /dev/null +++ b/homeassistant/components/letpot/const.py @@ -0,0 +1,10 @@ +"""Constants for the LetPot integration.""" + +DOMAIN = "letpot" + +CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires" +CONF_REFRESH_TOKEN = "refresh_token" +CONF_REFRESH_TOKEN_EXPIRES = "refresh_token_expires" +CONF_USER_ID = "user_id" + +REQUEST_UPDATE_TIMEOUT = 10 diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py new file mode 100644 index 00000000000..a2a35d566c6 --- /dev/null +++ b/homeassistant/components/letpot/coordinator.py @@ -0,0 +1,67 @@ +"""Coordinator for the LetPot integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from letpot.deviceclient import LetPotDeviceClient +from letpot.exceptions import LetPotAuthenticationException, LetPotException +from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import REQUEST_UPDATE_TIMEOUT + +if TYPE_CHECKING: + from . import LetPotConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): + """Class to handle data updates for a specific garden.""" + + config_entry: LetPotConfigEntry + + device: LetPotDevice + device_client: LetPotDeviceClient + + def __init__( + self, hass: HomeAssistant, info: AuthenticationInfo, device: LetPotDevice + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"LetPot {device.serial_number}", + ) + self._info = info + self.device = device + self.device_client = LetPotDeviceClient(info, device.serial_number) + + def _handle_status_update(self, status: LetPotDeviceStatus) -> None: + """Distribute status update to entities.""" + self.async_set_updated_data(data=status) + + async def _async_setup(self) -> None: + """Set up subscription for coordinator.""" + try: + await self.device_client.subscribe(self._handle_status_update) + except LetPotAuthenticationException as exc: + raise ConfigEntryAuthFailed from exc + + async def _async_update_data(self) -> LetPotDeviceStatus: + """Request an update from the device and wait for a status update or timeout.""" + try: + async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT): + await self.device_client.get_current_status() + except LetPotException as exc: + raise UpdateFailed(exc) from exc + + # The subscription task will have updated coordinator.data, so return that data. + # If we don't return anything here, coordinator.data will be set to None. + return self.data diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py new file mode 100644 index 00000000000..c9a8953b5d5 --- /dev/null +++ b/homeassistant/components/letpot/entity.py @@ -0,0 +1,25 @@ +"""Base class for LetPot entities.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LetPotDeviceCoordinator + + +class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): + """Defines a base LetPot entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: LetPotDeviceCoordinator) -> None: + """Initialize a LetPot entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device.serial_number)}, + name=coordinator.device.name, + manufacturer="LetPot", + model=coordinator.device_client.device_model_name, + model_id=coordinator.device_client.device_model_code, + serial_number=coordinator.device.serial_number, + ) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json new file mode 100644 index 00000000000..691584abc13 --- /dev/null +++ b/homeassistant/components/letpot/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "letpot", + "name": "LetPot", + "codeowners": ["@jpelgrom"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/letpot", + "integration_type": "hub", + "iot_class": "cloud_push", + "quality_scale": "bronze", + "requirements": ["letpot==0.3.0"] +} diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml new file mode 100644 index 00000000000..74b948ffbf7 --- /dev/null +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration only receives push-based updates. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: + status: done + comment: | + Push connection connects in coordinator _async_setup, disconnects in init async_unload_entry. + docs-configuration-parameters: + status: exempt + comment: | + The integration does not have configuration options. + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json new file mode 100644 index 00000000000..93913c2bc4d --- /dev/null +++ b/homeassistant/components/letpot/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address of your LetPot account.", + "password": "The password of your LetPot account." + } + }, + "reauth_confirm": { + "description": "The LetPot integration needs to re-authenticate your account {email}.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::letpot::config::step::user::data_description::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "entity": { + "time": { + "light_schedule_end": { + "name": "Light off" + }, + "light_schedule_start": { + "name": "Light on" + } + } + } +} diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py new file mode 100644 index 00000000000..229f02e0806 --- /dev/null +++ b/homeassistant/components/letpot/time.py @@ -0,0 +1,93 @@ +"""Support for LetPot time entities.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import time +from typing import Any + +from letpot.deviceclient import LetPotDeviceClient +from letpot.models import LetPotDeviceStatus + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LetPotConfigEntry +from .coordinator import LetPotDeviceCoordinator +from .entity import LetPotEntity + +# Each change pushes a 'full' device status with the change. The library will cache +# pending changes to avoid overwriting, but try to avoid a lot of parallelism. +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class LetPotTimeEntityDescription(TimeEntityDescription): + """Describes a LetPot time entity.""" + + value_fn: Callable[[LetPotDeviceStatus], time | None] + set_value_fn: Callable[[LetPotDeviceClient, time], Coroutine[Any, Any, None]] + + +TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( + LetPotTimeEntityDescription( + key="light_schedule_end", + translation_key="light_schedule_end", + value_fn=lambda status: None if status is None else status.light_schedule_end, + set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( + start=None, end=value + ), + entity_category=EntityCategory.CONFIG, + ), + LetPotTimeEntityDescription( + key="light_schedule_start", + translation_key="light_schedule_start", + value_fn=lambda status: None if status is None else status.light_schedule_start, + set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( + start=value, end=None + ), + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up LetPot time entities based on a config entry.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotTimeEntity(coordinator, description) + for description in TIME_SENSORS + for coordinator in coordinators + ) + + +class LetPotTimeEntity(LetPotEntity, TimeEntity): + """Defines a LetPot time entity.""" + + entity_description: LetPotTimeEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotTimeEntityDescription, + ) -> None: + """Initialize LetPot time entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def native_value(self) -> time | None: + """Return the time.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_value(self, value: time) -> None: + """Set the time.""" + await self.entity_description.set_value_fn( + self.coordinator.device_client, value + ) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index daab1353098..6dd60909c66 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.1"] + "requirements": ["thinqconnect==1.0.2"] } diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py index 8759869aad3..025f80f78b1 100644 --- a/homeassistant/components/lg_thinq/mqtt.py +++ b/homeassistant/components/lg_thinq/mqtt.py @@ -168,7 +168,7 @@ class ThinQMQTT: async def async_handle_device_event(self, message: dict) -> None: """Handle received mqtt message.""" unique_id = ( - f"{message["deviceId"]}_{list(message["report"].keys())[0]}" + f"{message['deviceId']}_{list(message['report'].keys())[0]}" if message["deviceType"] == DeviceType.WASHTOWER else message["deviceId"] ) diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 99b4df8176e..7baaab52403 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime, time, timedelta import logging from thinqconnect import DeviceType @@ -22,6 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator @@ -93,6 +95,11 @@ FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { native_unit_of_measurement=UnitOfTime.HOURS, translation_key=ThinQProperty.FILTER_LIFETIME, ), + ThinQProperty.FILTER_REMAIN_PERCENT: SensorEntityDescription( + key=ThinQProperty.FILTER_REMAIN_PERCENT, + native_unit_of_measurement=PERCENTAGE, + translation_key=ThinQProperty.FILTER_LIFETIME, + ), } HUMIDITY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.CURRENT_HUMIDITY: SensorEntityDescription( @@ -255,9 +262,90 @@ WATER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { translation_key=ThinQProperty.WATER_TYPE, ), } +ELAPSED_DAY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { + ThinQProperty.ELAPSED_DAY_STATE: SensorEntityDescription( + key=ThinQProperty.ELAPSED_DAY_STATE, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + translation_key=ThinQProperty.ELAPSED_DAY_STATE, + ), + ThinQProperty.ELAPSED_DAY_TOTAL: SensorEntityDescription( + key=ThinQProperty.ELAPSED_DAY_TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + translation_key=ThinQProperty.ELAPSED_DAY_TOTAL, + ), +} +TIME_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { + TimerProperty.LIGHT_START: SensorEntityDescription( + key=TimerProperty.LIGHT_START, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.LIGHT_START, + ), + TimerProperty.ABSOLUTE_TO_START: SensorEntityDescription( + key=TimerProperty.ABSOLUTE_TO_START, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.ABSOLUTE_TO_START, + ), + TimerProperty.ABSOLUTE_TO_STOP: SensorEntityDescription( + key=TimerProperty.ABSOLUTE_TO_STOP, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.ABSOLUTE_TO_STOP, + ), +} +TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { + TimerProperty.TOTAL: SensorEntityDescription( + key=TimerProperty.TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.TOTAL, + ), + TimerProperty.RELATIVE_TO_START: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_START, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.RELATIVE_TO_START, + ), + TimerProperty.RELATIVE_TO_STOP: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_STOP, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.RELATIVE_TO_STOP, + ), + TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP: SensorEntityDescription( + key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, + ), + TimerProperty.RELATIVE_TO_START_WM: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_START, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.RELATIVE_TO_START_WM, + ), + TimerProperty.RELATIVE_TO_STOP_WM: SensorEntityDescription( + key=TimerProperty.RELATIVE_TO_STOP, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.RELATIVE_TO_STOP_WM, + ), + TimerProperty.REMAIN: SensorEntityDescription( + key=TimerProperty.REMAIN, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.REMAIN, + ), + TimerProperty.RUNNING: SensorEntityDescription( + key=TimerProperty.RUNNING, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key=TimerProperty.RUNNING, + ), +} WASHER_SENSORS: tuple[SensorEntityDescription, ...] = ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], + TIMER_SENSOR_DESC[TimerProperty.TOTAL], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP_WM], + TIMER_SENSOR_DESC[TimerProperty.REMAIN], ) DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( @@ -268,6 +356,12 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_LIFETIME], + FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.AIR_PURIFIER_FAN: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -278,6 +372,9 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.AIR_PURIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -287,8 +384,11 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], + FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT], JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], JOB_MODE_SENSOR_DESC[ThinQProperty.PERSONALIZATION_MODE], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.COOKTOP: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], @@ -303,6 +403,9 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = PREFERENCE_SENSOR_DESC[ThinQProperty.RINSE_LEVEL], PREFERENCE_SENSOR_DESC[ThinQProperty.SOFTENING_LEVEL], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], + TIMER_SENSOR_DESC[TimerProperty.TOTAL], + TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], + TIMER_SENSOR_DESC[TimerProperty.REMAIN], ), DeviceType.DRYER: WASHER_SENSORS, DeviceType.HOME_BREW: ( @@ -313,6 +416,8 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO], RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], + ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE], + ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_TOTAL], ), DeviceType.HUMIDIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -322,6 +427,9 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.TEMPERATURE], AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], ), DeviceType.KIMCHI_REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -344,6 +452,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = TEMPERATURE_SENSOR_DESC[ThinQProperty.DAY_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.NIGHT_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.TEMPERATURE_STATE], + TIME_SENSOR_DESC[TimerProperty.LIGHT_START], ), DeviceType.REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -352,6 +461,8 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = DeviceType.ROBOT_CLEANER: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], + TIMER_SENSOR_DESC[TimerProperty.RUNNING], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], ), DeviceType.STICK_CLEANER: ( BATTERY_SENSOR_DESC[ThinQProperty.BATTERY_PERCENT], @@ -426,11 +537,59 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity): if entity_description.device_class == SensorDeviceClass.ENUM: self._attr_options = self.data.options + self._device_state: str | None = None + self._device_state_id = ( + ThinQProperty.CURRENT_STATE + if self.location is None + else f"{self.location}_{ThinQProperty.CURRENT_STATE}" + ) + def _update_status(self) -> None: """Update status itself.""" super()._update_status() - self._attr_native_value = self.data.value + value = self.data.value + + if isinstance(value, time): + local_now = datetime.now( + tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone) + ) + if value in [0, None, time.min]: + # Reset to None + value = None + elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: + if self.entity_description.key in TIME_SENSOR_DESC: + # Set timestamp for time + value = local_now.replace(hour=value.hour, minute=value.minute) + else: + # Set timestamp for delta + new_state = ( + self.coordinator.data[self._device_state_id].value + if self._device_state_id in self.coordinator.data + else None + ) + if ( + self.native_value is not None + and self._device_state == new_state + ): + # Skip update when same state + return + + self._device_state = new_state + time_delta = timedelta( + hours=value.hour, minutes=value.minute, seconds=value.second + ) + value = ( + (local_now - time_delta) + if self.entity_description.key == TimerProperty.RUNNING + else (local_now + time_delta) + ) + elif self.entity_description.device_class == SensorDeviceClass.DURATION: + # Set duration + value = self._get_duration( + value, self.entity_description.native_unit_of_measurement + ) + self._attr_native_value = value if (data_unit := self._get_unit_of_measurement(self.data.unit)) is not None: # For different from description's unit @@ -445,3 +604,10 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity): self.options, self.native_unit_of_measurement, ) + + def _get_duration(self, data: time, unit: str | None) -> float | None: + if unit == UnitOfTime.MINUTES: + return (data.hour * 60) + data.minute + if unit == UnitOfTime.SECONDS: + return (data.hour * 3600) + (data.minute * 60) + data.second + return 0 diff --git a/homeassistant/components/lg_thinq/vacuum.py b/homeassistant/components/lg_thinq/vacuum.py index 138b9ba55bf..6cbb731869c 100644 --- a/homeassistant/components/lg_thinq/vacuum.py +++ b/homeassistant/components/lg_thinq/vacuum.py @@ -9,15 +9,11 @@ from thinqconnect import DeviceType from thinqconnect.integration import ExtendedProperty from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_RETURNING, StateVacuumEntity, StateVacuumEntityDescription, + VacuumActivity, VacuumEntityFeature, ) -from homeassistant.const import STATE_IDLE, STATE_PAUSED from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -46,21 +42,21 @@ class State(StrEnum): ROBOT_STATUS_TO_HA = { - "charging": STATE_DOCKED, - "diagnosis": STATE_IDLE, - "homing": STATE_RETURNING, - "initializing": STATE_IDLE, - "macrosector": STATE_IDLE, - "monitoring_detecting": STATE_IDLE, - "monitoring_moving": STATE_IDLE, - "monitoring_positioning": STATE_IDLE, - "pause": STATE_PAUSED, - "reservation": STATE_IDLE, - "setdate": STATE_IDLE, - "sleep": STATE_IDLE, - "standby": STATE_IDLE, - "working": STATE_CLEANING, - "error": STATE_ERROR, + "charging": VacuumActivity.DOCKED, + "diagnosis": VacuumActivity.IDLE, + "homing": VacuumActivity.RETURNING, + "initializing": VacuumActivity.IDLE, + "macrosector": VacuumActivity.IDLE, + "monitoring_detecting": VacuumActivity.IDLE, + "monitoring_moving": VacuumActivity.IDLE, + "monitoring_positioning": VacuumActivity.IDLE, + "pause": VacuumActivity.PAUSED, + "reservation": VacuumActivity.IDLE, + "setdate": VacuumActivity.IDLE, + "sleep": VacuumActivity.IDLE, + "standby": VacuumActivity.IDLE, + "working": VacuumActivity.CLEANING, + "error": VacuumActivity.ERROR, } ROBOT_BATT_TO_HA = { "moveless": 5, @@ -114,7 +110,7 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity): super()._update_status() # Update state. - self._attr_state = ROBOT_STATUS_TO_HA[self.data.current_state] + self._attr_activity = ROBOT_STATUS_TO_HA[self.data.current_state] # Update battery. if (level := self.data.battery) is not None: @@ -135,7 +131,7 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity): """Start the device.""" if self.data.current_state == State.SLEEP: value = State.WAKE_UP - elif self._attr_state == STATE_PAUSED: + elif self._attr_activity == VacuumActivity.PAUSED: value = State.RESUME else: value = State.START diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index b02361e65ca..805fcce53ad 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -160,10 +160,8 @@ class LidarrSensor(LidarrEntity[T], SensorEntity): def queue_str(item: LidarrQueueItem) -> str: """Return string description of queue item.""" - if ( - item.sizeleft > 0 - and item.timeleft == "00:00:00" - or not hasattr(item, "trackedDownloadState") + if (item.sizeleft > 0 and item.timeleft == "00:00:00") or not hasattr( + item, "trackedDownloadState" ): return "stopped" return item.trackedDownloadState diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py index 053bb72c4fd..ee55a7589e2 100644 --- a/homeassistant/components/lifx/config_flow.py +++ b/homeassistant/components/lifx/config_flow.py @@ -9,12 +9,12 @@ from aiolifx.aiolifx import Light from aiolifx.connection import LIFXConnection import voluptuous as vol -from homeassistant.components import zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( @@ -72,7 +72,7 @@ class LifXConfigFlow(ConfigFlow, domain=DOMAIN): return await self._async_handle_discovery(host) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" return await self._async_handle_discovery(host=discovery_info.host) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 9b213cc9f6d..667afe1125d 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -64,3 +64,6 @@ DATA_LIFX_MANAGER = "lifx_manager" LIFX_CEILING_PRODUCT_IDS = {176, 177} _LOGGER = logging.getLogger(__package__) + +# _ATTR_COLOR_TEMP deprecated - to be removed in 2026.1 +_ATTR_COLOR_TEMP = "color_temp" diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 41fa04057f7..eaaff7e6540 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -21,7 +21,7 @@ from aiolifx.aiolifx import ( from aiolifx.connection import LIFXConnection from aiolifx_themes.themes import ThemeLibrary, ThemePainter from awesomeversion import AwesomeVersion -from propcache import cached_property +from propcache.api import cached_property from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, @@ -83,7 +83,7 @@ class SkyType(IntEnum): CLOUDS = 2 -class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): # noqa: PLR0904 +class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific lifx device.""" def __init__( @@ -456,7 +456,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): # noqa: PLR0904 ) self.active_effect = FirmwareEffect[effect.upper()] - async def async_set_matrix_effect( # noqa: PLR0917 + async def async_set_matrix_effect( self, effect: str, palette: list[tuple[int, int, int, int]] | None = None, diff --git a/homeassistant/components/lifx/icons.json b/homeassistant/components/lifx/icons.json index 58a7c89e266..c37d7641717 100644 --- a/homeassistant/components/lifx/icons.json +++ b/homeassistant/components/lifx/icons.json @@ -26,6 +26,9 @@ }, "effect_stop": { "service": "mdi:stop" + }, + "paint_theme": { + "service": "mdi:palette" } } } diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 759d08707cd..16c39c25219 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -8,6 +8,7 @@ from datetime import timedelta from typing import Any import aiolifx_effects +from aiolifx_themes.painter import ThemePainter from aiolifx_themes.themes import Theme, ThemeLibrary import voluptuous as vol @@ -15,7 +16,6 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_RGB_COLOR, @@ -30,7 +30,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids -from .const import ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN +from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN from .coordinator import LIFXUpdateCoordinator, Light from .util import convert_8_to_16, find_hsbk @@ -43,6 +43,7 @@ SERVICE_EFFECT_MOVE = "effect_move" SERVICE_EFFECT_PULSE = "effect_pulse" SERVICE_EFFECT_SKY = "effect_sky" SERVICE_EFFECT_STOP = "effect_stop" +SERVICE_PAINT_THEME = "paint_theme" ATTR_CHANGE = "change" ATTR_CLOUD_SATURATION_MIN = "cloud_saturation_min" @@ -84,6 +85,8 @@ EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX = 180 EFFECT_SKY_SKY_TYPES = ["Sunrise", "Sunset", "Clouds"] +PAINT_THEME_DEFAULT_TRANSITION = 1 + PULSE_MODE_BLINK = "blink" PULSE_MODE_BREATHE = "breathe" PULSE_MODE_PING = "ping" @@ -126,7 +129,8 @@ LIFX_EFFECT_PULSE_SCHEMA = cv.make_entity_service_schema( vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): vol.All( vol.Coerce(int), vol.Range(min=1500, max=9000) ), - vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): cv.positive_int, + # _ATTR_COLOR_TEMP deprecated - to be removed in 2026.1 + vol.Exclusive(_ATTR_COLOR_TEMP, COLOR_GROUP): cv.positive_int, ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Range(min=0.05)), ATTR_CYCLES: vol.All(vol.Coerce(float), vol.Range(min=1)), ATTR_MODE: vol.In(PULSE_MODES), @@ -201,17 +205,30 @@ LIFX_EFFECT_SKY_SCHEMA = cv.make_entity_service_schema( } ) - -SERVICES = ( - SERVICE_EFFECT_COLORLOOP, - SERVICE_EFFECT_FLAME, - SERVICE_EFFECT_MORPH, - SERVICE_EFFECT_MOVE, - SERVICE_EFFECT_PULSE, - SERVICE_EFFECT_SKY, - SERVICE_EFFECT_STOP, +LIFX_PAINT_THEME_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_TRANSITION: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=3600)), + vol.Exclusive(ATTR_THEME, COLOR_GROUP): vol.Optional( + vol.In(ThemeLibrary().themes) + ), + vol.Exclusive(ATTR_PALETTE, COLOR_GROUP): vol.All( + cv.ensure_list, [HSBK_SCHEMA] + ), + } ) +SERVICES_SCHEMA = { + SERVICE_EFFECT_COLORLOOP: LIFX_EFFECT_COLORLOOP_SCHEMA, + SERVICE_EFFECT_FLAME: LIFX_EFFECT_FLAME_SCHEMA, + SERVICE_EFFECT_MORPH: LIFX_EFFECT_MORPH_SCHEMA, + SERVICE_EFFECT_MOVE: LIFX_EFFECT_MOVE_SCHEMA, + SERVICE_EFFECT_PULSE: LIFX_EFFECT_PULSE_SCHEMA, + SERVICE_EFFECT_SKY: LIFX_EFFECT_SKY_SCHEMA, + SERVICE_EFFECT_STOP: LIFX_EFFECT_STOP_SCHEMA, + SERVICE_PAINT_THEME: LIFX_PAINT_THEME_SCHEMA, +} + class LIFXManager: """Representation of all known LIFX entities.""" @@ -225,7 +242,7 @@ class LIFXManager: @callback def async_unload(self) -> None: """Release resources.""" - for service in SERVICES: + for service in SERVICES_SCHEMA: self.hass.services.async_remove(DOMAIN, service) @callback @@ -253,54 +270,218 @@ class LIFXManager: if all_referenced: await self.start_effect(all_referenced, service.service, **service.data) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_PULSE, - service_handler, - schema=LIFX_EFFECT_PULSE_SCHEMA, + for service, schema in SERVICES_SCHEMA.items(): + self.hass.services.async_register( + DOMAIN, service, service_handler, schema=schema + ) + + @staticmethod + def build_theme(theme_name: str = "exciting", palette: list | None = None) -> Theme: + """Either return the predefined theme or build one from the palette.""" + if palette is None: + return ThemeLibrary().get_theme(theme_name) + + theme = Theme() + for hsbk in palette: + theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) + return theme + + async def _start_effect_flame( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Flame effect.""" + + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_FLAME, + speed=kwargs.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED), + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + for coordinator in coordinators + ) ) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_COLORLOOP, - service_handler, - schema=LIFX_EFFECT_COLORLOOP_SCHEMA, + async def _start_paint_theme( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Paint a theme across one or more LIFX bulbs.""" + theme_name = kwargs.get(ATTR_THEME, "exciting") + palette = kwargs.get(ATTR_PALETTE) + + theme = self.build_theme(theme_name, palette) + + await ThemePainter(self.hass.loop).paint( + theme, + bulbs, + duration=kwargs.get(ATTR_TRANSITION, PAINT_THEME_DEFAULT_TRANSITION), + power_on=kwargs.get(ATTR_POWER_ON, True), ) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_FLAME, - service_handler, - schema=LIFX_EFFECT_FLAME_SCHEMA, + async def _start_effect_morph( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Morph effect.""" + theme_name = kwargs.get(ATTR_THEME, "exciting") + palette = kwargs.get(ATTR_PALETTE) + + theme = self.build_theme(theme_name, palette) + + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_MORPH, + speed=kwargs.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED), + palette=theme.colors, + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + for coordinator in coordinators + ) ) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_MORPH, - service_handler, - schema=LIFX_EFFECT_MORPH_SCHEMA, + async def _start_effect_move( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Move effect.""" + await asyncio.gather( + *( + coordinator.async_set_multizone_effect( + effect=EFFECT_MOVE, + speed=kwargs.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED), + direction=kwargs.get(ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION), + theme_name=kwargs.get(ATTR_THEME), + power_on=kwargs.get(ATTR_POWER_ON, False), + ) + for coordinator in coordinators + ) ) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_MOVE, - service_handler, - schema=LIFX_EFFECT_MOVE_SCHEMA, + async def _start_effect_pulse( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the software-based Pulse effect.""" + effect = aiolifx_effects.EffectPulse( + power_on=bool(kwargs.get(ATTR_POWER_ON)), + period=kwargs.get(ATTR_PERIOD), + cycles=kwargs.get(ATTR_CYCLES), + mode=kwargs.get(ATTR_MODE), + hsbk=find_hsbk(self.hass, **kwargs), + ) + await self.effects_conductor.start(effect, bulbs) + + async def _start_effect_colorloop( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the software based Color Loop effect.""" + brightness = None + saturation_max = None + saturation_min = None + + if ATTR_BRIGHTNESS in kwargs: + brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) + elif ATTR_BRIGHTNESS_PCT in kwargs: + brightness = convert_8_to_16(round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100)) + + if ATTR_SATURATION_MAX in kwargs: + saturation_max = int(kwargs[ATTR_SATURATION_MAX] / 100 * 65535) + + if ATTR_SATURATION_MIN in kwargs: + saturation_min = int(kwargs[ATTR_SATURATION_MIN] / 100 * 65535) + + effect = aiolifx_effects.EffectColorloop( + power_on=bool(kwargs.get(ATTR_POWER_ON)), + period=kwargs.get(ATTR_PERIOD), + change=kwargs.get(ATTR_CHANGE), + spread=kwargs.get(ATTR_SPREAD), + transition=kwargs.get(ATTR_TRANSITION), + brightness=brightness, + saturation_max=saturation_max, + saturation_min=saturation_min, + ) + await self.effects_conductor.start(effect, bulbs) + + async def _start_effect_sky( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Sky effect.""" + palette = kwargs.get(ATTR_PALETTE) + if palette is not None: + theme = Theme() + for hsbk in palette: + theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) + + speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED) + sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE) + + cloud_saturation_min = kwargs.get( + ATTR_CLOUD_SATURATION_MIN, + EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN, + ) + cloud_saturation_max = kwargs.get( + ATTR_CLOUD_SATURATION_MAX, + EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX, ) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_SKY, - service_handler, - schema=LIFX_EFFECT_SKY_SCHEMA, + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_SKY, + speed=speed, + sky_type=sky_type, + cloud_saturation_min=cloud_saturation_min, + cloud_saturation_max=cloud_saturation_max, + palette=theme.colors, + ) + for coordinator in coordinators + ) ) - self.hass.services.async_register( - DOMAIN, - SERVICE_EFFECT_STOP, - service_handler, - schema=LIFX_EFFECT_STOP_SCHEMA, - ) + async def _start_effect_stop( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Stop any running software or firmware effect.""" + await self.effects_conductor.stop(bulbs) + + for coordinator in coordinators: + await coordinator.async_set_matrix_effect(effect=EFFECT_OFF, power_on=False) + await coordinator.async_set_multizone_effect( + effect=EFFECT_OFF, power_on=False + ) + + _effect_dispatch = { + SERVICE_EFFECT_COLORLOOP: _start_effect_colorloop, + SERVICE_EFFECT_FLAME: _start_effect_flame, + SERVICE_EFFECT_MORPH: _start_effect_morph, + SERVICE_EFFECT_MOVE: _start_effect_move, + SERVICE_EFFECT_PULSE: _start_effect_pulse, + SERVICE_EFFECT_SKY: _start_effect_sky, + SERVICE_EFFECT_STOP: _start_effect_stop, + SERVICE_PAINT_THEME: _start_paint_theme, + } async def start_effect( self, entity_ids: set[str], service: str, **kwargs: Any @@ -318,137 +499,5 @@ class LIFXManager: coordinators.append(coordinator) bulbs.append(coordinator.device) - if service == SERVICE_EFFECT_FLAME: - await asyncio.gather( - *( - coordinator.async_set_matrix_effect( - effect=EFFECT_FLAME, - speed=kwargs.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED), - power_on=kwargs.get(ATTR_POWER_ON, True), - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_MORPH: - theme_name = kwargs.get(ATTR_THEME, "exciting") - palette = kwargs.get(ATTR_PALETTE) - - if palette is not None: - theme = Theme() - for hsbk in palette: - theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) - else: - theme = ThemeLibrary().get_theme(theme_name) - - await asyncio.gather( - *( - coordinator.async_set_matrix_effect( - effect=EFFECT_MORPH, - speed=kwargs.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED), - palette=theme.colors, - power_on=kwargs.get(ATTR_POWER_ON, True), - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_MOVE: - await asyncio.gather( - *( - coordinator.async_set_multizone_effect( - effect=EFFECT_MOVE, - speed=kwargs.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED), - direction=kwargs.get( - ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION - ), - theme_name=kwargs.get(ATTR_THEME), - power_on=kwargs.get(ATTR_POWER_ON, False), - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_PULSE: - effect = aiolifx_effects.EffectPulse( - power_on=kwargs.get(ATTR_POWER_ON), - period=kwargs.get(ATTR_PERIOD), - cycles=kwargs.get(ATTR_CYCLES), - mode=kwargs.get(ATTR_MODE), - hsbk=find_hsbk(self.hass, **kwargs), - ) - await self.effects_conductor.start(effect, bulbs) - - elif service == SERVICE_EFFECT_COLORLOOP: - brightness = None - saturation_max = None - saturation_min = None - - if ATTR_BRIGHTNESS in kwargs: - brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) - elif ATTR_BRIGHTNESS_PCT in kwargs: - brightness = convert_8_to_16( - round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100) - ) - - if ATTR_SATURATION_MAX in kwargs: - saturation_max = int(kwargs[ATTR_SATURATION_MAX] / 100 * 65535) - - if ATTR_SATURATION_MIN in kwargs: - saturation_min = int(kwargs[ATTR_SATURATION_MIN] / 100 * 65535) - - effect = aiolifx_effects.EffectColorloop( - power_on=kwargs.get(ATTR_POWER_ON), - period=kwargs.get(ATTR_PERIOD), - change=kwargs.get(ATTR_CHANGE), - spread=kwargs.get(ATTR_SPREAD), - transition=kwargs.get(ATTR_TRANSITION), - brightness=brightness, - saturation_max=saturation_max, - saturation_min=saturation_min, - ) - await self.effects_conductor.start(effect, bulbs) - - elif service == SERVICE_EFFECT_SKY: - palette = kwargs.get(ATTR_PALETTE) - if palette is not None: - theme = Theme() - for hsbk in palette: - theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) - - speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED) - sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE) - - cloud_saturation_min = kwargs.get( - ATTR_CLOUD_SATURATION_MIN, - EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN, - ) - cloud_saturation_max = kwargs.get( - ATTR_CLOUD_SATURATION_MAX, - EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX, - ) - - await asyncio.gather( - *( - coordinator.async_set_matrix_effect( - effect=EFFECT_SKY, - speed=speed, - sky_type=sky_type, - cloud_saturation_min=cloud_saturation_min, - cloud_saturation_max=cloud_saturation_max, - palette=theme.colors, - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_STOP: - await self.effects_conductor.stop(bulbs) - - for coordinator in coordinators: - await coordinator.async_set_matrix_effect( - effect=EFFECT_OFF, power_on=False - ) - await coordinator.async_set_multizone_effect( - effect=EFFECT_OFF, power_on=False - ) + if start_effect_func := self._effect_dispatch.get(service): + await start_effect_func(self, bulbs, coordinators, **kwargs) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index c7d8a27a1c7..8d460c25322 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -23,6 +23,7 @@ "LIFX Ceiling", "LIFX Clean", "LIFX Color", + "LIFX Colour", "LIFX DLCOL", "LIFX Dlight", "LIFX DLWW", @@ -35,12 +36,14 @@ "LIFX Neon", "LIFX Nightvision", "LIFX PAR38", + "LIFX Permanent Outdoor", "LIFX Pls", "LIFX Plus", "LIFX Round", "LIFX Square", "LIFX String", "LIFX Tile", + "LIFX Tube", "LIFX White", "LIFX Z" ] @@ -48,8 +51,8 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.1.1", + "aiolifx==1.1.2", "aiolifx-effects==0.3.2", - "aiolifx-themes==0.5.5" + "aiolifx-themes==0.6.4" ] } diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml index c2eb2e249cb..ac4fbfc15af 100644 --- a/homeassistant/components/lifx/services.yaml +++ b/homeassistant/components/lifx/services.yaml @@ -186,28 +186,46 @@ effect_move: options: - "autumn" - "blissful" + - "bias_lighting" + - "calaveras" - "cheerful" + - "christmas" - "dream" - "energizing" - "epic" + - "evening" - "exciting" + - "fantasy" - "focusing" + - "gentle" - "halloween" - "hanukkah" - "holly" - - "independence_day" + - "hygge" + - "independence" - "intense" + - "love" + - "kwanzaa" - "mellow" + - "party" - "peaceful" - "powerful" + - "proud" + - "pumpkin" - "relaxing" + - "romance" - "santa" - "serene" + - "shamrock" - "soothing" + - "spacey" - "sports" - "spring" + - "stardust" + - "thanksgiving" - "tranquil" - "warming" + - "zombie" power_on: default: true selector: @@ -255,28 +273,46 @@ effect_morph: options: - "autumn" - "blissful" + - "bias_lighting" + - "calaveras" - "cheerful" + - "christmas" - "dream" - "energizing" - "epic" + - "evening" - "exciting" + - "fantasy" - "focusing" + - "gentle" - "halloween" - "hanukkah" - "holly" - - "independence_day" + - "hygge" + - "independence" - "intense" + - "love" + - "kwanzaa" - "mellow" + - "party" - "peaceful" - "powerful" + - "proud" + - "pumpkin" - "relaxing" + - "romance" - "santa" - "serene" + - "shamrock" - "soothing" + - "spacey" - "sports" - "spring" + - "stardust" + - "thanksgiving" - "tranquil" - "warming" + - "zombie" power_on: default: true selector: @@ -338,3 +374,73 @@ effect_stop: entity: integration: lifx domain: light +paint_theme: + target: + entity: + integration: lifx + domain: light + fields: + palette: + example: + - "[[0, 100, 100, 3500], [60, 100, 100, 3500]]" + selector: + object: + theme: + example: exciting + default: exciting + selector: + select: + mode: dropdown + options: + - "autumn" + - "blissful" + - "bias_lighting" + - "calaveras" + - "cheerful" + - "christmas" + - "dream" + - "energizing" + - "epic" + - "evening" + - "exciting" + - "fantasy" + - "focusing" + - "gentle" + - "halloween" + - "hanukkah" + - "holly" + - "hygge" + - "independence" + - "intense" + - "love" + - "kwanzaa" + - "mellow" + - "party" + - "peaceful" + - "powerful" + - "proud" + - "pumpkin" + - "relaxing" + - "romance" + - "santa" + - "serene" + - "shamrock" + - "soothing" + - "spacey" + - "sports" + - "spring" + - "stardust" + - "thanksgiving" + - "tranquil" + - "warming" + - "zombie" + transition: + selector: + number: + min: 0 + max: 3600 + unit_of_measurement: seconds + power_on: + default: true + selector: + boolean: diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 19d86e57f09..39102d904d5 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -209,7 +209,7 @@ }, "palette": { "name": "Palette", - "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-900) values to use for this effect. Overrides the theme attribute." + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect. Overrides the theme attribute." }, "theme": { "name": "[%key:component::lifx::entity::select::theme::name%]", @@ -254,6 +254,28 @@ "effect_stop": { "name": "Stop effect", "description": "Stops a running effect." + }, + "paint_theme": { + "name": "Paint Theme", + "description": "Paint either a provided theme or custom palette across one or more LIFX lights.", + "fields": { + "palette": { + "name": "Palette", + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to paint across the target lights. Overrides the theme attribute." + }, + "theme": { + "name": "[%key:component::lifx::entity::select::theme::name%]", + "description": "Predefined color theme to paint. Overridden by the palette attribute." + }, + "transition": { + "name": "Transition", + "description": "Duration in seconds to paint the theme." + }, + "power_on": { + "name": "Power on", + "description": "Powered off lights will be turned on before painting the theme." + } + } } } } diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 9782fe4adba..ffffe7a4856 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -16,10 +16,8 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_XY_COLOR, ) @@ -29,6 +27,7 @@ from homeassistant.helpers import device_registry as dr import homeassistant.util.color as color_util from .const import ( + _ATTR_COLOR_TEMP, _LOGGER, DEFAULT_ATTEMPTS, DOMAIN, @@ -114,17 +113,14 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | saturation = int(saturation / 100 * 65535) kelvin = 3500 - if ATTR_KELVIN in kwargs: + if _ATTR_COLOR_TEMP in kwargs: + # added in 2025.1, can be removed in 2026.1 _LOGGER.warning( - "The 'kelvin' parameter is deprecated. Please use 'color_temp_kelvin' for" + "The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for" " all service calls" ) - kelvin = kwargs.pop(ATTR_KELVIN) - saturation = 0 - - if ATTR_COLOR_TEMP in kwargs: kelvin = color_util.color_temperature_mired_to_kelvin( - kwargs.pop(ATTR_COLOR_TEMP) + kwargs.pop(_ATTR_COLOR_TEMP) ) saturation = 0 diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 37ee6fe88fd..65a89b7d688 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -5,13 +5,12 @@ from __future__ import annotations from collections.abc import Iterable import csv import dataclasses -from datetime import timedelta -from enum import IntFlag, StrEnum +from functools import partial import logging import os -from typing import Any, Self, cast, final +from typing import Any, Final, Self, cast, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -24,97 +23,84 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import bind_hass import homeassistant.util.color as color_util -from homeassistant.util.hass_dict import HassKey -DOMAIN = "light" -DATA_COMPONENT: HassKey[EntityComponent[LightEntity]] = HassKey(DOMAIN) +from .const import ( # noqa: F401 + COLOR_MODES_BRIGHTNESS, + COLOR_MODES_COLOR, + DATA_COMPONENT, + DATA_PROFILES, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, + DOMAIN, + SCAN_INTERVAL, + VALID_COLOR_MODES, + ColorMode, + LightEntityFeature, +) + ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE -SCAN_INTERVAL = timedelta(seconds=30) - -DATA_PROFILES: HassKey[Profiles] = HassKey(f"{DOMAIN}_profiles") - - -class LightEntityFeature(IntFlag): - """Supported features of the light entity.""" - - EFFECT = 4 - FLASH = 8 - TRANSITION = 32 # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the LightEntityFeature enum instead. -SUPPORT_BRIGHTNESS = 1 # Deprecated, replaced by color modes -SUPPORT_COLOR_TEMP = 2 # Deprecated, replaced by color modes -SUPPORT_EFFECT = 4 -SUPPORT_FLASH = 8 -SUPPORT_COLOR = 16 # Deprecated, replaced by color modes -SUPPORT_TRANSITION = 32 +_DEPRECATED_SUPPORT_BRIGHTNESS: Final = DeprecatedConstant( + 1, "supported_color_modes", "2026.1" +) # Deprecated, replaced by color modes +_DEPRECATED_SUPPORT_COLOR_TEMP: Final = DeprecatedConstant( + 2, "supported_color_modes", "2026.1" +) # Deprecated, replaced by color modes +_DEPRECATED_SUPPORT_EFFECT: Final = DeprecatedConstantEnum( + LightEntityFeature.EFFECT, "2026.1" +) +_DEPRECATED_SUPPORT_FLASH: Final = DeprecatedConstantEnum( + LightEntityFeature.FLASH, "2026.1" +) +_DEPRECATED_SUPPORT_COLOR: Final = DeprecatedConstant( + 16, "supported_color_modes", "2026.1" +) # Deprecated, replaced by color modes +_DEPRECATED_SUPPORT_TRANSITION: Final = DeprecatedConstantEnum( + LightEntityFeature.TRANSITION, "2026.1" +) # Color mode of the light ATTR_COLOR_MODE = "color_mode" # List of color modes supported by the light ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes" - -class ColorMode(StrEnum): - """Possible light color modes.""" - - UNKNOWN = "unknown" - """Ambiguous color mode""" - ONOFF = "onoff" - """Must be the only supported mode""" - BRIGHTNESS = "brightness" - """Must be the only supported mode""" - COLOR_TEMP = "color_temp" - HS = "hs" - XY = "xy" - RGB = "rgb" - RGBW = "rgbw" - RGBWW = "rgbww" - WHITE = "white" - """Must *NOT* be the only supported mode""" - - # These COLOR_MODE_* constants are deprecated as of Home Assistant 2022.5. # Please use the LightEntityFeature enum instead. -COLOR_MODE_UNKNOWN = "unknown" -COLOR_MODE_ONOFF = "onoff" -COLOR_MODE_BRIGHTNESS = "brightness" -COLOR_MODE_COLOR_TEMP = "color_temp" -COLOR_MODE_HS = "hs" -COLOR_MODE_XY = "xy" -COLOR_MODE_RGB = "rgb" -COLOR_MODE_RGBW = "rgbw" -COLOR_MODE_RGBWW = "rgbww" -COLOR_MODE_WHITE = "white" +_DEPRECATED_COLOR_MODE_UNKNOWN: Final = DeprecatedConstantEnum( + ColorMode.UNKNOWN, "2026.1" +) +_DEPRECATED_COLOR_MODE_ONOFF: Final = DeprecatedConstantEnum(ColorMode.ONOFF, "2026.1") +_DEPRECATED_COLOR_MODE_BRIGHTNESS: Final = DeprecatedConstantEnum( + ColorMode.BRIGHTNESS, "2026.1" +) +_DEPRECATED_COLOR_MODE_COLOR_TEMP: Final = DeprecatedConstantEnum( + ColorMode.COLOR_TEMP, "2026.1" +) +_DEPRECATED_COLOR_MODE_HS: Final = DeprecatedConstantEnum(ColorMode.HS, "2026.1") +_DEPRECATED_COLOR_MODE_XY: Final = DeprecatedConstantEnum(ColorMode.XY, "2026.1") +_DEPRECATED_COLOR_MODE_RGB: Final = DeprecatedConstantEnum(ColorMode.RGB, "2026.1") +_DEPRECATED_COLOR_MODE_RGBW: Final = DeprecatedConstantEnum(ColorMode.RGBW, "2026.1") +_DEPRECATED_COLOR_MODE_RGBWW: Final = DeprecatedConstantEnum(ColorMode.RGBWW, "2026.1") +_DEPRECATED_COLOR_MODE_WHITE: Final = DeprecatedConstantEnum(ColorMode.WHITE, "2026.1") -VALID_COLOR_MODES = { - ColorMode.ONOFF, - ColorMode.BRIGHTNESS, - ColorMode.COLOR_TEMP, - ColorMode.HS, - ColorMode.XY, - ColorMode.RGB, - ColorMode.RGBW, - ColorMode.RGBWW, - ColorMode.WHITE, -} -COLOR_MODES_BRIGHTNESS = VALID_COLOR_MODES - {ColorMode.ONOFF} -COLOR_MODES_COLOR = { - ColorMode.HS, - ColorMode.RGB, - ColorMode.RGBW, - ColorMode.RGBWW, - ColorMode.XY, -} # mypy: disallow-any-generics @@ -200,16 +186,26 @@ ATTR_RGBW_COLOR = "rgbw_color" ATTR_RGBWW_COLOR = "rgbww_color" ATTR_XY_COLOR = "xy_color" ATTR_HS_COLOR = "hs_color" -ATTR_COLOR_TEMP = "color_temp" # Deprecated in HA Core 2022.11 -ATTR_KELVIN = "kelvin" # Deprecated in HA Core 2022.11 -ATTR_MIN_MIREDS = "min_mireds" # Deprecated in HA Core 2022.11 -ATTR_MAX_MIREDS = "max_mireds" # Deprecated in HA Core 2022.11 ATTR_COLOR_TEMP_KELVIN = "color_temp_kelvin" ATTR_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin" ATTR_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin" ATTR_COLOR_NAME = "color_name" ATTR_WHITE = "white" +# Deprecated in HA Core 2022.11 +_DEPRECATED_ATTR_COLOR_TEMP: Final = DeprecatedConstant( + "color_temp", "kelvin equivalent (ATTR_COLOR_TEMP_KELVIN)", "2026.1" +) +_DEPRECATED_ATTR_KELVIN: Final = DeprecatedConstant( + "kelvin", "ATTR_COLOR_TEMP_KELVIN", "2026.1" +) +_DEPRECATED_ATTR_MIN_MIREDS: Final = DeprecatedConstant( + "min_mireds", "kelvin equivalent (ATTR_MAX_COLOR_TEMP_KELVIN)", "2026.1" +) +_DEPRECATED_ATTR_MAX_MIREDS: Final = DeprecatedConstant( + "max_mireds", "kelvin equivalent (ATTR_MIN_COLOR_TEMP_KELVIN)", "2026.1" +) + # Brightness of the light, 0..255 or percentage ATTR_BRIGHTNESS = "brightness" ATTR_BRIGHTNESS_PCT = "brightness_pct" @@ -254,11 +250,11 @@ LIGHT_TURN_ON_SCHEMA: VolDictType = { vol.Exclusive(ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP, vol.Exclusive(ATTR_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP_PCT, vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, - vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): vol.All( + vol.Exclusive(_DEPRECATED_ATTR_COLOR_TEMP.value, COLOR_GROUP): vol.All( vol.Coerce(int), vol.Range(min=1) ), vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): cv.positive_int, - vol.Exclusive(ATTR_KELVIN, COLOR_GROUP): cv.positive_int, + vol.Exclusive(_DEPRECATED_ATTR_KELVIN.value, COLOR_GROUP): cv.positive_int, vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): vol.All( vol.Coerce(tuple), vol.ExactSequence( @@ -321,19 +317,29 @@ def preprocess_turn_on_alternatives( _LOGGER.warning("Got unknown color %s, falling back to white", color_name) params[ATTR_RGB_COLOR] = (255, 255, 255) - if (mired := params.pop(ATTR_COLOR_TEMP, None)) is not None: + if (mired := params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value, None)) is not None: + _LOGGER.warning( + "Got `color_temp` argument in `turn_on` service, which is deprecated " + "and will break in Home Assistant 2026.1, please use " + "`color_temp_kelvin` argument" + ) kelvin = color_util.color_temperature_mired_to_kelvin(mired) - params[ATTR_COLOR_TEMP] = int(mired) + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired) params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin) - if (kelvin := params.pop(ATTR_KELVIN, None)) is not None: + if (kelvin := params.pop(_DEPRECATED_ATTR_KELVIN.value, None)) is not None: + _LOGGER.warning( + "Got `kelvin` argument in `turn_on` service, which is deprecated " + "and will break in Home Assistant 2026.1, please use " + "`color_temp_kelvin` argument" + ) mired = color_util.color_temperature_kelvin_to_mired(kelvin) - params[ATTR_COLOR_TEMP] = int(mired) + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired) params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin) if (kelvin := params.pop(ATTR_COLOR_TEMP_KELVIN, None)) is not None: mired = color_util.color_temperature_kelvin_to_mired(kelvin) - params[ATTR_COLOR_TEMP] = int(mired) + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired) params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin) brightness_pct = params.pop(ATTR_BRIGHTNESS_PCT, None) @@ -375,7 +381,7 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st if not brightness_supported(supported_color_modes): params.pop(ATTR_BRIGHTNESS, None) if ColorMode.COLOR_TEMP not in supported_color_modes: - params.pop(ATTR_COLOR_TEMP, None) + params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value, None) params.pop(ATTR_COLOR_TEMP_KELVIN, None) if ColorMode.HS not in supported_color_modes: params.pop(ATTR_HS_COLOR, None) @@ -457,7 +463,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: and ColorMode.COLOR_TEMP not in supported_color_modes and ColorMode.RGBWW in supported_color_modes ): - params.pop(ATTR_COLOR_TEMP) + params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value) color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) brightness = params.get(ATTR_BRIGHTNESS, light.brightness) params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww( @@ -467,7 +473,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: light.max_color_temp_kelvin, ) elif ColorMode.COLOR_TEMP not in legacy_supported_color_modes: - params.pop(ATTR_COLOR_TEMP) + params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value) color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) if color_supported(legacy_supported_color_modes): params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs( @@ -514,8 +520,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( *xy_color ) - params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - params[ATTR_COLOR_TEMP_KELVIN] + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) ) elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: rgb_color = params.pop(ATTR_RGB_COLOR) @@ -537,8 +545,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( *xy_color ) - params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - params[ATTR_COLOR_TEMP_KELVIN] + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) ) elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: xy_color = params.pop(ATTR_XY_COLOR) @@ -558,8 +568,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( *xy_color ) - params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - params[ATTR_COLOR_TEMP_KELVIN] + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) ) elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: rgbw_color = params.pop(ATTR_RGBW_COLOR) @@ -579,8 +591,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( *xy_color ) - params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - params[ATTR_COLOR_TEMP_KELVIN] + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) ) elif ( ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes @@ -603,8 +617,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( *xy_color ) - params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - params[ATTR_COLOR_TEMP_KELVIN] + params[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( + color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) ) # If white is set to True, set it to the light's brightness @@ -812,7 +828,7 @@ class Profiles: color_attributes = ( ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_COLOR_TEMP.value, ATTR_HS_COLOR, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, @@ -860,13 +876,13 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): { ATTR_SUPPORTED_COLOR_MODES, ATTR_EFFECT_LIST, - ATTR_MIN_MIREDS, - ATTR_MAX_MIREDS, + _DEPRECATED_ATTR_MIN_MIREDS.value, + _DEPRECATED_ATTR_MAX_MIREDS.value, ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_MAX_COLOR_TEMP_KELVIN, ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_COLOR_TEMP.value, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, @@ -880,17 +896,15 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): entity_description: LightEntityDescription _attr_brightness: int | None = None _attr_color_mode: ColorMode | str | None = None - _attr_color_temp: int | None = None _attr_color_temp_kelvin: int | None = None _attr_effect_list: list[str] | None = None _attr_effect: str | None = None _attr_hs_color: tuple[float, float] | None = None - # Default to the Philips Hue value that HA has always assumed - # https://developers.meethue.com/documentation/core-concepts + # We cannot set defaults without causing breaking changes until mireds + # are fully removed. Until then, developers can explicitly + # use DEFAULT_MIN_KELVIN and DEFAULT_MAX_KELVIN _attr_max_color_temp_kelvin: int | None = None _attr_min_color_temp_kelvin: int | None = None - _attr_max_mireds: int = 500 # 2000 K - _attr_min_mireds: int = 153 # 6500 K _attr_rgb_color: tuple[int, int, int] | None = None _attr_rgbw_color: tuple[int, int, int, int] | None = None _attr_rgbww_color: tuple[int, int, int, int, int] | None = None @@ -898,6 +912,11 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_supported_features: LightEntityFeature = LightEntityFeature(0) _attr_xy_color: tuple[float, float] | None = None + # Deprecated, see https://github.com/home-assistant/core/pull/79591 + _attr_color_temp: Final[int | None] = None + _attr_max_mireds: Final[int] = 500 # = 2000 K + _attr_min_mireds: Final[int] = 153 # = 6535.94 K (~ 6500 K) + __color_mode_reported = False @cached_property @@ -973,32 +992,70 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the rgbww color value [int, int, int, int, int].""" return self._attr_rgbww_color + @final @cached_property def color_temp(self) -> int | None: - """Return the CT color value in mireds.""" + """Return the CT color value in mireds. + + Deprecated, see https://github.com/home-assistant/core/pull/79591 + """ return self._attr_color_temp @property def color_temp_kelvin(self) -> int | None: """Return the CT color value in Kelvin.""" if self._attr_color_temp_kelvin is None and (color_temp := self.color_temp): + report_usage( + "is using mireds for current light color temperature, when " + "it should be adjusted to use the kelvin attribute " + "`_attr_color_temp_kelvin` or override the kelvin property " + "`color_temp_kelvin` (see " + "https://github.com/home-assistant/core/pull/79591)", + breaks_in_ha_version="2026.1", + core_behavior=ReportBehavior.LOG, + integration_domain=self.platform.platform_name + if self.platform + else None, + exclude_integrations={DOMAIN}, + ) return color_util.color_temperature_mired_to_kelvin(color_temp) return self._attr_color_temp_kelvin + @final @cached_property def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" + """Return the coldest color_temp that this light supports. + + Deprecated, see https://github.com/home-assistant/core/pull/79591 + """ return self._attr_min_mireds + @final @cached_property def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" + """Return the warmest color_temp that this light supports. + + Deprecated, see https://github.com/home-assistant/core/pull/79591 + """ return self._attr_max_mireds @property def min_color_temp_kelvin(self) -> int: """Return the warmest color_temp_kelvin that this light supports.""" if self._attr_min_color_temp_kelvin is None: + report_usage( + "is using mireds for warmest light color temperature, when " + "it should be adjusted to use the kelvin attribute " + "`_attr_min_color_temp_kelvin` or override the kelvin property " + "`min_color_temp_kelvin`, possibly with default DEFAULT_MIN_KELVIN " + "(see https://github.com/home-assistant/core/pull/79591)", + breaks_in_ha_version="2026.1", + core_behavior=ReportBehavior.LOG, + integration_domain=self.platform.platform_name + if self.platform + else None, + exclude_integrations={DOMAIN}, + ) return color_util.color_temperature_mired_to_kelvin(self.max_mireds) return self._attr_min_color_temp_kelvin @@ -1006,6 +1063,19 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def max_color_temp_kelvin(self) -> int: """Return the coldest color_temp_kelvin that this light supports.""" if self._attr_max_color_temp_kelvin is None: + report_usage( + "is using mireds for coldest light color temperature, when " + "it should be adjusted to use the kelvin attribute " + "`_attr_max_color_temp_kelvin` or override the kelvin property " + "`max_color_temp_kelvin`, possibly with default DEFAULT_MAX_KELVIN " + "(see https://github.com/home-assistant/core/pull/79591)", + breaks_in_ha_version="2026.1", + core_behavior=ReportBehavior.LOG, + integration_domain=self.platform.platform_name + if self.platform + else None, + exclude_integrations={DOMAIN}, + ) return color_util.color_temperature_mired_to_kelvin(self.min_mireds) return self._attr_max_color_temp_kelvin @@ -1032,16 +1102,16 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_MIN_COLOR_TEMP_KELVIN] = min_color_temp_kelvin data[ATTR_MAX_COLOR_TEMP_KELVIN] = max_color_temp_kelvin if not max_color_temp_kelvin: - data[ATTR_MIN_MIREDS] = None + data[_DEPRECATED_ATTR_MIN_MIREDS.value] = None else: - data[ATTR_MIN_MIREDS] = color_util.color_temperature_kelvin_to_mired( - max_color_temp_kelvin + data[_DEPRECATED_ATTR_MIN_MIREDS.value] = ( + color_util.color_temperature_kelvin_to_mired(max_color_temp_kelvin) ) if not min_color_temp_kelvin: - data[ATTR_MAX_MIREDS] = None + data[_DEPRECATED_ATTR_MAX_MIREDS.value] = None else: - data[ATTR_MAX_MIREDS] = color_util.color_temperature_kelvin_to_mired( - min_color_temp_kelvin + data[_DEPRECATED_ATTR_MAX_MIREDS.value] = ( + color_util.color_temperature_kelvin_to_mired(min_color_temp_kelvin) ) if LightEntityFeature.EFFECT in supported_features: data[ATTR_EFFECT_LIST] = self.effect_list @@ -1209,7 +1279,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_BRIGHTNESS] = self.brightness else: data[ATTR_BRIGHTNESS] = None - elif supported_features_value & SUPPORT_BRIGHTNESS: + elif supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value: # Backwards compatibility for ambiguous / incomplete states # Warning is printed by supported_features_compat, remove in 2025.1 if _is_on: @@ -1222,29 +1292,29 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): color_temp_kelvin = self.color_temp_kelvin data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin if color_temp_kelvin: - data[ATTR_COLOR_TEMP] = ( + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( color_util.color_temperature_kelvin_to_mired(color_temp_kelvin) ) else: - data[ATTR_COLOR_TEMP] = None + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None else: data[ATTR_COLOR_TEMP_KELVIN] = None - data[ATTR_COLOR_TEMP] = None - elif supported_features_value & SUPPORT_COLOR_TEMP: + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None + elif supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value: # Backwards compatibility # Warning is printed by supported_features_compat, remove in 2025.1 if _is_on: color_temp_kelvin = self.color_temp_kelvin data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin if color_temp_kelvin: - data[ATTR_COLOR_TEMP] = ( + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = ( color_util.color_temperature_kelvin_to_mired(color_temp_kelvin) ) else: - data[ATTR_COLOR_TEMP] = None + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None else: data[ATTR_COLOR_TEMP_KELVIN] = None - data[ATTR_COLOR_TEMP] = None + data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None if color_supported(legacy_supported_color_modes) or color_temp_supported( legacy_supported_color_modes @@ -1286,11 +1356,14 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): supported_features_value = supported_features.value supported_color_modes: set[ColorMode] = set() - if supported_features_value & SUPPORT_COLOR_TEMP: + if supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value: supported_color_modes.add(ColorMode.COLOR_TEMP) - if supported_features_value & SUPPORT_COLOR: + if supported_features_value & _DEPRECATED_SUPPORT_COLOR.value: supported_color_modes.add(ColorMode.HS) - if not supported_color_modes and supported_features_value & SUPPORT_BRIGHTNESS: + if ( + not supported_color_modes + and supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value + ): supported_color_modes = {ColorMode.BRIGHTNESS} if not supported_color_modes: @@ -1315,7 +1388,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): Remove this compatibility shim in 2025.1 or later. """ features = self.supported_features - if type(features) is not int: # noqa: E721 + if type(features) is not int: return features new_features = LightEntityFeature(features) if self._deprecated_supported_features_reported is True: @@ -1345,3 +1418,11 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return True # philips_js has known issues, we don't need users to open issues return self.platform.platform_name not in {"philips_js"} + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/light/const.py b/homeassistant/components/light/const.py new file mode 100644 index 00000000000..d27750a950d --- /dev/null +++ b/homeassistant/components/light/const.py @@ -0,0 +1,73 @@ +"""Provides constants for lights.""" + +from __future__ import annotations + +from datetime import timedelta +from enum import IntFlag, StrEnum +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import LightEntity, Profiles + +DOMAIN = "light" +DATA_COMPONENT: HassKey[EntityComponent[LightEntity]] = HassKey(DOMAIN) +SCAN_INTERVAL = timedelta(seconds=30) + +DATA_PROFILES: HassKey[Profiles] = HassKey(f"{DOMAIN}_profiles") + + +class LightEntityFeature(IntFlag): + """Supported features of the light entity.""" + + EFFECT = 4 + FLASH = 8 + TRANSITION = 32 + + +class ColorMode(StrEnum): + """Possible light color modes.""" + + UNKNOWN = "unknown" + """Ambiguous color mode""" + ONOFF = "onoff" + """Must be the only supported mode""" + BRIGHTNESS = "brightness" + """Must be the only supported mode""" + COLOR_TEMP = "color_temp" + HS = "hs" + XY = "xy" + RGB = "rgb" + RGBW = "rgbw" + RGBWW = "rgbww" + WHITE = "white" + """Must *NOT* be the only supported mode""" + + +VALID_COLOR_MODES = { + ColorMode.ONOFF, + ColorMode.BRIGHTNESS, + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.XY, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ColorMode.WHITE, +} +COLOR_MODES_BRIGHTNESS = VALID_COLOR_MODES - {ColorMode.ONOFF} +COLOR_MODES_COLOR = { + ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ColorMode.XY, +} + +# Default to the Philips Hue value that HA has always assumed +# https://developers.meethue.com/documentation/core-concepts +DEFAULT_MIN_KELVIN = 2000 # 500 mireds +DEFAULT_MAX_KELVIN = 6535 # 153 mireds diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index 45e9731c5b8..56bf7485e68 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -27,14 +27,13 @@ from . import ( ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS_STEP_PCT, ATTR_FLASH, - DOMAIN, FLASH_SHORT, VALID_BRIGHTNESS_PCT, VALID_FLASH, - LightEntityFeature, brightness_supported, get_supported_color_modes, ) +from .const import DOMAIN, LightEntityFeature # mypy: disallow-any-generics diff --git a/homeassistant/components/light/device_condition.py b/homeassistant/components/light/device_condition.py index f9bb7c30bd7..6dc702f8551 100644 --- a/homeassistant/components/light/device_condition.py +++ b/homeassistant/components/light/device_condition.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.condition import ConditionCheckerType from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from .const import DOMAIN # mypy: disallow-any-generics diff --git a/homeassistant/components/light/device_trigger.py b/homeassistant/components/light/device_trigger.py index 033ea75357e..1f6bfdbe6e9 100644 --- a/homeassistant/components/light/device_trigger.py +++ b/homeassistant/components/light/device_trigger.py @@ -10,7 +10,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from .const import DOMAIN TRIGGER_SCHEMA = vol.All( toggle_entity.TRIGGER_SCHEMA, diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index 458dbbde770..e496255029a 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -11,7 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent import homeassistant.util.color as color_util -from . import ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, DOMAIN +from . import ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 4024f2f84ba..4e994ab791d 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -15,11 +15,13 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import Context, HomeAssistant, State +from homeassistant.util import color as color_util from . import ( + _DEPRECATED_ATTR_COLOR_TEMP, ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_RGB_COLOR, @@ -28,9 +30,8 @@ from . import ( ATTR_TRANSITION, ATTR_WHITE, ATTR_XY_COLOR, - DOMAIN, - ColorMode, ) +from .const import DOMAIN, ColorMode _LOGGER = logging.getLogger(__name__) @@ -40,7 +41,8 @@ ATTR_GROUP = [ATTR_BRIGHTNESS, ATTR_EFFECT] COLOR_GROUP = [ ATTR_HS_COLOR, - ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_COLOR_TEMP.value, + ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -56,7 +58,7 @@ class ColorModeAttr(NamedTuple): COLOR_MODE_TO_ATTRIBUTE = { - ColorMode.COLOR_TEMP: ColorModeAttr(ATTR_COLOR_TEMP, ATTR_COLOR_TEMP), + ColorMode.COLOR_TEMP: ColorModeAttr(ATTR_COLOR_TEMP_KELVIN, ATTR_COLOR_TEMP_KELVIN), ColorMode.HS: ColorModeAttr(ATTR_HS_COLOR, ATTR_HS_COLOR), ColorMode.RGB: ColorModeAttr(ATTR_RGB_COLOR, ATTR_RGB_COLOR), ColorMode.RGBW: ColorModeAttr(ATTR_RGBW_COLOR, ATTR_RGBW_COLOR), @@ -125,13 +127,30 @@ async def _async_reproduce_state( color_mode = state.attributes[ATTR_COLOR_MODE] if cm_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): if (cm_attr_state := state.attributes.get(cm_attr.state_attr)) is None: + if ( + color_mode != ColorMode.COLOR_TEMP + or ( + mireds := state.attributes.get( + _DEPRECATED_ATTR_COLOR_TEMP.value + ) + ) + is None + ): + _LOGGER.warning( + "Color mode %s specified but attribute %s missing for: %s", + color_mode, + cm_attr.state_attr, + state.entity_id, + ) + return _LOGGER.warning( - "Color mode %s specified but attribute %s missing for: %s", + "Color mode %s specified but attribute %s missing for: %s, " + "using color_temp (mireds) as fallback", color_mode, cm_attr.state_attr, state.entity_id, ) - return + cm_attr_state = color_util.color_temperature_mired_to_kelvin(mireds) service_data[cm_attr.parameter] = cm_attr_state else: # Fall back to Choosing the first color that is specified diff --git a/homeassistant/components/light/significant_change.py b/homeassistant/components/light/significant_change.py index 1877c925622..773b7a6b898 100644 --- a/homeassistant/components/light/significant_change.py +++ b/homeassistant/components/light/significant_change.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.significant_change import check_absolute_change -from . import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR +from . import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR @callback @@ -44,10 +44,10 @@ def async_check_significant_change( return True if check_absolute_change( - # Default range 153..500 - old_attrs.get(ATTR_COLOR_TEMP), - new_attrs.get(ATTR_COLOR_TEMP), - 5, + # Default range 2000..6500 + old_attrs.get(ATTR_COLOR_TEMP_KELVIN), + new_attrs.get(ATTR_COLOR_TEMP_KELVIN), + 50, ): return True diff --git a/homeassistant/components/lightwave/climate.py b/homeassistant/components/lightwave/climate.py index 1016e8ce80d..942fb4a1fbc 100644 --- a/homeassistant/components/lightwave/climate.py +++ b/homeassistant/components/lightwave/climate.py @@ -55,7 +55,6 @@ class LightwaveTrv(ClimateEntity): ) _attr_target_temperature_step = 0.5 _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, name, device_id, lwlink, serial): """Initialize LightwaveTrv entity.""" diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index c6b3301081d..4b2b75be9d7 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -19,7 +19,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, @@ -38,7 +38,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.color import color_hs_to_RGB, color_temperature_mired_to_kelvin +from homeassistant.util.color import color_hs_to_RGB _LOGGER = logging.getLogger(__name__) @@ -217,8 +217,8 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): """Representation of a LimitessLED group.""" _attr_assumed_state = True - _attr_max_mireds = 370 - _attr_min_mireds = 154 + _attr_min_color_temp_kelvin = 2700 # 370 Mireds + _attr_max_color_temp_kelvin = 6500 # 154 Mireds _attr_should_poll = False def __init__(self, group: Group, config: dict[str, Any]) -> None: @@ -261,7 +261,9 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): if last_state := await self.async_get_last_state(): self._attr_is_on = last_state.state == STATE_ON self._attr_brightness = last_state.attributes.get("brightness") - self._attr_color_temp = last_state.attributes.get("color_temp") + self._attr_color_temp_kelvin = last_state.attributes.get( + "color_temp_kelvin" + ) self._attr_hs_color = last_state.attributes.get("hs_color") @property @@ -325,12 +327,12 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): else: args["color"] = self.limitlessled_color() - if ATTR_COLOR_TEMP in kwargs: + if ATTR_COLOR_TEMP_KELVIN in kwargs: assert self.supported_color_modes if ColorMode.HS in self.supported_color_modes: pipeline.white() self._attr_hs_color = WHITE - self._attr_color_temp = kwargs[ATTR_COLOR_TEMP] + self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] args["temperature"] = self.limitlessled_temperature() if args: @@ -354,12 +356,9 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): def limitlessled_temperature(self) -> float: """Convert Home Assistant color temperature units to percentage.""" - max_kelvin = color_temperature_mired_to_kelvin(self.min_mireds) - min_kelvin = color_temperature_mired_to_kelvin(self.max_mireds) - width = max_kelvin - min_kelvin - assert self.color_temp is not None - kelvin = color_temperature_mired_to_kelvin(self.color_temp) - temperature = (kelvin - min_kelvin) / width + width = self.max_color_temp_kelvin - self.min_color_temp_kelvin + assert self.color_temp_kelvin is not None + temperature = (self.color_temp_kelvin - self.min_color_temp_kelvin) / width return max(0, min(1, temperature)) def limitlessled_brightness(self) -> float: diff --git a/homeassistant/components/linkplay/config_flow.py b/homeassistant/components/linkplay/config_flow.py index 7dfdce238ff..11e4aabf257 100644 --- a/homeassistant/components/linkplay/config_flow.py +++ b/homeassistant/components/linkplay/config_flow.py @@ -9,9 +9,9 @@ from linkplay.discovery import linkplay_factory_httpapi_bridge from linkplay.exceptions import LinkPlayRequestException import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .utils import async_get_client_session @@ -27,7 +27,7 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN): self.data: dict[str, Any] = {} async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Zeroconf discovery.""" diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py index 00e2f39b233..74e067f5eb3 100644 --- a/homeassistant/components/linkplay/entity.py +++ b/homeassistant/components/linkplay/entity.py @@ -44,9 +44,15 @@ class LinkPlayBaseEntity(Entity): if model != MANUFACTURER_GENERIC: model_id = bridge.device.properties["project"] + connections: set[tuple[str, str]] = set() + if "MAC" in bridge.device.properties: + connections.add( + (dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"]) + ) + self._attr_device_info = dr.DeviceInfo( configuration_url=bridge.endpoint, - connections={(dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"])}, + connections=connections, hw_version=bridge.device.properties["hardware"], identifiers={(DOMAIN, bridge.device.uuid)}, manufacturer=manufacturer, diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index e74d22b8207..ec9a8759a30 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.0.20"], + "requirements": ["python-linkplay==0.1.3"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 3c55c4c4035..76274f987cd 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -4,15 +4,12 @@ from __future__ import annotations from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN -from .hub import LitterRobotHub - -type LitterRobotConfigEntry = ConfigEntry[LitterRobotHub] +from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator PLATFORMS_BY_TYPE = { Robot: ( @@ -41,11 +38,11 @@ def get_platforms_for_robots(robots: list[Robot]) -> set[Platform]: async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) -> bool: """Set up Litter-Robot from a config entry.""" - hub = LitterRobotHub(hass, entry.data) - await hub.login(load_robots=True, subscribe_for_updates=True) - entry.runtime_data = hub + coordinator = LitterRobotDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator - if platforms := get_platforms_for_robots(hub.account.robots): + if platforms := get_platforms_for_robots(coordinator.account.robots): await hass.config_entries.async_forward_entry_setups(entry, platforms) return True diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 91113d6c094..e6cf23fa27c 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -17,33 +17,17 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT -@dataclass(frozen=True) -class RequiredKeysMixin(Generic[_RobotT]): - """A class that describes robot binary sensor entity required keys.""" - - is_on_fn: Callable[[_RobotT], bool] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RobotBinarySensorEntityDescription( - BinarySensorEntityDescription, RequiredKeysMixin[_RobotT] + BinarySensorEntityDescription, Generic[_RobotT] ): """A class that describes robot binary sensor entities.""" - -class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEntity): - """Litter-Robot binary sensor entity.""" - - entity_description: RobotBinarySensorEntityDescription[_RobotT] - - @property - def is_on(self) -> bool: - """Return the state.""" - return self.entity_description.is_on_fn(self.robot) + is_on_fn: Callable[[_RobotT], bool] BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = { @@ -82,11 +66,24 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot binary sensors using config entry.""" - hub = entry.runtime_data + coordinator = entry.runtime_data async_add_entities( - LitterRobotBinarySensorEntity(robot=robot, hub=hub, description=description) - for robot in hub.account.robots + LitterRobotBinarySensorEntity( + robot=robot, coordinator=coordinator, description=description + ) + for robot in coordinator.account.robots for robot_type, entity_descriptions in BINARY_SENSOR_MAP.items() if isinstance(robot, robot_type) for description in entity_descriptions ) + + +class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEntity): + """Litter-Robot binary sensor entity.""" + + entity_description: RobotBinarySensorEntityDescription[_RobotT] + + @property + def is_on(self) -> bool: + """Return the state.""" + return self.entity_description.is_on_fn(self.robot) diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 6e6cc563c8e..01888e7fbae 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -4,70 +4,62 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass -import itertools from typing import Any, Generic -from pylitterbot import FeederRobot, LitterRobot3 +from pylitterbot import FeederRobot, LitterRobot3, LitterRobot4, Robot from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT +@dataclass(frozen=True, kw_only=True) +class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_RobotT]): + """A class that describes robot button entities.""" + + press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]] + + +ROBOT_BUTTON_MAP: dict[type[Robot], RobotButtonEntityDescription] = { + LitterRobot3: RobotButtonEntityDescription[LitterRobot3]( + key="reset_waste_drawer", + translation_key="reset_waste_drawer", + entity_category=EntityCategory.CONFIG, + press_fn=lambda robot: robot.reset_waste_drawer(), + ), + LitterRobot4: RobotButtonEntityDescription[LitterRobot4]( + key="reset", + translation_key="reset", + entity_category=EntityCategory.CONFIG, + press_fn=lambda robot: robot.reset(), + ), + FeederRobot: RobotButtonEntityDescription[FeederRobot]( + key="give_snack", + translation_key="give_snack", + press_fn=lambda robot: robot.give_snack(), + ), +} + + async def async_setup_entry( hass: HomeAssistant, entry: LitterRobotConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" - hub = entry.runtime_data - entities: list[LitterRobotButtonEntity] = list( - itertools.chain( - ( - LitterRobotButtonEntity( - robot=robot, hub=hub, description=LITTER_ROBOT_BUTTON - ) - for robot in hub.litter_robots() - if isinstance(robot, LitterRobot3) - ), - ( - LitterRobotButtonEntity( - robot=robot, hub=hub, description=FEEDER_ROBOT_BUTTON - ) - for robot in hub.feeder_robots() - ), + coordinator = entry.runtime_data + async_add_entities( + LitterRobotButtonEntity( + robot=robot, coordinator=coordinator, description=description ) + for robot in coordinator.account.robots + for robot_type, description in ROBOT_BUTTON_MAP.items() + if isinstance(robot, robot_type) ) - async_add_entities(entities) - - -@dataclass(frozen=True) -class RequiredKeysMixin(Generic[_RobotT]): - """A class that describes robot button entity required keys.""" - - press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]] - - -@dataclass(frozen=True) -class RobotButtonEntityDescription(ButtonEntityDescription, RequiredKeysMixin[_RobotT]): - """A class that describes robot button entities.""" - - -LITTER_ROBOT_BUTTON = RobotButtonEntityDescription[LitterRobot3]( - key="reset_waste_drawer", - translation_key="reset_waste_drawer", - entity_category=EntityCategory.CONFIG, - press_fn=lambda robot: robot.reset_waste_drawer(), -) -FEEDER_ROBOT_BUTTON = RobotButtonEntityDescription[FeederRobot]( - key="give_snack", - translation_key="give_snack", - press_fn=lambda robot: robot.give_snack(), -) class LitterRobotButtonEntity(LitterRobotEntity[_RobotT], ButtonEntity): @@ -78,4 +70,4 @@ class LitterRobotButtonEntity(LitterRobotEntity[_RobotT], ButtonEntity): async def async_press(self) -> None: """Press the button.""" await self.entity_description.press_fn(self.robot) - self.coordinator.async_set_updated_data(True) + self.coordinator.async_set_updated_data(None) diff --git a/homeassistant/components/litterrobot/hub.py b/homeassistant/components/litterrobot/coordinator.py similarity index 51% rename from homeassistant/components/litterrobot/hub.py rename to homeassistant/components/litterrobot/coordinator.py index 77050855c70..a56a6607d32 100644 --- a/homeassistant/components/litterrobot/hub.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -1,64 +1,66 @@ -"""A wrapper 'hub' for the Litter-Robot API.""" +"""The Litter-Robot coordinator.""" from __future__ import annotations -from collections.abc import Generator, Mapping +from collections.abc import Generator from datetime import timedelta import logging -from typing import Any from pylitterbot import Account, FeederRobot, LitterRobot from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -UPDATE_INTERVAL_SECONDS = 60 * 5 +UPDATE_INTERVAL = timedelta(minutes=5) + +type LitterRobotConfigEntry = ConfigEntry[LitterRobotDataUpdateCoordinator] -class LitterRobotHub: - """A Litter-Robot hub wrapper class.""" +class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]): + """The Litter-Robot data update coordinator.""" - def __init__(self, hass: HomeAssistant, data: Mapping[str, Any]) -> None: - """Initialize the Litter-Robot hub.""" - self._data = data - self.account = Account(websession=async_get_clientsession(hass)) + config_entry: LitterRobotConfigEntry - async def _async_update_data() -> bool: - """Update all device states from the Litter-Robot API.""" - await self.account.refresh_robots() - return True - - self.coordinator = DataUpdateCoordinator( + def __init__( + self, hass: HomeAssistant, config_entry: LitterRobotConfigEntry + ) -> None: + """Initialize the Litter-Robot data update coordinator.""" + super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, - update_method=_async_update_data, - update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS), + update_interval=UPDATE_INTERVAL, ) - async def login( - self, load_robots: bool = False, subscribe_for_updates: bool = False - ) -> None: - """Login to Litter-Robot.""" + self.account = Account(websession=async_get_clientsession(hass)) + + async def _async_update_data(self) -> None: + """Update all device states from the Litter-Robot API.""" + await self.account.refresh_robots() + + async def _async_setup(self) -> None: + """Set up the coordinator.""" try: await self.account.connect( - username=self._data[CONF_USERNAME], - password=self._data[CONF_PASSWORD], - load_robots=load_robots, - subscribe_for_updates=subscribe_for_updates, + username=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + load_robots=True, + subscribe_for_updates=True, ) except LitterRobotLoginException as ex: raise ConfigEntryAuthFailed("Invalid credentials") from ex except LitterRobotException as ex: - raise ConfigEntryNotReady("Unable to connect to Litter-Robot API") from ex + raise UpdateFailed("Unable to connect to Litter-Robot API") from ex def litter_robots(self) -> Generator[LitterRobot]: """Get Litter-Robots from the account.""" diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py index 4639404b92b..36cbbb730ce 100644 --- a/homeassistant/components/litterrobot/entity.py +++ b/homeassistant/components/litterrobot/entity.py @@ -9,44 +9,39 @@ from pylitterbot.robot import EVENT_UPDATE from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .hub import LitterRobotHub +from .coordinator import LitterRobotDataUpdateCoordinator _RobotT = TypeVar("_RobotT", bound=Robot) class LitterRobotEntity( - CoordinatorEntity[DataUpdateCoordinator[bool]], Generic[_RobotT] + CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_RobotT] ): """Generic Litter-Robot entity representing common data and methods.""" _attr_has_entity_name = True def __init__( - self, robot: _RobotT, hub: LitterRobotHub, description: EntityDescription + self, + robot: _RobotT, + coordinator: LitterRobotDataUpdateCoordinator, + description: EntityDescription, ) -> None: """Pass coordinator to CoordinatorEntity.""" - super().__init__(hub.coordinator) + super().__init__(coordinator) self.robot = robot - self.hub = hub self.entity_description = description - self._attr_unique_id = f"{self.robot.serial}-{description.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return the device information for a Litter-Robot.""" - assert self.robot.serial - return DeviceInfo( - identifiers={(DOMAIN, self.robot.serial)}, - manufacturer="Litter-Robot", - model=self.robot.model, - name=self.robot.name, - sw_version=getattr(self.robot, "firmware", None), + self._attr_unique_id = f"{robot.serial}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, robot.serial)}, + manufacturer="Whisker", + model=robot.model, + name=robot.name, + serial_number=robot.serial, + sw_version=getattr(robot, "firmware", None), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index 482031f8424..ba3df2114b7 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -17,6 +17,14 @@ } }, "select": { + "brightness_level": { + "default": "mdi:lightbulb-question", + "state": { + "low": "mdi:lightbulb-on-30", + "medium": "mdi:lightbulb-on-50", + "high": "mdi:lightbulb-on" + } + }, "cycle_delay": { "default": "mdi:timer-outline" }, diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 88396f9f9c1..4f1deb9a567 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.5.0"] + "requirements": ["pylitterbot==2024.0.0"] } diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml new file mode 100644 index 00000000000..3eae5d3e668 --- /dev/null +++ b/homeassistant/components/litterrobot/quality_scale.yaml @@ -0,0 +1,113 @@ +rules: + # Adjust platform files for consistent flow: + # [entity description classes] + # [entity descriptions] + # [async_setup_entry] + # [entity classes]) + # Remove RequiredKeyMixins and add kw_only to classes + # Wrap multiline lambdas in parenthesis + # Extend entity description in switch.py to use value_fn instead of getattr + # Deprecate extra state attributes in vacuum.py + # Bronze + action-setup: + status: todo + comment: | + Action async_set_sleep_mode is currently setup in the vacuum platform + appropriate-polling: + status: done + comment: | + Primarily relies on push data, but polls every 5 minutes for missed updates + brands: done + common-modules: + status: todo + comment: | + hub.py should be renamed to coordinator.py and updated accordingly + Also should not need to return bool (never used) + config-flow-test-coverage: + status: todo + comment: | + Fix stale title and docstring + Make sure every test ends in either ABORT or CREATE_ENTRY + so we also test that the flow is able to recover + config-flow: done + dependency-transparency: done + docs-actions: + status: todo + comment: Can be finished after async_set_sleep_mode is moved to async_setup + docs-high-level-description: done + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: todo + comment: Do we need to subscribe to both the coordinator and robot itself? + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: done + comment: No options to configure + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: + status: todo + comment: | + Move big data objects from common.py into JSON fixtures and oad them when needed. + Other fields can be moved to const.py. Consider snapshots and testing data updates + + # Gold + devices: + status: done + comment: Currently uses the device_info property, could be moved to _attr_device_info + diagnostics: todo + discovery-update-info: + status: done + comment: The integration is cloud-based + discovery: + status: todo + comment: Need to validate discovery + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: todo + comment: Check if we should disable any entities by default + entity-translations: + status: todo + comment: Make sure all translated states are in sentence case + exception-translations: todo + icon-translations: + status: todo + comment: BRIGHTNESS_LEVEL_ICON_MAP should be migrated to icons.json + reconfiguration-flow: todo + repair-issues: + status: done + comment: | + This integration doesn't have any cases where raising an issue is needed + stale-devices: + status: todo + comment: | + Currently handled via async_remove_config_entry_device, + but we should be able to remove devices automatically + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 948fad45a76..1a3d2fc2fb4 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -14,37 +14,22 @@ from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator from .entity import LitterRobotEntity, _RobotT -from .hub import LitterRobotHub _CastTypeT = TypeVar("_CastTypeT", int, float, str) -BRIGHTNESS_LEVEL_ICON_MAP: dict[BrightnessLevel | None, str] = { - BrightnessLevel.LOW: "mdi:lightbulb-on-30", - BrightnessLevel.MEDIUM: "mdi:lightbulb-on-50", - BrightnessLevel.HIGH: "mdi:lightbulb-on", - None: "mdi:lightbulb-question", -} - -@dataclass(frozen=True) -class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): - """A class that describes robot select entity required keys.""" - - current_fn: Callable[[_RobotT], _CastTypeT | None] - options_fn: Callable[[_RobotT], list[_CastTypeT]] - select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RobotSelectEntityDescription( - SelectEntityDescription, RequiredKeysMixin[_RobotT, _CastTypeT] + SelectEntityDescription, Generic[_RobotT, _CastTypeT] ): """A class that describes robot select entities.""" entity_category: EntityCategory = EntityCategory.CONFIG - icon_fn: Callable[[_RobotT], str] | None = None + current_fn: Callable[[_RobotT], _CastTypeT | None] + options_fn: Callable[[_RobotT], list[_CastTypeT]] + select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { @@ -59,14 +44,15 @@ ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { LitterRobot4: RobotSelectEntityDescription[LitterRobot4, str]( key="panel_brightness", translation_key="brightness_level", - current_fn=lambda robot: bri.name.lower() - if (bri := robot.panel_brightness) is not None - else None, - options_fn=lambda _: [level.name.lower() for level in BrightnessLevel], - select_fn=lambda robot, opt: robot.set_panel_brightness( - BrightnessLevel[opt.upper()] + current_fn=( + lambda robot: bri.name.lower() + if (bri := robot.panel_brightness) is not None + else None + ), + options_fn=lambda _: [level.name.lower() for level in BrightnessLevel], + select_fn=( + lambda robot, opt: robot.set_panel_brightness(BrightnessLevel[opt.upper()]) ), - icon_fn=lambda robot: BRIGHTNESS_LEVEL_ICON_MAP[robot.panel_brightness], ), FeederRobot: RobotSelectEntityDescription[FeederRobot, float]( key="meal_insert_size", @@ -85,14 +71,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot selects using config entry.""" - hub = entry.runtime_data - entities = [ - LitterRobotSelectEntity(robot=robot, hub=hub, description=description) - for robot in hub.account.robots + coordinator = entry.runtime_data + async_add_entities( + LitterRobotSelectEntity( + robot=robot, coordinator=coordinator, description=description + ) + for robot in coordinator.account.robots for robot_type, description in ROBOT_SELECT_MAP.items() if isinstance(robot, robot_type) - ] - async_add_entities(entities) + ) class LitterRobotSelectEntity( @@ -105,21 +92,14 @@ class LitterRobotSelectEntity( def __init__( self, robot: _RobotT, - hub: LitterRobotHub, + coordinator: LitterRobotDataUpdateCoordinator, description: RobotSelectEntityDescription[_RobotT, _CastTypeT], ) -> None: """Initialize a Litter-Robot select entity.""" - super().__init__(robot, hub, description) + super().__init__(robot, coordinator, description) options = self.entity_description.options_fn(self.robot) self._attr_options = list(map(str, options)) - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - if icon_fn := self.entity_description.icon_fn: - return str(icon_fn(self.robot)) - return super().icon - @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index c110b89c7da..6545d7c7ae7 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import Any, Generic, cast +from typing import Any, Generic from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot @@ -19,7 +19,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfMass from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT @@ -34,34 +34,12 @@ def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str return "mdi:gauge-low" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RobotSensorEntityDescription(SensorEntityDescription, Generic[_RobotT]): """A class that describes robot sensor entities.""" icon_fn: Callable[[Any], str | None] = lambda _: None - should_report: Callable[[_RobotT], bool] = lambda _: True - - -class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity): - """Litter-Robot sensor entity.""" - - entity_description: RobotSensorEntityDescription[_RobotT] - - @property - def native_value(self) -> float | datetime | str | None: - """Return the state.""" - if self.entity_description.should_report(self.robot): - if isinstance(val := getattr(self.robot, self.entity_description.key), str): - return val.lower() - return cast(float | datetime | None, val) - return None - - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - if (icon := self.entity_description.icon_fn(self.state)) is not None: - return icon - return super().icon + value_fn: Callable[[_RobotT], float | datetime | str | None] ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { @@ -72,24 +50,34 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { native_unit_of_measurement=PERCENTAGE, icon_fn=lambda state: icon_for_gauge_level(state, 10), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda robot: robot.waste_drawer_level, ), RobotSensorEntityDescription[LitterRobot]( key="sleep_mode_start_time", translation_key="sleep_mode_start_time", device_class=SensorDeviceClass.TIMESTAMP, - should_report=lambda robot: robot.sleep_mode_enabled, + value_fn=( + lambda robot: robot.sleep_mode_start_time + if robot.sleep_mode_enabled + else None + ), ), RobotSensorEntityDescription[LitterRobot]( key="sleep_mode_end_time", translation_key="sleep_mode_end_time", device_class=SensorDeviceClass.TIMESTAMP, - should_report=lambda robot: robot.sleep_mode_enabled, + value_fn=( + lambda robot: robot.sleep_mode_end_time + if robot.sleep_mode_enabled + else None + ), ), RobotSensorEntityDescription[LitterRobot]( key="last_seen", translation_key="last_seen", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda robot: robot.last_seen, ), RobotSensorEntityDescription[LitterRobot]( key="status_code", @@ -123,6 +111,9 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { "sdf", "spf", ], + value_fn=( + lambda robot: status.lower() if (status := robot.status_code) else None + ), ), ], LitterRobot4: [ @@ -132,6 +123,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { native_unit_of_measurement=PERCENTAGE, icon_fn=lambda state: icon_for_gauge_level(state, 10), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda robot: robot.litter_level, ), RobotSensorEntityDescription[LitterRobot4]( key="pet_weight", @@ -139,6 +131,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { native_unit_of_measurement=UnitOfMass.POUNDS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda robot: robot.pet_weight, ), ], FeederRobot: [ @@ -148,6 +141,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { native_unit_of_measurement=PERCENTAGE, icon_fn=lambda state: icon_for_gauge_level(state, 10), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda robot: robot.food_level, ) ], } @@ -159,12 +153,31 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot sensors using config entry.""" - hub = entry.runtime_data - entities = [ - LitterRobotSensorEntity(robot=robot, hub=hub, description=description) - for robot in hub.account.robots + coordinator = entry.runtime_data + async_add_entities( + LitterRobotSensorEntity( + robot=robot, coordinator=coordinator, description=description + ) + for robot in coordinator.account.robots for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items() if isinstance(robot, robot_type) for description in entity_descriptions - ] - async_add_entities(entities) + ) + + +class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity): + """Litter-Robot sensor entity.""" + + entity_description: RobotSensorEntityDescription[_RobotT] + + @property + def native_value(self) -> float | datetime | str | None: + """Return the state.""" + return self.entity_description.value_fn(self.robot) + + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + if (icon := self.entity_description.icon_fn(self.state)) is not None: + return icon + return super().icon diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 7acfad69735..19b007de068 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -5,6 +5,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The email address of your Whisker account.", + "password": "The password of your Whisker account." } }, "reauth_confirm": { @@ -12,6 +16,9 @@ "title": "[%key:common::config_flow::title::reauth%]", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::litterrobot::config::step::user::data_description::password%]" } } }, @@ -38,6 +45,9 @@ } }, "button": { + "reset": { + "name": "Reset" + }, "reset_waste_drawer": { "name": "Reset waste drawer" }, diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 133fd897cc6..7ded89d552b 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -13,22 +13,17 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT -@dataclass(frozen=True) -class RequiredKeysMixin(Generic[_RobotT]): - """A class that describes robot switch entity required keys.""" - - set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]] - - -@dataclass(frozen=True) -class RobotSwitchEntityDescription(SwitchEntityDescription, RequiredKeysMixin[_RobotT]): +@dataclass(frozen=True, kw_only=True) +class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_RobotT]): """A class that describes robot switch entities.""" entity_category: EntityCategory = EntityCategory.CONFIG + set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]] + value_fn: Callable[[_RobotT], bool] ROBOT_SWITCHES = [ @@ -36,15 +31,32 @@ ROBOT_SWITCHES = [ key="night_light_mode_enabled", translation_key="night_light_mode", set_fn=lambda robot, value: robot.set_night_light(value), + value_fn=lambda robot: robot.night_light_mode_enabled, ), RobotSwitchEntityDescription[LitterRobot | FeederRobot]( key="panel_lock_enabled", translation_key="panel_lockout", set_fn=lambda robot, value: robot.set_panel_lockout(value), + value_fn=lambda robot: robot.panel_lock_enabled, ), ] +async def async_setup_entry( + hass: HomeAssistant, + entry: LitterRobotConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Litter-Robot switches using config entry.""" + coordinator = entry.runtime_data + async_add_entities( + RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description) + for description in ROBOT_SWITCHES + for robot in coordinator.account.robots + if isinstance(robot, (LitterRobot, FeederRobot)) + ) + + class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity): """Litter-Robot switch entity.""" @@ -53,7 +65,7 @@ class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity): @property def is_on(self) -> bool | None: """Return true if switch is on.""" - return bool(getattr(self.robot, self.entity_description.key)) + return self.entity_description.value_fn(self.robot) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -62,19 +74,3 @@ class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.entity_description.set_fn(self.robot, False) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: LitterRobotConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Litter-Robot switches using config entry.""" - hub = entry.runtime_data - entities = [ - RobotSwitchEntity(robot=robot, hub=hub, description=description) - for description in ROBOT_SWITCHES - for robot in hub.account.robots - if isinstance(robot, (LitterRobot, FeederRobot)) - ] - async_add_entities(entities) diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index ace30d9f3a9..6e3743059b3 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -15,23 +15,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT -@dataclass(frozen=True) -class RequiredKeysMixin(Generic[_RobotT]): - """A class that describes robot time entity required keys.""" +@dataclass(frozen=True, kw_only=True) +class RobotTimeEntityDescription(TimeEntityDescription, Generic[_RobotT]): + """A class that describes robot time entities.""" value_fn: Callable[[_RobotT], time | None] set_fn: Callable[[_RobotT, time], Coroutine[Any, Any, bool]] -@dataclass(frozen=True) -class RobotTimeEntityDescription(TimeEntityDescription, RequiredKeysMixin[_RobotT]): - """A class that describes robot time entities.""" - - def _as_local_time(start: datetime | None) -> time | None: """Return a datetime as local time.""" return dt_util.as_local(start).time() if start else None @@ -42,8 +37,11 @@ LITTER_ROBOT_3_SLEEP_START = RobotTimeEntityDescription[LitterRobot3]( translation_key="sleep_mode_start_time", entity_category=EntityCategory.CONFIG, value_fn=lambda robot: _as_local_time(robot.sleep_mode_start_time), - set_fn=lambda robot, value: robot.set_sleep_mode( - robot.sleep_mode_enabled, value.replace(tzinfo=dt_util.get_default_time_zone()) + set_fn=( + lambda robot, value: robot.set_sleep_mode( + robot.sleep_mode_enabled, + value.replace(tzinfo=dt_util.get_default_time_zone()), + ) ), ) @@ -54,15 +52,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" - hub = entry.runtime_data + coordinator = entry.runtime_data async_add_entities( - [ - LitterRobotTimeEntity( - robot=robot, hub=hub, description=LITTER_ROBOT_3_SLEEP_START - ) - for robot in hub.litter_robots() - if isinstance(robot, LitterRobot3) - ] + LitterRobotTimeEntity( + robot=robot, + coordinator=coordinator, + description=LITTER_ROBOT_3_SLEEP_START, + ) + for robot in coordinator.litter_robots() + if isinstance(robot, LitterRobot3) ) diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py index 1d3e1dff57c..53ab23e9db8 100644 --- a/homeassistant/components/litterrobot/update.py +++ b/homeassistant/components/litterrobot/update.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity SCAN_INTERVAL = timedelta(days=1) @@ -34,12 +34,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot update platform.""" - hub = entry.runtime_data - entities = [ - RobotUpdateEntity(robot=robot, hub=hub, description=FIRMWARE_UPDATE_ENTITY) - for robot in hub.litter_robots() + coordinator = entry.runtime_data + entities = ( + RobotUpdateEntity( + robot=robot, coordinator=coordinator, description=FIRMWARE_UPDATE_ENTITY + ) + for robot in coordinator.litter_robots() if isinstance(robot, LitterRobot4) - ] + ) async_add_entities(entities, True) diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index f5553bf5d49..2f9e2e9b24d 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -10,12 +10,9 @@ from pylitterbot.enums import LitterBoxStatus import voluptuous as vol from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_PAUSED, StateVacuumEntity, StateVacuumEntityDescription, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.core import HomeAssistant @@ -23,22 +20,22 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import LitterRobotConfigEntry +from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity SERVICE_SET_SLEEP_MODE = "set_sleep_mode" LITTER_BOX_STATUS_STATE_MAP = { - LitterBoxStatus.CLEAN_CYCLE: STATE_CLEANING, - LitterBoxStatus.EMPTY_CYCLE: STATE_CLEANING, - LitterBoxStatus.CLEAN_CYCLE_COMPLETE: STATE_DOCKED, - LitterBoxStatus.CAT_DETECTED: STATE_DOCKED, - LitterBoxStatus.CAT_SENSOR_TIMING: STATE_DOCKED, - LitterBoxStatus.DRAWER_FULL_1: STATE_DOCKED, - LitterBoxStatus.DRAWER_FULL_2: STATE_DOCKED, - LitterBoxStatus.READY: STATE_DOCKED, - LitterBoxStatus.CAT_SENSOR_INTERRUPTED: STATE_PAUSED, - LitterBoxStatus.OFF: STATE_DOCKED, + LitterBoxStatus.CLEAN_CYCLE: VacuumActivity.CLEANING, + LitterBoxStatus.EMPTY_CYCLE: VacuumActivity.CLEANING, + LitterBoxStatus.CLEAN_CYCLE_COMPLETE: VacuumActivity.DOCKED, + LitterBoxStatus.CAT_DETECTED: VacuumActivity.DOCKED, + LitterBoxStatus.CAT_SENSOR_TIMING: VacuumActivity.DOCKED, + LitterBoxStatus.DRAWER_FULL_1: VacuumActivity.DOCKED, + LitterBoxStatus.DRAWER_FULL_2: VacuumActivity.DOCKED, + LitterBoxStatus.READY: VacuumActivity.DOCKED, + LitterBoxStatus.CAT_SENSOR_INTERRUPTED: VacuumActivity.PAUSED, + LitterBoxStatus.OFF: VacuumActivity.DOCKED, } LITTER_BOX_ENTITY = StateVacuumEntityDescription( @@ -52,12 +49,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" - hub = entry.runtime_data - entities = [ - LitterRobotCleaner(robot=robot, hub=hub, description=LITTER_BOX_ENTITY) - for robot in hub.litter_robots() - ] - async_add_entities(entities) + coordinator = entry.runtime_data + async_add_entities( + LitterRobotCleaner( + robot=robot, coordinator=coordinator, description=LITTER_BOX_ENTITY + ) + for robot in coordinator.litter_robots() + ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -78,16 +76,9 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): ) @property - def state(self) -> str: + def activity(self) -> VacuumActivity: """Return the state of the cleaner.""" - return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, STATE_ERROR) - - @property - def status(self) -> str: - """Return the status of the cleaner.""" - return ( - f"{self.robot.status.text}{' (Sleeping)' if self.robot.is_sleeping else ''}" - ) + return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, VacuumActivity.ERROR) async def async_start(self) -> None: """Start a clean cycle.""" @@ -124,13 +115,3 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): ) .timetz() ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device specific state attributes.""" - return { - "is_sleeping": self.robot.is_sleeping, - "sleep_mode_enabled": self.robot.sleep_mode_enabled, - "power_status": self.robot.power_status, - "status": self.status, - } diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index 26e36e68efa..fc9e381a1c3 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Final from aiohttp import ClientConnectorError -from aiolivisi import AioLivisi +from livisi.aiolivisi import AioLivisi from homeassistant import core from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 56fe63d351f..3ecdcb486c0 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from aiolivisi.const import CAPABILITY_CONFIG +from livisi.const import CAPABILITY_CONFIG from homeassistant.components.climate import ( ClimateEntity, @@ -68,7 +68,6 @@ class LivisiClimate(LivisiEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/livisi/config_flow.py b/homeassistant/components/livisi/config_flow.py index 7317aec0abc..ce14c0e44e9 100644 --- a/homeassistant/components/livisi/config_flow.py +++ b/homeassistant/components/livisi/config_flow.py @@ -6,7 +6,8 @@ from contextlib import suppress from typing import Any from aiohttp import ClientConnectorError -from aiolivisi import AioLivisi, errors as livisi_errors +from livisi import errors as livisi_errors +from livisi.aiolivisi import AioLivisi import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 7cb5757310f..b8b282c2829 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -6,8 +6,9 @@ from datetime import timedelta from typing import Any from aiohttp import ClientConnectorError -from aiolivisi import AioLivisi, LivisiEvent, Websocket -from aiolivisi.errors import TokenExpiredException +from livisi import LivisiEvent, Websocket +from livisi.aiolivisi import AioLivisi +from livisi.errors import TokenExpiredException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 3160b8f288a..af588b0e360 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from aiolivisi.const import CAPABILITY_MAP +from livisi.const import CAPABILITY_MAP from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json index e6f46324ed8..1077cacf2c4 100644 --- a/homeassistant/components/livisi/manifest.json +++ b/homeassistant/components/livisi/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/livisi", "iot_class": "local_polling", - "requirements": ["aiolivisi==0.0.19"] + "requirements": ["livisi==0.0.24"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 27798d0456c..21a4134a8b6 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==8.2.0"] + "requirements": ["ical==8.3.0"] } diff --git a/homeassistant/components/local_file/strings.json b/homeassistant/components/local_file/strings.json index abf31a6f94e..393cc5f2e46 100644 --- a/homeassistant/components/local_file/strings.json +++ b/homeassistant/components/local_file/strings.json @@ -39,8 +39,8 @@ }, "services": { "update_file_path": { - "name": "Updates file path", - "description": "Use this action to change the file displayed by the camera.", + "name": "Update file path", + "description": "Changes the file displayed by the camera.", "fields": { "file_path": { "name": "File path", diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index c126799c39d..68154f10885 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==8.2.0"] + "requirements": ["ical==8.3.0"] } diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index fad87145e00..60eb29240cd 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -9,7 +9,7 @@ import logging import re from typing import TYPE_CHECKING, Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -31,7 +31,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, @@ -67,10 +66,6 @@ class LockEntityFeature(IntFlag): OPEN = 1 -# The SUPPORT_OPEN constant is deprecated as of Home Assistant 2022.5. -# Please use the LockEntityFeature enum instead. -_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(LockEntityFeature.OPEN, "2025.1") - PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT} # mypy: disallow-any-generics @@ -290,12 +285,7 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @cached_property def supported_features(self) -> LockEntityFeature: """Return the list of supported features.""" - features = self._attr_supported_features - if type(features) is int: # noqa: E721 - new_features = LockEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features + return self._attr_supported_features async def async_internal_added_to_hass(self) -> None: """Call when the sensor entity is added to hass.""" diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index c33325d7dcb..40b904c1279 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast -from propcache import cached_property +from propcache.api import cached_property from sqlalchemy.engine.row import Row from homeassistant.components.recorder.filters import Filters diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 77aa71740f1..a53a604daae 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -115,9 +115,9 @@ class EventProcessor: include_entity_name: bool = True, ) -> None: """Init the event stream.""" - assert not ( - context_id and (entity_ids or device_ids) - ), "can't pass in both context_id and (entity_ids or device_ids)" + assert not (context_id and (entity_ids or device_ids)), ( + "can't pass in both context_id and (entity_ids or device_ids)" + ) self.hass = hass self.ent_reg = er.async_get(hass) self.event_types = event_types diff --git a/homeassistant/components/london_underground/const.py b/homeassistant/components/london_underground/const.py index 532f4333ba9..447ed4461f3 100644 --- a/homeassistant/components/london_underground/const.py +++ b/homeassistant/components/london_underground/const.py @@ -24,4 +24,10 @@ TUBE_LINES = [ "Piccadilly", "Victoria", "Waterloo & City", + "Liberty", + "Lioness", + "Mildmay", + "Suffragette", + "Weaver", + "Windrush", ] diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index fadeb6d16fa..051a18c9a32 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -107,7 +107,6 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): _attr_min_temp = MIN_TEMP _attr_max_temp = MAX_TEMP _attr_target_temperature_step = PRECISION_WHOLE - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/lookin/config_flow.py b/homeassistant/components/lookin/config_flow.py index aaf98a06fa8..abf2982765d 100644 --- a/homeassistant/components/lookin/config_flow.py +++ b/homeassistant/components/lookin/config_flow.py @@ -9,10 +9,10 @@ import aiohttp from aiolookin import Device, LookInHttpProtocol, NoUsableService import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -28,7 +28,7 @@ class LookinFlowHandler(ConfigFlow, domain=DOMAIN): self._name: str | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Start a discovery flow from zeroconf.""" uid: str = discovery_info.hostname.removesuffix(".local.") diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 8c82a7a6964..a3879d0412f 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -11,12 +11,12 @@ from loqedAPI import cloud_loqed, loqed import voluptuous as vol from homeassistant.components import webhook -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json index e4cd4b71045..38eae7eb7b2 100644 --- a/homeassistant/components/loqed/strings.json +++ b/homeassistant/components/loqed/strings.json @@ -3,7 +3,7 @@ "flow_title": "LOQED Touch Smartlock setup", "step": { "user": { - "description": "Login at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token.", + "description": "Log in at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API key by clicking 'Create' \n* Copy the created access token.", "data": { "name": "Name of your lock in the LOQED app.", "api_token": "[%key:common::config_flow::data::api_token%]" diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index d26e4f1d2d7..9b1c86edb36 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -1,10 +1,12 @@ """Support for the Lovelace UI.""" +from dataclasses import dataclass import logging +from typing import Any import voluptuous as vol -from homeassistant.components import frontend, onboarding, websocket_api +from homeassistant.components import frontend, websocket_api from homeassistant.config import ( async_hass_config_yaml, async_process_component_and_handle_errors, @@ -14,9 +16,9 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.service import async_register_admin_service -from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration +from homeassistant.util import slugify from . import dashboard, resources, websocket from .const import ( # noqa: F401 @@ -30,6 +32,7 @@ from .const import ( # noqa: F401 DEFAULT_ICON, DOMAIN, EVENT_LOVELACE_UPDATED, + LOVELACE_DATA, MODE_STORAGE, MODE_YAML, RESOURCE_CREATE_FIELDS, @@ -39,12 +42,25 @@ from .const import ( # noqa: F401 SERVICE_RELOAD_RESOURCES, STORAGE_DASHBOARD_CREATE_FIELDS, STORAGE_DASHBOARD_UPDATE_FIELDS, - url_slug, ) from .system_health import system_health_info # noqa: F401 _LOGGER = logging.getLogger(__name__) + +def _validate_url_slug(value: Any) -> str: + """Validate value is a valid url slug.""" + if value is None: + raise vol.Invalid("Slug should not be None") + if "-" not in value: + raise vol.Invalid("Url path needs to contain a hyphen (-)") + str_value = str(value) + slg = slugify(str_value, separator="-") + if str_value == slg: + return str_value + raise vol.Invalid(f"invalid slug {value} (try {slg})") + + CONF_DASHBOARDS = "dashboards" YAML_DASHBOARD_SCHEMA = vol.Schema( @@ -64,7 +80,7 @@ CONFIG_SCHEMA = vol.Schema( ), vol.Optional(CONF_DASHBOARDS): cv.schema_with_slug_keys( YAML_DASHBOARD_SCHEMA, - slug_validator=url_slug, + slug_validator=_validate_url_slug, ), vol.Optional(CONF_RESOURCES): [RESOURCE_SCHEMA], } @@ -74,6 +90,16 @@ CONFIG_SCHEMA = vol.Schema( ) +@dataclass +class LovelaceData: + """Dataclass to store information in hass.data.""" + + mode: str + dashboards: dict[str | None, dashboard.LovelaceConfig] + resources: resources.ResourceYAMLCollection | resources.ResourceStorageCollection + yaml_dashboards: dict[str | None, ConfigType] + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Lovelace commands.""" mode = config[DOMAIN][CONF_MODE] @@ -101,9 +127,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: resource_collection = await create_yaml_resource_col( hass, config[DOMAIN].get(CONF_RESOURCES) ) - hass.data[DOMAIN]["resources"] = resource_collection + hass.data[LOVELACE_DATA].resources = resource_collection default_config: dashboard.LovelaceConfig + resource_collection: ( + resources.ResourceYAMLCollection | resources.ResourceStorageCollection + ) if mode == MODE_YAML: default_config = dashboard.LovelaceYAML(hass, None, None) resource_collection = await create_yaml_resource_col(hass, yaml_resources) @@ -152,28 +181,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, websocket.websocket_lovelace_delete_config ) - hass.data[DOMAIN] = { + hass.data[LOVELACE_DATA] = LovelaceData( + mode=mode, # We store a dictionary mapping url_path: config. None is the default. - "mode": mode, - "dashboards": {None: default_config}, - "resources": resource_collection, - "yaml_dashboards": config[DOMAIN].get(CONF_DASHBOARDS, {}), - } + dashboards={None: default_config}, + resources=resource_collection, + yaml_dashboards=config[DOMAIN].get(CONF_DASHBOARDS, {}), + ) if hass.config.recovery_mode: return True - async def storage_dashboard_changed(change_type, item_id, item): + async def storage_dashboard_changed( + change_type: str, item_id: str, item: dict + ) -> None: """Handle a storage dashboard change.""" url_path = item[CONF_URL_PATH] if change_type == collection.CHANGE_REMOVED: frontend.async_remove_panel(hass, url_path) - await hass.data[DOMAIN]["dashboards"].pop(url_path).async_delete() + await hass.data[LOVELACE_DATA].dashboards.pop(url_path).async_delete() return if change_type == collection.CHANGE_ADDED: - existing = hass.data[DOMAIN]["dashboards"].get(url_path) + existing = hass.data[LOVELACE_DATA].dashboards.get(url_path) if existing: _LOGGER.warning( @@ -183,13 +214,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) return - hass.data[DOMAIN]["dashboards"][url_path] = dashboard.LovelaceStorage( + hass.data[LOVELACE_DATA].dashboards[url_path] = dashboard.LovelaceStorage( hass, item ) update = False else: - hass.data[DOMAIN]["dashboards"][url_path].config = item + hass.data[LOVELACE_DATA].dashboards[url_path].config = item update = True try: @@ -198,10 +229,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.warning("Failed to %s panel %s from storage", change_type, url_path) # Process YAML dashboards - for url_path, dashboard_conf in hass.data[DOMAIN]["yaml_dashboards"].items(): + for url_path, dashboard_conf in hass.data[LOVELACE_DATA].yaml_dashboards.items(): # For now always mode=yaml lovelace_config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf) - hass.data[DOMAIN]["dashboards"][url_path] = lovelace_config + hass.data[LOVELACE_DATA].dashboards[url_path] = lovelace_config try: _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False) @@ -211,9 +242,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Process storage dashboards dashboards_collection = dashboard.DashboardsCollection(hass) - # This can be removed when the map integration is removed - hass.data[DOMAIN]["dashboards_collection"] = dashboards_collection - dashboards_collection.async_add_listener(storage_dashboard_changed) await dashboards_collection.async_load() @@ -225,16 +253,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: STORAGE_DASHBOARD_UPDATE_FIELDS, ).async_setup(hass) - def create_map_dashboard(): - hass.async_create_task(_create_map_dashboard(hass)) - - if not onboarding.async_is_onboarded(hass): - onboarding.async_add_listener(hass, create_map_dashboard) - return True -async def create_yaml_resource_col(hass, yaml_resources): +async def create_yaml_resource_col( + hass: HomeAssistant, yaml_resources: list[ConfigType] | None +) -> resources.ResourceYAMLCollection: """Create yaml resources collection.""" if yaml_resources is None: default_config = dashboard.LovelaceYAML(hass, None, None) @@ -254,7 +278,9 @@ async def create_yaml_resource_col(hass, yaml_resources): @callback -def _register_panel(hass, url_path, mode, config, update): +def _register_panel( + hass: HomeAssistant, url_path: str | None, mode: str, config: dict, update: bool +) -> None: """Register a panel.""" kwargs = { "frontend_url_path": url_path, @@ -268,25 +294,3 @@ def _register_panel(hass, url_path, mode, config, update): kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON) frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs) - - -async def _create_map_dashboard(hass: HomeAssistant): - translations = await async_get_translations( - hass, hass.config.language, "dashboard", {onboarding.DOMAIN} - ) - title = translations["component.onboarding.dashboard.map.title"] - - dashboards_collection: dashboard.DashboardsCollection = hass.data[DOMAIN][ - "dashboards_collection" - ] - await dashboards_collection.async_create_item( - { - CONF_ALLOW_SINGLE_WORD: True, - CONF_ICON: "mdi:map", - CONF_TITLE: title, - CONF_URL_PATH: "map", - } - ) - - map_store: dashboard.LovelaceStorage = hass.data[DOMAIN]["dashboards"]["map"] - await map_store.async_save({"strategy": {"type": "map"}}) diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py index 82a92b94ae5..635425ba3dc 100644 --- a/homeassistant/components/lovelace/cast.py +++ b/homeassistant/components/lovelace/cast.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from pychromecast import Chromecast from pychromecast.const import CAST_TYPE_CHROMECAST @@ -23,8 +25,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.network import NoURLAvailableError, get_url -from .const import DOMAIN, ConfigNotFound -from .dashboard import LovelaceConfig +from .const import DOMAIN, LOVELACE_DATA, ConfigNotFound DEFAULT_DASHBOARD = "_default_" @@ -76,7 +77,7 @@ async def async_browse_media( can_expand=False, ) ] - for url_path in hass.data[DOMAIN]["dashboards"]: + for url_path in hass.data[LOVELACE_DATA].dashboards: if url_path is None: continue @@ -101,7 +102,7 @@ async def async_browse_media( BrowseMedia( title=view["title"], media_class=MediaClass.APP, - media_content_id=f'{info["url_path"]}/{view["path"]}', + media_content_id=f"{info['url_path']}/{view['path']}", media_content_type=DOMAIN, thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png", can_play=True, @@ -151,11 +152,13 @@ async def async_play_media( return True -async def _get_dashboard_info(hass, url_path): +async def _get_dashboard_info( + hass: HomeAssistant, url_path: str | None +) -> dict[str, Any]: """Load a dashboard and return info on views.""" if url_path == DEFAULT_DASHBOARD: url_path = None - dashboard: LovelaceConfig | None = hass.data[DOMAIN]["dashboards"].get(url_path) + dashboard = hass.data[LOVELACE_DATA].dashboards.get(url_path) if dashboard is None: raise ValueError("Invalid dashboard specified") @@ -172,7 +175,7 @@ async def _get_dashboard_info(hass, url_path): url_path = dashboard.url_path title = config.get("title", url_path) if config else url_path - views = [] + views: list[dict[str, Any]] = [] data = { "title": title, "url_path": url_path, diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 86f47fe2b5c..0450c62338d 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -1,6 +1,8 @@ """Constants for Lovelace.""" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING import voluptuous as vol @@ -14,9 +16,13 @@ from homeassistant.const import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType -from homeassistant.util import slugify +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from . import LovelaceData DOMAIN = "lovelace" +LOVELACE_DATA: HassKey[LovelaceData] = HassKey(DOMAIN) DEFAULT_ICON = "hass:view-dashboard" @@ -84,18 +90,5 @@ STORAGE_DASHBOARD_CREATE_FIELDS: VolDictType = { STORAGE_DASHBOARD_UPDATE_FIELDS = DASHBOARD_BASE_UPDATE_FIELDS -def url_slug(value: Any) -> str: - """Validate value is a valid url slug.""" - if value is None: - raise vol.Invalid("Slug should not be None") - if "-" not in value: - raise vol.Invalid("Url path needs to contain a hyphen (-)") - str_value = str(value) - slg = slugify(str_value, separator="-") - if str_value == slg: - return str_value - raise vol.Invalid(f"invalid slug {value} (try {slg})") - - class ConfigNotFound(HomeAssistantError): """When no config available.""" diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 411bbae9153..ddb54e7618f 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -7,7 +7,7 @@ import logging import os from pathlib import Path import time -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -27,6 +27,7 @@ from .const import ( DOMAIN, EVENT_LOVELACE_UPDATED, LOVELACE_CONFIG_FILE, + LOVELACE_DATA, MODE_STORAGE, MODE_YAML, STORAGE_DASHBOARD_CREATE_FIELDS, @@ -66,21 +67,25 @@ class LovelaceConfig(ABC): """Return mode of the lovelace config.""" @abstractmethod - async def async_get_info(self): + async def async_get_info(self) -> dict[str, Any]: """Return the config info.""" @abstractmethod async def async_load(self, force: bool) -> dict[str, Any]: """Load config.""" - async def async_save(self, config): + async def async_save(self, config: dict[str, Any]) -> None: """Save config.""" raise HomeAssistantError("Not supported") - async def async_delete(self): + async def async_delete(self) -> None: """Delete config.""" raise HomeAssistantError("Not supported") + @abstractmethod + async def async_json(self, force: bool) -> json_fragment: + """Return JSON representation of the config.""" + @callback def _config_updated(self) -> None: """Fire config updated event.""" @@ -112,7 +117,7 @@ class LovelaceStorage(LovelaceConfig): """Return mode of the lovelace config.""" return MODE_STORAGE - async def async_get_info(self): + async def async_get_info(self) -> dict[str, Any]: """Return the Lovelace storage info.""" data = self._data or await self._load() if data["config"] is None: @@ -128,7 +133,7 @@ class LovelaceStorage(LovelaceConfig): if (config := data["config"]) is None: raise ConfigNotFound - return config + return config # type: ignore[no-any-return] async def async_json(self, force: bool) -> json_fragment: """Return JSON representation of the config.""" @@ -138,19 +143,21 @@ class LovelaceStorage(LovelaceConfig): await self._load() return self._json_config or self._async_build_json() - async def async_save(self, config): + async def async_save(self, config: dict[str, Any]) -> None: """Save config.""" if self.hass.config.recovery_mode: raise HomeAssistantError("Saving not supported in recovery mode") if self._data is None: await self._load() + if TYPE_CHECKING: + assert self._data is not None self._data["config"] = config self._json_config = None self._config_updated() await self._store.async_save(self._data) - async def async_delete(self): + async def async_delete(self) -> None: """Delete config.""" if self.hass.config.recovery_mode: raise HomeAssistantError("Deleting not supported in recovery mode") @@ -194,7 +201,7 @@ class LovelaceYAML(LovelaceConfig): """Return mode of the lovelace config.""" return MODE_YAML - async def async_get_info(self): + async def async_get_info(self) -> dict[str, Any]: """Return the YAML storage mode.""" try: config = await self.async_load(False) @@ -250,7 +257,7 @@ class LovelaceYAML(LovelaceConfig): return is_updated, config, json -def _config_info(mode, config): +def _config_info(mode: str, config: dict[str, Any]) -> dict[str, Any]: """Generate info about the config.""" return { "mode": mode, @@ -264,7 +271,7 @@ class DashboardsCollection(collection.DictStorageCollection): CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS) UPDATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_UPDATE_FIELDS) - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize the dashboards collection.""" super().__init__( storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY), @@ -282,12 +289,12 @@ class DashboardsCollection(collection.DictStorageCollection): if url_path in self.hass.data[DATA_PANELS]: raise vol.Invalid("Panel url path needs to be unique") - return self.CREATE_SCHEMA(data) + return self.CREATE_SCHEMA(data) # type: ignore[no-any-return] @callback def _get_suggested_id(self, info: dict) -> str: """Suggest an ID based on the config.""" - return info[CONF_URL_PATH] + return info[CONF_URL_PATH] # type: ignore[no-any-return] async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" @@ -315,7 +322,7 @@ class DashboardsCollectionWebSocket(collection.DictStorageCollectionWebsocket): msg["id"], [ dashboard.config - for dashboard in hass.data[DOMAIN]["dashboards"].values() + for dashboard in hass.data[LOVELACE_DATA].dashboards.values() if dashboard.config ], ) diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index 316a31e8e9d..96f84ccbc60 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -34,11 +34,11 @@ class ResourceYAMLCollection: loaded = True - def __init__(self, data): + def __init__(self, data: list[dict[str, Any]]) -> None: """Initialize a resource YAML collection.""" self.data = data - async def async_get_info(self): + async def async_get_info(self) -> dict[str, int]: """Return the resources info for YAML mode.""" return {"resources": len(self.async_items() or [])} @@ -62,7 +62,7 @@ class ResourceStorageCollection(collection.DictStorageCollection): ) self.ll_config = ll_config - async def async_get_info(self): + async def async_get_info(self) -> dict[str, int]: """Return the resources info for YAML mode.""" if not self.loaded: await self.async_load() diff --git a/homeassistant/components/lovelace/system_health.py b/homeassistant/components/lovelace/system_health.py index 1e703768ae6..b629614d10d 100644 --- a/homeassistant/components/lovelace/system_health.py +++ b/homeassistant/components/lovelace/system_health.py @@ -1,12 +1,13 @@ """Provide info to system health.""" import asyncio +from typing import Any from homeassistant.components import system_health from homeassistant.const import CONF_MODE from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN, MODE_AUTO, MODE_STORAGE, MODE_YAML +from .const import LOVELACE_DATA, MODE_AUTO, MODE_STORAGE, MODE_YAML @callback @@ -17,15 +18,17 @@ def async_register( register.async_register_info(system_health_info, "/config/lovelace") -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" - health_info = {"dashboards": len(hass.data[DOMAIN]["dashboards"])} - health_info.update(await hass.data[DOMAIN]["resources"].async_get_info()) + health_info: dict[str, Any] = { + "dashboards": len(hass.data[LOVELACE_DATA].dashboards) + } + health_info.update(await hass.data[LOVELACE_DATA].resources.async_get_info()) dashboards_info = await asyncio.gather( *( - hass.data[DOMAIN]["dashboards"][dashboard].async_get_info() - for dashboard in hass.data[DOMAIN]["dashboards"] + hass.data[LOVELACE_DATA].dashboards[dashboard].async_get_info() + for dashboard in hass.data[LOVELACE_DATA].dashboards ) ) @@ -39,7 +42,7 @@ async def system_health_info(hass): else: health_info[key] = dashboard[key] - if hass.data[DOMAIN][CONF_MODE] == MODE_YAML: + if hass.data[LOVELACE_DATA].mode == MODE_YAML: health_info[CONF_MODE] = MODE_YAML elif MODE_STORAGE in modes: health_info[CONF_MODE] = MODE_STORAGE diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index e402ba92f16..5feb7deb449 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from functools import wraps -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -13,11 +14,21 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_fragment -from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound -from .dashboard import LovelaceStorage +from .const import CONF_URL_PATH, LOVELACE_DATA, ConfigNotFound +from .dashboard import LovelaceConfig + +if TYPE_CHECKING: + from .resources import ResourceStorageCollection + +type AsyncLovelaceWebSocketCommandHandler[_R] = Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any], LovelaceConfig], + Awaitable[_R], +] -def _handle_errors(func): +def _handle_errors[_R]( + func: AsyncLovelaceWebSocketCommandHandler[_R], +) -> websocket_api.AsyncWebSocketCommandHandler: """Handle error with WebSocket calls.""" @wraps(func) @@ -27,7 +38,7 @@ def _handle_errors(func): msg: dict[str, Any], ) -> None: url_path = msg.get(CONF_URL_PATH) - config: LovelaceStorage | None = hass.data[DOMAIN]["dashboards"].get(url_path) + config = hass.data[LOVELACE_DATA].dashboards.get(url_path) if config is None: connection.send_error( @@ -74,7 +85,9 @@ async def websocket_lovelace_resources_impl( This function is called by both Storage and YAML mode WS handlers. """ - resources = hass.data[DOMAIN]["resources"] + resources = hass.data[LOVELACE_DATA].resources + if TYPE_CHECKING: + assert isinstance(resources, ResourceStorageCollection) if hass.config.safe_mode: connection.send_result(msg["id"], []) @@ -100,7 +113,7 @@ async def websocket_lovelace_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], - config: LovelaceStorage, + config: LovelaceConfig, ) -> json_fragment: """Send Lovelace UI config over WebSocket connection.""" return await config.async_json(msg["force"]) @@ -120,7 +133,7 @@ async def websocket_lovelace_save_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], - config: LovelaceStorage, + config: LovelaceConfig, ) -> None: """Save Lovelace UI configuration.""" await config.async_save(msg["config"]) @@ -139,7 +152,7 @@ async def websocket_lovelace_delete_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], - config: LovelaceStorage, + config: LovelaceConfig, ) -> None: """Delete Lovelace UI configuration.""" await config.async_delete() diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py index dc881b393de..7db8b12c8d0 100644 --- a/homeassistant/components/lutron/fan.py +++ b/homeassistant/components/lutron/fan.py @@ -51,7 +51,6 @@ class LutronFan(LutronDevice, FanEntity): ) _lutron_device: Output _prev_percentage: int | None = None - _enable_turn_on_off_backwards_compatibility = False def set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py index a74de46346b..e56758b0af6 100644 --- a/homeassistant/components/lutron_caseta/button.py +++ b/homeassistant/components/lutron_caseta/button.py @@ -53,7 +53,7 @@ async def async_setup_entry( # Append the child device name to the end of the parent keypad # name to create the entity name - full_name = f'{parent_device_info.get("name")} {device_name}' + full_name = f"{parent_device_info.get('name')} {device_name}" # Set the device_info to the same as the Parent Keypad # The entities will be nested inside the keypad device entities.append( diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index cd566b767fb..767c3d2f2b7 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -12,10 +12,10 @@ from pylutron_caseta.pairing import PAIR_CA, PAIR_CERT, PAIR_KEY, async_pair from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( ABORT_REASON_CANNOT_CONNECT, @@ -69,7 +69,7 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA_USER) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" hostname = discovery_info.hostname @@ -90,7 +90,7 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_link() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by homekit discovery.""" return await self.async_step_zeroconf(discovery_info) diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py index 7493878bece..809b9e8d007 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -16,6 +16,7 @@ BRIDGE_DEVICE_ID = "1" DEVICE_TYPE_WHITE_TUNE = "WhiteTune" DEVICE_TYPE_SPECTRUM_TUNE = "SpectrumTune" +DEVICE_TYPE_COLOR_TUNE = "ColorTune" MANUFACTURER = "Lutron Electronics Co., Inc" diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 11da2220be9..d8fac38ce2b 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -99,6 +99,7 @@ class LutronCasetaTiltOnlyBlind(LutronCasetaUpdatableEntity, CoverEntity): PYLUTRON_TYPE_TO_CLASSES = { "SerenaTiltOnlyWoodBlind": LutronCasetaTiltOnlyBlind, + "Tilt": LutronCasetaTiltOnlyBlind, "SerenaHoneycombShade": LutronCasetaShade, "SerenaRollerShade": LutronCasetaShade, "TriathlonHoneycombShade": LutronCasetaShade, diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 0b432f88045..79b792935a8 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -277,6 +277,20 @@ FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( } ) +PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP = { + "button_0": 2, + "button_2": 4, +} +PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP = { + "button_0": 0, + "button_2": 2, +} +PADDLE_SWITCH_PICO_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_SUBTYPE): vol.In(PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP), + } +) + DEVICE_TYPE_SCHEMA_MAP = { "Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA, @@ -288,6 +302,7 @@ DEVICE_TYPE_SCHEMA_MAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, "FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, } DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { @@ -300,6 +315,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LIP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP, } DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { @@ -312,6 +328,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP, } LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP: dict[str, dict[int, str]] = { @@ -326,6 +343,7 @@ TRIGGER_SCHEMA = vol.Any( PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, + PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, ) diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index e2bf7f15098..69167929e14 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -50,7 +50,6 @@ class LutronCasetaFan(LutronCasetaUpdatableEntity, FanEntity): | FanEntityFeature.TURN_ON ) _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) - _enable_turn_on_off_backwards_compatibility = False @property def percentage(self) -> int | None: diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index 146ed826c14..722c9a15d91 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -24,7 +24,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEVICE_TYPE_SPECTRUM_TUNE, DEVICE_TYPE_WHITE_TUNE +from .const import ( + DEVICE_TYPE_COLOR_TUNE, + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_WHITE_TUNE, +) from .entity import LutronCasetaUpdatableEntity from .models import LutronCasetaData @@ -35,9 +39,18 @@ SUPPORTED_COLOR_MODE_DICT = { ColorMode.WHITE, }, DEVICE_TYPE_WHITE_TUNE: {ColorMode.COLOR_TEMP}, + DEVICE_TYPE_COLOR_TUNE: { + ColorMode.HS, + ColorMode.COLOR_TEMP, + ColorMode.WHITE, + }, } -WARM_DEVICE_TYPES = {DEVICE_TYPE_WHITE_TUNE, DEVICE_TYPE_SPECTRUM_TUNE} +WARM_DEVICE_TYPES = { + DEVICE_TYPE_WHITE_TUNE, + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_COLOR_TUNE, +} def to_lutron_level(level): @@ -90,8 +103,14 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity): ) self.supports_warm_cool = light_type in WARM_DEVICE_TYPES - self.supports_warm_dim = light_type == DEVICE_TYPE_SPECTRUM_TUNE - self.supports_spectrum_tune = light_type == DEVICE_TYPE_SPECTRUM_TUNE + self.supports_warm_dim = light_type in ( + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_COLOR_TUNE, + ) + self.supports_spectrum_tune = light_type in ( + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_COLOR_TUNE, + ) def _get_min_color_temp_kelvin(self, light: dict[str, Any]) -> int: """Return minimum supported color temperature. diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index ec278615743..bbb6df41a89 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.22.0"], + "requirements": ["pylutron-caseta==0.23.0"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index 5037d077a02..66f23926fbf 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -44,7 +44,7 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity): parent_keypad = keypads[device["parent_device"]] parent_device_info = parent_keypad["device_info"] # Append the child device name to the end of the parent keypad name to create the entity name - self._attr_name = f'{parent_device_info["name"]} {device["device_name"]}' + self._attr_name = f"{parent_device_info['name']} {device['device_name']}" # Set the device_info to the same as the Parent Keypad # The entities will be nested inside the keypad device self._attr_device_info = parent_device_info diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index bf8e17527e8..87b5d566bb8 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -174,7 +174,6 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/madvr/strings.json b/homeassistant/components/madvr/strings.json index 1a4f0f79aae..19f23afddaf 100644 --- a/homeassistant/components/madvr/strings.json +++ b/homeassistant/components/madvr/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Setup madVR Envy", + "title": "Set up madVR Envy", "description": "Your device needs to be on in order to add the integation.", "data": { "host": "[%key:common::config_flow::data::host%]", @@ -21,8 +21,8 @@ "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "The hostname or IP address of your madVR Envy device.", - "port": "The port your madVR Envy is listening on. In 99% of cases, leave this as the default." + "host": "[%key:component::madvr::config::step::user::data_description::host%]", + "port": "[%key:component::madvr::config::step::user::data_description::port%]" } } }, @@ -33,7 +33,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable." + "no_mac": "A MAC address was not found. It is required to identify the device. Please ensure your device is connectable." } }, "entity": { diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index e8d23434248..f7f974ffbb0 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -81,7 +81,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> ) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: """Migrate old config.""" if entry.version == 1 and entry.minor_version == 1: @@ -113,7 +113,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def setup_mastodon(entry: ConfigEntry) -> tuple[Mastodon, dict, dict]: +def setup_mastodon(entry: MastodonConfigEntry) -> tuple[Mastodon, dict, dict]: """Get mastodon details.""" client = create_mastodon_client( entry.data[CONF_BASE_URL], diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py index 7c0985570f7..1b93cbecd98 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -8,13 +8,8 @@ from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError import voluptuous as vol from yarl import URL -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_NAME, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, @@ -22,7 +17,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.util import slugify -from .const import CONF_BASE_URL, DEFAULT_URL, DOMAIN, LOGGER +from .const import CONF_BASE_URL, DOMAIN, LOGGER from .utils import construct_mastodon_username, create_mastodon_client STEP_USER_DATA_SCHEMA = vol.Schema( @@ -53,7 +48,6 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 2 - config_entry: ConfigEntry def check_connection( self, @@ -131,44 +125,3 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.show_user_form(user_input, errors) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import a config entry from configuration.yaml.""" - errors: dict[str, str] | None = None - - LOGGER.debug("Importing Mastodon from configuration.yaml") - - base_url = base_url_from_url(str(import_data.get(CONF_BASE_URL, DEFAULT_URL))) - client_id = str(import_data.get(CONF_CLIENT_ID)) - client_secret = str(import_data.get(CONF_CLIENT_SECRET)) - access_token = str(import_data.get(CONF_ACCESS_TOKEN)) - name = import_data.get(CONF_NAME) - - instance, account, errors = await self.hass.async_add_executor_job( - self.check_connection, - base_url, - client_id, - client_secret, - access_token, - ) - - if not errors: - name = construct_mastodon_username(instance, account) - await self.async_set_unique_id(slugify(name)) - self._abort_if_unique_id_configured() - - if not name: - name = construct_mastodon_username(instance, account) - - return self.async_create_entry( - title=name, - data={ - CONF_BASE_URL: base_url, - CONF_CLIENT_ID: client_id, - CONF_CLIENT_SECRET: client_secret, - CONF_ACCESS_TOKEN: access_token, - }, - ) - - reason = next(iter(errors.items()))[1] - return self.async_abort(reason=reason) diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 7878fc665a1..dd76d44a02c 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -14,14 +14,12 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_BASE_URL, DEFAULT_URL, DOMAIN, LOGGER +from .const import CONF_BASE_URL, DEFAULT_URL, LOGGER ATTR_MEDIA = "media" ATTR_TARGET = "target" @@ -46,51 +44,7 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> MastodonNotificationService | None: """Get the Mastodon notification service.""" - - if not discovery_info: - # Import config entry - - import_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - - if ( - import_result["type"] == FlowResultType.ABORT - and import_result["reason"] != "already_configured" - ): - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{import_result["reason"]}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{import_result["reason"]}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - return None - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - + if discovery_info is None: return None client: Mastodon = discovery_info.get("client") @@ -161,7 +115,7 @@ class MastodonNotificationService(BaseNotificationService): try: mediadata = self.client.media_post(media_path, mime_type=media_type) except MastodonAPIError: - LOGGER.error(f"Unable to upload image {media_path}") + LOGGER.error("Unable to upload image %s", media_path) return mediadata diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml new file mode 100644 index 00000000000..86702095e95 --- /dev/null +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -0,0 +1,99 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: + status: todo + comment: | + Mastodon.py does not have CI build/publish. + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: todo + comment: | + Legacy Notify needs rewriting once Notify architecture stabilizes. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + There are no configuration options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: todo + comment: | + Does not set parallel-updates on notify platform. + reauthentication-flow: + status: todo + comment: | + Waiting to move to oAuth. + test-coverage: + status: todo + comment: | + Legacy Notify needs rewriting once Notify architecture stabilizes. + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + Web service does not support discovery. + discovery: + status: exempt + comment: | + Web service does not support discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration connects to a single web service. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: todo + comment: | + Waiting to move to OAuth. + repair-issues: done + stale-devices: + status: exempt + comment: | + Web service does not go stale. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: + status: todo + comment: | + Requirement 'Mastodon.py==1.8.1' appears untyped diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py index a7a1d40fcc4..1bb59ad7c05 100644 --- a/homeassistant/components/mastodon/sensor.py +++ b/homeassistant/components/mastodon/sensor.py @@ -23,6 +23,9 @@ from .const import ( ) from .entity import MastodonEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class MastodonSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index c6aefefca06..9df94ecf204 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -25,20 +25,6 @@ "unknown": "Unknown error occured when connecting to the Mastodon instance." } }, - "issues": { - "deprecated_yaml_import_issue_unauthorized_error": { - "title": "YAML import failed due to an authentication error", - "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration." - }, - "deprecated_yaml_import_issue_network_error": { - "title": "YAML import failed because the instance was not found", - "description": "Configuring {integration_title} using YAML is being removed but no instance was found while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration." - }, - "deprecated_yaml_import_issue_unknown": { - "title": "YAML import failed with unknown error", - "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration." - } - }, "entity": { "sensor": { "followers": { diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index e06eed1176f..b173a2c850b 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["matrix_client"], "quality_scale": "legacy", - "requirements": ["matrix-nio==0.25.2", "Pillow==11.0.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==11.1.0"] } diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index e751387d7e8..e3e30fb704b 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -178,7 +178,7 @@ async def _client_listen( if entry.state != ConfigEntryState.LOADED: raise LOGGER.error("Failed to listen: %s", err) - except Exception as err: # noqa: BLE001 + except Exception as err: # We need to guard against unknown exceptions to not crash this task. LOGGER.exception("Unexpected exception: %s", err) if entry.state != ConfigEntryState.LOADED: diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 475e4a44538..dad780d9a87 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters -from matter_server.client.models.device_types import BridgedDevice +from matter_server.client.models.device_types import BridgedNode from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.config_entries import ConfigEntry @@ -45,6 +45,7 @@ class MatterAdapter: self.hass = hass self.config_entry = config_entry self.platform_handlers: dict[Platform, AddEntitiesCallback] = {} + self.discovered_entities: set[str] = set() def register_platform_handler( self, platform: Platform, add_entities: AddEntitiesCallback @@ -54,23 +55,19 @@ class MatterAdapter: async def setup_nodes(self) -> None: """Set up all existing nodes and subscribe to new nodes.""" - initialized_nodes: set[int] = set() for node in self.matter_client.get_nodes(): - initialized_nodes.add(node.node_id) self._setup_node(node) def node_added_callback(event: EventType, node: MatterNode) -> None: """Handle node added event.""" - initialized_nodes.add(node.node_id) self._setup_node(node) def node_updated_callback(event: EventType, node: MatterNode) -> None: """Handle node updated event.""" - if node.node_id in initialized_nodes: - return if not node.available: return - initialized_nodes.add(node.node_id) + # We always run the discovery logic again, + # because the firmware version could have been changed or features added. self._setup_node(node) def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None: @@ -165,7 +162,7 @@ class MatterAdapter: ( x for x in endpoint.device_types - if x.device_type != BridgedDevice.device_type + if x.device_type != BridgedNode.device_type ), None, ) @@ -237,11 +234,20 @@ class MatterAdapter: self._create_device_registry(endpoint) # run platform discovery from device type instances for entity_info in async_discover_entities(endpoint): + discovery_key = ( + f"{entity_info.platform}_{endpoint.node.node_id}_{endpoint.endpoint_id}_" + f"{entity_info.primary_attribute.cluster_id}_" + f"{entity_info.primary_attribute.attribute_id}_" + f"{entity_info.entity_description.key}" + ) + if discovery_key in self.discovered_entities: + continue LOGGER.debug( "Creating %s entity for %s", entity_info.platform, entity_info.primary_attribute, ) + self.discovered_entities.add(discovery_key) new_entity = entity_info.entity_class( self.matter_client, endpoint, entity_info ) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 875b063dc88..6882078a712 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -159,6 +159,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterBinarySensor, required_attributes=(clusters.DoorLock.Attributes.DoorState,), + featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 918b334061b..153124a4f7e 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -69,6 +69,7 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterCommandButton, required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,), value_contains=clusters.Identify.Commands.Identify.command_id, + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.BUTTON, diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index cdbe1e36245..be6f024695d 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -187,7 +187,7 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF _feature_map: int | None = None - _enable_turn_on_off_backwards_compatibility = False + _platform_translation_key = "thermostat" async def async_set_temperature(self, **kwargs: Any) -> None: @@ -310,13 +310,11 @@ class MatterClimate(MatterEntity, ClimateEntity): ): match running_state_value: case ( - ThermostatRunningState.Heat - | ThermostatRunningState.HeatStage2 + ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2 ): self._attr_hvac_action = HVACAction.HEATING case ( - ThermostatRunningState.Cool - | ThermostatRunningState.CoolStage2 + ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2 ): self._attr_hvac_action = HVACAction.COOLING case ( diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py index 6f7505eb61f..0c73ccd4089 100644 --- a/homeassistant/components/matter/config_flow.py +++ b/homeassistant/components/matter/config_flow.py @@ -16,7 +16,6 @@ from homeassistant.components.hassio import ( AddonState, ) from homeassistant.components.onboarding import async_is_onboarded -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant @@ -25,6 +24,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .addon import get_addon_manager from .const import ( diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py index a0e160a6c01..8018d5e09ed 100644 --- a/homeassistant/components/matter/const.py +++ b/homeassistant/components/matter/const.py @@ -13,3 +13,5 @@ LOGGER = logging.getLogger(__package__) # prefixes to identify device identifier id types ID_TYPE_DEVICE_ID = "deviceid" ID_TYPE_SERIAL = "serial" + +FEATUREMAP_ATTRIBUTE_ID = 65532 diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 5b07f9a069f..de03d250836 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -13,6 +13,7 @@ from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS from .button import DISCOVERY_SCHEMAS as BUTTON_SCHEMAS from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS +from .const import FEATUREMAP_ATTRIBUTE_ID from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS @@ -121,12 +122,33 @@ def async_discover_entities( continue # check for required value in (primary) attribute + primary_attribute = schema.required_attributes[0] + primary_value = endpoint.get_attribute_value(None, primary_attribute) if schema.value_contains is not None and ( - (primary_attribute := next((x for x in schema.required_attributes), None)) - is None - or (value := endpoint.get_attribute_value(None, primary_attribute)) is None - or not isinstance(value, list) - or schema.value_contains not in value + isinstance(primary_value, list) + and schema.value_contains not in primary_value + ): + continue + + # check for value that may not be present + if schema.value_is_not is not None and ( + schema.value_is_not == primary_value + or ( + isinstance(primary_value, list) and schema.value_is_not in primary_value + ) + ): + continue + + # check for required value in cluster featuremap + if schema.featuremap_contains is not None and ( + not bool( + int( + endpoint.get_attribute_value( + primary_attribute.cluster_id, FEATUREMAP_ATTRIBUTE_ID + ) + ) + & schema.featuremap_contains + ) ): continue @@ -147,6 +169,7 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, + discovery_schema=schema, ) # prevent re-discovery of the primary attribute if not allowed diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 7c378fe465e..61c62d8b564 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -11,14 +11,15 @@ from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage -from propcache import cached_property +from propcache.api import cached_property from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import UndefinedType -from .const import DOMAIN, ID_TYPE_DEVICE_ID +from .const import DOMAIN, FEATUREMAP_ATTRIBUTE_ID, ID_TYPE_DEVICE_ID from .helpers import get_device_id if TYPE_CHECKING: @@ -140,6 +141,19 @@ class MatterEntity(Entity): node_filter=self._endpoint.node.node_id, ) ) + # subscribe to FeatureMap attribute (as that can dynamically change) + self._unsubscribes.append( + self.matter_client.subscribe_events( + callback=self._on_featuremap_update, + event_filter=EventType.ATTRIBUTE_UPDATED, + node_filter=self._endpoint.node.node_id, + attr_path_filter=create_attribute_path( + endpoint=self._endpoint.endpoint_id, + cluster_id=self._entity_info.primary_attribute.cluster_id, + attribute_id=FEATUREMAP_ATTRIBUTE_ID, + ), + ) + ) @cached_property def name(self) -> str | UndefinedType | None: @@ -159,6 +173,29 @@ class MatterEntity(Entity): self._update_from_device() self.async_write_ha_state() + @callback + def _on_featuremap_update( + self, event: EventType, data: tuple[int, str, int] | None + ) -> None: + """Handle FeatureMap attribute updates.""" + if data is None: + return + new_value = data[2] + # handle edge case where a Feature is removed from a cluster + if ( + self._entity_info.discovery_schema.featuremap_contains is not None + and not bool( + new_value & self._entity_info.discovery_schema.featuremap_contains + ) + ): + # this entity is no longer supported by the device + ent_reg = er.async_get(self.hass) + ent_reg.async_remove(self.entity_id) + + return + # all other cases, just update the entity + self._on_matter_event(event, data) + @callback def _update_from_device(self) -> None: """Update data from Matter device.""" diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 51c2fb0c882..593693dbbf9 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -58,7 +58,7 @@ class MatterFan(MatterEntity, FanEntity): _last_known_preset_mode: str | None = None _last_known_percentage: int = 0 - _enable_turn_on_off_backwards_compatibility = False + _feature_map: int | None = None _platform_translation_key = "fan" diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 32c9f057e47..bd8665eb18b 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -36,6 +36,11 @@ } } }, + "select": { + "temperature_level": { + "default": "mdi:thermometer" + } + }, "sensor": { "contamination_state": { "default": "mdi:air-filter" @@ -43,6 +48,9 @@ "air_quality": { "default": "mdi:air-filter" }, + "bat_replacement_description": { + "default": "mdi:battery-sync" + }, "hepa_filter_condition": { "default": "mdi:filter-check" }, @@ -54,6 +62,18 @@ }, "valve_position": { "default": "mdi:valve" + }, + "battery_replacement_description": { + "default": "mdi:battery-sync-outline" + } + }, + "switch": { + "child_lock": { + "default": "mdi:lock", + "state": { + "on": "mdi:lock", + "off": "mdi:lock-off" + } } } } diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 6d184bcc01f..5a2768d1d50 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -9,10 +9,12 @@ from matter_server.client.models import device_types from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ColorMode, LightEntity, LightEntityDescription, @@ -23,6 +25,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from .const import LOGGER from .entity import MatterEntity @@ -37,9 +40,9 @@ from .util import ( ) COLOR_MODE_MAP = { - clusters.ColorControl.Enums.ColorMode.kCurrentHueAndCurrentSaturation: ColorMode.HS, - clusters.ColorControl.Enums.ColorMode.kCurrentXAndCurrentY: ColorMode.XY, - clusters.ColorControl.Enums.ColorMode.kColorTemperature: ColorMode.COLOR_TEMP, + clusters.ColorControl.Enums.ColorModeEnum.kCurrentHueAndCurrentSaturation: ColorMode.HS, + clusters.ColorControl.Enums.ColorModeEnum.kCurrentXAndCurrentY: ColorMode.XY, + clusters.ColorControl.Enums.ColorModeEnum.kColorTemperatureMireds: ColorMode.COLOR_TEMP, } # there's a bug in (at least) Espressif's implementation of light transitions @@ -90,6 +93,8 @@ class MatterLight(MatterEntity, LightEntity): _supports_color_temperature = False _transitions_disabled = False _platform_translation_key = "light" + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN async def _set_xy_color( self, xy_color: tuple[float, float], transition: float = 0.0 @@ -131,12 +136,16 @@ class MatterLight(MatterEntity, LightEntity): ) ) - async def _set_color_temp(self, color_temp: int, transition: float = 0.0) -> None: + async def _set_color_temp( + self, color_temp_kelvin: int, transition: float = 0.0 + ) -> None: """Set color temperature.""" - + color_temp_mired = color_util.color_temperature_kelvin_to_mired( + color_temp_kelvin + ) await self.send_device_command( clusters.ColorControl.Commands.MoveToColorTemperature( - colorTemperatureMireds=color_temp, + colorTemperatureMireds=color_temp_mired, # transition in matter is measured in tenths of a second transitionTime=int(transition * 10), # allow setting the color while the light is off, @@ -286,7 +295,7 @@ class MatterLight(MatterEntity, LightEntity): hs_color = kwargs.get(ATTR_HS_COLOR) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = kwargs.get(ATTR_COLOR_TEMP) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) brightness = kwargs.get(ATTR_BRIGHTNESS) transition = kwargs.get(ATTR_TRANSITION, 0) if self._transitions_disabled: @@ -298,10 +307,10 @@ class MatterLight(MatterEntity, LightEntity): elif xy_color is not None and ColorMode.XY in self.supported_color_modes: await self._set_xy_color(xy_color, transition) elif ( - color_temp is not None + color_temp_kelvin is not None and ColorMode.COLOR_TEMP in self.supported_color_modes ): - await self._set_color_temp(color_temp, transition) + await self._set_color_temp(color_temp_kelvin, transition) if brightness is not None and self._supports_brightness: await self._set_brightness(brightness, transition) @@ -346,21 +355,21 @@ class MatterLight(MatterEntity, LightEntity): if ( capabilities - & clusters.ColorControl.Bitmaps.ColorCapabilities.kHueSaturationSupported + & clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kHueSaturation ): supported_color_modes.add(ColorMode.HS) self._supports_color = True if ( capabilities - & clusters.ColorControl.Bitmaps.ColorCapabilities.kXYAttributesSupported + & clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kXy ): supported_color_modes.add(ColorMode.XY) self._supports_color = True if ( capabilities - & clusters.ColorControl.Bitmaps.ColorCapabilities.kColorTemperatureSupported + & clusters.ColorControl.Bitmaps.ColorCapabilitiesBitmap.kColorTemperature ): supported_color_modes.add(ColorMode.COLOR_TEMP) self._supports_color_temperature = True @@ -368,12 +377,16 @@ class MatterLight(MatterEntity, LightEntity): clusters.ColorControl.Attributes.ColorTempPhysicalMinMireds ) if min_mireds > 0: - self._attr_min_mireds = min_mireds + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(min_mireds) + ) max_mireds = self.get_matter_attribute_value( clusters.ColorControl.Attributes.ColorTempPhysicalMaxMireds ) - if min_mireds > 0: - self._attr_max_mireds = max_mireds + if max_mireds > 0: + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(max_mireds) + ) supported_color_modes = filter_supported_color_modes(supported_color_modes) self._attr_supported_color_modes = supported_color_modes @@ -399,8 +412,13 @@ class MatterLight(MatterEntity, LightEntity): if self._supports_brightness: self._attr_brightness = self._get_brightness() - if self._supports_color_temperature: - self._attr_color_temp = self._get_color_temperature() + if ( + self._supports_color_temperature + and (color_temperature := self._get_color_temperature()) > 0 + ): + self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( + color_temperature + ) if self._supports_color: self._attr_color_mode = color_mode = self._get_color_mode() @@ -414,7 +432,7 @@ class MatterLight(MatterEntity, LightEntity): and color_mode == ColorMode.XY ): self._attr_xy_color = self._get_xy_color() - elif self._attr_color_temp is not None: + elif self._attr_color_temp_kelvin is not None: self._attr_color_mode = ColorMode.COLOR_TEMP elif self._attr_brightness is not None: self._attr_color_mode = ColorMode.BRIGHTNESS diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index c5e10554fe7..d69d0fd3dab 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -206,6 +206,5 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterLock, required_attributes=(clusters.DoorLock.Attributes.LockState,), - optional_attributes=(clusters.DoorLock.Attributes.DoorState,), ), ] diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 4573fe17401..669fa1af8c4 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -7,6 +7,6 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==6.6.0"], + "requirements": ["python-matter-server==7.0.0"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index f04c0f7e107..f1fd7ca9fa3 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -51,6 +51,9 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type + # the original discovery schema used to create this entity + discovery_schema: MatterDiscoverySchema + @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -113,6 +116,15 @@ class MatterDiscoverySchema: # NOTE: only works for list values value_contains: Any | None = None + # [optional] the primary attribute value must NOT have this value + # for example to filter out invalid values (such as empty string instead of null) + # in case of a list value, the list may not contain this value + value_is_not: Any | None = None + + # [optional] the primary attribute's cluster featuremap must contain this value + # for example for the DoorSensor on a DoorLock Cluster + featuremap_contains: int | None = None + # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index cc312cdc66a..22929c60b89 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -15,7 +15,13 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform, UnitOfLength, UnitOfTime +from homeassistant.const import ( + EntityCategory, + Platform, + UnitOfLength, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -155,4 +161,25 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterNumber, required_attributes=(custom_clusters.EveCluster.Attributes.Altitude,), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="EveTemperatureOffset", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + translation_key="temperature_offset", + native_max_value=25, + native_min_value=-25, + native_step=0.5, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + measurement_to_ha=lambda x: None if x is None else x / 10, + ha_to_native_value=lambda x: round(x * 10), + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + clusters.Thermostat.Attributes.LocalTemperatureCalibration, + ), + vendor_id=(4874,), + ), ] diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 1a2fc36c014..317c8515d4b 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand from chip.clusters.Types import Nullable from matter_server.common.helpers.util import create_attribute_path_from_attribute @@ -47,7 +49,18 @@ class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescrip """Describe Matter select entities.""" -class MatterSelectEntity(MatterEntity, SelectEntity): +@dataclass(frozen=True, kw_only=True) +class MatterListSelectEntityDescription(MatterSelectEntityDescription): + """Describe Matter select entities for MatterListSelectEntity.""" + + # command: a callback to create the command to send to the device + # the callback's argument will be the index of the selected list value + command: Callable[[int], ClusterCommand] + # list attribute: the attribute descriptor to get the list of values (= list of strings) + list_attribute: type[ClusterAttributeDescriptor] + + +class MatterAttributeSelectEntity(MatterEntity, SelectEntity): """Representation of a select entity from Matter Attribute read/write.""" entity_description: MatterSelectEntityDescription @@ -76,7 +89,7 @@ class MatterSelectEntity(MatterEntity, SelectEntity): self._attr_current_option = value_convert(value) -class MatterModeSelectEntity(MatterSelectEntity): +class MatterModeSelectEntity(MatterAttributeSelectEntity): """Representation of a select entity from Matter (Mode) Cluster attribute(s).""" async def async_select_option(self, option: str) -> None: @@ -111,6 +124,37 @@ class MatterModeSelectEntity(MatterSelectEntity): self._attr_name = desc +class MatterListSelectEntity(MatterEntity, SelectEntity): + """Representation of a select entity from Matter list and selected item Cluster attribute(s).""" + + entity_description: MatterListSelectEntityDescription + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + option_id = self._attr_options.index(option) + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=self.entity_description.command(option_id), + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + list_values = cast( + list[str], + self.get_matter_attribute_value(self.entity_description.list_attribute), + ) + self._attr_options = list_values + current_option_idx: int = self.get_matter_attribute_value( + self._entity_info.primary_attribute + ) + try: + self._attr_current_option = list_values[current_option_idx] + except IndexError: + self._attr_current_option = None + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -230,7 +274,7 @@ DISCOVERY_SCHEMAS = [ "previous": None, }.get, ), - entity_class=MatterSelectEntity, + entity_class=MatterAttributeSelectEntity, required_attributes=(clusters.OnOff.Attributes.StartUpOnOff,), ), MatterDiscoverySchema( @@ -251,7 +295,44 @@ DISCOVERY_SCHEMAS = [ "low": 2, }.get, ), - entity_class=MatterSelectEntity, + entity_class=MatterAttributeSelectEntity, required_attributes=(clusters.SmokeCoAlarm.Attributes.SmokeSensitivityLevel,), ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterSelectEntityDescription( + key="TrvTemperatureDisplayMode", + entity_category=EntityCategory.CONFIG, + translation_key="temperature_display_mode", + options=["Celsius", "Fahrenheit"], + measurement_to_ha={ + 0: "Celsius", + 1: "Fahrenheit", + }.get, + ha_to_native_value={ + "Celsius": 0, + "Fahrenheit": 1, + }.get, + ), + entity_class=MatterAttributeSelectEntity, + required_attributes=( + clusters.ThermostatUserInterfaceConfiguration.Attributes.TemperatureDisplayMode, + ), + ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterListSelectEntityDescription( + key="TemperatureControlSelectedTemperatureLevel", + translation_key="temperature_level", + command=lambda selected_index: clusters.TemperatureControl.Commands.SetTemperature( + targetTemperatureLevel=selected_index + ), + list_attribute=clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, + ), + entity_class=MatterListSelectEntity, + required_attributes=( + clusters.TemperatureControl.Attributes.SelectedTemperatureLevel, + clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, + ), + ), ] diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index e10f081d497..d8fe56278df 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -222,15 +222,31 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="PowerSourceBatVoltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, - measurement_to_ha=lambda x: x / 1000, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatVoltage,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PowerSourceBatReplacementDescription", + translation_key="battery_replacement_description", + native_unit_of_measurement=None, + device_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.PowerSource.Attributes.BatReplacementDescription, + ), + # Some manufacturers returns an empty string + value_is_not="", + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -566,10 +582,10 @@ DISCOVERY_SCHEMAS = [ key="ElectricalPowerMeasurementWatt", device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=UnitOfPower.WATT, + native_unit_of_measurement=UnitOfPower.MILLIWATT, + suggested_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, - measurement_to_ha=lambda x: x / 1000, ), entity_class=MatterSensor, required_attributes=( @@ -582,10 +598,10 @@ DISCOVERY_SCHEMAS = [ key="ElectricalPowerMeasurementVoltage", device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, suggested_display_precision=0, state_class=SensorStateClass.MEASUREMENT, - measurement_to_ha=lambda x: x / 1000, ), entity_class=MatterSensor, required_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,), @@ -596,10 +612,10 @@ DISCOVERY_SCHEMAS = [ key="ElectricalPowerMeasurementActiveCurrent", device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, - measurement_to_ha=lambda x: x / 1000, ), entity_class=MatterSensor, required_attributes=( @@ -612,11 +628,12 @@ DISCOVERY_SCHEMAS = [ key="ElectricalEnergyMeasurementCumulativeEnergyImported", device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, # id 0 of the EnergyMeasurementStruct is the cumulative energy (in mWh) - measurement_to_ha=lambda x: x.energy / 1000000, + measurement_to_ha=lambda x: x.energy, ), entity_class=MatterSensor, required_attributes=( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 69fa68765b3..4054adba530 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -161,6 +161,9 @@ }, "altitude": { "name": "Altitude above Sea Level" + }, + "temperature_offset": { + "name": "Temperature offset" } }, "light": { @@ -196,6 +199,12 @@ "toggle": "[%key:common::action::toggle%]", "previous": "Previous" } + }, + "temperature_level": { + "name": "Temperature level" + }, + "temperature_display_mode": { + "name": "Temperature display mode" } }, "sensor": { @@ -245,6 +254,9 @@ }, "valve_position": { "name": "Valve position" + }, + "battery_replacement_description": { + "name": "Battery type" } }, "switch": { @@ -253,6 +265,9 @@ }, "power": { "name": "Power" + }, + "child_lock": { + "name": "Child lock" } }, "vacuum": { diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 75269de953c..2a1e6d59a06 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -2,10 +2,12 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from chip.clusters import Objects as clusters from matter_server.client.models import device_types +from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.switch import ( SwitchDeviceClass, @@ -13,11 +15,11 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -61,6 +63,49 @@ class MatterSwitch(MatterEntity, SwitchEntity): ) +@dataclass(frozen=True) +class MatterNumericSwitchEntityDescription( + SwitchEntityDescription, MatterEntityDescription +): + """Describe Matter Numeric Switch entities.""" + + +class MatterNumericSwitch(MatterSwitch): + """Representation of a Matter Enum Attribute as a Switch entity.""" + + entity_description: MatterNumericSwitchEntityDescription + + async def _async_set_native_value(self, value: bool) -> None: + """Update the current value.""" + matter_attribute = self._entity_info.primary_attribute + if value_convert := self.entity_description.ha_to_native_value: + send_value = value_convert(value) + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + matter_attribute, + ), + value=send_value, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + await self._async_set_native_value(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + await self._async_set_native_value(False) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.measurement_to_ha: + value = value_convert(value) + self._attr_is_on = value + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -139,4 +184,25 @@ DISCOVERY_SCHEMAS = [ device_types.Speaker, ), ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=MatterNumericSwitchEntityDescription( + key="EveTrvChildLock", + entity_category=EntityCategory.CONFIG, + translation_key="child_lock", + measurement_to_ha={ + 0: False, + 1: True, + }.get, + ha_to_native_value={ + False: 0, + True: 1, + }.get, + ), + entity_class=MatterNumericSwitch, + required_attributes=( + clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout, + ), + vendor_id=(4874,), + ), ] diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 2ecd7128df6..e98e1ad0bbd 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -9,16 +9,13 @@ from chip.clusters import Objects as clusters from matter_server.client.models import device_types from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_RETURNING, StateVacuumEntity, StateVacuumEntityDescription, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_IDLE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -127,25 +124,25 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): operational_state: int = self.get_matter_attribute_value( clusters.RvcOperationalState.Attributes.OperationalState ) - state: str | None = None + state: VacuumActivity | None = None if TYPE_CHECKING: assert self._supported_run_modes is not None if operational_state in (OperationalState.CHARGING, OperationalState.DOCKED): - state = STATE_DOCKED + state = VacuumActivity.DOCKED elif operational_state == OperationalState.SEEKING_CHARGER: - state = STATE_RETURNING + state = VacuumActivity.RETURNING elif operational_state in ( OperationalState.UNABLE_TO_COMPLETE_OPERATION, OperationalState.UNABLE_TO_START_OR_RESUME, ): - state = STATE_ERROR + state = VacuumActivity.ERROR elif (run_mode := self._supported_run_modes.get(run_mode_raw)) is not None: tags = {x.value for x in run_mode.modeTags} if ModeTag.CLEANING in tags: - state = STATE_CLEANING + state = VacuumActivity.CLEANING elif ModeTag.IDLE in tags: - state = STATE_IDLE - self._attr_state = state + state = VacuumActivity.IDLE + self._attr_activity = state @callback def _calculate_features(self) -> None: diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index b14efbbe073..296da4f0ab4 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -73,7 +73,6 @@ class MaxCubeClimate(ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, handler, device): """Initialize MAX! Cube ClimateEntity.""" @@ -172,8 +171,8 @@ class MaxCubeClimate(ClimateEntity): else: return None - # Assume heating when valve is open - if valve > 0: + # Assume heating when valve is open. + if valve: return HVACAction.HEATING return HVACAction.OFF if self.hvac_mode == HVACMode.OFF else HVACAction.IDLE diff --git a/homeassistant/components/mcp_server/__init__.py b/homeassistant/components/mcp_server/__init__.py new file mode 100644 index 00000000000..e523f46228f --- /dev/null +++ b/homeassistant/components/mcp_server/__init__.py @@ -0,0 +1,43 @@ +"""The Model Context Protocol Server integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from . import http +from .const import DOMAIN +from .session import SessionManager +from .types import MCPServerConfigEntry + +__all__ = [ + "CONFIG_SCHEMA", + "DOMAIN", + "async_setup", + "async_setup_entry", + "async_unload_entry", +] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Model Context Protocol component.""" + http.async_register(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: MCPServerConfigEntry) -> bool: + """Set up Model Context Protocol Server from a config entry.""" + + entry.runtime_data = SessionManager() + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: MCPServerConfigEntry) -> bool: + """Unload a config entry.""" + session_manager = entry.runtime_data + session_manager.close() + return True diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py new file mode 100644 index 00000000000..8d68c6a868a --- /dev/null +++ b/homeassistant/components/mcp_server/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for the Model Context Protocol Server integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +MORE_INFO_URL = "https://www.home-assistant.io/integrations/mcp_server/#configuration" + + +class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Model Context Protocol Server.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + llm_apis = {api.id: api.name for api in llm.async_get_apis(self.hass)} + + if user_input is not None: + return self.async_create_entry( + title=llm_apis[user_input[CONF_LLM_HASS_API]], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_LLM_HASS_API, + default=llm.LLM_API_ASSIST, + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + label=name, + value=llm_api_id, + ) + for llm_api_id, name in llm_apis.items() + ] + ) + ), + } + ), + description_placeholders={"more_info_url": MORE_INFO_URL}, + ) diff --git a/homeassistant/components/mcp_server/const.py b/homeassistant/components/mcp_server/const.py new file mode 100644 index 00000000000..1aa81f445a1 --- /dev/null +++ b/homeassistant/components/mcp_server/const.py @@ -0,0 +1,4 @@ +"""Constants for the Model Context Protocol Server integration.""" + +DOMAIN = "mcp_server" +TITLE = "Model Context Protocol Server" diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py new file mode 100644 index 00000000000..433d978cef7 --- /dev/null +++ b/homeassistant/components/mcp_server/http.py @@ -0,0 +1,170 @@ +"""Model Context Protocol transport protocol for Server Sent Events (SSE). + +This registers HTTP endpoints that supports SSE as a transport layer +for the Model Context Protocol. There are two HTTP endpoints: + +- /mcp_server/sse: The SSE endpoint that is used to establish a session + with the client and glue to the MCP server. This is used to push responses + to the client. +- /mcp_server/messages: The endpoint that is used by the client to send + POST requests with new requests for the MCP server. The request contains + a session identifier. The response to the client is passed over the SSE + session started on the other endpoint. + +See https://modelcontextprotocol.io/docs/concepts/transports +""" + +import logging + +from aiohttp import web +from aiohttp.web_exceptions import HTTPBadRequest, HTTPNotFound +from aiohttp_sse import sse_response +import anyio +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp import types + +from homeassistant.components import conversation +from homeassistant.components.http import KEY_HASS, HomeAssistantView +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import llm + +from .const import DOMAIN +from .server import create_server +from .session import Session +from .types import MCPServerConfigEntry + +_LOGGER = logging.getLogger(__name__) + +SSE_API = f"/{DOMAIN}/sse" +MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}" + + +@callback +def async_register(hass: HomeAssistant) -> None: + """Register the websocket API.""" + hass.http.register_view(ModelContextProtocolSSEView()) + hass.http.register_view(ModelContextProtocolMessagesView()) + + +def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry: + """Get the first enabled MCP server config entry. + + The ConfigEntry contains a reference to the actual MCP server used to + serve the Model Context Protocol. + + Will raise an HTTP error if the expected configuration is not present. + """ + config_entries: list[MCPServerConfigEntry] = [ + config_entry + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.state == ConfigEntryState.LOADED + ] + if not config_entries: + raise HTTPNotFound(text="Model Context Protocol server is not configured") + if len(config_entries) > 1: + raise HTTPNotFound(text="Found multiple Model Context Protocol configurations") + return config_entries[0] + + +class ModelContextProtocolSSEView(HomeAssistantView): + """Model Context Protocol SSE endpoint.""" + + name = f"{DOMAIN}:sse" + url = SSE_API + + async def get(self, request: web.Request) -> web.StreamResponse: + """Process SSE messages for the Model Context Protocol. + + This is a long running request for the lifetime of the client session + and is the primary transport layer between the client and server. + + Pairs of buffered streams act as a bridge between the transport protocol + (SSE over HTTP views) and the Model Context Protocol. The MCP SDK + manages all protocol details and invokes commands on our MCP server. + """ + hass = request.app[KEY_HASS] + entry = async_get_config_entry(hass) + session_manager = entry.runtime_data + + context = llm.LLMContext( + platform=DOMAIN, + context=self.context(request), + user_prompt=None, + language="*", + assistant=conversation.DOMAIN, + device_id=None, + ) + llm_api_id = entry.data[CONF_LLM_HASS_API] + server = await create_server(hass, llm_api_id, context) + options = await hass.async_add_executor_job( + server.create_initialization_options # Reads package for version info + ) + + read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] + read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] + read_stream_writer, read_stream = anyio.create_memory_object_stream(0) + + write_stream: MemoryObjectSendStream[types.JSONRPCMessage] + write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage] + write_stream, write_stream_reader = anyio.create_memory_object_stream(0) + + async with ( + sse_response(request) as response, + session_manager.create(Session(read_stream_writer)) as session_id, + ): + session_uri = MESSAGES_API.format(session_id=session_id) + _LOGGER.debug("Sending SSE endpoint: %s", session_uri) + await response.send(session_uri, event="endpoint") + + async def sse_reader() -> None: + """Forward MCP server responses to the client.""" + async for message in write_stream_reader: + _LOGGER.debug("Sending SSE message: %s", message) + await response.send( + message.model_dump_json(by_alias=True, exclude_none=True), + event="message", + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(sse_reader) + await server.run(read_stream, write_stream, options) + return response + + +class ModelContextProtocolMessagesView(HomeAssistantView): + """Model Context Protocol messages endpoint.""" + + name = f"{DOMAIN}:messages" + url = MESSAGES_API + + async def post( + self, + request: web.Request, + session_id: str, + ) -> web.StreamResponse: + """Process incoming messages for the Model Context Protocol. + + The request passes a session ID which is used to identify the original + SSE connection. This view parses incoming messages from the transport + layer then writes them to the MCP server stream for the session. + """ + hass = request.app[KEY_HASS] + config_entry = async_get_config_entry(hass) + + session_manager = config_entry.runtime_data + if (session := session_manager.get(session_id)) is None: + _LOGGER.info("Could not find session ID: '%s'", session_id) + raise HTTPNotFound(text=f"Could not find session ID '{session_id}'") + + json_data = await request.json() + try: + message = types.JSONRPCMessage.model_validate(json_data) + except ValueError as err: + _LOGGER.info("Failed to parse message: %s", err) + raise HTTPBadRequest(text="Could not parse message") from err + + _LOGGER.debug("Received client message: %s", message) + await session.read_stream_writer.send(message) + return web.Response(status=200) diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json new file mode 100644 index 00000000000..18b2e5bc417 --- /dev/null +++ b/homeassistant/components/mcp_server/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "mcp_server", + "name": "Model Context Protocol Server", + "codeowners": ["@allenporter"], + "config_flow": true, + "dependencies": ["homeassistant", "http", "conversation"], + "documentation": "https://www.home-assistant.io/integrations/mcp_server", + "integration_type": "service", + "iot_class": "local_push", + "quality_scale": "silver", + "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.8.0"], + "single_config_entry": true +} diff --git a/homeassistant/components/mcp_server/quality_scale.yaml b/homeassistant/components/mcp_server/quality_scale.yaml new file mode 100644 index 00000000000..546b4147285 --- /dev/null +++ b/homeassistant/components/mcp_server/quality_scale.yaml @@ -0,0 +1,118 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Service does not register actions + appropriate-polling: + status: exempt + comment: Service is not polling + brands: done + common-modules: + status: exempt + comment: Service does not have entities or coordinators + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Service does not register actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Service does not subscribe to events + entity-unique-id: + status: exempt + comment: Service does not have entities + has-entity-name: + status: exempt + comment: Service does not have entities + runtime-data: + status: exempt + comment: No configuration state is used by the integration + test-before-configure: + status: exempt + comment: Service does not a connection + test-before-setup: + status: exempt + comment: Service does not a connection + unique-config-entry: + status: done + comment: Integration requires a single config entry. + + # Silver + action-exceptions: + status: exempt + comment: Service does not register actions + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Service does not have entities + integration-owner: done + log-when-unavailable: + status: exempt + comment: Service does not have entities + parallel-updates: + status: exempt + comment: Service does not have entities + reauthentication-flow: + status: exempt + comment: Service does not require authentication + test-coverage: done + + # Gold + devices: + status: exempt + comment: Service does not have entities + diagnostics: todo + discovery-update-info: + status: exempt + comment: Service does not support discovery + discovery: + status: exempt + comment: Service does not support discovery + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Service does not support devices + entity-category: + status: exempt + comment: Service does not have entities + entity-device-class: + status: exempt + comment: Service does not have entities + entity-disabled-by-default: + status: exempt + comment: Service does not have entities + entity-translations: + status: exempt + comment: Service does not have entities + exception-translations: todo + icon-translations: + status: exempt + comment: Service does not have entities + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Service does not have anything to repair + stale-devices: + status: exempt + comment: Service does not have devices + + # Platinum + async-dependency: + status: exempt + comment: Service does not communicate with devices + inject-websession: + status: exempt + comment: Service does not communicate with devices + strict-typing: done diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py new file mode 100644 index 00000000000..ba21abd722c --- /dev/null +++ b/homeassistant/components/mcp_server/server.py @@ -0,0 +1,108 @@ +"""The Model Context Protocol Server implementation. + +The Model Context Protocol python sdk defines a Server API that provides the +MCP message handling logic and error handling. The server implementation provided +here is independent of the lower level transport protocol. + +See https://modelcontextprotocol.io/docs/concepts/architecture#implementation-example +""" + +from collections.abc import Callable, Sequence +import json +import logging +from typing import Any + +from mcp import types +from mcp.server import Server +import voluptuous as vol +from voluptuous_openapi import convert + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm + +_LOGGER = logging.getLogger(__name__) + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> types.Tool: + """Format tool specification.""" + input_schema = convert(tool.parameters, custom_serializer=custom_serializer) + return types.Tool( + name=tool.name, + description=tool.description or "", + inputSchema={ + "type": "object", + "properties": input_schema["properties"], + }, + ) + + +async def create_server( + hass: HomeAssistant, llm_api_id: str, llm_context: llm.LLMContext +) -> Server: + """Create a new Model Context Protocol Server. + + A Model Context Protocol Server object is associated with a single session. + The MCP SDK handles the details of the protocol. + """ + + server = Server("home-assistant") + + @server.list_prompts() # type: ignore[no-untyped-call, misc] + async def handle_list_prompts() -> list[types.Prompt]: + llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + return [ + types.Prompt( + name=llm_api.api.name, + description=f"Default prompt for the Home Assistant LLM API {llm_api.api.name}", + ) + ] + + @server.get_prompt() # type: ignore[no-untyped-call, misc] + async def handle_get_prompt( + name: str, arguments: dict[str, str] | None + ) -> types.GetPromptResult: + llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + if name != llm_api.api.name: + raise ValueError(f"Unknown prompt: {name}") + + return types.GetPromptResult( + description=f"Default prompt for the Home Assistant LLM API {llm_api.api.name}", + messages=[ + types.PromptMessage( + role="assistant", + content=types.TextContent( + type="text", + text=llm_api.api_prompt, + ), + ) + ], + ) + + @server.list_tools() # type: ignore[no-untyped-call, misc] + async def list_tools() -> list[types.Tool]: + """List available time tools.""" + llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + return [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools] + + @server.call_tool() # type: ignore[no-untyped-call, misc] + async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]: + """Handle calling tools.""" + llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + tool_input = llm.ToolInput(tool_name=name, tool_args=arguments) + _LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args) + + try: + tool_response = await llm_api.async_call_tool(tool_input) + except (HomeAssistantError, vol.Invalid) as e: + raise HomeAssistantError(f"Error calling tool: {e}") from e + return [ + types.TextContent( + type="text", + text=json.dumps(tool_response), + ) + ] + + return server diff --git a/homeassistant/components/mcp_server/session.py b/homeassistant/components/mcp_server/session.py new file mode 100644 index 00000000000..4c586fd32a0 --- /dev/null +++ b/homeassistant/components/mcp_server/session.py @@ -0,0 +1,60 @@ +"""Model Context Protocol sessions. + +A session is a long-lived connection between the client and server that is used +to exchange messages. The server pushes messages to the client over the session +and the client sends messages to the server over the session. +""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from dataclasses import dataclass +import logging + +from anyio.streams.memory import MemoryObjectSendStream +from mcp import types + +from homeassistant.util import ulid as ulid_util + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class Session: + """A session for the Model Context Protocol.""" + + read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] + + +class SessionManager: + """Manage SSE sessions for the MCP transport layer. + + This class is used to manage the lifecycle of SSE sessions. It is responsible for + creating new sessions, resuming existing sessions, and closing sessions. + """ + + def __init__(self) -> None: + """Initialize the SSE server transport.""" + self._sessions: dict[str, Session] = {} + + @asynccontextmanager + async def create(self, session: Session) -> AsyncGenerator[str]: + """Context manager to create a new session ID and close when done.""" + session_id = ulid_util.ulid_now() + _LOGGER.debug("Creating session: %s", session_id) + self._sessions[session_id] = session + try: + yield session_id + finally: + _LOGGER.debug("Closing session: %s", session_id) + if session_id in self._sessions: # close() may have already been called + self._sessions.pop(session_id) + + def get(self, session_id: str) -> Session | None: + """Get an existing session.""" + return self._sessions.get(session_id) + + def close(self) -> None: + """Close any open sessions.""" + for session in self._sessions.values(): + session.read_stream_writer.close() + self._sessions.clear() diff --git a/homeassistant/components/mcp_server/strings.json b/homeassistant/components/mcp_server/strings.json new file mode 100644 index 00000000000..fbd14038ddc --- /dev/null +++ b/homeassistant/components/mcp_server/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "description": "See the [integration documentation]({more_info_url}) for setup instructions.", + "data": { + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]" + }, + "data_description": { + "llm_hass_api": "The method for controling Home Assistant to expose with the Model Context Protocol." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/mcp_server/types.py b/homeassistant/components/mcp_server/types.py new file mode 100644 index 00000000000..56ce0469e25 --- /dev/null +++ b/homeassistant/components/mcp_server/types.py @@ -0,0 +1,7 @@ +"""Types for the MCP server integration.""" + +from homeassistant.config_entries import ConfigEntry + +from .session import SessionManager + +type MCPServerConfigEntry = ConfigEntry[SessionManager] diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index 443c8fdd991..5e1523b939a 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -52,9 +52,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo about = await client.get_about() version = create_version(about.version) except MealieAuthenticationError as error: - raise ConfigEntryAuthFailed from error + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from error except MealieError as error: - raise ConfigEntryNotReady(error) from error + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_failed", + ) from error if not version.valid: LOGGER.warning( diff --git a/homeassistant/components/mealie/calendar.py b/homeassistant/components/mealie/calendar.py index 4c11c639c79..729bc16c6fd 100644 --- a/homeassistant/components/mealie/calendar.py +++ b/homeassistant/components/mealie/calendar.py @@ -13,6 +13,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import MealieConfigEntry, MealieMealplanCoordinator from .entity import MealieEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/mealie/config_flow.py b/homeassistant/components/mealie/config_flow.py index 2f90ceaf97a..2addd23284e 100644 --- a/homeassistant/components/mealie/config_flow.py +++ b/homeassistant/components/mealie/config_flow.py @@ -38,6 +38,10 @@ class MealieConfigFlow(ConfigFlow, domain=DOMAIN): ) -> tuple[dict[str, str], str | None]: """Check connection to the Mealie API.""" assert self.host is not None + + if "/hassio/ingress/" in self.host: + return {"base": "ingress_url"}, None + client = MealieClient( self.host, token=api_token, diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py index 051586e53c2..7d4f23d706e 100644 --- a/homeassistant/components/mealie/coordinator.py +++ b/homeassistant/components/mealie/coordinator.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed import homeassistant.util.dt as dt_util -from .const import LOGGER +from .const import DOMAIN, LOGGER WEEK = timedelta(days=7) @@ -53,7 +53,7 @@ class MealieDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): super().__init__( hass, LOGGER, - name=self._name, + name=f"Mealie {self._name}", update_interval=self._update_interval, ) self.client = client @@ -63,9 +63,15 @@ class MealieDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): try: return await self._async_update_internal() except MealieAuthenticationError as error: - raise ConfigEntryAuthFailed from error + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from error except MealieConnectionError as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key=f"update_failed_{self._name}", + ) from error @abstractmethod async def _async_update_internal(self) -> _DataT: @@ -77,7 +83,7 @@ class MealieMealplanCoordinator( ): """Class to manage fetching Mealie data.""" - _name = "MealieMealplan" + _name = "mealplan" _update_interval = timedelta(hours=1) async def _async_update_internal(self) -> dict[MealplanEntryType, list[Mealplan]]: @@ -106,7 +112,7 @@ class MealieShoppingListCoordinator( ): """Class to manage fetching Mealie Shopping list data.""" - _name = "MealieShoppingList" + _name = "shopping_list" _update_interval = timedelta(minutes=5) async def _async_update_internal( @@ -130,7 +136,7 @@ class MealieShoppingListCoordinator( class MealieStatisticsCoordinator(MealieDataUpdateCoordinator[Statistics]): """Class to manage fetching Mealie Statistics data.""" - _name = "MealieStatistics" + _name = "statistics" _update_interval = timedelta(minutes=15) async def _async_update_internal( diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index f594f1398e3..6e55abcdcad 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mealie", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["aiomealie==0.9.3"] + "requirements": ["aiomealie==0.9.5"] } diff --git a/homeassistant/components/mealie/quality_scale.yaml b/homeassistant/components/mealie/quality_scale.yaml new file mode 100644 index 00000000000..738c5b99d91 --- /dev/null +++ b/homeassistant/components/mealie/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have any configuration parameters. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: done + comment: | + The integration adds new todo lists on runtime. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have any irrelevant entities. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: done + comment: | + The integration removes removed todo lists on runtime. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/mealie/sensor.py b/homeassistant/components/mealie/sensor.py index 141a28ecdab..e4b1655a9d1 100644 --- a/homeassistant/components/mealie/sensor.py +++ b/homeassistant/components/mealie/sensor.py @@ -17,6 +17,8 @@ from homeassistant.helpers.typing import StateType from .coordinator import MealieConfigEntry, MealieStatisticsCoordinator from .entity import MealieEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class MealieStatisticsSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index f195be37b11..ca8c28f9d13 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -92,7 +92,7 @@ SERVICE_SET_MEALPLAN_SCHEMA = vol.Any( [x.lower() for x in MealplanEntryType] ), vol.Required(ATTR_NOTE_TITLE): str, - vol.Required(ATTR_NOTE_TEXT): str, + vol.Optional(ATTR_NOTE_TEXT): str, } ), ) diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 5555d3ffa21..fa63252e837 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -1,4 +1,9 @@ { + "common": { + "data_description_host": "The URL of your Mealie instance, for example, http://192.168.1.123:1234", + "data_description_api_token": "The API token of your Mealie instance from your user profile within Mealie.", + "data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates." + }, "config": { "step": { "user": { @@ -8,13 +13,18 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The URL of your Mealie instance." + "host": "[%key:component::mealie::common::data_description_host%]", + "api_token": "[%key:component::mealie::common::data_description_api_token%]", + "verify_ssl": "[%key:component::mealie::common::data_description_verify_ssl%]" } }, "reauth_confirm": { "description": "Please reauthenticate with Mealie.", "data": { "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "[%key:component::mealie::common::data_description_api_token%]" } }, "reconfigure": { @@ -23,12 +33,18 @@ "host": "[%key:common::config_flow::data::url%]", "api_token": "[%key:common::config_flow::data::api_token%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "[%key:component::mealie::common::data_description_host%]", + "api_token": "[%key:component::mealie::common::data_description_api_token%]", + "verify_ssl": "[%key:component::mealie::common::data_description_verify_ssl%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "ingress_url": "Ingress URLs are only used for accessing the Mealie UI. Use your Home Assistant IP address and the network port within the configuration tab of the Mealie add-on.", "unknown": "[%key:common::config_flow::error::unknown%]", "mealie_version": "Minimum required version is v1.0.0. Please upgrade Mealie and then retry." }, @@ -110,6 +126,21 @@ }, "version_error": { "message": "You are running {mealie_version} of Mealie. Minimum required version is {min_version}. Please upgrade Mealie and then retry." + }, + "auth_failed": { + "message": "Authentication failed. Please reauthenticate." + }, + "update_failed_mealplan": { + "message": "Could not fetch mealplan data." + }, + "update_failed_shopping_list": { + "message": "Could not fetch shopping list data." + }, + "update_failed_statistics": { + "message": "Could not fetch statistics data." + }, + "setup_failed": { + "message": "Could not connect to the Mealie instance." } }, "services": { @@ -198,8 +229,8 @@ "description": "The type of dish to set the recipe to." }, "recipe_id": { - "name": "[%key:component::mealie::services::get_recipe::fields::recipe_id::name%]", - "description": "[%key:component::mealie::services::get_recipe::fields::recipe_id::description%]" + "name": "Recipe ID", + "description": "The recipe ID or the slug of the recipe to get." }, "note_title": { "name": "Meal note title", diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index 508b6aeb5e2..be04b00113e 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -20,6 +20,7 @@ from .const import DOMAIN from .coordinator import MealieConfigEntry, MealieShoppingListCoordinator from .entity import MealieEntity +PARALLEL_UPDATES = 0 TODO_STATUS_MAP = { False: TodoItemStatus.NEEDS_ACTION, True: TodoItemStatus.COMPLETED, @@ -147,29 +148,19 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): """Update an item on the list.""" list_items = self.shopping_items - for items in list_items: - if items.item_id == item.uid: - position = items.position - break - list_item: ShoppingItem | None = next( (x for x in list_items if x.item_id == item.uid), None ) + assert list_item is not None + position = list_item.position - if not list_item: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="item_not_found_error", - translation_placeholders={"shopping_list_item": item.uid or ""}, - ) - - udpdate_shopping_item = MutateShoppingItem( + update_shopping_item = MutateShoppingItem( item_id=list_item.item_id, list_id=list_item.list_id, note=list_item.note, display=list_item.display, checked=item.status == TodoItemStatus.COMPLETED, - position=list_item.position, + position=position, is_food=list_item.is_food, disable_amount=list_item.disable_amount, quantity=list_item.quantity, @@ -181,16 +172,16 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): stripped_item_summary = item.summary.strip() if item.summary else item.summary if list_item.display.strip() != stripped_item_summary: - udpdate_shopping_item.note = stripped_item_summary - udpdate_shopping_item.position = position - udpdate_shopping_item.is_food = False - udpdate_shopping_item.food_id = None - udpdate_shopping_item.quantity = 0.0 - udpdate_shopping_item.checked = item.status == TodoItemStatus.COMPLETED + update_shopping_item.note = stripped_item_summary + update_shopping_item.position = position + update_shopping_item.is_food = False + update_shopping_item.food_id = None + update_shopping_item.quantity = 0.0 + update_shopping_item.checked = item.status == TodoItemStatus.COMPLETED try: await self.coordinator.client.update_shopping_item( - list_item.item_id, udpdate_shopping_item + list_item.item_id, update_shopping_item ) except MealieError as exception: raise HomeAssistantError( diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index ebfa79d7190..becca8e6da8 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2024.11.04"], + "requirements": ["yt-dlp[default]==2025.01.15"], "single_config_entry": true } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 291b1ec1e2a..e109b0418c9 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -21,7 +21,7 @@ import aiohttp from aiohttp import web from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE from aiohttp.typedefs import LooseHeaders -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from yarl import URL @@ -780,7 +780,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): Remove this compatibility shim in 2025.1 or later. """ features = self.supported_features - if type(features) is int: # noqa: E721 + if type(features) is int: new_features = MediaPlayerEntityFeature(features) self._report_deprecated_supported_features_values(new_features) return new_features diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index ff246e420ce..be06ae22cdc 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -282,7 +282,7 @@ }, "clear_playlist": { "name": "Clear playlist", - "description": "Clears the playlist." + "description": "Removes all items from the playlist." }, "shuffle_set": { "name": "Shuffle", @@ -310,7 +310,7 @@ "fields": { "group_members": { "name": "Group members", - "description": "The players which will be synced with the playback specified in `target`." + "description": "The players which will be synced with the playback specified in 'Targets'." } } }, diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 3ea8f581245..5c6165a3477 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -38,18 +38,18 @@ from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia __all__ = [ "DOMAIN", - "is_media_source_id", - "generate_media_source_id", - "async_browse_media", - "async_resolve_media", - "BrowseMediaSource", - "PlayMedia", - "MediaSourceItem", - "Unresolvable", - "MediaSource", - "MediaSourceError", "MEDIA_CLASS_MAP", "MEDIA_MIME_TYPES", + "BrowseMediaSource", + "MediaSource", + "MediaSourceError", + "MediaSourceItem", + "PlayMedia", + "Unresolvable", + "async_browse_media", + "async_resolve_media", + "generate_media_source_id", + "is_media_source_id", ] diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 482ed0e855f..53bd8213262 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import ABC from dataclasses import dataclass from typing import Any, cast @@ -102,7 +101,7 @@ class MediaSourceItem: return cls(hass, domain, identifier, target_media_player) -class MediaSource(ABC): +class MediaSource: """Represents a source of media files.""" name: str | None = None diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 08b3658c270..4defd47bc39 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -115,7 +115,6 @@ class MelCloudClimate(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: MelCloudDevice) -> None: """Initialize the climate.""" diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index b604ee5016e..d2c9d67f29a 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -126,9 +126,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN, ) - or isinstance(err, AttributeError) - and err.name == "get" - ): + ) or (isinstance(err, AttributeError) and err.name == "get"): errors["base"] = "invalid_auth" else: errors["base"] = "cannot_connect" @@ -165,9 +163,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN, ) - or isinstance(err, AttributeError) - and err.name == "get" - ): + ) or (isinstance(err, AttributeError) and err.name == "get"): errors["base"] = "invalid_auth" else: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/melissa/climate.py b/homeassistant/components/melissa/climate.py index 0ad663faa2a..ff68820d70f 100644 --- a/homeassistant/components/melissa/climate.py +++ b/homeassistant/components/melissa/climate.py @@ -65,7 +65,6 @@ class MelissaClimate(ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, api, serial_number, init_data): """Initialize the climate device.""" diff --git a/homeassistant/components/met_eireann/manifest.json b/homeassistant/components/met_eireann/manifest.json index 72afc6977dd..7b913df4d3c 100644 --- a/homeassistant/components/met_eireann/manifest.json +++ b/homeassistant/components/met_eireann/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/met_eireann", "iot_class": "cloud_polling", "loggers": ["meteireann"], - "requirements": ["PyMetEireann==2021.8.0"] + "requirements": ["PyMetEireann==2024.11.0"] } diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 1d4f8293c5e..4b79b046b75 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -6,6 +6,7 @@ import logging from meteofrance_api.client import MeteoFranceClient from meteofrance_api.helpers import is_valid_warning_department from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain +from requests import RequestException import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -83,7 +84,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=_async_update_data_rain, update_interval=SCAN_INTERVAL_RAIN, ) - await coordinator_rain.async_config_entry_first_refresh() + try: + await coordinator_rain._async_refresh(log_failures=False) # noqa: SLF001 + except RequestException: + _LOGGER.warning( + "1 hour rain forecast not available: %s is not in covered zone", + entry.title, + ) department = coordinator_forecast.data.position.get("dept") _LOGGER.debug( @@ -128,8 +135,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = { UNDO_UPDATE_LISTENER: undo_listener, COORDINATOR_FORECAST: coordinator_forecast, - COORDINATOR_RAIN: coordinator_rain, } + if coordinator_rain and coordinator_rain.last_update_success: + hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain if coordinator_alert and coordinator_alert.last_update_success: hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index d8dbdfc4265..826716f1679 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -187,7 +187,7 @@ async def async_setup_entry( """Set up the Meteo-France sensor platform.""" data = hass.data[DOMAIN][entry.entry_id] coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST] - coordinator_rain: DataUpdateCoordinator[Rain] | None = data[COORDINATOR_RAIN] + coordinator_rain: DataUpdateCoordinator[Rain] | None = data.get(COORDINATOR_RAIN) coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get( COORDINATOR_ALERT ) diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 11199e126cf..116b3ef0341 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL from .coordinator import MillDataUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 5c5c7882634..0df2fe9335e 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -41,6 +41,7 @@ from .const import ( SERVICE_SET_ROOM_TEMP, ) from .coordinator import MillDataUpdateCoordinator +from .entity import MillBaseEntity SET_ROOM_TEMP_SCHEMA = vol.Schema( { @@ -85,10 +86,9 @@ async def async_setup_entry( ) -class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): +class MillHeater(MillBaseEntity, ClimateEntity): """Representation of a Mill Thermostat device.""" - _attr_has_entity_name = True _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP @@ -100,27 +100,15 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( - self, coordinator: MillDataUpdateCoordinator, heater: mill.Heater + self, coordinator: MillDataUpdateCoordinator, device: mill.Heater ) -> None: """Initialize the thermostat.""" - super().__init__(coordinator) - - self._available = False - - self._id = heater.device_id - self._attr_unique_id = heater.device_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, heater.device_id)}, - manufacturer=MANUFACTURER, - model=heater.model, - name=heater.name, - ) - - self._update_attr(heater) + super().__init__(coordinator, device) + self._attr_unique_id = device.device_id + self._update_attr(device) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -144,36 +132,25 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): ) await self.coordinator.async_request_refresh() - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self._available - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._update_attr(self.coordinator.data[self._id]) - self.async_write_ha_state() - - @callback - def _update_attr(self, heater): - self._available = heater.available + def _update_attr(self, device: mill.Heater) -> None: + self._available = device.available self._attr_extra_state_attributes = { - "open_window": heater.open_window, - "controlled_by_tibber": heater.tibber_control, + "open_window": device.open_window, + "controlled_by_tibber": device.tibber_control, } - if heater.room_name: - self._attr_extra_state_attributes["room"] = heater.room_name - self._attr_extra_state_attributes["avg_room_temp"] = heater.room_avg_temp + if device.room_name: + self._attr_extra_state_attributes["room"] = device.room_name + self._attr_extra_state_attributes["avg_room_temp"] = device.room_avg_temp else: self._attr_extra_state_attributes["room"] = "Independent device" - self._attr_target_temperature = heater.set_temp - self._attr_current_temperature = heater.current_temp - if heater.is_heating: + self._attr_target_temperature = device.set_temp + self._attr_current_temperature = device.current_temp + if device.is_heating: self._attr_hvac_action = HVACAction.HEATING else: self._attr_hvac_action = HVACAction.IDLE - if heater.power_status: + if device.power_status: self._attr_hvac_mode = HVACMode.HEAT else: self._attr_hvac_mode = HVACMode.OFF @@ -194,7 +171,6 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: MillDataUpdateCoordinator) -> None: """Initialize the thermostat.""" diff --git a/homeassistant/components/mill/entity.py b/homeassistant/components/mill/entity.py new file mode 100644 index 00000000000..f24dbeb2c26 --- /dev/null +++ b/homeassistant/components/mill/entity.py @@ -0,0 +1,54 @@ +"""Base entity for Mill devices.""" + +from __future__ import annotations + +from abc import abstractmethod + +from mill import Heater, MillDevice + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import MillDataUpdateCoordinator + + +class MillBaseEntity(CoordinatorEntity[MillDataUpdateCoordinator]): + """Representation of a Mill number device.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MillDataUpdateCoordinator, + mill_device: MillDevice, + ) -> None: + """Initialize the number.""" + super().__init__(coordinator) + + self._id = mill_device.device_id + self._available = False + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mill_device.device_id)}, + name=mill_device.name, + manufacturer=MANUFACTURER, + model=mill_device.model, + ) + self._update_attr(mill_device) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr(self.coordinator.data[self._id]) + self.async_write_ha_state() + + @abstractmethod + @callback + def _update_attr(self, device: MillDevice | Heater) -> None: + """Update the attribute of the entity.""" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._available diff --git a/homeassistant/components/mill/number.py b/homeassistant/components/mill/number.py new file mode 100644 index 00000000000..af27159caf0 --- /dev/null +++ b/homeassistant/components/mill/number.py @@ -0,0 +1,61 @@ +"""Support for mill wifi-enabled home heaters.""" + +from __future__ import annotations + +from mill import MillDevice + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME, UnitOfPower +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CLOUD, CONNECTION_TYPE, DOMAIN +from .coordinator import MillDataUpdateCoordinator +from .entity import MillBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Mill Number.""" + if entry.data.get(CONNECTION_TYPE) == CLOUD: + mill_data_coordinator: MillDataUpdateCoordinator = hass.data[DOMAIN][CLOUD][ + entry.data[CONF_USERNAME] + ] + + async_add_entities( + MillNumber(mill_data_coordinator, mill_device) + for mill_device in mill_data_coordinator.data.values() + ) + + +class MillNumber(MillBaseEntity, NumberEntity): + """Representation of a Mill number device.""" + + _attr_device_class = NumberDeviceClass.POWER + _attr_native_max_value = 2000 + _attr_native_min_value = 0 + _attr_native_step = 1 + _attr_native_unit_of_measurement = UnitOfPower.WATT + + def __init__( + self, + coordinator: MillDataUpdateCoordinator, + mill_device: MillDevice, + ) -> None: + """Initialize the number.""" + super().__init__(coordinator, mill_device) + self._attr_unique_id = f"{mill_device.device_id}_max_heating_power" + self._update_attr(mill_device) + + @callback + def _update_attr(self, device: MillDevice) -> None: + self._attr_native_value = device.data["deviceSettings"]["reported"].get( + "max_heater_power" + ) + self._available = device.available and self._attr_native_value is not None + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.coordinator.mill_data_connection.max_heating_power(self._id, value) diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index c4b975ab039..018b9466deb 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -25,6 +25,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -41,6 +42,8 @@ from .const import ( TEMPERATURE, TVOC, ) +from .coordinator import MillDataUpdateCoordinator +from .entity import MillBaseEntity HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -179,37 +182,19 @@ async def async_setup_entry( async_add_entities(entities) -class MillSensor(CoordinatorEntity, SensorEntity): +class MillSensor(MillBaseEntity, SensorEntity): """Representation of a Mill Sensor device.""" - _attr_has_entity_name = True - - def __init__(self, coordinator, entity_description, mill_device): + def __init__( + self, + coordinator: MillDataUpdateCoordinator, + entity_description: SensorEntityDescription, + mill_device: mill.Socket | mill.Heater, + ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) - - self._id = mill_device.device_id + super().__init__(coordinator, mill_device) self.entity_description = entity_description - self._available = False self._attr_unique_id = f"{mill_device.device_id}_{entity_description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, mill_device.device_id)}, - name=mill_device.name, - manufacturer=MANUFACTURER, - model=mill_device.model, - ) - self._update_attr(mill_device) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._update_attr(self.coordinator.data[self._id]) - self.async_write_ha_state() - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self._available @callback def _update_attr(self, device): @@ -217,12 +202,16 @@ class MillSensor(CoordinatorEntity, SensorEntity): self._attr_native_value = getattr(device, self.entity_description.key) -class LocalMillSensor(CoordinatorEntity, SensorEntity): +class LocalMillSensor(CoordinatorEntity[MillDataUpdateCoordinator], SensorEntity): """Representation of a Mill Sensor device.""" _attr_has_entity_name = True - def __init__(self, coordinator, entity_description): + def __init__( + self, + coordinator: MillDataUpdateCoordinator, + entity_description: SensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -239,6 +228,6 @@ class LocalMillSensor(CoordinatorEntity, SensorEntity): ) @property - def native_value(self): + def native_value(self) -> StateType: """Return the native value of the sensor.""" return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/mill/strings.json b/homeassistant/components/mill/strings.json index 21e3e7a44a5..212da68c3e9 100644 --- a/homeassistant/components/mill/strings.json +++ b/homeassistant/components/mill/strings.json @@ -63,15 +63,15 @@ }, "away_temp": { "name": "Away temperature", - "description": "Away temp." + "description": "Room temperature in away mode" }, "comfort_temp": { "name": "Comfort temperature", - "description": "Comfort temp." + "description": "Room temperature in comfort mode" }, "sleep_temp": { "name": "Sleep temperature", - "description": "Sleep temp." + "description": "Room temperature in sleep mode" } } } diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 8f016e2de00..f937c304471 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" # Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083) - hass.async_add_executor_job(load_dnspython_rdata_classes) + await hass.async_add_executor_job(load_dnspython_rdata_classes) # Create API instance. api = MinecraftServer( diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml new file mode 100644 index 00000000000..fc3db3b3075 --- /dev/null +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -0,0 +1,114 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration doesn't provide any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: + status: todo + comment: Check removal and replacement of name in config flow with the title (server address). + config-flow-test-coverage: + status: todo + comment: | + Merge test_show_config_form with full flow test. + Move full flow test to the top of all tests. + All test cases should end in either CREATE_ENTRY or ABORT. + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration doesn't provide any service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: done + comment: Handled by coordinator. + entity-unique-id: + status: done + comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information. + has-entity-name: done + runtime-data: todo + test-before-configure: done + test-before-setup: + status: done + comment: | + Raising ConfigEntryNotReady, if either the initialization or + refresh of coordinator isn't successful. + unique-config-entry: + status: done + comment: | + As there is no unique information available from the dependency mcstatus, + the server address is used to identify that the same service is already configured. + + # Silver + action-exceptions: + status: exempt + comment: Integration doesn't provide any service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration doesn't support any configuration parameters. + docs-installation-parameters: done + entity-unavailable: + status: done + comment: Handled by coordinator. + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by coordinator. + parallel-updates: + status: todo + comment: | + Although this is handled by the coordinator and no service actions are provided, + PARALLEL_UPDATES should still be set to 0 in binary_sensor and sensor according to the rule. + reauthentication-flow: + status: exempt + comment: No authentication is required for the integration. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery: + status: exempt + comment: No discovery possible. + discovery-update-info: + status: exempt + comment: | + No discovery possible. Users can use the (local or public) hostname instead of an IP address, + if static IP addresses cannot be configured. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: A minecraft server can only have one device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair use-cases for this integration. + stale-devices: todo + + # Platinum + async-dependency: + status: done + comment: | + Lookup API of the dependency mcstatus for Bedrock Edition servers is not async, + but is non-blocking and therefore OK to be called. Refer to mcstatus FAQ + https://mcstatus.readthedocs.io/en/stable/pages/faq/#why-doesn-t-bedrockserver-have-an-async-lookup-method + inject-websession: + status: exempt + comment: Integration isn't making any HTTP requests. + strict-typing: done diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 48f8c726836..1a331e16482 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -from typing import cast import voluptuous as vol @@ -46,9 +45,13 @@ from homeassistant.const import ( CONF_TYPE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, + SERVICE_RELOAD, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.helpers.reload import async_integration_yaml_config +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from .const import ( @@ -85,6 +88,8 @@ from .const import ( CONF_HVAC_MODE_OFF, CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_VALUES, + CONF_HVAC_OFF_VALUE, + CONF_HVAC_ON_VALUE, CONF_HVAC_ONOFF_REGISTER, CONF_INPUT_TYPE, CONF_MAX_TEMP, @@ -126,6 +131,8 @@ from .const import ( CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, DEFAULT_HUB, + DEFAULT_HVAC_OFF_VALUE, + DEFAULT_HVAC_ON_VALUE, DEFAULT_SCAN_INTERVAL, DEFAULT_TEMP_UNIT, MODBUS_DOMAIN as DOMAIN, @@ -135,7 +142,7 @@ from .const import ( UDP, DataType, ) -from .modbus import ModbusHub, async_modbus_setup +from .modbus import DATA_MODBUS_HUBS, ModbusHub, async_modbus_setup from .validators import ( duplicate_fan_mode_validator, duplicate_swing_mode_validator, @@ -252,6 +259,12 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, + vol.Optional( + CONF_HVAC_ON_VALUE, default=DEFAULT_HVAC_ON_VALUE + ): cv.positive_int, + vol.Optional( + CONF_HVAC_OFF_VALUE, default=DEFAULT_HVAC_OFF_VALUE + ): cv.positive_int, vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, vol.Optional(CONF_HVAC_MODE_REGISTER): vol.Maybe( { @@ -444,25 +457,33 @@ CONFIG_SCHEMA = vol.Schema( def get_hub(hass: HomeAssistant, name: str) -> ModbusHub: """Return modbus hub with name.""" - return cast(ModbusHub, hass.data[DOMAIN][name]) + return hass.data[DATA_MODBUS_HUBS][name] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Modbus component.""" if DOMAIN not in config: return True - return await async_modbus_setup( - hass, - config, - ) + async def _reload_config(call: Event | ServiceCall) -> None: + """Reload Modbus.""" + if DATA_MODBUS_HUBS not in hass.data: + _LOGGER.error("Modbus cannot reload, because it was never loaded") + return + hubs = hass.data[DATA_MODBUS_HUBS] + for hub in hubs.values(): + await hub.async_close() + reset_platforms = async_get_platforms(hass, DOMAIN) + for reset_platform in reset_platforms: + _LOGGER.debug("Reload modbus resetting platform: %s", reset_platform.domain) + await reset_platform.async_reset() + reload_config = await async_integration_yaml_config(hass, DOMAIN) + if not reload_config: + _LOGGER.debug("Modbus not present anymore") + return + _LOGGER.debug("Modbus reloading") + await async_modbus_setup(hass, reload_config) -async def async_reset_platform(hass: HomeAssistant, integration_name: str) -> None: - """Release modbus resources.""" - if DOMAIN not in hass.data: - _LOGGER.error("Modbus cannot reload, because it was never loaded") - return - _LOGGER.debug("Modbus reloading") - hubs = hass.data[DOMAIN] - for name in hubs: - await hubs[name].async_close() + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) + + return await async_modbus_setup(hass, config) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index b50d21faf42..28d1be24587 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime import logging from typing import Any @@ -104,29 +103,24 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): if state := await self.async_get_last_state(): self._attr_is_on = state.state == STATE_ON - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the state of the sensor.""" # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True result = await self._hub.async_pb_call( self._slave, self._address, self._count, self._input_type ) - self._call_active = False if result is None: self._attr_available = False self._result = [] else: self._attr_available = True if self._input_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): - self._result = result.bits + self._result = [int(bit) for bit in result.bits] else: self._result = result.registers self._attr_is_on = bool(self._result[0] & 1) - self.async_write_ha_state() if self._coordinator: self._coordinator.async_set_updated_data(self._result) @@ -159,7 +153,6 @@ class SlaveSensor( """Handle entity which will be added.""" if state := await self.async_get_last_state(): self._attr_is_on = state.state == STATE_ON - self.async_write_ha_state() await super().async_added_to_hass() @callback diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index bcbaa0f32af..e1a2688048d 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime import logging import struct from typing import Any, cast @@ -69,6 +68,8 @@ from .const import ( CONF_HVAC_MODE_OFF, CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_VALUES, + CONF_HVAC_OFF_VALUE, + CONF_HVAC_ON_VALUE, CONF_HVAC_ONOFF_REGISTER, CONF_MAX_TEMP, CONF_MIN_TEMP, @@ -111,15 +112,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus climate.""" - if discovery_info is None: + if discovery_info is None or not (climates := discovery_info[CONF_CLIMATES]): return - - entities = [] - for entity in discovery_info[CONF_CLIMATES]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - entities.append(ModbusThermostat(hass, hub, entity)) - - async_add_entities(entities) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusThermostat(hass, hub, config) for config in climates) class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): @@ -130,7 +126,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -252,6 +247,8 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if CONF_HVAC_ONOFF_REGISTER in config: self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] self._hvac_onoff_write_registers = config[CONF_WRITE_REGISTERS] + self._hvac_on_value = config[CONF_HVAC_ON_VALUE] + self._hvac_off_value = config[CONF_HVAC_OFF_VALUE] if HVACMode.OFF not in self._attr_hvac_modes: self._attr_hvac_modes.append(HVACMode.OFF) else: @@ -267,19 +264,26 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" if self._hvac_onoff_register is not None: - # Turn HVAC Off by writing 0 to the On/Off register, or 1 otherwise. + # Turn HVAC Off by writing self._hvac_off_value to the On/Off + # register, or self._hvac_on_value otherwise. if self._hvac_onoff_write_registers: await self._hub.async_pb_call( self._slave, self._hvac_onoff_register, - [0 if hvac_mode == HVACMode.OFF else 1], + [ + self._hvac_off_value + if hvac_mode == HVACMode.OFF + else self._hvac_on_value + ], CALL_TYPE_WRITE_REGISTERS, ) else: await self._hub.async_pb_call( self._slave, self._hvac_onoff_register, - 0 if hvac_mode == HVACMode.OFF else 1, + self._hvac_off_value + if hvac_mode == HVACMode.OFF + else self._hvac_on_value, CALL_TYPE_WRITE_REGISTER, ) @@ -303,7 +307,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): ) break - await self.async_update() + await self._async_update_write_state() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" @@ -325,7 +329,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTER, ) - await self.async_update() + await self._async_update_write_state() async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing mode.""" @@ -348,7 +352,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTER, ) break - await self.async_update() + await self._async_update_write_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -403,9 +407,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTERS, ) self._attr_available = result is not None - await self.async_update() + await self._async_update_write_state() - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update Target & Current Temperature.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval @@ -477,11 +481,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): onoff = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._hvac_onoff_register, raw=True ) - if onoff == 0: + if onoff == self._hvac_off_value: self._attr_hvac_mode = HVACMode.OFF - self.async_write_ha_state() - async def _async_read_register( self, register_type: str, register: int, raw: bool | None = False ) -> float | None: diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 7a1a4121a93..e11e15fff20 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -60,6 +60,8 @@ CONF_FAN_MODE_DIFFUSE = "state_fan_diffuse" CONF_FAN_MODE_VALUES = "values" CONF_HVAC_MODE_REGISTER = "hvac_mode_register" CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" +CONF_HVAC_ON_VALUE = "hvac_on_value" +CONF_HVAC_OFF_VALUE = "hvac_off_value" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" CONF_HVAC_MODE_COOL = "state_cool" @@ -139,6 +141,8 @@ DEFAULT_SCAN_INTERVAL = 15 # seconds DEFAULT_SLAVE = 1 DEFAULT_STRUCTURE_PREFIX = ">f" DEFAULT_TEMP_UNIT = "C" +DEFAULT_HVAC_ON_VALUE = 1 +DEFAULT_HVAC_OFF_VALUE = 0 MODBUS_DOMAIN = "modbus" ACTIVE_SCAN_INTERVAL = 2 # limit to force an extra update diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index eb9dac58900..5e7b008ff7c 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime from typing import Any from homeassistant.components.cover import CoverEntity, CoverEntityFeature, CoverState @@ -37,15 +36,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus cover.""" - if discovery_info is None: + if discovery_info is None or not (covers := discovery_info[CONF_COVERS]): return - - covers = [] - for cover in discovery_info[CONF_COVERS]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - covers.append(ModbusCover(hass, hub, cover)) - - async_add_entities(covers) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusCover(hass, hub, config) for config in covers) class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): @@ -117,7 +111,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._slave, self._write_address, self._state_open, self._write_type ) self._attr_available = result is not None - await self.async_update() + await self._async_update_write_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" @@ -125,9 +119,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._slave, self._write_address, self._state_closed, self._write_type ) self._attr_available = result is not None - await self.async_update() + await self._async_update_write_state() - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the state of the cover.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval @@ -136,11 +130,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): ) if result is None: self._attr_available = False - self.async_write_ha_state() return self._attr_available = True if self._input_type == CALL_TYPE_COIL: self._set_attr_state(bool(result.bits[0] & 1)) else: self._set_attr_state(int(result.registers[0])) - self.async_write_ha_state() diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 90833516e59..35b7c02aa05 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod +import asyncio from collections.abc import Callable from datetime import datetime, timedelta import logging @@ -79,12 +80,14 @@ class BasePlatform(Entity): """Initialize the Modbus binary sensor.""" self._hub = hub - self._slave = entry.get(CONF_SLAVE) or entry.get(CONF_DEVICE_ADDRESS, 0) + if (conf_slave := entry.get(CONF_SLAVE)) is not None: + self._slave = conf_slave + else: + self._slave = entry.get(CONF_DEVICE_ADDRESS, 1) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] self._value: str | None = None self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) - self._call_active = False self._cancel_timer: Callable[[], None] | None = None self._cancel_call: Callable[[], None] | None = None @@ -98,46 +101,82 @@ class BasePlatform(Entity): def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: return None - assert isinstance( - val, (float, int) - ), f"Expected float or int but {config_name} was {type(val)}" + assert isinstance(val, (float, int)), ( + f"Expected float or int but {config_name} was {type(val)}" + ) return val self._min_value = get_optional_numeric_config(CONF_MIN_VALUE) self._max_value = get_optional_numeric_config(CONF_MAX_VALUE) self._nan_value = entry.get(CONF_NAN_VALUE) self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS) + self._update_lock = asyncio.Lock() @abstractmethod - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Virtual function to be overwritten.""" + async def async_update(self, now: datetime | None = None) -> None: + """Update the entity state.""" + async with self._update_lock: + await self._async_update() + + async def _async_update_write_state(self) -> None: + """Update the entity state and write it to the state machine.""" + await self.async_update() + self.async_write_ha_state() + + async def _async_update_if_not_in_progress( + self, now: datetime | None = None + ) -> None: + """Update the entity state if not already in progress.""" + if self._update_lock.locked(): + _LOGGER.debug("Update for entity %s is already in progress", self.name) + return + await self._async_update_write_state() + @callback def async_run(self) -> None: """Remote start entity.""" - self.async_hold(update=False) - self._cancel_call = async_call_later( - self.hass, timedelta(milliseconds=100), self.async_update - ) + self._async_cancel_update_polling() + self._async_schedule_future_update(0.1) if self._scan_interval > 0: self._cancel_timer = async_track_time_interval( - self.hass, self.async_update, timedelta(seconds=self._scan_interval) + self.hass, + self._async_update_if_not_in_progress, + timedelta(seconds=self._scan_interval), ) self._attr_available = True self.async_write_ha_state() @callback - def async_hold(self, update: bool = True) -> None: - """Remote stop entity.""" + def _async_schedule_future_update(self, delay: float) -> None: + """Schedule an update in the future.""" + self._async_cancel_future_pending_update() + self._cancel_call = async_call_later( + self.hass, delay, self._async_update_if_not_in_progress + ) + + @callback + def _async_cancel_future_pending_update(self) -> None: + """Cancel a future pending update.""" if self._cancel_call: self._cancel_call() self._cancel_call = None + + def _async_cancel_update_polling(self) -> None: + """Cancel the polling.""" if self._cancel_timer: self._cancel_timer() self._cancel_timer = None - if update: - self._attr_available = False - self.async_write_ha_state() + + @callback + def async_hold(self) -> None: + """Remote stop entity.""" + self._async_cancel_future_pending_update() + self._async_cancel_update_polling() + self._attr_available = False + self.async_write_ha_state() async def async_base_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -312,6 +351,7 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): self._attr_is_on = True elif state.state == STATE_OFF: self._attr_is_on = False + await super().async_added_to_hass() async def async_turn(self, command: int) -> None: """Evaluate switch result.""" @@ -330,34 +370,29 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): return if self._verify_delay: - async_call_later(self.hass, self._verify_delay, self.async_update) - else: - await self.async_update() + self._async_schedule_future_update(self._verify_delay) + return + + await self._async_update_write_state() async def async_turn_off(self, **kwargs: Any) -> None: """Set switch off.""" await self.async_turn(self._command_off) - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the entity state.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval if not self._verify_active: self._attr_available = True - self.async_write_ha_state() return # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True result = await self._hub.async_pb_call( self._slave, self._verify_address, 1, self._verify_type ) - self._call_active = False if result is None: self._attr_available = False - self.async_write_ha_state() return self._attr_available = True @@ -379,4 +414,3 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): self._verify_address, value, ) - self.async_write_ha_state() diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index 5d12fe37fd1..8636ef4521a 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -25,21 +25,15 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus fans.""" - if discovery_info is None: + if discovery_info is None or not (fans := discovery_info[CONF_FANS]): return - fans = [] - - for entry in discovery_info[CONF_FANS]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - fans.append(ModbusFan(hass, hub, entry)) - async_add_entities(fans) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusFan(hass, hub, config) for config in fans) class ModbusFan(BaseSwitch, FanEntity): """Class representing a Modbus fan.""" - _enable_turn_on_off_backwards_compatibility = False - def __init__( self, hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any] ) -> None: diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 42745c2bb78..ce1c881733e 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -12,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .entity import BaseSwitch -from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -24,14 +23,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus lights.""" - if discovery_info is None: + if discovery_info is None or not (lights := discovery_info[CONF_LIGHTS]): return - - lights = [] - for entry in discovery_info[CONF_LIGHTS]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - lights.append(ModbusLight(hass, hub, entry)) - async_add_entities(lights) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusLight(hass, hub, config) for config in lights) class ModbusLight(BaseSwitch, LightEntity): diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 7cba4692eb6..120175c65c2 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], - "requirements": ["pymodbus==3.6.9"] + "requirements": ["pymodbus==3.8.3"] } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index d85b4e0e67f..319c68f50f0 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -14,8 +14,8 @@ from pymodbus.client import ( AsyncModbusUdpClient, ) from pymodbus.exceptions import ModbusException -from pymodbus.pdu import ModbusResponse -from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer +from pymodbus.framer import FramerType +from pymodbus.pdu import ModbusPDU import voluptuous as vol from homeassistant.const import ( @@ -34,8 +34,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_ADDRESS, @@ -71,50 +71,59 @@ from .const import ( from .validators import check_config _LOGGER = logging.getLogger(__name__) +DATA_MODBUS_HUBS: HassKey[dict[str, ModbusHub]] = HassKey(DOMAIN) -ConfEntry = namedtuple("ConfEntry", "call_type attr func_name") # noqa: PYI024 -RunEntry = namedtuple("RunEntry", "attr func") # noqa: PYI024 +ConfEntry = namedtuple("ConfEntry", "call_type attr func_name value_attr_name") # noqa: PYI024 +RunEntry = namedtuple("RunEntry", "attr func value_attr_name") # noqa: PYI024 PB_CALL = [ ConfEntry( CALL_TYPE_COIL, "bits", "read_coils", + "count", ), ConfEntry( CALL_TYPE_DISCRETE, "bits", "read_discrete_inputs", + "count", ), ConfEntry( CALL_TYPE_REGISTER_HOLDING, "registers", "read_holding_registers", + "count", ), ConfEntry( CALL_TYPE_REGISTER_INPUT, "registers", "read_input_registers", + "count", ), ConfEntry( CALL_TYPE_WRITE_COIL, - "value", + "bits", "write_coil", + "value", ), ConfEntry( CALL_TYPE_WRITE_COILS, "count", "write_coils", + "values", ), ConfEntry( CALL_TYPE_WRITE_REGISTER, - "value", + "registers", "write_register", + "value", ), ConfEntry( CALL_TYPE_WRITE_REGISTERS, "count", "write_registers", + "values", ), ] @@ -125,20 +134,18 @@ async def async_modbus_setup( ) -> bool: """Set up Modbus component.""" - await async_setup_reload_service(hass, DOMAIN, [DOMAIN]) - if config[DOMAIN]: config[DOMAIN] = check_config(hass, config[DOMAIN]) if not config[DOMAIN]: return False - if DOMAIN in hass.data and config[DOMAIN] == []: - hubs = hass.data[DOMAIN] - for name in hubs: - if not await hubs[name].async_setup(): + if DATA_MODBUS_HUBS in hass.data and config[DOMAIN] == []: + hubs = hass.data[DATA_MODBUS_HUBS] + for hub in hubs.values(): + if not await hub.async_setup(): return False - hub_collect = hass.data[DOMAIN] + hub_collect = hass.data[DATA_MODBUS_HUBS] else: - hass.data[DOMAIN] = hub_collect = {} + hass.data[DATA_MODBUS_HUBS] = hub_collect = {} for conf_hub in config[DOMAIN]: my_hub = ModbusHub(hass, conf_hub) @@ -158,8 +165,6 @@ async def async_modbus_setup( async def async_stop_modbus(event: Event) -> None: """Stop Modbus service.""" - - async_dispatcher_send(hass, SIGNAL_STOP_ENTITY) for client in hub_collect.values(): await client.async_close() @@ -267,14 +272,13 @@ class ModbusHub: "port": client_config[CONF_PORT], "timeout": client_config[CONF_TIMEOUT], "retries": 3, - "retry_on_empty": True, } if self._config_type == SERIAL: # serial configuration if client_config[CONF_METHOD] == "ascii": - self._pb_params["framer"] = ModbusAsciiFramer + self._pb_params["framer"] = FramerType.ASCII else: - self._pb_params["framer"] = ModbusRtuFramer + self._pb_params["framer"] = FramerType.RTU self._pb_params.update( { "baudrate": client_config[CONF_BAUDRATE], @@ -287,9 +291,9 @@ class ModbusHub: # network configuration self._pb_params["host"] = client_config[CONF_HOST] if self._config_type == RTUOVERTCP: - self._pb_params["framer"] = ModbusRtuFramer + self._pb_params["framer"] = FramerType.RTU else: - self._pb_params["framer"] = ModbusSocketFramer + self._pb_params["framer"] = FramerType.SOCKET if CONF_MSG_WAIT in client_config: self._msg_wait = client_config[CONF_MSG_WAIT] / 1000 @@ -328,7 +332,9 @@ class ModbusHub: for entry in PB_CALL: func = getattr(self._client, entry.func_name) - self._pb_request[entry.call_type] = RunEntry(entry.attr, func) + self._pb_request[entry.call_type] = RunEntry( + entry.attr, func, entry.value_attr_name + ) self.hass.async_create_background_task( self.async_pb_connect(), "modbus-connect" @@ -372,12 +378,15 @@ class ModbusHub: async def low_level_pb_call( self, slave: int | None, address: int, value: int | list[int], use_call: str - ) -> ModbusResponse | None: + ) -> ModbusPDU | None: """Call sync. pymodbus.""" - kwargs = {"slave": slave} if slave else {} + kwargs: dict[str, Any] = ( + {ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1} + ) entry = self._pb_request[use_call] + kwargs[entry.value_attr_name] = value try: - result: ModbusResponse = await entry.func(address, value, **kwargs) + result: ModbusPDU = await entry.func(address, **kwargs) except ModbusException as exception_error: error = f"Error: device: {slave} address: {address} -> {exception_error!s}" self._log_error(error) @@ -405,7 +414,7 @@ class ModbusHub: address: int, value: int | list[int], use_call: str, - ) -> ModbusResponse | None: + ) -> ModbusPDU | None: """Convert async to sync pymodbus call.""" if self._config_delay: return None diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index d5a16c95cc4..2c2efb70d5a 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime import logging from typing import Any @@ -106,7 +105,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): if state: self._attr_native_value = state.native_value - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the state of the sensor.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 71413391a5f..44b0575d419 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -12,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .entity import BaseSwitch -from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -24,15 +23,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus switches.""" - switches = [] - - if discovery_info is None: + if discovery_info is None or not (switches := discovery_info[CONF_SWITCHES]): return - - for entry in discovery_info[CONF_SWITCHES]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - switches.append(ModbusSwitch(hass, hub, entry)) - async_add_entities(switches) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusSwitch(hass, hub, config) for config in switches) class ModbusSwitch(BaseSwitch, SwitchEntity): diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py index 98e6708a34c..237fafa69d7 100644 --- a/homeassistant/components/modem_callerid/config_flow.py +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components import usb from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_NAME +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DEFAULT_NAME, DOMAIN, EXCEPTIONS @@ -30,9 +31,7 @@ class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN): """Set up flow instance.""" self._device: str | None = None - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" dev_path = discovery_info.device unique_id = f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py index 6799dbf97d3..d10c7604722 100644 --- a/homeassistant/components/modern_forms/config_flow.py +++ b/homeassistant/components/modern_forms/config_flow.py @@ -7,10 +7,10 @@ from typing import Any from aiomodernforms import ModernFormsConnectionError, ModernFormsDevice import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -22,7 +22,7 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - host: str | None = None + host: str mac: str | None = None name: str @@ -30,10 +30,16 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle setup by user for Modern Forms integration.""" - return await self._handle_config_flow(user_input) + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=USER_SCHEMA, + ) + self.host = user_input[CONF_HOST] + return await self._handle_config_flow() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.hostname.rstrip(".") @@ -44,40 +50,26 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): self.mac = discovery_info.properties.get(CONF_MAC) self.name = name - # Prepare configuration flow - return await self._handle_config_flow({}, True) + # Loop through self._handle_config_flow to ensure we load the + # MAC if it is missing, and abort if already configured + return await self._handle_config_flow(True) async def async_step_zeroconf_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by zeroconf.""" - return await self._handle_config_flow(user_input) + return await self._handle_config_flow() async def _handle_config_flow( - self, user_input: dict[str, Any] | None = None, prepare: bool = False + self, initial_zeroconf: bool = False ) -> ConfigFlowResult: """Config flow handler for ModernForms.""" - # Request user input, unless we are preparing discovery flow - if user_input is None: - user_input = {} - if not prepare: - if self.source == SOURCE_ZEROCONF: - return self.async_show_form( - step_id="zeroconf_confirm", - description_placeholders={"name": self.name}, - ) - return self.async_show_form( - step_id="user", - data_schema=USER_SCHEMA, - ) - - if self.source == SOURCE_ZEROCONF: - user_input[CONF_HOST] = self.host - user_input[CONF_MAC] = self.mac - - if user_input.get(CONF_MAC) is None or not prepare: + if self.mac is None or not initial_zeroconf: + # User flow + # Or zeroconf without MAC + # Or zeroconf with MAC, but need to ensure device is still available session = async_get_clientsession(self.hass) - device = ModernFormsDevice(user_input[CONF_HOST], session=session) + device = ModernFormsDevice(self.host, session=session) try: device = await device.update() except ModernFormsConnectionError: @@ -88,20 +80,21 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=USER_SCHEMA, errors={"base": "cannot_connect"}, ) - user_input[CONF_MAC] = device.info.mac_address + self.mac = device.info.mac_address + if self.source != SOURCE_ZEROCONF: + self.name = device.info.device_name # Check if already configured - await self.async_set_unique_id(user_input[CONF_MAC]) - self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) + await self.async_set_unique_id(self.mac) + self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) - title = device.info.device_name - if self.source == SOURCE_ZEROCONF: - title = self.name - - if prepare: - return await self.async_step_zeroconf_confirm() + if initial_zeroconf: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self.name}, + ) return self.async_create_entry( - title=title, - data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: user_input[CONF_MAC]}, + title=self.name, + data={CONF_HOST: self.host, CONF_MAC: self.mac}, ) diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index a599c5b6dd6..988edcb60e5 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -78,7 +78,6 @@ class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity): | FanEntityFeature.TURN_ON ) _attr_translation_key = "fan" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py index 33f17271800..7c24dad4469 100644 --- a/homeassistant/components/moehlenhoff_alpha2/climate.py +++ b/homeassistant/components/moehlenhoff_alpha2/climate.py @@ -47,7 +47,6 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_modes = [PRESET_AUTO, PRESET_DAY, PRESET_NIGHT] - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: Alpha2BaseCoordinator, heat_area_id: str) -> None: """Initialize Alpha2 ClimateEntity.""" diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py index e6f795ecc91..5e5512a60bf 100644 --- a/homeassistant/components/mold_indicator/config_flow.py +++ b/homeassistant/components/mold_indicator/config_flow.py @@ -51,15 +51,6 @@ async def validate_input( DATA_SCHEMA_OPTIONS = vol.Schema( { - vol.Required(CONF_CALIBRATION_FACTOR): NumberSelector( - NumberSelectorConfig(step=0.1, mode=NumberSelectorMode.BOX) - ) - } -) - -DATA_SCHEMA_CONFIG = vol.Schema( - { - vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), vol.Required(CONF_INDOOR_TEMP): EntitySelector( EntitySelectorConfig( domain=Platform.SENSOR, device_class=SensorDeviceClass.TEMPERATURE @@ -75,6 +66,15 @@ DATA_SCHEMA_CONFIG = vol.Schema( domain=Platform.SENSOR, device_class=SensorDeviceClass.TEMPERATURE ) ), + vol.Required(CONF_CALIBRATION_FACTOR): NumberSelector( + NumberSelectorConfig(step=0.1, mode=NumberSelectorMode.BOX) + ), + } +) + +DATA_SCHEMA_CONFIG = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), } ).extend(DATA_SCHEMA_OPTIONS.schema) diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py index 5bfdc02c61e..e6ab84a4e74 100644 --- a/homeassistant/components/monarch_money/config_flow.py +++ b/homeassistant/components/monarch_money/config_flow.py @@ -87,7 +87,7 @@ async def validate_login( except LoginFailedException as err: raise InvalidAuth from err - LOGGER.debug(f"Connection successful - saving session to file {SESSION_FILE}") + LOGGER.debug("Connection successful - saving session to file %s", SESSION_FILE) LOGGER.debug("Obtaining subscription id") subs: MonarchSubscription = await monarch_client.get_subscription_details() assert subs is not None diff --git a/homeassistant/components/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py index 223d7b05ffe..caac551f986 100644 --- a/homeassistant/components/monzo/coordinator.py +++ b/homeassistant/components/monzo/coordinator.py @@ -3,13 +3,14 @@ from dataclasses import dataclass from datetime import timedelta import logging +from pprint import pformat from typing import Any -from monzopy import AuthorisationExpiredError +from monzopy import AuthorisationExpiredError, InvalidMonzoAPIResponseError from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import AuthenticatedMonzoAPI from .const import DOMAIN @@ -45,5 +46,16 @@ class MonzoCoordinator(DataUpdateCoordinator[MonzoData]): pots = await self.api.user_account.pots() except AuthorisationExpiredError as err: raise ConfigEntryAuthFailed from err + except InvalidMonzoAPIResponseError as err: + message = "Invalid Monzo API response." + if err.missing_key: + _LOGGER.debug( + "%s\nMissing key: %s\nResponse:\n%s", + message, + err.missing_key, + pformat(err.response), + ) + message += " Enabling debug logging for details." + raise UpdateFailed(message) from err return MonzoData(accounts, pots) diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index e961880375c..d8d1e7c21f1 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -7,7 +7,6 @@ from typing import Any from motionblinds import MotionDiscovery, MotionGateway import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -17,6 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_INTERFACE, @@ -82,7 +82,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress).replace(":", "") diff --git a/homeassistant/components/motionblinds_ble/sensor.py b/homeassistant/components/motionblinds_ble/sensor.py index aa0f5ef7c90..740a0509a9e 100644 --- a/homeassistant/components/motionblinds_ble/sensor.py +++ b/homeassistant/components/motionblinds_ble/sensor.py @@ -6,7 +6,6 @@ from collections.abc import Callable from dataclasses import dataclass import logging from math import ceil -from typing import Generic, TypeVar from motionblindsble.const import ( MotionBlindType, @@ -45,11 +44,9 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -_T = TypeVar("_T") - @dataclass(frozen=True, kw_only=True) -class MotionblindsBLESensorEntityDescription(SensorEntityDescription, Generic[_T]): +class MotionblindsBLESensorEntityDescription[_T](SensorEntityDescription): """Entity description of a sensor entity with initial_value attribute.""" initial_value: str | None = None @@ -110,7 +107,7 @@ async def async_setup_entry( async_add_entities(entities) -class MotionblindsBLESensorEntity(MotionblindsBLEEntity, SensorEntity, Generic[_T]): +class MotionblindsBLESensorEntity[_T](MotionblindsBLEEntity, SensorEntity): """Representation of a sensor entity.""" entity_description: MotionblindsBLESensorEntityDescription[_T] diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py index 19d3557d36b..50a1e334f1d 100644 --- a/homeassistant/components/motionmount/config_flow.py +++ b/homeassistant/components/motionmount/config_flow.py @@ -7,7 +7,6 @@ from typing import Any import motionmount import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, ConfigFlow, @@ -15,6 +14,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, EMPTY_MAC @@ -80,7 +80,7 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=name, data=user_input) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/mpd/config_flow.py b/homeassistant/components/mpd/config_flow.py index 36777a205f9..e4bfc2136d8 100644 --- a/homeassistant/components/mpd/config_flow.py +++ b/homeassistant/components/mpd/config_flow.py @@ -10,7 +10,7 @@ from mpd.asyncio import MPDClient import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from .const import DOMAIN, LOGGER @@ -66,36 +66,3 @@ class MPDConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=SCHEMA, errors=errors, ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Attempt to import the existing configuration.""" - self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]}) - client = MPDClient() - client.timeout = 30 - client.idletimeout = 10 - try: - async with timeout(35): - await client.connect(import_data[CONF_HOST], import_data[CONF_PORT]) - if CONF_PASSWORD in import_data: - await client.password(import_data[CONF_PASSWORD]) - with suppress(mpd.ConnectionError): - client.disconnect() - except ( - TimeoutError, - gaierror, - mpd.ConnectionError, - OSError, - ): - return self.async_abort(reason="cannot_connect") - except Exception: # noqa: BLE001 - LOGGER.exception("Unknown exception") - return self.async_abort(reason="unknown") - - return self.async_create_entry( - title=import_data.get(CONF_NAME, "Music Player Daemon"), - data={ - CONF_HOST: import_data[CONF_HOST], - CONF_PORT: import_data[CONF_PORT], - CONF_PASSWORD: import_data.get(CONF_PASSWORD), - }, - ) diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 92f0f5cfcc4..a79d933a782 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -26,15 +26,12 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle import homeassistant.util.dt as dt_util @@ -71,54 +68,6 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the MPD platform.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - if ( - result["type"] is FlowResultType.CREATE_ENTRY - or result["reason"] == "already_configured" - ): - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Music Player Daemon", - }, - ) - return - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Music Player Daemon", - }, - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/mpd/strings.json b/homeassistant/components/mpd/strings.json index fc922ab128a..44cbe8b2bb2 100644 --- a/homeassistant/components/mpd/strings.json +++ b/homeassistant/components/mpd/strings.json @@ -19,15 +19,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "title": "The {integration_title} YAML configuration import cannot connect to daemon", - "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nPlease make sure {integration_title} is turned on, and restart Home Assistant to try importing again. Otherwise, please remove the YAML from your configuration and add the integration manually." - }, - "deprecated_yaml_import_issue_unknown": { - "title": "The {integration_title} YAML configuration could not be imported", - "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nPlease make sure {integration_title} is turned on, and restart Home Assistant to try importing again. Otherwise, please remove the YAML from your configuration and add the integration manually." - } } } diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index bcad8747c39..8b16e9fa53d 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -6,14 +6,14 @@ import asyncio from collections.abc import Callable from datetime import datetime import logging -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast import voluptuous as vol from homeassistant import config as conf_util from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, SERVICE_RELOAD +from homeassistant.const import CONF_DISCOVERY, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigValidationError, @@ -25,7 +25,6 @@ from homeassistant.helpers import ( entity_registry as er, event as ev, issue_registry as ir, - template, ) from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -39,7 +38,7 @@ from homeassistant.util.async_ import create_eager_task # Loading the config flow file will register the flow from . import debug_info, discovery -from .client import ( # noqa: F401 +from .client import ( MQTT, async_publish, async_subscribe, @@ -47,9 +46,9 @@ from .client import ( # noqa: F401 publish, subscribe, ) -from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA # noqa: F401 +from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA from .config_integration import CONFIG_SCHEMA_BASE -from .const import ( # noqa: F401 +from .const import ( ATTR_PAYLOAD, ATTR_QOS, ATTR_RETAIN, @@ -70,6 +69,8 @@ from .const import ( # noqa: F401 CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + CONFIG_ENTRY_MINOR_VERSION, + CONFIG_ENTRY_VERSION, DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_PREFIX, @@ -77,10 +78,11 @@ from .const import ( # noqa: F401 DEFAULT_RETAIN, DOMAIN, ENTITY_PLATFORMS, + ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, TEMPLATE_ERRORS, ) -from .models import ( # noqa: F401 +from .models import ( DATA_MQTT, DATA_MQTT_AVAILABLE, MqttCommandTemplate, @@ -91,13 +93,13 @@ from .models import ( # noqa: F401 ReceiveMessage, convert_outgoing_mqtt_payload, ) -from .subscription import ( # noqa: F401 +from .subscription import ( EntitySubscription, async_prepare_subscribe_topics, async_subscribe_topics, async_unsubscribe_topics, ) -from .util import ( # noqa: F401 +from .util import ( async_create_certificate_temp_files, async_forward_entry_setup_and_setup_discovery, async_wait_for_mqtt_client, @@ -108,13 +110,88 @@ from .util import ( # noqa: F401 valid_subscribe_topic, ) +__all__ = [ + "ATTR_PAYLOAD", + "ATTR_QOS", + "ATTR_RETAIN", + "ATTR_TOPIC", + "CONFIG_ENTRY_MINOR_VERSION", + "CONFIG_ENTRY_VERSION", + "CONF_BIRTH_MESSAGE", + "CONF_BROKER", + "CONF_CERTIFICATE", + "CONF_CLIENT_CERT", + "CONF_CLIENT_KEY", + "CONF_COMMAND_TOPIC", + "CONF_DISCOVERY_PREFIX", + "CONF_KEEPALIVE", + "CONF_QOS", + "CONF_STATE_TOPIC", + "CONF_TLS_INSECURE", + "CONF_TOPIC", + "CONF_TRANSPORT", + "CONF_WILL_MESSAGE", + "CONF_WS_HEADERS", + "CONF_WS_PATH", + "DATA_MQTT", + "DATA_MQTT_AVAILABLE", + "DEFAULT_DISCOVERY", + "DEFAULT_ENCODING", + "DEFAULT_PREFIX", + "DEFAULT_QOS", + "DEFAULT_RETAIN", + "DOMAIN", + "ENTITY_PLATFORMS", + "ENTRY_OPTION_FIELDS", + "MQTT", + "MQTT_BASE_SCHEMA", + "MQTT_CONNECTION_STATE", + "MQTT_RO_SCHEMA", + "MQTT_RW_SCHEMA", + "SERVICE_RELOAD", + "TEMPLATE_ERRORS", + "EntitySubscription", + "MqttCommandTemplate", + "MqttData", + "MqttValueTemplate", + "PayloadSentinel", + "PublishPayloadType", + "ReceiveMessage", + "SetupPhases", + "async_check_config_schema", + "async_create_certificate_temp_files", + "async_forward_entry_setup_and_setup_discovery", + "async_migrate_entry", + "async_prepare_subscribe_topics", + "async_publish", + "async_remove_config_entry_device", + "async_setup", + "async_setup_entry", + "async_subscribe", + "async_subscribe_connection_status", + "async_subscribe_topics", + "async_unload_entry", + "async_unsubscribe_topics", + "async_wait_for_mqtt_client", + "convert_outgoing_mqtt_payload", + "create_eager_task", + "is_connected", + "mqtt_config_entry_enabled", + "platforms_from_config", + "publish", + "subscribe", + "valid_publish_topic", + "valid_qos_schema", + "valid_subscribe_topic", + "websocket_mqtt_info", + "websocket_subscribe", +] + _LOGGER = logging.getLogger(__name__) SERVICE_PUBLISH = "publish" SERVICE_DUMP = "dump" -ATTR_TOPIC_TEMPLATE = "topic_template" -ATTR_PAYLOAD_TEMPLATE = "payload_template" ATTR_EVALUATE_PAYLOAD = "evaluate_payload" MAX_RECONNECT_WAIT = 300 # seconds @@ -155,25 +232,16 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) - -# The use of a topic_template and payload_template in an mqtt publish action call -# have been deprecated with HA Core 2024.8.0 and will be removed with HA Core 2025.2.0 - # Publish action call validation schema -MQTT_PUBLISH_SCHEMA = vol.All( - vol.Schema( - { - vol.Exclusive(ATTR_TOPIC, CONF_TOPIC): valid_publish_topic, - vol.Exclusive(ATTR_TOPIC_TEMPLATE, CONF_TOPIC): cv.string, - vol.Exclusive(ATTR_PAYLOAD, CONF_PAYLOAD): cv.string, - vol.Exclusive(ATTR_PAYLOAD_TEMPLATE, CONF_PAYLOAD): cv.string, - vol.Optional(ATTR_EVALUATE_PAYLOAD): cv.boolean, - vol.Optional(ATTR_QOS, default=DEFAULT_QOS): valid_qos_schema, - vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - }, - required=True, - ), - cv.has_at_least_one_key(ATTR_TOPIC, ATTR_TOPIC_TEMPLATE), +MQTT_PUBLISH_SCHEMA = vol.Schema( + { + vol.Required(ATTR_TOPIC): valid_publish_topic, + vol.Required(ATTR_PAYLOAD): cv.string, + vol.Optional(ATTR_EVALUATE_PAYLOAD): cv.boolean, + vol.Optional(ATTR_QOS, default=DEFAULT_QOS): valid_qos_schema, + vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + }, + required=True, ) @@ -233,86 +301,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_publish_service(call: ServiceCall) -> None: """Handle MQTT publish service calls.""" - msg_topic: str | None = call.data.get(ATTR_TOPIC) - msg_topic_template: str | None = call.data.get(ATTR_TOPIC_TEMPLATE) + msg_topic: str = call.data[ATTR_TOPIC] if not mqtt_config_entry_enabled(hass): raise ServiceValidationError( translation_key="mqtt_not_setup_cannot_publish", translation_domain=DOMAIN, - translation_placeholders={ - "topic": str(msg_topic or msg_topic_template) - }, + translation_placeholders={"topic": msg_topic}, ) mqtt_data = hass.data[DATA_MQTT] - payload: PublishPayloadType = call.data.get(ATTR_PAYLOAD) + payload: PublishPayloadType = call.data[ATTR_PAYLOAD] evaluate_payload: bool = call.data.get(ATTR_EVALUATE_PAYLOAD, False) - payload_template: str | None = call.data.get(ATTR_PAYLOAD_TEMPLATE) qos: int = call.data[ATTR_QOS] retain: bool = call.data[ATTR_RETAIN] - if msg_topic_template is not None: - # The use of a topic_template in an mqtt publish action call - # has been deprecated with HA Core 2024.8.0 - # and will be removed with HA Core 2025.2.0 - rendered_topic: Any = MqttCommandTemplate( - template.Template(msg_topic_template, hass), - ).async_render() - ir.async_create_issue( - hass, - DOMAIN, - f"topic_template_deprecation_{rendered_topic}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="topic_template_deprecation", - translation_placeholders={ - "topic_template": msg_topic_template, - "topic": rendered_topic, - }, - ) - try: - msg_topic = valid_publish_topic(rendered_topic) - except vol.Invalid as err: - err_str = str(err) - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_publish_topic", - translation_placeholders={ - "error": err_str, - "topic": str(rendered_topic), - "topic_template": str(msg_topic_template), - }, - ) from err - if payload_template is not None: - # The use of a payload_template in an mqtt publish action call - # has been deprecated with HA Core 2024.8.0 - # and will be removed with HA Core 2025.2.0 - if TYPE_CHECKING: - assert msg_topic is not None - ir.async_create_issue( - hass, - DOMAIN, - f"payload_template_deprecation_{msg_topic}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="payload_template_deprecation", - translation_placeholders={ - "topic": msg_topic, - "payload_template": payload_template, - }, - ) - payload = MqttCommandTemplate( - template.Template(payload_template, hass) - ).async_render() - elif evaluate_payload: + if evaluate_payload: # Convert quoted binary literal to raw data payload = convert_outgoing_mqtt_payload(payload) - if TYPE_CHECKING: - assert msg_topic is not None await mqtt_data.client.async_publish(msg_topic, payload, qos, retain) hass.services.async_register( @@ -355,15 +362,45 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate the options from config entry data.""" + _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + data: dict[str, Any] = dict(entry.data) + options: dict[str, Any] = dict(entry.options) + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if entry.version == 1 and entry.minor_version < 2: + # Can be removed when config entry is bumped to version 2.1 + # with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1 + # From 2026.1 we will write version 2.1 + for key in ENTRY_OPTION_FIELDS: + if key not in data: + continue + options[key] = data.pop(key) + hass.config_entries.async_update_entry( + entry, + data=data, + options=options, + version=CONFIG_ENTRY_VERSION, + minor_version=CONFIG_ENTRY_MINOR_VERSION, + ) + + _LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" - conf: dict[str, Any] mqtt_data: MqttData async def _setup_client() -> tuple[MqttData, dict[str, Any]]: """Set up the MQTT client.""" # Fetch configuration - conf = dict(entry.data) + conf = dict(entry.data | entry.options) hass_config = await conf_util.async_hass_config_yaml(hass) mqtt_yaml = CONFIG_SCHEMA(hass_config).get(DOMAIN, []) await async_create_certificate_temp_files(hass, conf) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 65e24d5d780..584b238b3a8 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -23,6 +23,7 @@ ABBREVIATIONS = { "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", "clr_temp_cmd_t": "color_temp_command_topic", + "clr_temp_k": "color_temp_kelvin", "clr_temp_stat_t": "color_temp_state_topic", "clr_temp_tpl": "color_temp_template", "clr_temp_val_tpl": "color_temp_value_template", @@ -92,6 +93,8 @@ ABBREVIATIONS = { "min_hum": "min_humidity", "max_mirs": "max_mireds", "min_mirs": "min_mireds", + "max_k": "max_kelvin", + "min_k": "min_kelvin", "max_temp": "max_temp", "min_temp": "min_temp", "migr_discvry": "migrate_discovery", diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index a626e0e5b28..16a02e4956e 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -119,7 +119,7 @@ MAX_PACKETS_TO_READ = 500 type SocketType = socket.socket | ssl.SSLSocket | mqtt.WebsocketWrapper | Any -type SubscribePayloadType = str | bytes # Only bytes if encoding is None +type SubscribePayloadType = str | bytes | bytearray # Only bytes if encoding is None def publish( @@ -220,14 +220,13 @@ def async_subscribe_internal( mqtt_data = hass.data[DATA_MQTT] except KeyError as exc: raise HomeAssistantError( - f"Cannot subscribe to topic '{topic}', " - "make sure MQTT is set up correctly", + f"Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly", translation_key="mqtt_not_setup_cannot_subscribe", translation_domain=DOMAIN, translation_placeholders={"topic": topic}, ) from exc client = mqtt_data.client - if not client.connected and not mqtt_config_entry_enabled(hass): + if not mqtt_config_entry_enabled(hass): raise HomeAssistantError( f"Cannot subscribe to topic '{topic}', MQTT is not enabled", translation_key="mqtt_not_setup_cannot_subscribe", @@ -661,7 +660,7 @@ class MQTT: self.conf.get(CONF_PORT, DEFAULT_PORT), self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE), ) - except OSError as err: + except (OSError, mqtt.WebsocketConnectionError) as err: _LOGGER.error("Failed to connect to MQTT server due to exception: %s", err) self._async_connection_result(False) finally: @@ -695,12 +694,15 @@ class MQTT: async def _reconnect_loop(self) -> None: """Reconnect to the MQTT server.""" + # pylint: disable-next=import-outside-toplevel + import paho.mqtt.client as mqtt + while True: if not self.connected: try: async with self._connection_lock, self._async_connect_in_executor(): await self.hass.async_add_executor_job(self._mqttc.reconnect) - except OSError as err: + except (OSError, mqtt.WebsocketConnectionError) as err: _LOGGER.debug( "Error re-connecting to MQTT server due to exception: %s", err ) @@ -776,7 +778,11 @@ class MQTT: else: del self._wildcard_subscriptions[subscription] except (KeyError, ValueError) as exc: - raise HomeAssistantError("Can't remove subscription twice") from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="mqtt_not_setup_cannot_unsubscribe_twice", + translation_placeholders={"topic": topic}, + ) from exc @callback def _async_queue_subscriptions( @@ -822,7 +828,11 @@ class MQTT: ) -> Callable[[], None]: """Set up a subscription to a topic with the provided qos.""" if not isinstance(topic, str): - raise HomeAssistantError("Topic needs to be a string!") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="mqtt_topic_not_a_string", + translation_placeholders={"topic": topic}, + ) if job_type is None: job_type = get_hassjob_callable_job_type(msg_callback) @@ -1213,7 +1223,11 @@ class MQTT: import paho.mqtt.client as mqtt raise HomeAssistantError( - f"Error talking to MQTT: {mqtt.error_string(result_code)}" + translation_domain=DOMAIN, + translation_key="mqtt_broker_error", + translation_placeholders={ + "error_message": mqtt.error_string(result_code) + }, ) # Create the mid event if not created, either _mqtt_handle_mid or diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 2419e3f32ac..e62303472ed 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -521,7 +521,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED _attr_target_temperature_low: float | None = None _attr_target_temperature_high: float | None = None - _enable_turn_on_off_backwards_compatibility = False @staticmethod def config_schema() -> VolSchemaType: diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 69306a1c383..a4d400dfea2 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -18,6 +18,7 @@ import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -75,6 +76,8 @@ from .const import ( CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + CONFIG_ENTRY_MINOR_VERSION, + CONFIG_ENTRY_VERSION, DEFAULT_BIRTH, DEFAULT_DISCOVERY, DEFAULT_ENCODING, @@ -204,7 +207,9 @@ def update_password_from_user_input( class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + # Can be bumped to version 2.1 with HA Core 2026.1.0 + VERSION = CONFIG_ENTRY_VERSION # 1 + MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION # 2 _hassio_discovery: dict[str, Any] | None = None _addon_manager: AddonManager @@ -331,7 +336,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): break else: raise AddonError( - f"Failed to correctly start {addon_manager.addon_name} add-on" + translation_domain=DOMAIN, + translation_key="addon_start_failed", + translation_placeholders={"addon": addon_manager.addon_name}, ) async def async_step_user( @@ -467,21 +474,32 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} fields: OrderedDict[Any, Any] = OrderedDict() validated_user_input: dict[str, Any] = {} + if is_reconfigure := (self.source == SOURCE_RECONFIGURE): + reconfigure_entry = self._get_reconfigure_entry() if await async_get_broker_settings( self, fields, - None, + reconfigure_entry.data if is_reconfigure else None, user_input, validated_user_input, errors, ): + if is_reconfigure: + update_password_from_user_input( + reconfigure_entry.data.get(CONF_PASSWORD), validated_user_input + ) + can_connect = await self.hass.async_add_executor_job( try_connection, validated_user_input, ) if can_connect: - validated_user_input[CONF_DISCOVERY] = DEFAULT_DISCOVERY + if is_reconfigure: + return self.async_update_reload_and_abort( + reconfigure_entry, + data=validated_user_input, + ) return self.async_create_entry( title=validated_user_input[CONF_BROKER], data=validated_user_input, @@ -493,6 +511,12 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): step_id="broker", data_schema=vol.Schema(fields), errors=errors ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + return await self.async_step_broker() + async def async_step_hassio( self, discovery_info: HassioServiceInfo ) -> ConfigFlowResult: @@ -543,58 +567,17 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): class MQTTOptionsFlowHandler(OptionsFlow): """Handle MQTT options.""" - def __init__(self) -> None: - """Initialize MQTT options flow.""" - self.broker_config: dict[str, str | int] = {} - async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the MQTT options.""" - return await self.async_step_broker() - - async def async_step_broker( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the MQTT broker configuration.""" - errors: dict[str, str] = {} - fields: OrderedDict[Any, Any] = OrderedDict() - validated_user_input: dict[str, Any] = {} - if await async_get_broker_settings( - self, - fields, - self.config_entry.data, - user_input, - validated_user_input, - errors, - ): - self.broker_config.update( - update_password_from_user_input( - self.config_entry.data.get(CONF_PASSWORD), validated_user_input - ), - ) - can_connect = await self.hass.async_add_executor_job( - try_connection, - self.broker_config, - ) - - if can_connect: - return await self.async_step_options() - - errors["base"] = "cannot_connect" - - return self.async_show_form( - step_id="broker", - data_schema=vol.Schema(fields), - errors=errors, - last_step=False, - ) + return await self.async_step_options() async def async_step_options( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the MQTT options.""" errors = {} - current_config = self.config_entry.data - options_config: dict[str, Any] = {} + + options_config: dict[str, Any] = dict(self.config_entry.options) bad_input: bool = False def _birth_will(birt_or_will: str) -> dict[str, Any]: @@ -653,26 +636,18 @@ class MQTTOptionsFlowHandler(OptionsFlow): options_config[CONF_WILL_MESSAGE] = {} if not bad_input: - updated_config = {} - updated_config.update(self.broker_config) - updated_config.update(options_config) - self.hass.config_entries.async_update_entry( - self.config_entry, - data=updated_config, - title=str(self.broker_config[CONF_BROKER]), - ) - return self.async_create_entry(title="", data={}) + return self.async_create_entry(data=options_config) birth = { **DEFAULT_BIRTH, - **current_config.get(CONF_BIRTH_MESSAGE, {}), + **options_config.get(CONF_BIRTH_MESSAGE, {}), } will = { **DEFAULT_WILL, - **current_config.get(CONF_WILL_MESSAGE, {}), + **options_config.get(CONF_WILL_MESSAGE, {}), } - discovery = current_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY) - discovery_prefix = current_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX) + discovery = options_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY) + discovery_prefix = options_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX) # build form fields: OrderedDict[vol.Marker, Any] = OrderedDict() @@ -685,8 +660,8 @@ class MQTTOptionsFlowHandler(OptionsFlow): fields[ vol.Optional( "birth_enable", - default=CONF_BIRTH_MESSAGE not in current_config - or current_config[CONF_BIRTH_MESSAGE] != {}, + default=CONF_BIRTH_MESSAGE not in options_config + or options_config[CONF_BIRTH_MESSAGE] != {}, ) ] = BOOLEAN_SELECTOR fields[ @@ -708,8 +683,8 @@ class MQTTOptionsFlowHandler(OptionsFlow): fields[ vol.Optional( "will_enable", - default=CONF_WILL_MESSAGE not in current_config - or current_config[CONF_WILL_MESSAGE] != {}, + default=CONF_WILL_MESSAGE not in options_config + or options_config[CONF_WILL_MESSAGE] != {}, ) ] = BOOLEAN_SELECTOR fields[ @@ -793,11 +768,8 @@ async def async_get_broker_settings( validated_user_input.update(user_input) client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT) client_key_id: str | None = user_input.get(CONF_CLIENT_KEY) - if ( - client_certificate_id - and not client_key_id - or not client_certificate_id - and client_key_id + if (client_certificate_id and not client_key_id) or ( + not client_certificate_id and client_key_id ): errors["base"] = "invalid_inclusion" return False @@ -807,14 +779,20 @@ async def async_get_broker_settings( # Return to form for file upload CA cert or client cert and key if ( - not client_certificate - and user_input.get(SET_CLIENT_CERT) - and not client_certificate_id - or not certificate - and user_input.get(SET_CA_CERT, "off") == "custom" - and not certificate_id - or user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS - and CONF_WS_PATH not in user_input + ( + not client_certificate + and user_input.get(SET_CLIENT_CERT) + and not client_certificate_id + ) + or ( + not certificate + and user_input.get(SET_CA_CERT, "off") == "custom" + and not certificate_id + ) + or ( + user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS + and CONF_WS_PATH not in user_input + ) ): return False diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 9f1c55a54e0..007b3b7e576 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -4,7 +4,7 @@ import logging import jinja2 -from homeassistant.const import CONF_PAYLOAD, Platform +from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform from homeassistant.exceptions import TemplateError ATTR_DISCOVERY_HASH = "discovery_hash" @@ -56,12 +56,15 @@ CONF_SUPPORTED_FEATURES = "supported_features" CONF_ACTION_TEMPLATE = "action_template" CONF_ACTION_TOPIC = "action_topic" +CONF_COLOR_TEMP_KELVIN = "color_temp_kelvin" CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" CONF_ENABLED_BY_DEFAULT = "enabled_by_default" CONF_ENTITY_PICTURE = "entity_picture" +CONF_MAX_KELVIN = "max_kelvin" +CONF_MIN_KELVIN = "min_kelvin" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" @@ -160,6 +163,20 @@ MQTT_CONNECTION_STATE = "mqtt_connection_state" PAYLOAD_EMPTY_JSON = "{}" PAYLOAD_NONE = "None" +CONFIG_ENTRY_VERSION = 1 +CONFIG_ENTRY_MINOR_VERSION = 2 + +# Split mqtt entry data and options +# Can be removed when config entry is bumped to version 2.1 +# with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1 +# From 2026.1 we will write version 2.1 +ENTRY_OPTION_FIELDS = ( + CONF_DISCOVERY, + CONF_DISCOVERY_PREFIX, + "birth_message", + "will_message", +) + ENTITY_PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 80faf879587..8665ac26961 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -148,7 +148,10 @@ class Trigger: def async_remove() -> None: """Remove trigger.""" if instance not in self.trigger_instances: - raise HomeAssistantError("Can't remove trigger twice") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="mqtt_trigger_cannot_remove_twice", + ) if instance.remove: instance.remove() diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 8104c37574b..7a17c1f3409 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any from homeassistant.components import device_tracker from homeassistant.components.diagnostics import async_redact_data @@ -18,7 +18,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from . import debug_info, is_connected -from .models import DATA_MQTT REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} REDACT_STATE_DEVICE_TRACKER = {ATTR_LATITUDE, ATTR_LONGITUDE} @@ -45,11 +44,10 @@ def _async_get_diagnostics( device: DeviceEntry | None = None, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - mqtt_instance = hass.data[DATA_MQTT].client - if TYPE_CHECKING: - assert mqtt_instance is not None - - redacted_config = async_redact_data(mqtt_instance.conf, REDACT_CONFIG) + redacted_config = { + "data": async_redact_data(dict(entry.data), REDACT_CONFIG), + "options": dict(entry.options), + } data = { "connected": is_connected(hass), diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a5ddb3ef4e6..21d250db29a 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -138,7 +138,10 @@ def get_origin_log_string( support_url_log = "" if include_url and (support_url := get_origin_support_url(discovery_payload)): support_url_log = f", support URL: {support_url}" - return f" from external application {origin_info["name"]}{sw_version_log}{support_url_log}" + return ( + " from external application " + f"{origin_info['name']}{sw_version_log}{support_url_log}" + ) @callback @@ -383,7 +386,7 @@ async def async_start( # noqa: C901 _async_add_component(discovery_payload) @callback - def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901 + def async_discovery_message_received(msg: ReceiveMessage) -> None: """Process the received message.""" mqtt_data.last_discovery = msg.timestamp payload = msg.payload diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 46b2c9e1d42..fb047cc8d5e 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -137,7 +137,7 @@ MQTT_ATTRIBUTES_BLOCKED = { "extra_state_attributes", "force_update", "icon", - "name", + "friendly_name", "should_poll", "state", "supported_features", @@ -1185,6 +1185,33 @@ def device_info_from_specifications( return info +@callback +def ensure_via_device_exists( + hass: HomeAssistant, device_info: DeviceInfo | None, config_entry: ConfigEntry +) -> None: + """Ensure the via device is in the device registry.""" + if ( + device_info is None + or CONF_VIA_DEVICE not in device_info + or (device_registry := dr.async_get(hass)).async_get_device( + identifiers={device_info["via_device"]} + ) + ): + return + + # Ensure the via device exists in the device registry + _LOGGER.debug( + "Device identifier %s via_device reference from device_info %s " + "not found in the Device Registry, creating new entry", + device_info["via_device"], + device_info, + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={device_info["via_device"]}, + ) + + class MqttEntityDeviceInfo(Entity): """Mixin used for mqtt platforms that support the device registry.""" @@ -1203,6 +1230,7 @@ class MqttEntityDeviceInfo(Entity): device_info = self.device_info if device_info is not None: + ensure_via_device_exists(self.hass, device_info, self._config_entry) device_registry.async_get_or_create( config_entry_id=config_entry_id, **device_info ) @@ -1256,6 +1284,7 @@ class MqttEntity( self, hass, discovery_data, self.discovery_update ) MqttEntityDeviceInfo.__init__(self, config.get(CONF_DEVICE), config_entry) + ensure_via_device_exists(self.hass, self.device_info, self._config_entry) def _init_entity_id(self) -> None: """Set entity_id from object_id if defined in config.""" @@ -1490,6 +1519,8 @@ def update_device( config_entry_id = config_entry.entry_id device_info = device_info_from_specifications(config[CONF_DEVICE]) + ensure_via_device_exists(hass, device_info, config_entry) + if config_entry_id is not None and device_info is not None: update_device_info = cast(dict[str, Any], device_info) update_device_info["config_entry_id"] = config_entry_id diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index d9812aaaf48..f665f2c4016 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -151,7 +151,7 @@ class MqttEvent(MqttEntity, EventEntity): ) except KeyError: _LOGGER.warning( - ("`event_type` missing in JSON event payload, " " '%s' on topic %s"), + "`event_type` missing in JSON event payload, '%s' on topic %s", payload, msg.topic, ) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index b3c0f22789c..4d2e764a0d5 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -226,7 +226,6 @@ class MqttFan(MqttEntity, FanEntity): _optimistic_preset_mode: bool _payload: dict[str, Any] _speed_range: tuple[int, int] - _enable_turn_on_off_backwards_compatibility = False @staticmethod def config_schema() -> VolSchemaType: diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index de6a9d4c126..eaaa80af223 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -9,20 +9,25 @@ from typing import Any, cast import voluptuous as vol from homeassistant.components.light import ( + _DEPRECATED_ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_MAX_MIREDS, + _DEPRECATED_ATTR_MIN_MIREDS, ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_WHITE, ATTR_XY_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ENTITY_ID_FORMAT, ColorMode, LightEntity, @@ -46,7 +51,10 @@ import homeassistant.util.color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( + CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, @@ -112,12 +120,15 @@ MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset( { ATTR_COLOR_MODE, ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_COLOR_TEMP.value, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + _DEPRECATED_ATTR_MAX_MIREDS.value, + ATTR_MIN_COLOR_TEMP_KELVIN, + _DEPRECATED_ATTR_MIN_MIREDS.value, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -174,6 +185,7 @@ PLATFORM_SCHEMA_MODERN_BASIC = ( vol.Optional(CONF_COLOR_TEMP_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_COLOR_TEMP_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_COLOR_TEMP_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_EFFECT_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_EFFECT_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), @@ -185,6 +197,8 @@ PLATFORM_SCHEMA_MODERN_BASIC = ( vol.Optional(CONF_HS_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE): vol.In( VALUES_ON_COMMAND_TYPE @@ -231,6 +245,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED _topic: dict[str, str | None] _payload: dict[str, str] + _color_temp_kelvin: bool _command_templates: dict[ str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType] ] @@ -240,7 +255,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): _optimistic: bool _optimistic_brightness: bool _optimistic_color_mode: bool - _optimistic_color_temp: bool + _optimistic_color_temp_kelvin: bool _optimistic_effect: bool _optimistic_hs_color: bool _optimistic_rgb_color: bool @@ -255,8 +270,18 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._attr_min_mireds = config.get(CONF_MIN_MIREDS, super().min_mireds) - self._attr_max_mireds = config.get(CONF_MAX_MIREDS, super().max_mireds) + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(max_mireds) + if (max_mireds := config.get(CONF_MAX_MIREDS)) + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) + ) + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(min_mireds) + if (min_mireds := config.get(CONF_MIN_MIREDS)) + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) + ) + self._attr_effect_list = config.get(CONF_EFFECT_LIST) topic: dict[str, str | None] = { @@ -321,7 +346,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): and topic[CONF_RGB_STATE_TOPIC] is None ) ) - self._optimistic_color_temp = ( + self._optimistic_color_temp_kelvin = ( optimistic or topic[CONF_COLOR_TEMP_STATE_TOPIC] is None ) self._optimistic_effect = optimistic or topic[CONF_EFFECT_STATE_TOPIC] is None @@ -472,10 +497,8 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): def _converter( r: int, g: int, b: int, cw: int, ww: int ) -> tuple[int, int, int]: - min_kelvin = color_util.color_temperature_mired_to_kelvin(self.max_mireds) - max_kelvin = color_util.color_temperature_mired_to_kelvin(self.min_mireds) return color_util.color_rgbww_to_rgb( - r, g, b, cw, ww, min_kelvin, max_kelvin + r, g, b, cw, ww, self.min_color_temp_kelvin, self.max_color_temp_kelvin ) rgbww = self._rgbx_received( @@ -512,7 +535,12 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if self._optimistic_color_mode: self._attr_color_mode = ColorMode.COLOR_TEMP - self._attr_color_temp = int(payload) + if self._color_temp_kelvin: + self._attr_color_temp_kelvin = int(payload) + return + self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( + int(payload) + ) @callback def _effect_received(self, msg: ReceiveMessage) -> None: @@ -559,7 +587,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): self._attr_xy_color = cast(tuple[float, float], xy_color) @callback - def _prepare_subscribe_topics(self) -> None: # noqa: C901 + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" self.add_subscription(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) self.add_subscription( @@ -586,7 +614,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): self.add_subscription( CONF_COLOR_TEMP_STATE_TOPIC, self._color_temp_received, - {"_attr_color_mode", "_attr_color_temp"}, + {"_attr_color_mode", "_attr_color_temp_kelvin"}, ) self.add_subscription( CONF_EFFECT_STATE_TOPIC, self._effect_received, {"_attr_effect"} @@ -625,7 +653,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): restore_state(ATTR_RGBW_COLOR) restore_state(ATTR_RGBWW_COLOR) restore_state(ATTR_COLOR_MODE) - restore_state(ATTR_COLOR_TEMP) + restore_state(ATTR_COLOR_TEMP_KELVIN) restore_state(ATTR_EFFECT) restore_state(ATTR_HS_COLOR) restore_state(ATTR_XY_COLOR) @@ -797,14 +825,23 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): await publish(CONF_RGBWW_COMMAND_TOPIC, rgbww_s) should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS]) if ( - ATTR_COLOR_TEMP in kwargs + ATTR_COLOR_TEMP_KELVIN in kwargs and self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None ): ct_command_tpl = self._command_templates[CONF_COLOR_TEMP_COMMAND_TEMPLATE] - color_temp = ct_command_tpl(int(kwargs[ATTR_COLOR_TEMP]), None) + color_temp = ct_command_tpl( + kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ), + None, + ) await publish(CONF_COLOR_TEMP_COMMAND_TOPIC, color_temp) should_update |= set_optimistic( - ATTR_COLOR_TEMP, kwargs[ATTR_COLOR_TEMP], ColorMode.COLOR_TEMP + ATTR_COLOR_TEMP_KELVIN, + kwargs[ATTR_COLOR_TEMP_KELVIN], + ColorMode.COLOR_TEMP, ) if ( diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 89f338f6bab..2d152ca12c8 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, @@ -22,6 +22,8 @@ from homeassistant.components.light import ( ATTR_TRANSITION, ATTR_WHITE, ATTR_XY_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, DOMAIN as LIGHT_DOMAIN, ENTITY_ID_FORMAT, FLASH_LONG, @@ -59,7 +61,10 @@ from homeassistant.util.yaml import dump as yaml_dump from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( + CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, @@ -201,6 +206,7 @@ _PLATFORM_SCHEMA_BASE = ( # CONF_COLOR_TEMP was deprecated with HA Core 2024.4 and will be # removed with HA Core 2025.3 vol.Optional(CONF_COLOR_TEMP, default=DEFAULT_COLOR_TEMP): cv.boolean, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), vol.Optional( @@ -214,6 +220,8 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional(CONF_HS, default=DEFAULT_HS): cv.boolean, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_QOS, default=DEFAULT_QOS): vol.All( vol.Coerce(int), vol.In([0, 1, 2]) @@ -273,8 +281,17 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._attr_max_mireds = config.get(CONF_MAX_MIREDS, super().max_mireds) - self._attr_min_mireds = config.get(CONF_MIN_MIREDS, super().min_mireds) + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(max_mireds) + if (max_mireds := config.get(CONF_MAX_MIREDS)) + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) + ) + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(min_mireds) + if (min_mireds := config.get(CONF_MIN_MIREDS)) + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) + ) self._attr_effect_list = config.get(CONF_EFFECT_LIST) self._topic = { @@ -370,7 +387,13 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): return try: if color_mode == ColorMode.COLOR_TEMP: - self._attr_color_temp = int(values["color_temp"]) + self._attr_color_temp_kelvin = ( + values["color_temp"] + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( + values["color_temp"] + ) + ) self._attr_color_mode = ColorMode.COLOR_TEMP elif color_mode == ColorMode.HS: hue = float(values["color"]["h"]) @@ -469,12 +492,18 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): # Deprecated color handling try: if values["color_temp"] is None: - self._attr_color_temp = None + self._attr_color_temp_kelvin = None else: - self._attr_color_temp = int(values["color_temp"]) # type: ignore[arg-type] + self._attr_color_temp_kelvin = ( + values["color_temp"] # type: ignore[assignment] + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( + values["color_temp"] # type: ignore[arg-type] + ) + ) except KeyError: pass - except ValueError: + except (TypeError, ValueError): _LOGGER.warning( "Invalid color temp value '%s' received for entity %s", values["color_temp"], @@ -496,7 +525,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._state_received, { "_attr_brightness", - "_attr_color_temp", + "_attr_color_temp_kelvin", "_attr_effect", "_attr_hs_color", "_attr_is_on", @@ -522,8 +551,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = last_attributes.get( ATTR_COLOR_MODE, self.color_mode ) - self._attr_color_temp = last_attributes.get( - ATTR_COLOR_TEMP, self.color_temp + self._attr_color_temp_kelvin = last_attributes.get( + ATTR_COLOR_TEMP_KELVIN, self.color_temp_kelvin ) self._attr_effect = last_attributes.get(ATTR_EFFECT, self.effect) self._attr_hs_color = last_attributes.get(ATTR_HS_COLOR, self.hs_color) @@ -623,7 +652,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): message["color"]["s"] = hs_color[1] if self._optimistic: - self._attr_color_temp = None + self._attr_color_temp_kelvin = None self._attr_hs_color = kwargs[ATTR_HS_COLOR] should_update = True @@ -690,12 +719,17 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_brightness = kwargs[ATTR_BRIGHTNESS] should_update = True - if ATTR_COLOR_TEMP in kwargs: - message["color_temp"] = int(kwargs[ATTR_COLOR_TEMP]) - + if ATTR_COLOR_TEMP_KELVIN in kwargs: + message["color_temp"] = ( + kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) + ) if self._optimistic: self._attr_color_mode = ColorMode.COLOR_TEMP - self._attr_color_temp = kwargs[ATTR_COLOR_TEMP] + self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] self._attr_hs_color = None should_update = True diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index c4f9cad44c5..69bc801ff1e 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -10,11 +10,13 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ENTITY_ID_FORMAT, ColorMode, LightEntity, @@ -37,7 +39,14 @@ import homeassistant.util.color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA -from ..const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC, PAYLOAD_NONE +from ..const import ( + CONF_COLOR_TEMP_KELVIN, + CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, + CONF_STATE_TOPIC, + PAYLOAD_NONE, +) from ..entity import MqttEntity from ..models import ( MqttCommandTemplate, @@ -83,12 +92,15 @@ PLATFORM_SCHEMA_MODERN_TEMPLATE = ( { vol.Optional(CONF_BLUE_TEMPLATE): cv.template, vol.Optional(CONF_BRIGHTNESS_TEMPLATE): cv.template, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_COLOR_TEMP_TEMPLATE): cv.template, vol.Required(CONF_COMMAND_OFF_TEMPLATE): cv.template, vol.Required(CONF_COMMAND_ON_TEMPLATE): cv.template, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EFFECT_TEMPLATE): cv.template, vol.Optional(CONF_GREEN_TEMPLATE): cv.template, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), @@ -126,8 +138,17 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._attr_max_mireds = config.get(CONF_MAX_MIREDS, super().max_mireds) - self._attr_min_mireds = config.get(CONF_MIN_MIREDS, super().min_mireds) + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(max_mireds) + if (max_mireds := config.get(CONF_MAX_MIREDS)) + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) + ) + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(min_mireds) + if (min_mireds := config.get(CONF_MIN_MIREDS)) + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) + ) self._attr_effect_list = config.get(CONF_EFFECT_LIST) self._topics = { @@ -213,8 +234,12 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( msg.payload ) - self._attr_color_temp = ( - int(color_temp) if color_temp != "None" else None + self._attr_color_temp_kelvin = ( + int(color_temp) + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin(int(color_temp)) + if color_temp != "None" + else None ) except ValueError: _LOGGER.warning("Invalid color temperature value received") @@ -256,7 +281,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): { "_attr_brightness", "_attr_color_mode", - "_attr_color_temp", + "_attr_color_temp_kelvin", "_attr_effect", "_attr_hs_color", "_attr_is_on", @@ -275,8 +300,10 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if last_state.attributes.get(ATTR_HS_COLOR): self._attr_hs_color = last_state.attributes.get(ATTR_HS_COLOR) self._update_color_mode() - if last_state.attributes.get(ATTR_COLOR_TEMP): - self._attr_color_temp = last_state.attributes.get(ATTR_COLOR_TEMP) + if last_state.attributes.get(ATTR_COLOR_TEMP_KELVIN): + self._attr_color_temp_kelvin = last_state.attributes.get( + ATTR_COLOR_TEMP_KELVIN + ) if last_state.attributes.get(ATTR_EFFECT): self._attr_effect = last_state.attributes.get(ATTR_EFFECT) @@ -295,11 +322,17 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if self._optimistic: self._attr_brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_COLOR_TEMP in kwargs: - values["color_temp"] = int(kwargs[ATTR_COLOR_TEMP]) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + values["color_temp"] = ( + kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) + ) if self._optimistic: - self._attr_color_temp = kwargs[ATTR_COLOR_TEMP] + self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] self._attr_hs_color = None self._update_color_mode() @@ -325,7 +358,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): values["sat"] = hs_color[1] if self._optimistic: - self._attr_color_temp = None + self._attr_color_temp_kelvin = None self._attr_hs_color = kwargs[ATTR_HS_COLOR] self._update_color_mode() diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 081449b142a..25e98c01aaf 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -7,6 +7,7 @@ "dependencies": ["file_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/mqtt", "iot_class": "local_push", + "quality_scale": "platinum", "requirements": ["paho-mqtt==1.6.1"], "single_config_entry": true } diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index a9bf1829b63..9b47a3ad23a 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -179,14 +179,14 @@ class MqttNumber(MqttEntity, RestoreNumber): return if num_value is not None and ( - num_value < self.min_value or num_value > self.max_value + num_value < self.native_min_value or num_value > self.native_max_value ): _LOGGER.error( "Invalid value for %s: %s (range %s - %s)", self.entity_id, num_value, - self.min_value, - self.max_value, + self.native_min_value, + self.native_max_value, ) return diff --git a/homeassistant/components/mqtt/quality_scale.yaml b/homeassistant/components/mqtt/quality_scale.yaml index b3084f67da3..b17812acd91 100644 --- a/homeassistant/components/mqtt/quality_scale.yaml +++ b/homeassistant/components/mqtt/quality_scale.yaml @@ -29,9 +29,12 @@ rules: MQTT broker, this happens during integration setup, and only one config entry is allowed. test-before-configure: done - test-before-setup: done + test-before-setup: + status: exempt + comment: > + We choose to early exit the entry as it can take some time for the client + to connect. Waiting for the client would increase the overall setup time. unique-config-entry: done - # Silver config-entry-unloading: done log-when-unavailable: done @@ -119,4 +122,8 @@ rules: status: exempt comment: | This integration does not use web sessions. - strict-typing: done + strict-typing: + status: done + comment: > + Typing for 'paho-mqtt==1.6.1' supported via 'types-paho-mqtt==1.6.0.20240321' + (requirements_test.txt). diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 22f64053d23..1cc5ba2d2e5 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -217,10 +217,7 @@ class MqttSiren(MqttEntity, SirenEntity): try: json_payload = json_loads_object(payload) _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), + "JSON payload detected after processing payload '%s' on topic %s", json_payload, msg.topic, ) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 7cf35783569..3815b6adbd5 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -11,14 +11,6 @@ "invalid_platform_config": { "title": "Invalid config found for mqtt {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." - }, - "payload_template_deprecation": { - "title": "Deprecated option used in mqtt publish action call", - "description": "Deprecated `payload_template` option used in MQTT publish action call to topic `{topic}` from payload template `{payload_template}`. Use the `payload` option instead. In automations templates are supported natively. Update the automation or script to use the `payload` option instead and restart Home Assistant to fix this issue." - }, - "topic_template_deprecation": { - "title": "Deprecated option used in mqtt publish action call", - "description": "Deprecated `topic_template` option used in MQTT publish action call to topic `{topic}` from topic template `{topic_template}`. Use the `topic` option instead. In automations templates are supported natively. Update the automation or script to use the `topic` option instead and restart Home Assistant to fix this issue." } }, "config": { @@ -101,6 +93,7 @@ "addon_connection_failed": "Failed to connect to the {addon} add-on. Check the add-on status and try again later.", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "error": { @@ -114,7 +107,7 @@ "bad_ws_headers": "Supply valid HTTP headers as a JSON object", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_inclusion": "The client certificate and private key must be configurered together" + "invalid_inclusion": "The client certificate and private key must be configured together" } }, "device_automation": { @@ -208,7 +201,7 @@ "birth_payload": "The `birth` message that is published when MQTT is ready and connected.", "birth_qos": "The quality of service of the `birth` message that is published when MQTT is ready and connected", "birth_retain": "When set, Home Assistant will retain the `birth` message published to your MQTT broker.", - "will_enable": "When set, Home Assistant will ask your broker to publish a `will` message when MQTT is stopped or when it looses the connection to your broker.", + "will_enable": "When set, Home Assistant will ask your broker to publish a `will` message when MQTT is stopped or when it loses the connection to your broker.", "will_topic": "The MQTT topic your MQTT broker will publish a `will` message to.", "will_payload": "The message your MQTT broker `will` publish when the MQTT integration is stopped or when the connection is lost.", "will_qos": "The quality of service of the `will` message that is published by your MQTT broker.", @@ -289,6 +282,9 @@ } }, "exceptions": { + "addon_start_failed": { + "message": "Failed to correctly start {addon} add-on." + }, "command_template_error": { "message": "Parsing template `{command_template}` for entity `{entity_id}` failed with error: {error}." }, @@ -298,11 +294,23 @@ "invalid_publish_topic": { "message": "Unable to publish: topic template `{topic_template}` produced an invalid topic `{topic}` after rendering ({error})" }, + "mqtt_broker_error": { + "message": "Error talking to MQTT: {error_message}." + }, "mqtt_not_setup_cannot_subscribe": { "message": "Cannot subscribe to topic \"{topic}\", make sure MQTT is set up correctly." }, "mqtt_not_setup_cannot_publish": { "message": "Cannot publish to topic \"{topic}\", make sure MQTT is set up correctly." + }, + "mqtt_not_setup_cannot_unsubscribe_twice": { + "message": "Cannot unsubscribe topic \"{topic}\" twice." + }, + "mqtt_topic_not_a_string": { + "message": "Topic needs to be a string! Got: {topic}." + }, + "mqtt_trigger_cannot_remove_twice": { + "message": "Can't remove trigger twice." } } } diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index c90174e8a01..0a54bcdb378 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -91,7 +91,7 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): _entity_id_format = switch.ENTITY_ID_FORMAT _optimistic: bool - _is_on_map: dict[str | bytes, bool | None] + _is_on_map: dict[str | bytes | bytearray, bool | None] _command_template: Callable[[PublishPayloadType], PublishPayloadType] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 99b4e5cb821..59742d24b60 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -151,10 +151,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): rendered_json_payload = json_loads(payload) if isinstance(rendered_json_payload, dict): _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), + "JSON payload detected after processing payload '%s' on topic %s", rendered_json_payload, msg.topic, ) diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index ac6dca3cbbc..743bfb363f3 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -10,20 +10,12 @@ import voluptuous as vol from homeassistant.components import vacuum from homeassistant.components.vacuum import ( ENTITY_ID_FORMAT, - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, - CONF_NAME, - STATE_IDLE, - STATE_PAUSED, -) +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -45,13 +37,20 @@ BATTERY = "battery_level" FAN_SPEED = "fan_speed" STATE = "state" -POSSIBLE_STATES: dict[str, str] = { - STATE_IDLE: STATE_IDLE, - STATE_DOCKED: STATE_DOCKED, - STATE_ERROR: STATE_ERROR, - STATE_PAUSED: STATE_PAUSED, - STATE_RETURNING: STATE_RETURNING, - STATE_CLEANING: STATE_CLEANING, +STATE_IDLE = "idle" +STATE_DOCKED = "docked" +STATE_ERROR = "error" +STATE_PAUSED = "paused" +STATE_RETURNING = "returning" +STATE_CLEANING = "cleaning" + +POSSIBLE_STATES: dict[str, VacuumActivity] = { + STATE_IDLE: VacuumActivity.IDLE, + STATE_DOCKED: VacuumActivity.DOCKED, + STATE_ERROR: VacuumActivity.ERROR, + STATE_PAUSED: VacuumActivity.PAUSED, + STATE_RETURNING: VacuumActivity.RETURNING, + STATE_CLEANING: VacuumActivity.CLEANING, } CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES @@ -265,7 +264,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): if STATE in payload and ( (state := payload[STATE]) in POSSIBLE_STATES or state is None ): - self._attr_state = ( + self._attr_activity = ( POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None ) del payload[STATE] @@ -277,7 +276,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): self.add_subscription( CONF_STATE_TOPIC, self._state_message_received, - {"_attr_battery_level", "_attr_fan_speed", "_attr_state"}, + {"_attr_battery_level", "_attr_fan_speed", "_attr_activity"}, ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 9f0fc1aad27..052f4f556c1 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -17,24 +17,30 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, async_delete_issue, ) +from .actions import register_actions from .const import DOMAIN, LOGGER if TYPE_CHECKING: from music_assistant_models.event import MassEvent -type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] + from homeassistant.helpers.typing import ConfigType PLATFORMS = [Platform.MEDIA_PLAYER] CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] + @dataclass class MusicAssistantEntryData: @@ -44,10 +50,16 @@ class MusicAssistantEntryData: listen_task: asyncio.Task +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Music Assistant component.""" + register_actions(hass) + return True + + async def async_setup_entry( hass: HomeAssistant, entry: MusicAssistantConfigEntry ) -> bool: - """Set up from a config entry.""" + """Set up Music Assistant from a config entry.""" http_session = async_get_clientsession(hass, verify_ssl=False) mass_url = entry.data[CONF_URL] mass = MusicAssistantClient(mass_url, http_session) @@ -97,6 +109,7 @@ async def async_setup_entry( listen_task.cancel() raise ConfigEntryNotReady("Music Assistant client not ready") from err + # store the listen task and mass client in the entry data entry.runtime_data = MusicAssistantEntryData(mass, listen_task) # If the listen task is already failed, we need to raise ConfigEntryNotReady diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py new file mode 100644 index 00000000000..f3297bf0a6f --- /dev/null +++ b/homeassistant/components/music_assistant/actions.py @@ -0,0 +1,212 @@ +"""Custom actions (previously known as services) for the Music Assistant integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.enums import MediaType +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_ALBUM_ARTISTS_ONLY, + ATTR_ALBUM_TYPE, + ATTR_ALBUMS, + ATTR_ARTISTS, + ATTR_CONFIG_ENTRY_ID, + ATTR_FAVORITE, + ATTR_ITEMS, + ATTR_LIBRARY_ONLY, + ATTR_LIMIT, + ATTR_MEDIA_TYPE, + ATTR_OFFSET, + ATTR_ORDER_BY, + ATTR_PLAYLISTS, + ATTR_RADIO, + ATTR_SEARCH, + ATTR_SEARCH_ALBUM, + ATTR_SEARCH_ARTIST, + ATTR_SEARCH_NAME, + ATTR_TRACKS, + DOMAIN, +) +from .schemas import ( + LIBRARY_RESULTS_SCHEMA, + SEARCH_RESULT_SCHEMA, + media_item_dict_from_mass_item, +) + +if TYPE_CHECKING: + from music_assistant_client import MusicAssistantClient + + from . import MusicAssistantConfigEntry + +SERVICE_SEARCH = "search" +SERVICE_GET_LIBRARY = "get_library" +DEFAULT_OFFSET = 0 +DEFAULT_LIMIT = 25 +DEFAULT_SORT_ORDER = "name" + + +@callback +def get_music_assistant_client( + hass: HomeAssistant, config_entry_id: str +) -> MusicAssistantClient: + """Get the Music Assistant client for the given config entry.""" + entry: MusicAssistantConfigEntry | None + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError("Entry not found") + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError("Entry not loaded") + return entry.runtime_data.mass + + +@callback +def register_actions(hass: HomeAssistant) -> None: + """Register custom actions.""" + hass.services.async_register( + DOMAIN, + SERVICE_SEARCH, + handle_search, + schema=vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_SEARCH_NAME): cv.string, + vol.Optional(ATTR_MEDIA_TYPE): vol.All( + cv.ensure_list, [vol.Coerce(MediaType)] + ), + vol.Optional(ATTR_SEARCH_ARTIST): cv.string, + vol.Optional(ATTR_SEARCH_ALBUM): cv.string, + vol.Optional(ATTR_LIMIT, default=5): vol.Coerce(int), + vol.Optional(ATTR_LIBRARY_ONLY, default=False): cv.boolean, + } + ), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_LIBRARY, + handle_get_library, + schema=vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), + vol.Optional(ATTR_FAVORITE): cv.boolean, + vol.Optional(ATTR_SEARCH): cv.string, + vol.Optional(ATTR_LIMIT): cv.positive_int, + vol.Optional(ATTR_OFFSET): int, + vol.Optional(ATTR_ORDER_BY): cv.string, + vol.Optional(ATTR_ALBUM_TYPE): list[MediaType], + vol.Optional(ATTR_ALBUM_ARTISTS_ONLY): cv.boolean, + } + ), + supports_response=SupportsResponse.ONLY, + ) + + +async def handle_search(call: ServiceCall) -> ServiceResponse: + """Handle queue_command action.""" + mass = get_music_assistant_client(call.hass, call.data[ATTR_CONFIG_ENTRY_ID]) + search_name = call.data[ATTR_SEARCH_NAME] + search_artist = call.data.get(ATTR_SEARCH_ARTIST) + search_album = call.data.get(ATTR_SEARCH_ALBUM) + if search_album and search_artist: + search_name = f"{search_artist} - {search_album} - {search_name}" + elif search_album: + search_name = f"{search_album} - {search_name}" + elif search_artist: + search_name = f"{search_artist} - {search_name}" + search_results = await mass.music.search( + search_query=search_name, + media_types=call.data.get(ATTR_MEDIA_TYPE, MediaType.ALL), + limit=call.data[ATTR_LIMIT], + library_only=call.data[ATTR_LIBRARY_ONLY], + ) + response: ServiceResponse = SEARCH_RESULT_SCHEMA( + { + ATTR_ARTISTS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.artists + ], + ATTR_ALBUMS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.albums + ], + ATTR_TRACKS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.tracks + ], + ATTR_PLAYLISTS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.playlists + ], + ATTR_RADIO: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.radio + ], + } + ) + return response + + +async def handle_get_library(call: ServiceCall) -> ServiceResponse: + """Handle get_library action.""" + mass = get_music_assistant_client(call.hass, call.data[ATTR_CONFIG_ENTRY_ID]) + media_type = call.data[ATTR_MEDIA_TYPE] + limit = call.data.get(ATTR_LIMIT, DEFAULT_LIMIT) + offset = call.data.get(ATTR_OFFSET, DEFAULT_OFFSET) + order_by = call.data.get(ATTR_ORDER_BY, DEFAULT_SORT_ORDER) + base_params = { + "favorite": call.data.get(ATTR_FAVORITE), + "search": call.data.get(ATTR_SEARCH), + "limit": limit, + "offset": offset, + "order_by": order_by, + } + if media_type == MediaType.ALBUM: + library_result = await mass.music.get_library_albums( + **base_params, + album_types=call.data.get(ATTR_ALBUM_TYPE), + ) + elif media_type == MediaType.ARTIST: + library_result = await mass.music.get_library_artists( + **base_params, + album_artists_only=call.data.get(ATTR_ALBUM_ARTISTS_ONLY), + ) + elif media_type == MediaType.TRACK: + library_result = await mass.music.get_library_tracks( + **base_params, + ) + elif media_type == MediaType.RADIO: + library_result = await mass.music.get_library_radios( + **base_params, + ) + elif media_type == MediaType.PLAYLIST: + library_result = await mass.music.get_library_playlists( + **base_params, + ) + else: + raise ServiceValidationError(f"Unsupported media type {media_type}") + + response: ServiceResponse = LIBRARY_RESULTS_SCHEMA( + { + ATTR_ITEMS: [ + media_item_dict_from_mass_item(mass, item) for item in library_result + ], + ATTR_LIMIT: limit, + ATTR_OFFSET: offset, + ATTR_ORDER_BY: order_by, + ATTR_MEDIA_TYPE: media_type, + } + ) + return response diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py index fc50a2d654b..b00924c97a5 100644 --- a/homeassistant/components/music_assistant/config_flow.py +++ b/homeassistant/components/music_assistant/config_flow.py @@ -13,11 +13,11 @@ from music_assistant_client.exceptions import ( from music_assistant_models.api import ServerInfoMessage import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -93,7 +93,7 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=get_manual_schema({})) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Mass server. diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 6512f58b96c..1980c495278 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -14,5 +14,55 @@ ATTR_GROUP_PARENTS = "group_parents" ATTR_MASS_PLAYER_TYPE = "mass_player_type" ATTR_ACTIVE_QUEUE = "active_queue" ATTR_STREAM_TITLE = "stream_title" +ATTR_MEDIA_TYPE = "media_type" +ATTR_SEARCH_NAME = "name" +ATTR_SEARCH_ARTIST = "artist" +ATTR_SEARCH_ALBUM = "album" +ATTR_LIMIT = "limit" +ATTR_LIBRARY_ONLY = "library_only" +ATTR_FAVORITE = "favorite" +ATTR_SEARCH = "search" +ATTR_OFFSET = "offset" +ATTR_ORDER_BY = "order_by" +ATTR_ALBUM_TYPE = "album_type" +ATTR_ALBUM_ARTISTS_ONLY = "album_artists_only" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_URI = "uri" +ATTR_IMAGE = "image" +ATTR_VERSION = "version" +ATTR_ARTISTS = "artists" +ATTR_ALBUMS = "albums" +ATTR_TRACKS = "tracks" +ATTR_PLAYLISTS = "playlists" +ATTR_RADIO = "radio" +ATTR_ITEMS = "items" +ATTR_RADIO_MODE = "radio_mode" +ATTR_MEDIA_ID = "media_id" +ATTR_ARTIST = "artist" +ATTR_ALBUM = "album" +ATTR_URL = "url" +ATTR_USE_PRE_ANNOUNCE = "use_pre_announce" +ATTR_ANNOUNCE_VOLUME = "announce_volume" +ATTR_SOURCE_PLAYER = "source_player" +ATTR_AUTO_PLAY = "auto_play" +ATTR_QUEUE_ID = "queue_id" +ATTR_ACTIVE = "active" +ATTR_SHUFFLE_ENABLED = "shuffle_enabled" +ATTR_REPEAT_MODE = "repeat_mode" +ATTR_CURRENT_INDEX = "current_index" +ATTR_ELAPSED_TIME = "elapsed_time" +ATTR_CURRENT_ITEM = "current_item" +ATTR_NEXT_ITEM = "next_item" +ATTR_QUEUE_ITEM_ID = "queue_item_id" +ATTR_DURATION = "duration" +ATTR_MEDIA_ITEM = "media_item" +ATTR_STREAM_DETAILS = "stream_details" +ATTR_CONTENT_TYPE = "content_type" +ATTR_SAMPLE_RATE = "sample_rate" +ATTR_BIT_DEPTH = "bit_depth" +ATTR_STREAM_TITLE = "stream_title" +ATTR_PROVIDER = "provider" +ATTR_ITEM_ID = "item_id" + LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/music_assistant/icons.json b/homeassistant/components/music_assistant/icons.json new file mode 100644 index 00000000000..0fa64b8d273 --- /dev/null +++ b/homeassistant/components/music_assistant/icons.json @@ -0,0 +1,10 @@ +{ + "services": { + "play_media": { "service": "mdi:play" }, + "play_announcement": { "service": "mdi:bullhorn" }, + "transfer_queue": { "service": "mdi:transfer" }, + "search": { "service": "mdi:magnify" }, + "get_queue": { "service": "mdi:playlist-music" }, + "get_library": { "service": "mdi:music-box-multiple" } + } +} diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 65e6652407f..f5cdcf50673 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.0.5"], + "requirements": ["music-assistant-client==1.0.8"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py new file mode 100644 index 00000000000..e65d6d4a975 --- /dev/null +++ b/homeassistant/components/music_assistant/media_browser.py @@ -0,0 +1,351 @@ +"""Media Source Implementation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from music_assistant_models.media_items import MediaItemType + +from homeassistant.components import media_source +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + MediaType, +) +from homeassistant.core import HomeAssistant + +from .const import DEFAULT_NAME, DOMAIN + +if TYPE_CHECKING: + from music_assistant_client import MusicAssistantClient + +MEDIA_TYPE_RADIO = "radio" + +PLAYABLE_MEDIA_TYPES = [ + MediaType.PLAYLIST, + MediaType.ALBUM, + MediaType.ARTIST, + MEDIA_TYPE_RADIO, + MediaType.TRACK, +] + +LIBRARY_ARTISTS = "artists" +LIBRARY_ALBUMS = "albums" +LIBRARY_TRACKS = "tracks" +LIBRARY_PLAYLISTS = "playlists" +LIBRARY_RADIO = "radio" + + +LIBRARY_TITLE_MAP = { + LIBRARY_ARTISTS: "Artists", + LIBRARY_ALBUMS: "Albums", + LIBRARY_TRACKS: "Tracks", + LIBRARY_PLAYLISTS: "Playlists", + LIBRARY_RADIO: "Radio stations", +} + +LIBRARY_MEDIA_CLASS_MAP = { + LIBRARY_ARTISTS: MediaClass.ARTIST, + LIBRARY_ALBUMS: MediaClass.ALBUM, + LIBRARY_TRACKS: MediaClass.TRACK, + LIBRARY_PLAYLISTS: MediaClass.PLAYLIST, + LIBRARY_RADIO: MediaClass.MUSIC, # radio is not accepted by HA +} + +MEDIA_CONTENT_TYPE_FLAC = "audio/flac" +THUMB_SIZE = 200 + + +def media_source_filter(item: BrowseMedia) -> bool: + """Filter media sources.""" + return item.media_content_type.startswith("audio/") + + +async def async_browse_media( + hass: HomeAssistant, + mass: MusicAssistantClient, + media_content_id: str | None, + media_content_type: str | None, +) -> BrowseMedia: + """Browse media.""" + if media_content_id is None: + return await build_main_listing(hass) + + assert media_content_type is not None + + if media_source.is_media_source_id(media_content_id): + return await media_source.async_browse_media( + hass, media_content_id, content_filter=media_source_filter + ) + + if media_content_id == LIBRARY_ARTISTS: + return await build_artists_listing(mass) + if media_content_id == LIBRARY_ALBUMS: + return await build_albums_listing(mass) + if media_content_id == LIBRARY_TRACKS: + return await build_tracks_listing(mass) + if media_content_id == LIBRARY_PLAYLISTS: + return await build_playlists_listing(mass) + if media_content_id == LIBRARY_RADIO: + return await build_radio_listing(mass) + if "artist" in media_content_id: + return await build_artist_items_listing(mass, media_content_id) + if "album" in media_content_id: + return await build_album_items_listing(mass, media_content_id) + if "playlist" in media_content_id: + return await build_playlist_items_listing(mass, media_content_id) + + raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}") + + +async def build_main_listing(hass: HomeAssistant) -> BrowseMedia: + """Build main browse listing.""" + children: list[BrowseMedia] = [] + for library, media_class in LIBRARY_MEDIA_CLASS_MAP.items(): + child_source = BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=library, + media_content_type=DOMAIN, + title=LIBRARY_TITLE_MAP[library], + children_media_class=media_class, + can_play=False, + can_expand=True, + ) + children.append(child_source) + + try: + item = await media_source.async_browse_media( + hass, None, content_filter=media_source_filter + ) + # If domain is None, it's overview of available sources + if item.domain is None and item.children is not None: + children.extend(item.children) + else: + children.append(item) + except media_source.BrowseError: + pass + + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type=DOMAIN, + title=DEFAULT_NAME, + can_play=False, + can_expand=True, + children=children, + ) + + +async def build_playlists_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Playlists browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_PLAYLISTS] + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_PLAYLISTS, + media_content_type=MediaType.PLAYLIST, + title=LIBRARY_TITLE_MAP[LIBRARY_PLAYLISTS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=sorted( + [ + build_item(mass, item, can_expand=True) + # we only grab the first page here because the + # HA media browser does not support paging + for item in await mass.music.get_library_playlists(limit=500) + if item.available + ], + key=lambda x: x.title, + ), + ) + + +async def build_playlist_items_listing( + mass: MusicAssistantClient, identifier: str +) -> BrowseMedia: + """Build Playlist items browse listing.""" + playlist = await mass.music.get_item_by_uri(identifier) + + return BrowseMedia( + media_class=MediaClass.PLAYLIST, + media_content_id=playlist.uri, + media_content_type=MediaType.PLAYLIST, + title=playlist.name, + can_play=True, + can_expand=True, + children_media_class=MediaClass.TRACK, + children=[ + build_item(mass, item, can_expand=False) + # we only grab the first page here because the + # HA media browser does not support paging + for item in await mass.music.get_playlist_tracks( + playlist.item_id, playlist.provider + ) + if item.available + ], + ) + + +async def build_artists_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Albums browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ARTISTS] + + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_ARTISTS, + media_content_type=MediaType.ARTIST, + title=LIBRARY_TITLE_MAP[LIBRARY_ARTISTS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=sorted( + [ + build_item(mass, artist, can_expand=True) + # we only grab the first page here because the + # HA media browser does not support paging + for artist in await mass.music.get_library_artists(limit=500) + if artist.available + ], + key=lambda x: x.title, + ), + ) + + +async def build_artist_items_listing( + mass: MusicAssistantClient, identifier: str +) -> BrowseMedia: + """Build Artist items browse listing.""" + artist = await mass.music.get_item_by_uri(identifier) + albums = await mass.music.get_artist_albums(artist.item_id, artist.provider) + + return BrowseMedia( + media_class=MediaType.ARTIST, + media_content_id=artist.uri, + media_content_type=MediaType.ARTIST, + title=artist.name, + can_play=True, + can_expand=True, + children_media_class=MediaClass.ALBUM, + children=[ + build_item(mass, album, can_expand=True) + for album in albums + if album.available + ], + ) + + +async def build_albums_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Albums browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ALBUMS] + + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_ALBUMS, + media_content_type=MediaType.ALBUM, + title=LIBRARY_TITLE_MAP[LIBRARY_ALBUMS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=sorted( + [ + build_item(mass, album, can_expand=True) + # we only grab the first page here because the + # HA media browser does not support paging + for album in await mass.music.get_library_albums(limit=500) + if album.available + ], + key=lambda x: x.title, + ), + ) + + +async def build_album_items_listing( + mass: MusicAssistantClient, identifier: str +) -> BrowseMedia: + """Build Album items browse listing.""" + album = await mass.music.get_item_by_uri(identifier) + tracks = await mass.music.get_album_tracks(album.item_id, album.provider) + + return BrowseMedia( + media_class=MediaType.ALBUM, + media_content_id=album.uri, + media_content_type=MediaType.ALBUM, + title=album.name, + can_play=True, + can_expand=True, + children_media_class=MediaClass.TRACK, + children=[ + build_item(mass, track, False) for track in tracks if track.available + ], + ) + + +async def build_tracks_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Tracks browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_TRACKS] + + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_TRACKS, + media_content_type=MediaType.TRACK, + title=LIBRARY_TITLE_MAP[LIBRARY_TRACKS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=sorted( + [ + build_item(mass, track, can_expand=False) + # we only grab the first page here because the + # HA media browser does not support paging + for track in await mass.music.get_library_tracks(limit=500) + if track.available + ], + key=lambda x: x.title, + ), + ) + + +async def build_radio_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Radio browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_RADIO] + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_RADIO, + media_content_type=DOMAIN, + title=LIBRARY_TITLE_MAP[LIBRARY_RADIO], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=[ + build_item(mass, track, can_expand=False, media_class=media_class) + # we only grab the first page here because the + # HA media browser does not support paging + for track in await mass.music.get_library_radios(limit=500) + if track.available + ], + ) + + +def build_item( + mass: MusicAssistantClient, + item: MediaItemType, + can_expand: bool = True, + media_class: Any = None, +) -> BrowseMedia: + """Return BrowseMedia for MediaItem.""" + if artists := getattr(item, "artists", None): + title = f"{artists[0].name} - {item.name}" + else: + title = item.name + img_url = mass.get_media_item_image_url(item) + + return BrowseMedia( + media_class=media_class or item.media_type.value, + media_content_id=item.uri, + media_content_type=MediaType.MUSIC, + title=title, + can_play=True, + can_expand=can_expand, + thumbnail=img_url, + ) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index d898322c293..9aa7498a2ee 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -3,25 +3,28 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine, Mapping +from collections.abc import Callable, Coroutine, Mapping from contextlib import suppress import functools import os -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Concatenate from music_assistant_models.enums import ( EventType, MediaType, PlayerFeature, + PlayerState as MassPlayerState, QueueOption, RepeatMode as MassRepeatMode, ) from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType, Track +import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( + ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_EXTRA, BrowseMedia, MediaPlayerDeviceClass, @@ -33,16 +36,45 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) -from homeassistant.const import STATE_OFF -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_NAME, STATE_OFF +from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.util.dt import utc_from_timestamp from . import MusicAssistantConfigEntry -from .const import ATTR_ACTIVE_QUEUE, ATTR_MASS_PLAYER_TYPE, DOMAIN +from .const import ( + ATTR_ACTIVE, + ATTR_ACTIVE_QUEUE, + ATTR_ALBUM, + ATTR_ANNOUNCE_VOLUME, + ATTR_ARTIST, + ATTR_AUTO_PLAY, + ATTR_CURRENT_INDEX, + ATTR_CURRENT_ITEM, + ATTR_ELAPSED_TIME, + ATTR_ITEMS, + ATTR_MASS_PLAYER_TYPE, + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_NEXT_ITEM, + ATTR_QUEUE_ID, + ATTR_RADIO_MODE, + ATTR_REPEAT_MODE, + ATTR_SHUFFLE_ENABLED, + ATTR_SOURCE_PLAYER, + ATTR_URL, + ATTR_USE_PRE_ANNOUNCE, + DOMAIN, +) from .entity import MusicAssistantEntity +from .media_browser import async_browse_media +from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient @@ -78,27 +110,21 @@ QUEUE_OPTION_MAP = { MediaPlayerEnqueue.REPLACE: QueueOption.REPLACE, } -ATTR_RADIO_MODE = "radio_mode" -ATTR_MEDIA_ID = "media_id" -ATTR_MEDIA_TYPE = "media_type" -ATTR_ARTIST = "artist" -ATTR_ALBUM = "album" -ATTR_URL = "url" -ATTR_USE_PRE_ANNOUNCE = "use_pre_announce" -ATTR_ANNOUNCE_VOLUME = "announce_volume" -ATTR_SOURCE_PLAYER = "source_player" -ATTR_AUTO_PLAY = "auto_play" +SERVICE_PLAY_MEDIA_ADVANCED = "play_media" +SERVICE_PLAY_ANNOUNCEMENT = "play_announcement" +SERVICE_TRANSFER_QUEUE = "transfer_queue" +SERVICE_GET_QUEUE = "get_queue" def catch_musicassistant_error[_R, **P]( - func: Callable[..., Awaitable[_R]], -) -> Callable[..., Coroutine[Any, Any, _R | None]]: + func: Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]]: """Check and log commands to players.""" @functools.wraps(func) async def wrapper( self: MusicAssistantPlayer, *args: P.args, **kwargs: P.kwargs - ) -> _R | None: + ) -> _R: """Catch Music Assistant errors and convert to Home Assistant error.""" try: return await func(self, *args, **kwargs) @@ -137,6 +163,44 @@ async def async_setup_entry( async_add_entities(mass_players) + # add platform service for play_media with advanced options + platform = async_get_current_platform() + platform.async_register_entity_service( + SERVICE_PLAY_MEDIA_ADVANCED, + { + vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), + vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption), + vol.Optional(ATTR_ARTIST): cv.string, + vol.Optional(ATTR_ALBUM): cv.string, + vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool), + }, + "_async_handle_play_media", + ) + platform.async_register_entity_service( + SERVICE_PLAY_ANNOUNCEMENT, + { + vol.Required(ATTR_URL): cv.string, + vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool), + vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int), + }, + "_async_handle_play_announcement", + ) + platform.async_register_entity_service( + SERVICE_TRANSFER_QUEUE, + { + vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id, + vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool), + }, + "_async_handle_transfer_queue", + ) + platform.async_register_entity_service( + SERVICE_GET_QUEUE, + schema=None, + func="_async_handle_get_queue", + supports_response=SupportsResponse.ONLY, + ) + class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Representation of MediaPlayerEntity from Music Assistant Player.""" @@ -150,7 +214,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): super().__init__(mass, player_id) self._attr_icon = self.player.icon.replace("mdi-", "mdi:") self._attr_supported_features = SUPPORTED_FEATURES - if PlayerFeature.SYNC in self.player.supported_features: + if PlayerFeature.SET_MEMBERS in self.player.supported_features: self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING if PlayerFeature.VOLUME_MUTE in self.player.supported_features: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE @@ -357,24 +421,26 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" player_ids: list[str] = [] + entity_registry = er.async_get(self.hass) for child_entity_id in group_members: # resolve HA entity_id to MA player_id - if (hass_state := self.hass.states.get(child_entity_id)) is None: - continue - if (mass_player_id := hass_state.attributes.get("mass_player_id")) is None: - continue - player_ids.append(mass_player_id) - await self.mass.players.player_command_sync_many(self.player_id, player_ids) + if not (entity_reg_entry := entity_registry.async_get(child_entity_id)): + raise HomeAssistantError(f"Entity {child_entity_id} not found") + # unique id is the MA player_id + player_ids.append(entity_reg_entry.unique_id) + await self.mass.players.player_command_group_many(self.player_id, player_ids) @catch_musicassistant_error async def async_unjoin_player(self) -> None: """Remove this player from any group.""" - await self.mass.players.player_command_unsync(self.player_id) + await self.mass.players.player_command_ungroup(self.player_id) @catch_musicassistant_error async def _async_handle_play_media( self, media_id: list[str], + artist: str | None = None, + album: str | None = None, enqueue: MediaPlayerEnqueue | QueueOption | None = None, radio_mode: bool | None = None, media_type: str | None = None, @@ -401,6 +467,14 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): elif await asyncio.to_thread(os.path.isfile, media_id_str): media_uris.append(media_id_str) continue + # last resort: search for media item by name/search + if item := await self.mass.music.get_item_by_name( + name=media_id_str, + artist=artist, + album=album, + media_type=MediaType(media_type) if media_type else None, + ): + media_uris.append(item.uri) if not media_uris: raise HomeAssistantError( @@ -434,16 +508,69 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self.player_id, url, use_pre_announce, announce_volume ) + @catch_musicassistant_error + async def _async_handle_transfer_queue( + self, source_player: str | None = None, auto_play: bool | None = None + ) -> None: + """Transfer the current queue to another player.""" + if not source_player: + # no source player given; try to find a playing player(queue) + for queue in self.mass.player_queues: + if queue.state == MassPlayerState.PLAYING: + source_queue_id = queue.queue_id + break + else: + raise HomeAssistantError( + "Source player not specified and no playing player found." + ) + else: + # resolve HA entity_id to MA player_id + entity_registry = er.async_get(self.hass) + if (entity := entity_registry.async_get(source_player)) is None: + raise HomeAssistantError("Source player not available.") + source_queue_id = entity.unique_id # unique_id is the MA player_id + target_queue_id = self.player_id + await self.mass.player_queues.transfer_queue( + source_queue_id, target_queue_id, auto_play + ) + + @catch_musicassistant_error + async def _async_handle_get_queue(self) -> ServiceResponse: + """Handle get_queue action.""" + if not self.active_queue: + raise HomeAssistantError("No active queue found") + active_queue = self.active_queue + response: ServiceResponse = QUEUE_DETAILS_SCHEMA( + { + ATTR_QUEUE_ID: active_queue.queue_id, + ATTR_ACTIVE: active_queue.active, + ATTR_NAME: active_queue.display_name, + ATTR_ITEMS: active_queue.items, + ATTR_SHUFFLE_ENABLED: active_queue.shuffle_enabled, + ATTR_REPEAT_MODE: active_queue.repeat_mode.value, + ATTR_CURRENT_INDEX: active_queue.current_index, + ATTR_ELAPSED_TIME: active_queue.corrected_elapsed_time, + ATTR_CURRENT_ITEM: queue_item_dict_from_mass_item( + self.mass, active_queue.current_item + ), + ATTR_NEXT_ITEM: queue_item_dict_from_mass_item( + self.mass, active_queue.next_item + ), + } + ) + return response + async def async_browse_media( self, media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" - return await media_source.async_browse_media( + return await async_browse_media( self.hass, + self.mass, media_content_id, - content_filter=lambda item: item.media_content_type.startswith("audio/"), + media_content_type, ) def _update_media_image_url( @@ -465,7 +592,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self, player: Player, queue: PlayerQueue | None ) -> None: """Update media attributes for the active queue item.""" - # pylint: disable=too-many-statements self._attr_media_artist = None self._attr_media_album_artist = None self._attr_media_album_name = None @@ -486,17 +612,13 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): # shuffle and repeat are not (yet) supported for external sources self._attr_shuffle = None self._attr_repeat = None - if TYPE_CHECKING: - assert player.elapsed_time is not None - self._attr_media_position = int(player.elapsed_time) + self._attr_media_position = int(player.elapsed_time or 0) self._attr_media_position_updated_at = ( utc_from_timestamp(player.elapsed_time_last_updated) if player.elapsed_time_last_updated else None ) - if TYPE_CHECKING: - assert player.elapsed_time is not None - self._prev_time = player.elapsed_time + self._prev_time = player.elapsed_time or 0 return if queue is None: diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py new file mode 100644 index 00000000000..9caae2ee0b4 --- /dev/null +++ b/homeassistant/components/music_assistant/schemas.py @@ -0,0 +1,182 @@ +"""Voluptuous schemas for Music Assistant integration service responses.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from music_assistant_models.enums import MediaType +import voluptuous as vol + +from homeassistant.const import ATTR_NAME +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_ACTIVE, + ATTR_ALBUM, + ATTR_ALBUMS, + ATTR_ARTISTS, + ATTR_BIT_DEPTH, + ATTR_CONTENT_TYPE, + ATTR_CURRENT_INDEX, + ATTR_CURRENT_ITEM, + ATTR_DURATION, + ATTR_ELAPSED_TIME, + ATTR_IMAGE, + ATTR_ITEM_ID, + ATTR_ITEMS, + ATTR_LIMIT, + ATTR_MEDIA_ITEM, + ATTR_MEDIA_TYPE, + ATTR_NEXT_ITEM, + ATTR_OFFSET, + ATTR_ORDER_BY, + ATTR_PLAYLISTS, + ATTR_PROVIDER, + ATTR_QUEUE_ID, + ATTR_QUEUE_ITEM_ID, + ATTR_RADIO, + ATTR_REPEAT_MODE, + ATTR_SAMPLE_RATE, + ATTR_SHUFFLE_ENABLED, + ATTR_STREAM_DETAILS, + ATTR_STREAM_TITLE, + ATTR_TRACKS, + ATTR_URI, + ATTR_VERSION, +) + +if TYPE_CHECKING: + from music_assistant_client import MusicAssistantClient + from music_assistant_models.media_items import ItemMapping, MediaItemType + from music_assistant_models.queue_item import QueueItem + +MEDIA_ITEM_SCHEMA = vol.Schema( + { + vol.Required(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), + vol.Required(ATTR_URI): cv.string, + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_VERSION): cv.string, + vol.Optional(ATTR_IMAGE, default=None): vol.Any(None, cv.string), + vol.Optional(ATTR_ARTISTS): [vol.Self], + vol.Optional(ATTR_ALBUM): vol.Self, + } +) + + +def media_item_dict_from_mass_item( + mass: MusicAssistantClient, + item: MediaItemType | ItemMapping | None, +) -> dict[str, Any] | None: + """Parse a Music Assistant MediaItem.""" + if not item: + return None + base = { + ATTR_MEDIA_TYPE: item.media_type, + ATTR_URI: item.uri, + ATTR_NAME: item.name, + ATTR_VERSION: item.version, + ATTR_IMAGE: mass.get_media_item_image_url(item), + } + if artists := getattr(item, "artists", None): + base[ATTR_ARTISTS] = [media_item_dict_from_mass_item(mass, x) for x in artists] + if album := getattr(item, "album", None): + base[ATTR_ALBUM] = media_item_dict_from_mass_item(mass, album) + return base + + +SEARCH_RESULT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ARTISTS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_ALBUMS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_TRACKS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_PLAYLISTS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_RADIO): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + }, +) + +LIBRARY_RESULTS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ITEMS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_LIMIT): int, + vol.Required(ATTR_OFFSET): int, + vol.Required(ATTR_ORDER_BY): str, + vol.Required(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), + } +) + +AUDIO_FORMAT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONTENT_TYPE): str, + vol.Required(ATTR_SAMPLE_RATE): int, + vol.Required(ATTR_BIT_DEPTH): int, + vol.Required(ATTR_PROVIDER): str, + vol.Required(ATTR_ITEM_ID): str, + } +) + +QUEUE_ITEM_SCHEMA = vol.Schema( + { + vol.Required(ATTR_QUEUE_ITEM_ID): cv.string, + vol.Required(ATTR_NAME): cv.string, + vol.Optional(ATTR_DURATION, default=None): vol.Any(None, int), + vol.Optional(ATTR_MEDIA_ITEM, default=None): vol.Any( + None, vol.Schema(MEDIA_ITEM_SCHEMA) + ), + vol.Optional(ATTR_STREAM_DETAILS): vol.Schema(AUDIO_FORMAT_SCHEMA), + vol.Optional(ATTR_STREAM_TITLE, default=None): vol.Any(None, cv.string), + } +) + + +def queue_item_dict_from_mass_item( + mass: MusicAssistantClient, + item: QueueItem | None, +) -> dict[str, Any] | None: + """Parse a Music Assistant QueueItem.""" + if not item: + return None + base = { + ATTR_QUEUE_ITEM_ID: item.queue_item_id, + ATTR_NAME: item.name, + ATTR_DURATION: item.duration, + ATTR_MEDIA_ITEM: media_item_dict_from_mass_item(mass, item.media_item), + } + if streamdetails := item.streamdetails: + base[ATTR_STREAM_TITLE] = streamdetails.stream_title + base[ATTR_STREAM_DETAILS] = { + ATTR_CONTENT_TYPE: streamdetails.audio_format.content_type.value, + ATTR_SAMPLE_RATE: streamdetails.audio_format.sample_rate, + ATTR_BIT_DEPTH: streamdetails.audio_format.bit_depth, + ATTR_PROVIDER: streamdetails.provider, + ATTR_ITEM_ID: streamdetails.item_id, + } + + return base + + +QUEUE_DETAILS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_QUEUE_ID): str, + vol.Required(ATTR_ACTIVE): bool, + vol.Required(ATTR_NAME): str, + vol.Required(ATTR_ITEMS): int, + vol.Required(ATTR_SHUFFLE_ENABLED): bool, + vol.Required(ATTR_REPEAT_MODE): str, + vol.Required(ATTR_CURRENT_INDEX): vol.Any(None, int), + vol.Required(ATTR_ELAPSED_TIME): vol.Coerce(int), + vol.Required(ATTR_CURRENT_ITEM): vol.Any(None, QUEUE_ITEM_SCHEMA), + vol.Required(ATTR_NEXT_ITEM): vol.Any(None, QUEUE_ITEM_SCHEMA), + } +) diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml new file mode 100644 index 00000000000..73e8e2d7521 --- /dev/null +++ b/homeassistant/components/music_assistant/services.yaml @@ -0,0 +1,233 @@ +# Descriptions for Music Assistant custom services + +play_media: + target: + entity: + domain: media_player + integration: music_assistant + supported_features: + - media_player.MediaPlayerEntityFeature.PLAY_MEDIA + fields: + media_id: + required: true + example: "spotify://playlist/aabbccddeeff" + selector: + object: + media_type: + example: "playlist" + selector: + select: + translation_key: media_type + options: + - artist + - album + - playlist + - track + - radio + artist: + example: "Queen" + selector: + text: + album: + example: "News of the world" + selector: + text: + enqueue: + selector: + select: + options: + - "play" + - "replace" + - "next" + - "replace_next" + - "add" + translation_key: enqueue + radio_mode: + advanced: true + selector: + boolean: + +play_announcement: + target: + entity: + domain: media_player + integration: music_assistant + supported_features: + - media_player.MediaPlayerEntityFeature.PLAY_MEDIA + - media_player.MediaPlayerEntityFeature.MEDIA_ANNOUNCE + fields: + url: + required: true + example: "http://someremotesite.com/doorbell.mp3" + selector: + text: + use_pre_announce: + example: "true" + selector: + boolean: + announce_volume: + example: 75 + selector: + number: + min: 1 + max: 100 + step: 1 + +transfer_queue: + target: + entity: + domain: media_player + integration: music_assistant + fields: + source_player: + selector: + entity: + domain: media_player + integration: music_assistant + auto_play: + example: "true" + selector: + boolean: + +get_queue: + target: + entity: + domain: media_player + integration: music_assistant + supported_features: + - media_player.MediaPlayerEntityFeature.PLAY_MEDIA + +search: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: music_assistant + name: + required: true + example: "We Are The Champions" + selector: + text: + media_type: + example: "playlist" + selector: + select: + multiple: true + translation_key: media_type + options: + - artist + - album + - playlist + - track + - radio + artist: + example: "Queen" + selector: + text: + album: + example: "News of the world" + selector: + text: + limit: + advanced: true + example: 25 + default: 5 + selector: + number: + min: 1 + max: 100 + step: 1 + library_only: + example: "true" + default: false + selector: + boolean: + +get_library: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: music_assistant + media_type: + required: true + example: "playlist" + selector: + select: + translation_key: media_type + options: + - artist + - album + - playlist + - track + - radio + favorite: + example: "true" + default: false + selector: + boolean: + search: + example: "We Are The Champions" + selector: + text: + limit: + advanced: true + example: 25 + default: 25 + selector: + number: + min: 1 + max: 500 + step: 1 + offset: + advanced: true + example: 25 + default: 0 + selector: + number: + min: 1 + max: 1000000 + step: 1 + order_by: + example: "random" + selector: + select: + translation_key: order_by + options: + - name + - name_desc + - sort_name + - sort_name_desc + - timestamp_added + - timestamp_added_desc + - last_played + - last_played_desc + - play_count + - play_count_desc + - year + - year_desc + - position + - position_desc + - artist_name + - artist_name_desc + - random + - random_play_count + album_type: + example: "single" + selector: + select: + multiple: true + translation_key: album_type + options: + - album + - single + - compilation + - ep + - unknown + album_artists_only: + example: "true" + default: false + selector: + boolean: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index f15b0b1b306..af366c94310 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -37,6 +37,150 @@ "description": "Check if there are updates available for the Music Assistant Server and/or integration." } }, + "services": { + "play_media": { + "name": "Play media", + "description": "Play media on a Music Assistant player with more fine-grained control options.", + "fields": { + "media_id": { + "name": "Media ID(s)", + "description": "URI or name of the item you want to play. Specify a list if you want to play/enqueue multiple items." + }, + "media_type": { + "name": "Media type", + "description": "The type of the content to play. Such as artist, album, track or playlist. Will be auto-determined if omitted." + }, + "enqueue": { + "name": "Enqueue", + "description": "If the content should be played now or added to the queue." + }, + "artist": { + "name": "Artist name", + "description": "When specifying a track or album by name in the Media ID field, you can optionally restrict results by this artist name." + }, + "album": { + "name": "Album name", + "description": "When specifying a track by name in the Media ID field, you can optionally restrict results by this album name." + }, + "radio_mode": { + "name": "Enable radio mode", + "description": "Enable radio mode to auto-generate a playlist based on the selection." + } + } + }, + "play_announcement": { + "name": "Play announcement", + "description": "Play announcement on a Music Assistant player with more fine-grained control options.", + "fields": { + "url": { + "name": "URL", + "description": "URL to the notification sound." + }, + "use_pre_announce": { + "name": "Use pre-announce", + "description": "Use pre-announcement sound for the announcement. Omit to use the player default." + }, + "announce_volume": { + "name": "Announce volume", + "description": "Use a forced volume level for the announcement. Omit to use player default." + } + } + }, + "transfer_queue": { + "name": "Transfer queue", + "description": "Transfer the player's queue to another player.", + "fields": { + "source_player": { + "name": "Source media player", + "description": "The source media player which has the queue you want to transfer. When omitted, the first playing player will be used." + }, + "auto_play": { + "name": "Auto play", + "description": "Start playing the queue on the target player. Omit to use the default behavior." + } + } + }, + "get_queue": { + "name": "Get playerQueue details (advanced)", + "description": "Get the details of the currently active queue of a Music Assistant player." + }, + "search": { + "name": "Search Music Assistant", + "description": "Perform a global search on the Music Assistant library and all providers.", + "fields": { + "config_entry_id": { + "name": "Music Assistant instance", + "description": "Select the Music Assistant instance to perform the search on." + }, + "name": { + "name": "Search name", + "description": "The name/title to search for." + }, + "media_type": { + "name": "Media type(s)", + "description": "The type of the content to search. Such as artist, album, track, radio, or playlist. All types if omitted." + }, + "artist": { + "name": "Artist name", + "description": "When specifying a track or album name in the name field, you can optionally restrict results by this artist name." + }, + "album": { + "name": "Album name", + "description": "When specifying a track name in the name field, you can optionally restrict results by this album name." + }, + "limit": { + "name": "Limit", + "description": "Maximum number of items to return (per media type)." + }, + "library_only": { + "name": "Only library items", + "description": "Only include results that are in the library." + } + } + }, + "get_library": { + "name": "Get Library items", + "description": "Get items from a Music Assistant library.", + "fields": { + "config_entry_id": { + "name": "[%key:component::music_assistant::services::search::fields::config_entry_id::name%]", + "description": "[%key:component::music_assistant::services::search::fields::config_entry_id::description%]" + }, + "media_type": { + "name": "Media type", + "description": "The media type for which to request details for." + }, + "favorite": { + "name": "Favorites only", + "description": "Filter items so only favorites items are returned." + }, + "search": { + "name": "Search", + "description": "Optional search string to search through this library." + }, + "limit": { + "name": "Limit", + "description": "Maximum number of items to return." + }, + "offset": { + "name": "Offset", + "description": "Offset to start the list from." + }, + "order_by": { + "name": "Order By", + "description": "Sort the list by this field." + }, + "album_type": { + "name": "Album type filter (albums library only)", + "description": "Filter albums by type." + }, + "album_artists_only": { + "name": "Enable album artists filter (only for artist library)", + "description": "Only return Album Artists when listing the Artists library items." + } + } + } + }, "selector": { "enqueue": { "options": { @@ -46,6 +190,46 @@ "replace": "Play now and clear queue", "replace_next": "Play next and clear queue" } + }, + "media_type": { + "options": { + "artist": "Artist", + "album": "Album", + "track": "Track", + "playlist": "Playlist", + "radio": "Radio" + } + }, + "order_by": { + "options": { + "name": "Name", + "name_desc": "Name (desc)", + "sort_name": "Sort name", + "sort_name_desc": "Sort name (desc)", + "timestamp_added": "Added", + "timestamp_added_desc": "Added (desc)", + "last_played": "Last played", + "last_played_desc": "Last played (desc)", + "play_count": "Play count", + "play_count_desc": "Play count (desc)", + "year": "Year", + "year_desc": "Year (desc)", + "position": "Position", + "position_desc": "Position (desc)", + "artist_name": "Artist name", + "artist_name_desc": "Artist name (desc)", + "random": "Random", + "random_play_count": "Random + least played" + } + }, + "album_type": { + "options": { + "album": "Album", + "single": "Single", + "ep": "EP", + "compilation": "Compilation", + "unknown": "Unknown" + } } } } diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index ce15faa589c..23b7c47ebf3 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -72,7 +72,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): """Representation of a MySensors HVAC.""" _attr_hvac_modes = OPERATION_LIST - _enable_turn_on_off_backwards_compatibility = False @property def supported_features(self) -> ClimateEntityFeature: diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py index d801f27817d..5ad114e973e 100644 --- a/homeassistant/components/myuplink/__init__.py +++ b/homeassistant/components/myuplink/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations from http import HTTPStatus +import logging from aiohttp import ClientError, ClientResponseError +import jwt from myuplink import MyUplinkAPI, get_manufacturer, get_model, get_system_name from homeassistant.config_entries import ConfigEntry @@ -22,6 +24,8 @@ from .api import AsyncConfigEntryAuth from .const import DOMAIN, OAUTH2_SCOPES from .coordinator import MyUplinkDataCoordinator +_LOGGER = logging.getLogger(__name__) + PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, @@ -51,13 +55,25 @@ async def async_setup_entry( await auth.async_get_access_token() except ClientResponseError as err: if err.status in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}: - raise ConfigEntryAuthFailed from err - raise ConfigEntryNotReady from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="config_entry_auth_failed", + ) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from err except ClientError as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from err if set(config_entry.data["token"]["scope"].split(" ")) != set(OAUTH2_SCOPES): - raise ConfigEntryAuthFailed("Incorrect OAuth2 scope") + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="incorrect_oauth2_scope", + ) # Setup MyUplinkAPI and coordinator for data fetch api = MyUplinkAPI(auth) @@ -73,14 +89,16 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MyUplinkConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @callback def create_devices( - hass: HomeAssistant, config_entry: ConfigEntry, coordinator: MyUplinkDataCoordinator + hass: HomeAssistant, + config_entry: MyUplinkConfigEntry, + coordinator: MyUplinkDataCoordinator, ) -> None: """Update all devices.""" device_registry = dr.async_get(hass) @@ -109,3 +127,27 @@ async def async_remove_config_entry_device( return not device_entry.identifiers.intersection( (DOMAIN, device_id) for device_id in myuplink_data.data.devices ) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: MyUplinkConfigEntry +) -> bool: + """Migrate old entry.""" + + # Use sub(ject) from access_token as unique_id + if config_entry.version == 1 and config_entry.minor_version == 1: + token = jwt.decode( + config_entry.data["token"]["access_token"], + options={"verify_signature": False}, + ) + uid = token["sub"] + hass.config_entries.async_update_entry( + config_entry, unique_id=uid, minor_version=2 + ) + _LOGGER.info( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py index 953859986d0..d903c7cbfae 100644 --- a/homeassistant/components/myuplink/binary_sensor.py +++ b/homeassistant/components/myuplink/binary_sensor.py @@ -155,7 +155,7 @@ class MyUplinkDeviceBinarySensor(MyUplinkEntity, BinarySensorEntity): self, coordinator: MyUplinkDataCoordinator, device_id: str, - entity_description: BinarySensorEntityDescription | None, + entity_description: BinarySensorEntityDescription, unique_id_suffix: str, ) -> None: """Initialize the binary_sensor.""" @@ -165,8 +165,7 @@ class MyUplinkDeviceBinarySensor(MyUplinkEntity, BinarySensorEntity): unique_id_suffix=unique_id_suffix, ) - if entity_description is not None: - self.entity_description = entity_description + self.entity_description = entity_description @property def is_on(self) -> bool: @@ -185,7 +184,7 @@ class MyUplinkSystemBinarySensor(MyUplinkSystemEntity, BinarySensorEntity): coordinator: MyUplinkDataCoordinator, system_id: str, device_id: str, - entity_description: BinarySensorEntityDescription | None, + entity_description: BinarySensorEntityDescription, unique_id_suffix: str, ) -> None: """Initialize the binary_sensor.""" @@ -196,8 +195,7 @@ class MyUplinkSystemBinarySensor(MyUplinkSystemEntity, BinarySensorEntity): unique_id_suffix=unique_id_suffix, ) - if entity_description is not None: - self.entity_description = entity_description + self.entity_description = entity_description @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/myuplink/config_flow.py b/homeassistant/components/myuplink/config_flow.py index 554347cfd19..cf0428f59ce 100644 --- a/homeassistant/components/myuplink/config_flow.py +++ b/homeassistant/components/myuplink/config_flow.py @@ -4,7 +4,13 @@ from collections.abc import Mapping import logging from typing import Any -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +import jwt + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, +) from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, OAUTH2_SCOPES @@ -15,6 +21,8 @@ class OAuth2FlowHandler( ): """Config flow to handle myUplink OAuth2 authentication.""" + VERSION = 1 + MINOR_VERSION = 2 DOMAIN = DOMAIN @property @@ -44,10 +52,30 @@ class OAuth2FlowHandler( return await self.async_step_user() + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """User initiated reconfiguration.""" + return await self.async_step_user() + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create or update the config entry.""" + + token = jwt.decode( + data["token"]["access_token"], options={"verify_signature": False} + ) + uid = token["sub"] + await self.async_set_unique_id(uid) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="account_mismatch") return self.async_update_reload_and_abort( self._get_reauth_entry(), data=data ) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="account_mismatch") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data=data + ) + self._abort_if_unique_id_configured() return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/myuplink/helpers.py b/homeassistant/components/myuplink/helpers.py index de5486d8dea..5751d574e04 100644 --- a/homeassistant/components/myuplink/helpers.py +++ b/homeassistant/components/myuplink/helpers.py @@ -26,10 +26,8 @@ def find_matching_platform( if len(device_point.enum_values) > 0 and device_point.writable: return Platform.SELECT - if ( - description - and description.native_unit_of_measurement == "DM" - or (device_point.raw["maxValue"] and device_point.raw["minValue"]) + if (description and description.native_unit_of_measurement == "DM") or ( + device_point.raw["maxValue"] and device_point.raw["minValue"] ): if device_point.writable: return Platform.NUMBER @@ -95,11 +93,17 @@ PARAMETER_ID_TO_EXCLUDE_F730 = ( ) PARAMETER_ID_TO_INCLUDE_SMO20 = ( + "40013", + "40033", "40940", + "44069", + "44071", + "44073", "47011", "47015", "47028", "47032", + "47398", "50004", ) diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json index 0e638a72715..d3242115acb 100644 --- a/homeassistant/components/myuplink/manifest.json +++ b/homeassistant/components/myuplink/manifest.json @@ -6,5 +6,6 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/myuplink", "iot_class": "cloud_polling", - "requirements": ["myuplink==0.6.0"] + "quality_scale": "silver", + "requirements": ["myuplink==0.7.0"] } diff --git a/homeassistant/components/myuplink/number.py b/homeassistant/components/myuplink/number.py index b05ab5d46c9..e1cbd393947 100644 --- a/homeassistant/components/myuplink/number.py +++ b/homeassistant/components/myuplink/number.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator -from .const import F_SERIES +from .const import DOMAIN, F_SERIES from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity, transform_model_series @@ -110,13 +110,16 @@ class MyUplinkNumber(MyUplinkEntity, NumberEntity): # Internal properties self.point_id = device_point.parameter_id self._attr_name = device_point.parameter_name + _scale = float(device_point.scale_value if device_point.scale_value else 1.0) self._attr_native_min_value = ( - device_point.raw["minValue"] if device_point.raw["minValue"] else -30000 - ) * float(device_point.raw.get("scaleValue", 1)) + device_point.min_value if device_point.min_value else -30000 + ) * _scale self._attr_native_max_value = ( - device_point.raw["maxValue"] if device_point.raw["maxValue"] else 30000 - ) * float(device_point.raw.get("scaleValue", 1)) - self._attr_step_value = device_point.raw.get("stepValue", 20) + device_point.max_value if device_point.max_value else 30000 + ) * _scale + self._attr_native_step = ( + device_point.step_value if device_point.step_value else 1.0 + ) * _scale if entity_description is not None: self.entity_description = entity_description @@ -134,7 +137,13 @@ class MyUplinkNumber(MyUplinkEntity, NumberEntity): ) except ClientError as err: raise HomeAssistantError( - f"Failed to set new value {value} for {self.point_id}/{self.entity_id}" + translation_domain=DOMAIN, + translation_key="set_number_error", + translation_placeholders={ + "entity": self.entity_id, + "point": self.point_id, + "value": str(value), + }, ) from err await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/myuplink/quality_scale.yaml b/homeassistant/components/myuplink/quality_scale.yaml new file mode 100644 index 00000000000..be0780a206c --- /dev/null +++ b/homeassistant/components/myuplink/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration parameters + docs-installation-parameters: + status: done + comment: Described in installation instructions + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: Handled by coordinator + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + Not possible to discover these devices. + discovery: + status: exempt + comment: | + Not possible to discover these devices. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: done + comment: | + Datapoint names are read from the API metadata and used as entity names in HA. + It is not feasible to use the API names as translation keys as they can change between + firmware and API upgrades and the number of appliance models and firmware releases are huge. + Entity names translations are therefore not implemented for the time being. + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + No repair-issues are raised. + stale-devices: + status: done + comment: | + There is no way for the integration to know if a device is gone temporarily or permanently. User is allowed to delete a stale device from GUI. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/myuplink/select.py b/homeassistant/components/myuplink/select.py index c0fb66602de..0074d1c75ff 100644 --- a/homeassistant/components/myuplink/select.py +++ b/homeassistant/components/myuplink/select.py @@ -5,13 +5,14 @@ from typing import cast from aiohttp import ClientError from myuplink import DevicePoint -from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.components.select import SelectEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .const import DOMAIN from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity @@ -30,14 +31,12 @@ async def async_setup_entry( for point_id, device_point in point_data.items(): if skip_entity(device_point.category, device_point): continue - description = None - if find_matching_platform(device_point, description) == Platform.SELECT: + if find_matching_platform(device_point, None) == Platform.SELECT: entities.append( MyUplinkSelect( coordinator=coordinator, device_id=device_id, device_point=device_point, - entity_description=description, unique_id_suffix=point_id, ) ) @@ -53,7 +52,6 @@ class MyUplinkSelect(MyUplinkEntity, SelectEntity): coordinator: MyUplinkDataCoordinator, device_id: str, device_point: DevicePoint, - entity_description: SelectEntityDescription | None, unique_id_suffix: str, ) -> None: """Initialize the select.""" @@ -89,7 +87,13 @@ class MyUplinkSelect(MyUplinkEntity, SelectEntity): ) except ClientError as err: raise HomeAssistantError( - f"Failed to set new option {self.options_rev[option]} for {self.point_id}/{self.entity_id}" + translation_domain=DOMAIN, + translation_key="set_select_error", + translation_placeholders={ + "entity": self.entity_id, + "option": self.options_rev[option], + "point": self.point_id, + }, ) from err await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index ef827fc1fb1..fa50e8a7001 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -325,10 +325,10 @@ class MyUplinkEnumSensor(MyUplinkDevicePointSensor): } @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Sensor state value for enum sensor.""" device_point = self.coordinator.data.points[self.device_id][self.point_id] - return self.options_map[str(int(device_point.value))] # type: ignore[no-any-return] + return self.options_map.get(str(int(device_point.value))) class MyUplinkEnumRawSensor(MyUplinkDevicePointSensor): diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index 997c6fe54b6..939aa2f17c8 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -23,6 +23,8 @@ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "account_mismatch": "The used account does not match the original account", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" }, "create_entry": { @@ -40,5 +42,25 @@ "name": "Status" } } + }, + "exceptions": { + "config_entry_auth_failed": { + "message": "Error while logging in to the API. Please check your credentials." + }, + "config_entry_not_ready": { + "message": "Error while loading the integration." + }, + "incorrect_oauth2_scope": { + "message": "Stored permissions are invalid. Please login again to update permissions." + }, + "set_number_error": { + "message": "Failed to set new value {value} for {point}/{entity}." + }, + "set_select_error": { + "message": "Failed to set new option {option} for {point}/{entity}." + }, + "set_switch_error": { + "message": "Failed to set state for {entity}." + } } } diff --git a/homeassistant/components/myuplink/switch.py b/homeassistant/components/myuplink/switch.py index 75ba6bd7819..3addc7ce6a9 100644 --- a/homeassistant/components/myuplink/switch.py +++ b/homeassistant/components/myuplink/switch.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator -from .const import F_SERIES +from .const import DOMAIN, F_SERIES from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity, transform_model_series @@ -129,7 +129,11 @@ class MyUplinkDevicePointSwitch(MyUplinkEntity, SwitchEntity): ) except aiohttp.ClientError as err: raise HomeAssistantError( - f"Failed to set state for {self.entity_id}" + translation_domain=DOMAIN, + translation_key="set_switch_error", + translation_placeholders={ + "entity": self.entity_id, + }, ) from err await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 494ce9fdac0..fa94971e2ef 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -17,12 +17,12 @@ from nettigo_air_monitor import ( ) import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -138,7 +138,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.host = discovery_info.host diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index d837aa69b9d..c3a559de50b 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], - "requirements": ["nettigo-air-monitor==3.3.0"], + "requirements": ["nettigo-air-monitor==4.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 27ef9a887fe..253387c254a 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -10,11 +10,15 @@ from typing import Any, Final, cast from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.util.json import JsonObjectType, JsonValueType, load_json_object from .const import DOMAIN @@ -86,31 +90,31 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_link() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Zeroconf discovery.""" _LOGGER.debug("Zeroconf discovered: %s", discovery_info) return await self._async_homekit_zeroconf_discovery_handler(discovery_info) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Homekit discovery.""" _LOGGER.debug("Homekit discovered: %s", discovery_info) return await self._async_homekit_zeroconf_discovery_handler(discovery_info) async def _async_homekit_zeroconf_discovery_handler( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Homekit and Zeroconf discovery.""" return await self._async_discovery_handler( discovery_info.host, discovery_info.name.replace(f".{discovery_info.type}", ""), - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID], + discovery_info.properties[ATTR_PROPERTIES_ID], ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf SSDP discovery.""" _LOGGER.debug("SSDP discovered: %s", discovery_info) diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 19d817b9999..681053fa573 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -2,12 +2,11 @@ from __future__ import annotations -import math from typing import Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -17,10 +16,6 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_mired_to_kelvin as mired_to_kelvin, -) from . import NanoleafConfigEntry from .coordinator import NanoleafCoordinator @@ -51,10 +46,8 @@ class NanoleafLight(NanoleafEntity, LightEntity): """Initialize the Nanoleaf light.""" super().__init__(coordinator) self._attr_unique_id = self._nanoleaf.serial_no - self._attr_min_mireds = math.ceil( - 1000000 / self._nanoleaf.color_temperature_max - ) - self._attr_max_mireds = kelvin_to_mired(self._nanoleaf.color_temperature_min) + self._attr_max_color_temp_kelvin = self._nanoleaf.color_temperature_max + self._attr_min_color_temp_kelvin = self._nanoleaf.color_temperature_min @property def brightness(self) -> int: @@ -62,9 +55,9 @@ class NanoleafLight(NanoleafEntity, LightEntity): return int(self._nanoleaf.brightness * 2.55) @property - def color_temp(self) -> int: - """Return the current color temperature.""" - return kelvin_to_mired(self._nanoleaf.color_temperature) + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + return self._nanoleaf.color_temperature @property def effect(self) -> str | None: @@ -106,7 +99,7 @@ class NanoleafLight(NanoleafEntity, LightEntity): """Instruct the light to turn on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) hs_color = kwargs.get(ATTR_HS_COLOR) - color_temp_mired = kwargs.get(ATTR_COLOR_TEMP) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) effect = kwargs.get(ATTR_EFFECT) transition = kwargs.get(ATTR_TRANSITION) @@ -120,10 +113,8 @@ class NanoleafLight(NanoleafEntity, LightEntity): hue, saturation = hs_color await self._nanoleaf.set_hue(int(hue)) await self._nanoleaf.set_saturation(int(saturation)) - elif color_temp_mired: - await self._nanoleaf.set_color_temperature( - mired_to_kelvin(color_temp_mired) - ) + elif color_temp_kelvin: + await self._nanoleaf.set_color_temperature(color_temp_kelvin) if transition: if brightness: # tune to the required brightness in n seconds await self._nanoleaf.set_brightness( diff --git a/homeassistant/components/nasweb/manifest.json b/homeassistant/components/nasweb/manifest.json index 69efdafbc82..8a4ecdbee84 100644 --- a/homeassistant/components/nasweb/manifest.json +++ b/homeassistant/components/nasweb/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nasweb", "integration_type": "hub", "iot_class": "local_push", - "requirements": ["webio-api==0.1.8"] + "requirements": ["webio-api==0.1.11"] } diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json index b8af8cd54db..8b93ea10d79 100644 --- a/homeassistant/components/nasweb/strings.json +++ b/homeassistant/components/nasweb/strings.json @@ -14,9 +14,9 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "missing_internal_url": "Make sure Home Assistant has valid internal url", - "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and HomeAssistant.", - "missing_status": "Did not received any status updates within the expected time window. Make sure the Home Assistant Internal URL is reachable from the NASweb device.", + "missing_internal_url": "Make sure Home Assistant has a valid internal URL", + "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant.", + "missing_status": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -25,13 +25,13 @@ }, "exceptions": { "config_entry_error_invalid_authentication": { - "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create new one with correct username/password." + "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create a new one with the correct username/password." }, "config_entry_error_internal_error": { - "message": "Something isn't right with device internal configuration. Try restarting the device and HomeAssistant. If the issue persists contact support at {support_email}" + "message": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant. If the issue persists contact support at {support_email}" }, "config_entry_error_no_status_update": { - "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant Internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" + "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" }, "config_entry_error_missing_internal_url": { "message": "[%key:component::nasweb::config::error::missing_internal_url%]" diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json index e2c983167b1..0324fdb8fad 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -42,24 +42,24 @@ }, "services": { "custom_cleaning": { - "name": "Zone cleaning action", - "description": "Zone cleaning action specific to Neato Botvacs.", + "name": "Custom cleaning", + "description": "Starts a custom cleaning of your house.", "fields": { "mode": { - "name": "Set cleaning mode", - "description": "Set the cleaning mode: 1 for eco and 2 for turbo. Defaults to turbo if not set." + "name": "Cleaning mode", + "description": "Sets the cleaning mode: 1 for eco and 2 for turbo. Defaults to turbo if not set." }, "navigation": { - "name": "Set navigation mode", - "description": "Set the navigation mode: 1 for normal, 2 for extra care, 3 for deep. Defaults to normal if not set." + "name": "Navigation mode", + "description": "Sets the navigation mode: 1 for normal, 2 for extra care, 3 for deep. Defaults to normal if not set." }, "category": { "name": "Use cleaning map", "description": "Whether to use a persistent map or not for cleaning (i.e. No go lines): 2 for no map, 4 for map. Default to using map if not set (and fallback to no map if no map is found)." }, "zone": { - "name": "Name of the zone to clean (Only Botvac D7)", - "description": "Only supported on the Botvac D7. Name of the zone to clean. Defaults to no zone i.e. complete house cleanup." + "name": "Zone", + "description": "Name of the zone to clean (only supported on the Botvac D7). Defaults to no zone i.e. complete house cleanup." } } } diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 77ca5346b10..1a9285964a2 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -12,15 +12,12 @@ import voluptuous as vol from homeassistant.components.vacuum import ( ATTR_STATUS, - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODE, STATE_IDLE, STATE_PAUSED +from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo @@ -169,23 +166,23 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): robot_alert = None if self._state["state"] == 1: if self._state["details"]["isCharging"]: - self._attr_state = STATE_DOCKED + self._attr_activity = VacuumActivity.DOCKED self._status_state = "Charging" elif ( self._state["details"]["isDocked"] and not self._state["details"]["isCharging"] ): - self._attr_state = STATE_DOCKED + self._attr_activity = VacuumActivity.DOCKED self._status_state = "Docked" else: - self._attr_state = STATE_IDLE + self._attr_activity = VacuumActivity.IDLE self._status_state = "Stopped" if robot_alert is not None: self._status_state = robot_alert elif self._state["state"] == 2: if robot_alert is None: - self._attr_state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING self._status_state = ( f"{MODE.get(self._state['cleaning']['mode'])} " f"{ACTION.get(self._state['action'])}" @@ -200,10 +197,10 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): else: self._status_state = robot_alert elif self._state["state"] == 3: - self._attr_state = STATE_PAUSED + self._attr_activity = VacuumActivity.PAUSED self._status_state = "Paused" elif self._state["state"] == 4: - self._attr_state = STATE_ERROR + self._attr_activity = VacuumActivity.ERROR self._status_state = ERRORS.get(self._state["error"]) self._attr_battery_level = self._state["details"]["charge"] @@ -326,9 +323,9 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): def return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" try: - if self._attr_state == STATE_CLEANING: + if self._attr_activity == VacuumActivity.CLEANING: self.robot.pause_cleaning() - self._attr_state = STATE_RETURNING + self._attr_activity = VacuumActivity.RETURNING self.robot.send_to_base() except NeatoRobotException as ex: _LOGGER.error( @@ -380,7 +377,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): "Start cleaning zone '%s' with robot %s", zone, self.entity_id ) - self._attr_state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING try: self.robot.start_cleaning(mode, navigation, category, boundary_id) except NeatoRobotException as ex: diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json index 8a8a20c453b..0ef9d8d86f3 100644 --- a/homeassistant/components/nederlandse_spoorwegen/manifest.json +++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen", "iot_class": "cloud_polling", "quality_scale": "legacy", - "requirements": ["nsapi==3.0.5"] + "requirements": ["nsapi==3.1.2"] } diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index e89969cbe16..8adc0e4f714 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -27,7 +27,6 @@ from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.components.camera import Image, img_util from homeassistant.components.http import KEY_HASS_USER from homeassistant.components.http.view import HomeAssistantView -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_CLIENT_ID, @@ -55,13 +54,12 @@ from homeassistant.helpers.typing import ConfigType from . import api from .const import ( + CONF_CLOUD_PROJECT_ID, CONF_PROJECT_ID, CONF_SUBSCRIBER_ID, CONF_SUBSCRIBER_ID_IMPORTED, CONF_SUBSCRIPTION_NAME, - DATA_DEVICE_MANAGER, DATA_SDM, - DATA_SUBSCRIBER, DOMAIN, ) from .events import EVENT_NAME_MAP, NEST_EVENT @@ -72,6 +70,7 @@ from .media_source import ( async_get_media_source_devices, async_get_transcoder, ) +from .types import NestConfigEntry, NestData _LOGGER = logging.getLogger(__name__) @@ -113,11 +112,8 @@ THUMBNAIL_SIZE_PX = 175 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Nest components with dispatch between old/new flows.""" - hass.data[DOMAIN] = {} - hass.http.register_view(NestEventMediaView(hass)) hass.http.register_view(NestEventMediaThumbnailView(hass)) - return True @@ -128,12 +124,12 @@ class SignalUpdateCallback: self, hass: HomeAssistant, config_reload_cb: Callable[[], Awaitable[None]], - config_entry_id: str, + config_entry: NestConfigEntry, ) -> None: """Initialize EventCallback.""" self._hass = hass self._config_reload_cb = config_reload_cb - self._config_entry_id = config_entry_id + self._config_entry = config_entry async def async_handle_event(self, event_message: EventMessage) -> None: """Process an incoming EventMessage.""" @@ -181,17 +177,17 @@ class SignalUpdateCallback: message["zones"] = image_event.zones self._hass.bus.async_fire(NEST_EVENT, message) - def _supported_traits(self, device_id: str) -> list[TraitType]: - if not ( - device_manager := self._hass.data[DOMAIN] - .get(self._config_entry_id, {}) - .get(DATA_DEVICE_MANAGER) - ) or not (device := device_manager.devices.get(device_id)): + def _supported_traits(self, device_id: str) -> list[str]: + if ( + not self._config_entry.runtime_data + or not (device_manager := self._config_entry.runtime_data.device_manager) + or not (device := device_manager.devices.get(device_id)) + ): return [] return list(device.traits) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool: """Set up Nest from a config entry with dispatch between old/new flows.""" if DATA_SDM not in entry.data: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) @@ -215,63 +211,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_config_reload() -> None: await hass.config_entries.async_reload(entry.entry_id) - update_callback = SignalUpdateCallback(hass, async_config_reload, entry.entry_id) + update_callback = SignalUpdateCallback(hass, async_config_reload, entry) subscriber.set_update_callback(update_callback.async_handle_event) try: - await subscriber.start_async() + unsub = await subscriber.start_async() except AuthException as err: raise ConfigEntryAuthFailed( f"Subscriber authentication error: {err!s}" ) from err except ConfigurationException as err: _LOGGER.error("Configuration error: %s", err) - subscriber.stop_async() return False except SubscriberException as err: - subscriber.stop_async() raise ConfigEntryNotReady(f"Subscriber error: {err!s}") from err try: device_manager = await subscriber.async_get_device_manager() except ApiException as err: - subscriber.stop_async() + unsub() raise ConfigEntryNotReady(f"Device manager error: {err!s}") from err @callback def on_hass_stop(_: Event) -> None: """Close connection when hass stops.""" - subscriber.stop_async() + unsub() entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) - hass.data[DOMAIN][entry.entry_id] = { - DATA_SUBSCRIBER: subscriber, - DATA_DEVICE_MANAGER: device_manager, - } + entry.async_on_unload(unsub) + entry.runtime_data = NestData( + subscriber=subscriber, + device_manager=device_manager, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool: """Unload a config entry.""" - if DATA_SDM not in entry.data: - # Legacy API - return True - _LOGGER.debug("Stopping nest subscriber") - subscriber = hass.data[DOMAIN][entry.entry_id][DATA_SUBSCRIBER] - subscriber.stop_async() - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: NestConfigEntry) -> None: """Handle removal of pubsub subscriptions created during config flow.""" if ( DATA_SDM not in entry.data @@ -281,24 +266,25 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: or CONF_SUBSCRIBER_ID_IMPORTED in entry.data ): return - - subscriber = await api.new_subscriber(hass, entry) - if not subscriber: - return - _LOGGER.debug("Deleting subscriber '%s'", subscriber.subscriber_id) + if (subscription_name := entry.data.get(CONF_SUBSCRIPTION_NAME)) is None: + subscription_name = entry.data[CONF_SUBSCRIBER_ID] + admin_client = api.new_pubsub_admin_client( + hass, + access_token=entry.data["token"]["access_token"], + cloud_project_id=entry.data[CONF_CLOUD_PROJECT_ID], + ) + _LOGGER.debug("Deleting subscription '%s'", subscription_name) try: - await subscriber.delete_subscription() - except (AuthException, SubscriberException) as err: + await admin_client.delete_subscription(subscription_name) + except ApiException as err: _LOGGER.warning( ( "Unable to delete subscription '%s'; Will be automatically cleaned up" " by cloud console: %s" ), - subscriber.subscriber_id, + subscription_name, err, ) - finally: - subscriber.stop_async() class NestEventViewBase(HomeAssistantView, ABC): diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 5c65a70c75d..e86e326b1c2 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -12,7 +12,6 @@ from google_nest_sdm.admin_client import PUBSUB_API_HOST, AdminClient from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow @@ -24,6 +23,7 @@ from .const import ( OAUTH2_TOKEN, SDM_SCOPES, ) +from .types import NestConfigEntry _LOGGER = logging.getLogger(__name__) @@ -102,7 +102,7 @@ class AccessTokenAuthImpl(AbstractAuth): async def new_subscriber( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: NestConfigEntry ) -> GoogleNestSubscriber | None: """Create a GoogleNestSubscriber.""" implementation = ( diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index b7e0f210741..df02f17444f 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -17,7 +17,6 @@ from google_nest_sdm.camera_traits import ( WebRtcStream, ) from google_nest_sdm.device import Device -from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.exceptions import ApiException from webrtc_models import RTCIceCandidateInit @@ -29,15 +28,14 @@ from homeassistant.components.camera import ( WebRTCSendMessage, ) from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -from .const import DATA_DEVICE_MANAGER, DOMAIN from .device_info import NestDeviceInfo +from .types import NestConfigEntry _LOGGER = logging.getLogger(__name__) @@ -53,15 +51,12 @@ BACKOFF_MULTIPLIER = 1.5 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the cameras.""" - device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ - DATA_DEVICE_MANAGER - ] entities: list[NestCameraBaseEntity] = [] - for device in device_manager.devices.values(): + for device in entry.runtime_data.device_manager.devices.values(): if (live_stream := device.traits.get(CameraLiveStreamTrait.NAME)) is None: continue if StreamingProtocol.WEB_RTC in live_stream.supported_protocols: diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 03fb641d0e5..3193d592120 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -5,8 +5,7 @@ from __future__ import annotations from typing import Any, cast from google_nest_sdm.device import Device -from google_nest_sdm.device_manager import DeviceManager -from google_nest_sdm.device_traits import FanTrait, TemperatureTrait +from google_nest_sdm.device_traits import FanTrait, HumidityTrait, TemperatureTrait from google_nest_sdm.exceptions import ApiException from google_nest_sdm.thermostat_traits import ( ThermostatEcoTrait, @@ -28,14 +27,13 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_DEVICE_MANAGER, DOMAIN from .device_info import NestDeviceInfo +from .types import NestConfigEntry # Mapping for sdm.devices.traits.ThermostatMode mode field THERMOSTAT_MODE_MAP: dict[str, HVACMode] = { @@ -78,17 +76,13 @@ MIN_TEMP_RANGE = 1.66667 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the client entities.""" - device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ - DATA_DEVICE_MANAGER - ] - async_add_entities( ThermostatEntity(device) - for device in device_manager.devices.values() + for device in entry.runtime_data.device_manager.devices.values() if ThermostatHvacTrait.NAME in device.traits ) @@ -101,7 +95,6 @@ class ThermostatEntity(ClimateEntity): _attr_has_entity_name = True _attr_should_poll = False _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: Device) -> None: """Initialize ThermostatEntity.""" @@ -140,6 +133,14 @@ class ThermostatEntity(ClimateEntity): trait: TemperatureTrait = self._device.traits[TemperatureTrait.NAME] return trait.ambient_temperature_celsius + @property + def current_humidity(self) -> float | None: + """Return the current humidity.""" + if HumidityTrait.NAME not in self._device.traits: + return None + trait: HumidityTrait = self._device.traits[HumidityTrait.NAME] + return trait.ambient_humidity_percent + @property def target_temperature(self) -> float | None: """Return the temperature currently set to be reached.""" diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py index 0a828dcbf78..9950d1d5c2a 100644 --- a/homeassistant/components/nest/const.py +++ b/homeassistant/components/nest/const.py @@ -2,8 +2,6 @@ DOMAIN = "nest" DATA_SDM = "sdm" -DATA_SUBSCRIBER = "subscriber" -DATA_DEVICE_MANAGER = "device_manager" WEB_AUTH_DOMAIN = DOMAIN INSTALLED_AUTH_DOMAIN = f"{DOMAIN}.installed" diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 33793fe836b..facd429b139 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -7,11 +7,12 @@ from collections.abc import Mapping from google_nest_sdm.device import Device from google_nest_sdm.device_traits import ConnectivityTrait, InfoTrait +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from .const import CONNECTIVITY_TRAIT_OFFLINE, DATA_DEVICE_MANAGER, DOMAIN +from .const import CONNECTIVITY_TRAIT_OFFLINE, DOMAIN DEVICE_TYPE_MAP: dict[str, str] = { "sdm.devices.types.CAMERA": "Camera", @@ -81,14 +82,12 @@ class NestDeviceInfo: @callback def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]: """Return a mapping of all nest devices for all config entries.""" - devices = {} - for entry_id in hass.data[DOMAIN]: - if not (device_manager := hass.data[DOMAIN][entry_id].get(DATA_DEVICE_MANAGER)): - continue - devices.update( - {device.name: device for device in device_manager.devices.values()} - ) - return devices + return { + device.name: device + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.state == ConfigEntryState.LOADED + for device in config_entry.runtime_data.device_manager.devices.values() + } @callback diff --git a/homeassistant/components/nest/diagnostics.py b/homeassistant/components/nest/diagnostics.py index 57ce4291cc6..345e15b0593 100644 --- a/homeassistant/components/nest/diagnostics.py +++ b/homeassistant/components/nest/diagnostics.py @@ -5,46 +5,26 @@ from __future__ import annotations from typing import Any from google_nest_sdm import diagnostics -from google_nest_sdm.device import Device -from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.device_traits import InfoTrait from homeassistant.components.camera import diagnostics as camera_diagnostics -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import DATA_DEVICE_MANAGER, DATA_SDM, DOMAIN +from .types import NestConfigEntry REDACT_DEVICE_TRAITS = {InfoTrait.NAME} -@callback -def _async_get_nest_devices( - hass: HomeAssistant, config_entry: ConfigEntry -) -> dict[str, Device]: - """Return dict of available devices.""" - if DATA_SDM not in config_entry.data: - return {} - - if ( - config_entry.entry_id not in hass.data[DOMAIN] - or DATA_DEVICE_MANAGER not in hass.data[DOMAIN][config_entry.entry_id] - ): - return {} - - device_manager: DeviceManager = hass.data[DOMAIN][config_entry.entry_id][ - DATA_DEVICE_MANAGER - ] - return device_manager.devices - - async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: NestConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - nest_devices = _async_get_nest_devices(hass, config_entry) - if not nest_devices: + if ( + not hasattr(config_entry, "runtime_data") + or not config_entry.runtime_data + or not (nest_devices := config_entry.runtime_data.device_manager.devices) + ): return {} data: dict[str, Any] = { **diagnostics.get_diagnostics(), @@ -62,11 +42,11 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NestConfigEntry, device: DeviceEntry, ) -> dict[str, Any]: """Return diagnostics for a device.""" - nest_devices = _async_get_nest_devices(hass, config_entry) + nest_devices = config_entry.runtime_data.device_manager.devices nest_device_id = next(iter(device.identifiers))[1] nest_device = nest_devices.get(nest_device_id) return nest_device.get_diagnostics() if nest_device else {} diff --git a/homeassistant/components/nest/event.py b/homeassistant/components/nest/event.py index a6d70fe86d5..1a2c0317496 100644 --- a/homeassistant/components/nest/event.py +++ b/homeassistant/components/nest/event.py @@ -4,7 +4,6 @@ from dataclasses import dataclass import logging from google_nest_sdm.device import Device -from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.event import EventMessage, EventType from google_nest_sdm.traits import TraitType @@ -13,11 +12,9 @@ from homeassistant.components.event import ( EventEntity, EventEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_DEVICE_MANAGER, DOMAIN from .device_info import NestDeviceInfo from .events import ( EVENT_CAMERA_MOTION, @@ -26,6 +23,7 @@ from .events import ( EVENT_DOORBELL_CHIME, EVENT_NAME_MAP, ) +from .types import NestConfigEntry _LOGGER = logging.getLogger(__name__) @@ -68,16 +66,12 @@ ENTITY_DESCRIPTIONS = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the sensors.""" - - device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ - DATA_DEVICE_MANAGER - ] async_add_entities( NestTraitEventEntity(desc, device) - for device in device_manager.devices.values() + for device in entry.runtime_data.device_manager.devices.values() for desc in ENTITY_DESCRIPTIONS if any(trait in device.traits for trait in desc.trait_types) ) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 07c34c51568..e14474dc309 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==6.1.5"] + "requirements": ["google-nest-sdm==7.0.0"] } diff --git a/homeassistant/components/nest/quality_scale.yaml b/homeassistant/components/nest/quality_scale.yaml new file mode 100644 index 00000000000..969ee66059d --- /dev/null +++ b/homeassistant/components/nest/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + config-flow: + status: todo + comment: Some fields are missing a data_description + brands: done + dependency-transparency: done + common-modules: + status: exempt + comment: The integration does not have a base entity or coordinator. + has-entity-name: done + action-setup: + status: exempt + comment: The integration does not register actions. + appropriate-polling: + status: exempt + comment: The integration does not poll. + test-before-configure: + status: todo + comment: | + The integration does a connection test in the configuration flow, however + it does not fail if the user has ipv6 misconfigured. + entity-event-setup: done + unique-config-entry: done + entity-unique-id: done + docs-installation-instructions: done + docs-removal-instructions: todo + test-before-setup: + status: todo + comment: | + The integration does tests on setup, however the most common issues + observed are related to ipv6 misconfigurations and the error messages + are not self explanatory and can be improved. + docs-high-level-description: done + config-flow-test-coverage: + status: todo + comment: | + The integration has full test coverage however it does not yet assert the specific contents of the + unique id of the created entry. Additional tests coverage for combinations of features like + `test_dhcp_discovery_with_creds` would also be useful. + Tests can be improved so that all end in either CREATE_ENTRY or ABORT. + docs-actions: done + runtime-data: done + + # Silver + log-when-unavailable: todo + config-entry-unloading: todo + reauthentication-flow: + status: todo + comment: | + Supports reauthentication, however can be improved to ensure the user does not change accounts + action-exceptions: todo + docs-installation-parameters: todo + integration-owner: todo + parallel-updates: todo + test-coverage: todo + docs-configuration-parameters: todo + entity-unavailable: todo + + # Gold + docs-examples: todo + discovery-update-info: todo + entity-device-class: todo + entity-translations: todo + docs-data-update: todo + entity-disabled-by-default: todo + discovery: todo + exception-translations: todo + devices: todo + docs-supported-devices: todo + icon-translations: todo + docs-known-limitations: todo + stale-devices: todo + docs-supported-functions: todo + repair-issues: todo + reconfiguration-flow: todo + entity-category: todo + dynamic-devices: todo + docs-troubleshooting: todo + diagnostics: todo + docs-use-cases: todo + + # Platinum + async-dependency: todo + strict-typing: todo + inject-websession: todo diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index edd359619fd..02a0e305813 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations import logging from google_nest_sdm.device import Device -from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.device_traits import HumidityTrait, TemperatureTrait from homeassistant.components.sensor import ( @@ -13,13 +12,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_DEVICE_MANAGER, DOMAIN from .device_info import NestDeviceInfo +from .types import NestConfigEntry _LOGGER = logging.getLogger(__name__) @@ -33,15 +31,12 @@ DEVICE_TYPE_MAP = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the sensors.""" - device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ - DATA_DEVICE_MANAGER - ] entities: list[SensorEntity] = [] - for device in device_manager.devices.values(): + for device in entry.runtime_data.device_manager.devices.values(): if TemperatureTrait.NAME in device.traits: entities.append(TemperatureSensor(device)) if HumidityTrait.NAME in device.traits: diff --git a/homeassistant/components/nest/types.py b/homeassistant/components/nest/types.py new file mode 100644 index 00000000000..bd6cd5cd887 --- /dev/null +++ b/homeassistant/components/nest/types.py @@ -0,0 +1,19 @@ +"""Type definitions for Nest.""" + +from dataclasses import dataclass + +from google_nest_sdm.device_manager import DeviceManager +from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber + +from homeassistant.config_entries import ConfigEntry + + +@dataclass +class NestData: + """Data for the Nest integration.""" + + subscriber: GoogleNestSubscriber + device_manager: DeviceManager + + +type NestConfigEntry = ConfigEntry[NestData] diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 6f14c9c76bb..9c92724c543 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -257,7 +257,6 @@ async def async_remove_config_entry_device( return not any( identifier for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - and identifier[1] in modules + if (identifier[0] == DOMAIN and identifier[1] in modules) or identifier[1] in rooms ) diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py new file mode 100644 index 00000000000..7b2899c84aa --- /dev/null +++ b/homeassistant/components/netatmo/button.py @@ -0,0 +1,73 @@ +"""Support for Netatmo/Bubendorff button.""" + +from __future__ import annotations + +import logging + +from pyatmo import modules as NaModules + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON +from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .entity import NetatmoModuleEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Netatmo button platform.""" + + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoCoverPreferredPositionButton(netatmo_device) + _LOGGER.debug("Adding button %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_BUTTON, _create_entity) + ) + + +class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): + """Representation of a Netatmo cover preferred position button device.""" + + _attr_configuration_url = CONF_URL_CONTROL + _attr_entity_registry_enabled_default = False + _attr_translation_key = "preferred_position" + device: NaModules.Shutter + + def __init__(self, netatmo_device: NetatmoDevice) -> None: + """Initialize the Netatmo device.""" + super().__init__(netatmo_device) + + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self.home.entity_id, + SIGNAL_NAME: f"{HOME}-{self.home.entity_id}", + }, + ] + ) + self._attr_unique_id = ( + f"{self.device.entity_id}-{self.device_type}-preferred_position" + ) + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + # No state to update for button + + async def async_press(self) -> None: + """Handle button press to move the cover to a preferred position.""" + _LOGGER.debug("Moving %s to a preferred position", self.device.entity_id) + await self.device.async_move_to_preferred_position() diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 752dee5a952..02c955beac3 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -192,7 +192,6 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): _attr_name = None _away: bool | None = None _connected: bool | None = None - _enable_turn_on_off_backwards_compatibility = False _away_temperature: float | None = None _hg_temperature: float | None = None diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 74f2ebc84b2..d69a62f37f9 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -10,6 +10,7 @@ DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CAMERA, Platform.CLIMATE, Platform.COVER, @@ -45,6 +46,7 @@ NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" NETATMO_CREATE_CLIMATE = "netatmo_create_climate" NETATMO_CREATE_COVER = "netatmo_create_cover" +NETATMO_CREATE_BUTTON = "netatmo_create_button" NETATMO_CREATE_FAN = "netatmo_create_fan" NETATMO_CREATE_LIGHT = "netatmo_create_light" NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 3a28c3b8336..283ccc3740e 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -33,6 +33,7 @@ from .const import ( DOMAIN, MANUFACTURER, NETATMO_CREATE_BATTERY, + NETATMO_CREATE_BUTTON, NETATMO_CREATE_CAMERA, NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_CLIMATE, @@ -350,7 +351,10 @@ class NetatmoDataHandler: NETATMO_CREATE_CAMERA_LIGHT, ], NetatmoDeviceCategory.dimmer: [NETATMO_CREATE_LIGHT], - NetatmoDeviceCategory.shutter: [NETATMO_CREATE_COVER], + NetatmoDeviceCategory.shutter: [ + NETATMO_CREATE_COVER, + NETATMO_CREATE_BUTTON, + ], NetatmoDeviceCategory.switch: [ NETATMO_CREATE_LIGHT, NETATMO_CREATE_SWITCH, diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 8610882a453..9f3fe7174ff 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -35,7 +35,7 @@ async def async_setup_entry( @callback def _create_entity(netatmo_device: NetatmoDevice) -> None: entity = NetatmoFan(netatmo_device) - _LOGGER.debug("Adding cover %s", entity) + _LOGGER.debug("Adding fan %s", entity) async_add_entities([entity]) entry.async_on_unload( @@ -51,7 +51,6 @@ class NetatmoFan(NetatmoModuleEntity, FanEntity): _attr_configuration_url = CONF_URL_CONTROL _attr_name = None device: NaModules.Fan - _enable_turn_on_off_backwards_compatibility = False def __init__(self, netatmo_device: NetatmoDevice) -> None: """Initialize of Netatmo fan.""" diff --git a/homeassistant/components/netatmo/icons.json b/homeassistant/components/netatmo/icons.json index 9f712e08f33..099c6aa1784 100644 --- a/homeassistant/components/netatmo/icons.json +++ b/homeassistant/components/netatmo/icons.json @@ -13,6 +13,11 @@ } } }, + "button": { + "preferred_position": { + "default": "mdi:window-shutter-auto" + } + }, "sensor": { "temp_trend": { "default": "mdi:trending-up" diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 6b91aa204b2..23b800e460d 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -181,6 +181,11 @@ } } }, + "button": { + "preferred_position": { + "name": "Preferred position" + } + }, "sensor": { "temp_trend": { "name": "Temperature trend" diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json index 199073298ab..8901a271de2 100644 --- a/homeassistant/components/netdata/manifest.json +++ b/homeassistant/components/netdata/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["netdata"], "quality_scale": "legacy", - "requirements": ["netdata==1.1.0"] + "requirements": ["netdata==1.3.0"] } diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index b77a4392ef4..f33349c56ce 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -70,7 +71,9 @@ async def async_setup_platform( port = config[CONF_PORT] resources = config[CONF_RESOURCES] - netdata = NetdataData(Netdata(host, port=port, timeout=20.0)) + netdata = NetdataData( + Netdata(host, port=port, timeout=20.0, httpx_client=get_async_client(hass)) + ) await netdata.async_update() if netdata.api.metrics is None: diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index 965e3618645..a0a5b76eee5 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -24,6 +23,12 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from homeassistant.util.network import is_ipv4_address from .const import ( @@ -129,7 +134,7 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Initialize flow from ssdp.""" updated_data: dict[str, str | int | bool] = {} @@ -144,10 +149,10 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Netgear ssdp discovery info: %s", discovery_info) - if ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp: + if ATTR_UPNP_SERIAL not in discovery_info.upnp: return self.async_abort(reason="no_serial") - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_SERIAL]) self._abort_if_unique_id_configured(updates=updated_data) if device_url.scheme == "https": @@ -157,18 +162,14 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN): updated_data[CONF_PORT] = DEFAULT_PORT for model in MODELS_PORT_80: - if discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith( + if discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, "").startswith( model - ) or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith( - model - ): + ) or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "").startswith(model): updated_data[CONF_PORT] = PORT_80 for model in MODELS_PORT_5555: - if discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith( + if discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, "").startswith( model - ) or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith( - model - ): + ) or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "").startswith(model): updated_data[CONF_PORT] = PORT_5555 updated_data[CONF_SSL] = True diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index 40394677362..f7a683326d3 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -37,6 +37,7 @@ MODELS_PORT_80 = [ "RBR", "RBS", "RBW", + "RS", "LBK", "LBR", "CBK", diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 1e4bf2480e9..d81f556193b 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -210,6 +210,12 @@ class NetgearRouter: for device in self.devices.values(): device["active"] = now - device["last_seen"] <= self._consider_home + if not device["active"]: + device["link_rate"] = None + device["signal"] = None + device["ip"] = None + device["ssid"] = None + device["conn_ap_mac"] = None if new_device: _LOGGER.debug("Netgear tracker: new device found") diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 72087dd28db..d807f7aed0a 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -47,18 +47,21 @@ SENSOR_TYPES = { key="type", translation_key="link_type", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "link_rate": SensorEntityDescription( key="link_rate", translation_key="link_rate", native_unit_of_measurement="Mbps", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "signal": SensorEntityDescription( key="signal", translation_key="signal_strength", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ssid": SensorEntityDescription( key="ssid", @@ -69,6 +72,7 @@ SENSOR_TYPES = { key="conn_ap_mac", translation_key="access_point_mac", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), } @@ -326,8 +330,6 @@ async def async_setup_entry( class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): """Representation of a device connected to a Netgear router.""" - _attr_entity_registry_enabled_default = False - def __init__( self, coordinator: DataUpdateCoordinator, @@ -342,6 +344,11 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): self._attr_unique_id = f"{self._mac}-{attribute}" self._state = device.get(attribute) + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._device.get(self._attribute) is not None + @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 9f3b1aeec9e..4d4e72c156f 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -19,8 +19,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "not_ipv4_address": "No IPv4 address in ssdp discovery information", - "no_serial": "No serial number in ssdp discovery information" + "not_ipv4_address": "No IPv4 address in SSDP discovery information", + "no_serial": "No serial number in SSDP discovery information" } }, "options": { @@ -48,7 +48,7 @@ "name": "SSID" }, "access_point_mac": { - "name": "Access point mac" + "name": "Access point MAC" }, "upload_today": { "name": "Upload today" diff --git a/homeassistant/components/network/strings.json b/homeassistant/components/network/strings.json new file mode 100644 index 00000000000..6aca7343221 --- /dev/null +++ b/homeassistant/components/network/strings.json @@ -0,0 +1,10 @@ +{ + "system_health": { + "info": { + "adapters": "Adapters", + "ipv4_addresses": "IPv4 addresses", + "ipv6_addresses": "IPv6 addresses", + "announce_addresses": "Announce addresses" + } + } +} diff --git a/homeassistant/components/network/system_health.py b/homeassistant/components/network/system_health.py new file mode 100644 index 00000000000..ebabe055539 --- /dev/null +++ b/homeassistant/components/network/system_health.py @@ -0,0 +1,53 @@ +"""Provide info to system health.""" + +from typing import Any + +from homeassistant.components import system_health +from homeassistant.core import HomeAssistant, callback + +from . import Adapter, async_get_adapters, async_get_announce_addresses +from .models import IPv4ConfiguredAddress, IPv6ConfiguredAddress + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.async_register_info(system_health_info, "/config/network") + + +def _format_ips(ips: list[IPv4ConfiguredAddress] | list[IPv6ConfiguredAddress]) -> str: + return ", ".join([f"{ip['address']}/{ip['network_prefix']!s}" for ip in ips]) + + +def _get_adapter_info(adapter: Adapter) -> str: + state = "enabled" if adapter["enabled"] else "disabled" + default = ", default" if adapter["default"] else "" + auto = ", auto" if adapter["auto"] else "" + return f"{adapter['name']} ({state}{default}{auto})" + + +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: + """Get info for the info page.""" + + adapters = await async_get_adapters(hass) + data: dict[str, Any] = { + # k: v for adapter in adapters for k, v in _get_adapter_info(adapter).items() + "adapters": ", ".join([_get_adapter_info(adapter) for adapter in adapters]), + "ipv4_addresses": ", ".join( + [ + f"{adapter['name']} ({_format_ips(adapter['ipv4'])})" + for adapter in adapters + ] + ), + "ipv6_addresses": ", ".join( + [ + f"{adapter['name']} ({_format_ips(adapter['ipv6'])})" + for adapter in adapters + ] + ), + "announce_addresses": ", ".join(await async_get_announce_addresses(hass)), + } + + return data diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index 9b22607d5a8..becd664756b 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -155,7 +155,6 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): """Provides Nexia Climate support.""" _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: NexiaDataUpdateCoordinator, zone: NexiaThermostatZone diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index f89d6ec29a9..94db90e7f58 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -74,7 +74,6 @@ class NibeClimateEntity(CoordinatorEntity[CoilCoordinator], ClimateEntity): _attr_target_temperature_step = 0.5 _attr_max_temp = 35.0 _attr_min_temp = 5.0 - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index ed6d18f7888..faaac5f165a 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -12,7 +12,7 @@ from nibe.coil import Coil, CoilData from nibe.connection import Connection from nibe.exceptions import CoilNotFoundException, ReadException from nibe.heatpump import HeatPump, Series -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 407cdfcfd57..049ba905f04 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.13.0"] + "requirements": ["nibe==2.14.0"] } diff --git a/homeassistant/components/nice_go/config_flow.py b/homeassistant/components/nice_go/config_flow.py index da3940117e9..291d4221d6c 100644 --- a/homeassistant/components/nice_go/config_flow.py +++ b/homeassistant/components/nice_go/config_flow.py @@ -50,7 +50,7 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN): ) except AuthFailedError: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -92,7 +92,7 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN): ) except AuthFailedError: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/nice_go/const.py b/homeassistant/components/nice_go/const.py index a6635368f7b..c02bcb3c234 100644 --- a/homeassistant/components/nice_go/const.py +++ b/homeassistant/components/nice_go/const.py @@ -15,8 +15,8 @@ CONF_REFRESH_TOKEN_CREATION_TIME = "refresh_token_creation_time" REFRESH_TOKEN_EXPIRY_TIME = timedelta(days=30) SUPPORTED_DEVICE_TYPES = { - Platform.LIGHT: ["WallStation"], - Platform.SWITCH: ["WallStation"], + Platform.LIGHT: ["WallStation", "WallStation_ESP32"], + Platform.SWITCH: ["WallStation", "WallStation_ESP32"], } KNOWN_UNSUPPORTED_DEVICE_TYPES = { Platform.LIGHT: ["Mms100"], diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index 29c0d8233fe..07b20bbbf10 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -239,7 +239,6 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): ].type, # Device type is not sent in device state update, and it can't change, so we just reuse the existing one BarrierState( deviceId=raw_data["deviceId"], - desired=json.loads(raw_data["desired"]), reported=json.loads(raw_data["reported"]), connectionState=ConnectionState( connected=raw_data["connectionState"]["connected"], diff --git a/homeassistant/components/nice_go/cover.py b/homeassistant/components/nice_go/cover.py index a823e931804..6360e398b96 100644 --- a/homeassistant/components/nice_go/cover.py +++ b/homeassistant/components/nice_go/cover.py @@ -21,6 +21,7 @@ from .entity import NiceGOEntity DEVICE_CLASSES = { "WallStation": CoverDeviceClass.GARAGE, "Mms100": CoverDeviceClass.GATE, + "WallStation_ESP32": CoverDeviceClass.GARAGE, } PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index 817d7ef9bc9..1af23ec4d9b 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["nice_go"], - "requirements": ["nice-go==0.3.10"] + "requirements": ["nice-go==1.0.0"] } diff --git a/homeassistant/components/nice_go/strings.json b/homeassistant/components/nice_go/strings.json index 07dabf7d39f..224996e6408 100644 --- a/homeassistant/components/nice_go/strings.json +++ b/homeassistant/components/nice_go/strings.json @@ -6,12 +6,20 @@ "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::nice_go::config::step::user::data_description::email%]", + "password": "[%key:component::nice_go::config::step::user::data_description::password%]" } }, "user": { "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address used to log in to the Nice G.O. app", + "password": "The password used to log in to the Nice G.O. app" } } }, diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py index 2cb5c70d1dd..ae4e8986816 100644 --- a/homeassistant/components/niko_home_control/__init__.py +++ b/homeassistant/components/niko_home_control/__init__.py @@ -1 +1,76 @@ -"""The niko_home_control component.""" +"""The Niko home control integration.""" + +from __future__ import annotations + +from nclib.errors import NetcatError +from nhc.controller import NHCController + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er + +from .const import _LOGGER + +PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT] + +type NikoHomeControlConfigEntry = ConfigEntry[NHCController] + + +async def async_setup_entry( + hass: HomeAssistant, entry: NikoHomeControlConfigEntry +) -> bool: + """Set Niko Home Control from a config entry.""" + controller = NHCController(entry.data[CONF_HOST]) + try: + await controller.connect() + except NetcatError as err: + raise ConfigEntryNotReady("cannot connect to controller.") from err + except OSError as err: + raise ConfigEntryNotReady( + "unknown error while connecting to controller." + ) from err + + entry.runtime_data = controller + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: NikoHomeControlConfigEntry +) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.minor_version < 2: + registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(registry, config_entry.entry_id) + + for entry in entries: + if entry.unique_id.startswith("light-"): + action_id = entry.unique_id.split("-")[-1] + new_unique_id = f"{config_entry.entry_id}-{action_id}" + registry.async_update_entity( + entry.entity_id, new_unique_id=new_unique_id + ) + + hass.config_entries.async_update_entry(config_entry, minor_version=2) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: NikoHomeControlConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/niko_home_control/config_flow.py b/homeassistant/components/niko_home_control/config_flow.py new file mode 100644 index 00000000000..f37e5e9248a --- /dev/null +++ b/homeassistant/components/niko_home_control/config_flow.py @@ -0,0 +1,68 @@ +"""Config flow for the Niko home control integration.""" + +from __future__ import annotations + +from typing import Any + +from nhc.controller import NHCController +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST + +from .const import DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +async def test_connection(host: str) -> str | None: + """Test if we can connect to the Niko Home Control controller.""" + + controller = NHCController(host, 8000) + try: + await controller.connect() + except Exception: # noqa: BLE001 + return "cannot_connect" + return None + + +class NikoHomeControlConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Niko Home Control.""" + + MINOR_VERSION = 2 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + error = await test_connection(user_input[CONF_HOST]) + if not error: + return self.async_create_entry( + title="Niko Home Control", + data=user_input, + ) + errors["base"] = error + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: + """Import a config entry.""" + self._async_abort_entries_match({CONF_HOST: import_info[CONF_HOST]}) + error = await test_connection(import_info[CONF_HOST]) + + if not error: + return self.async_create_entry( + title="Niko Home Control", + data={CONF_HOST: import_info[CONF_HOST]}, + ) + return self.async_abort(reason=error) diff --git a/homeassistant/components/niko_home_control/const.py b/homeassistant/components/niko_home_control/const.py new file mode 100644 index 00000000000..82b7ce7ed38 --- /dev/null +++ b/homeassistant/components/niko_home_control/const.py @@ -0,0 +1,6 @@ +"""Constants for niko_home_control integration.""" + +import logging + +DOMAIN = "niko_home_control" +_LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/niko_home_control/cover.py b/homeassistant/components/niko_home_control/cover.py new file mode 100644 index 00000000000..51e2a8a702d --- /dev/null +++ b/homeassistant/components/niko_home_control/cover.py @@ -0,0 +1,54 @@ +"""Cover Platform for Niko Home Control.""" + +from __future__ import annotations + +from typing import Any + +from nhc.cover import NHCCover + +from homeassistant.components.cover import CoverEntity, CoverEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import NikoHomeControlConfigEntry +from .entity import NikoHomeControlEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NikoHomeControlConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Niko Home Control cover entry.""" + controller = entry.runtime_data + + async_add_entities( + NikoHomeControlCover(cover, controller, entry.entry_id) + for cover in controller.covers + ) + + +class NikoHomeControlCover(NikoHomeControlEntity, CoverEntity): + """Representation of a Niko Cover.""" + + _attr_name = None + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + _action: NHCCover + + def open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + self._action.open() + + def close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + self._action.close() + + def stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + self._action.stop() + + def update_state(self): + """Update HA state.""" + self._attr_is_closed = self._action.state == 0 diff --git a/homeassistant/components/niko_home_control/entity.py b/homeassistant/components/niko_home_control/entity.py new file mode 100644 index 00000000000..fe14e09d957 --- /dev/null +++ b/homeassistant/components/niko_home_control/entity.py @@ -0,0 +1,50 @@ +"""Base class for Niko Home Control entities.""" + +from abc import abstractmethod + +from nhc.action import NHCAction +from nhc.controller import NHCController + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class NikoHomeControlEntity(Entity): + """Base class for Niko Home Control entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, action: NHCAction, controller: NHCController, unique_id: str + ) -> None: + """Set up the Niko Home Control entity.""" + self._controller = controller + self._action = action + self._attr_unique_id = unique_id = f"{unique_id}-{action.id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Niko", + name=action.name, + suggested_area=action.suggested_area, + ) + self.update_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + self.async_on_remove( + self._controller.register_callback( + self._action.id, self.async_update_callback + ) + ) + + async def async_update_callback(self, state: int) -> None: + """Handle updates from the controller.""" + self.update_state() + self.async_write_ha_state() + + @abstractmethod + def update_state(self) -> None: + """Update the state of the entity.""" diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index b2d41f3a41e..80f47e56438 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -1,12 +1,10 @@ -"""Support for Niko Home Control.""" +"""Light platform Niko Home Control.""" from __future__ import annotations -from datetime import timedelta -import logging from typing import Any -import nikohomecontrol +from nhc.light import NHCLight import voluptuous as vol from homeassistant.components.light import ( @@ -16,18 +14,20 @@ from homeassistant.components.light import ( LightEntity, brightness_supported, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) -SCAN_INTERVAL = timedelta(seconds=30) +from . import NHCController, NikoHomeControlConfigEntry +from .const import DOMAIN +from .entity import NikoHomeControlEntity +# delete after 2025.7.0 PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.string}) @@ -38,86 +38,89 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Niko Home Control light platform.""" - host = config[CONF_HOST] - - try: - nhc = nikohomecontrol.NikoHomeControl( - {"ip": host, "port": 8000, "timeout": 20000} + # Start import flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + if ( + result.get("type") == FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Niko Home Control", + }, ) - niko_data = NikoHomeControlData(hass, nhc) - await niko_data.async_update() - except OSError as err: - _LOGGER.error("Unable to access %s (%s)", host, err) - raise PlatformNotReady from err + return - async_add_entities( - [NikoHomeControlLight(light, niko_data) for light in nhc.list_actions()], True + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Niko Home Control", + }, ) -class NikoHomeControlLight(LightEntity): - """Representation of an Niko Light.""" +async def async_setup_entry( + hass: HomeAssistant, + entry: NikoHomeControlConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Niko Home Control light entry.""" + controller = entry.runtime_data - def __init__(self, light, data): + async_add_entities( + NikoHomeControlLight(light, controller, entry.entry_id) + for light in controller.lights + ) + + +class NikoHomeControlLight(NikoHomeControlEntity, LightEntity): + """Representation of a Niko Light.""" + + _attr_name = None + _action: NHCLight + + def __init__( + self, action: NHCLight, controller: NHCController, unique_id: str + ) -> None: """Set up the Niko Home Control light platform.""" - self._data = data - self._light = light - self._attr_unique_id = f"light-{light.id}" - self._attr_name = light.name - self._attr_is_on = light.is_on + super().__init__(action, controller, unique_id) self._attr_color_mode = ColorMode.ONOFF self._attr_supported_color_modes = {ColorMode.ONOFF} - if light._state["type"] == 2: # noqa: SLF001 + if action.is_dimmable: self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + self._attr_brightness = round(action.state * 2.55) def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - _LOGGER.debug("Turn on: %s", self.name) - self._light.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) + self._action.turn_on(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" - _LOGGER.debug("Turn off: %s", self.name) - self._light.turn_off() + self._action.turn_off() - async def async_update(self) -> None: - """Get the latest data from NikoHomeControl API.""" - await self._data.async_update() - state = self._data.get_state(self._light.id) - self._attr_is_on = state != 0 + def update_state(self) -> None: + """Handle updates from the controller.""" + state = self._action.state + self._attr_is_on = state > 0 if brightness_supported(self.supported_color_modes): - self._attr_brightness = state * 2.55 - - -class NikoHomeControlData: - """The class for handling data retrieval.""" - - def __init__(self, hass, nhc): - """Set up Niko Home Control Data object.""" - self._nhc = nhc - self.hass = hass - self.available = True - self.data = {} - self._system_info = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): - """Get the latest data from the NikoHomeControl API.""" - _LOGGER.debug("Fetching async state in bulk") - try: - self.data = await self.hass.async_add_executor_job( - self._nhc.list_actions_raw - ) - self.available = True - except OSError as ex: - _LOGGER.error("Unable to retrieve data from Niko, %s", str(ex)) - self.available = False - - def get_state(self, aid): - """Find and filter state based on action id.""" - for state in self.data: - if state["id"] == aid: - return state["value1"] - _LOGGER.error("Failed to retrieve state off unknown light") - return None + self._attr_brightness = round(state * 2.55) diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index 316dc1dc958..57f83180eb0 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -1,10 +1,10 @@ { "domain": "niko_home_control", "name": "Niko Home Control", - "codeowners": [], + "codeowners": ["@VandeurenGlenn"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/niko_home_control", - "iot_class": "local_polling", + "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "quality_scale": "legacy", - "requirements": ["niko-home-control==0.2.1"] + "requirements": ["nhc==0.3.9"] } diff --git a/homeassistant/components/niko_home_control/strings.json b/homeassistant/components/niko_home_control/strings.json new file mode 100644 index 00000000000..495dca94c0c --- /dev/null +++ b/homeassistant/components/niko_home_control/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up your Niko Home Control instance.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Niko Home Control controller." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "YAML import failed due to a connection error", + "description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + } + } +} diff --git a/homeassistant/components/nissan_leaf/strings.json b/homeassistant/components/nissan_leaf/strings.json index d733e39a0fc..78335ab4c14 100644 --- a/homeassistant/components/nissan_leaf/strings.json +++ b/homeassistant/components/nissan_leaf/strings.json @@ -2,17 +2,17 @@ "services": { "start_charge": { "name": "Start charge", - "description": "Starts the vehicle charging. It must be plugged in first!\n.", + "description": "Starts the vehicle charging. It must be plugged in first!", "fields": { "vin": { "name": "VIN", - "description": "The vehicle identification number (VIN) of the vehicle, 17 characters\n." + "description": "The vehicle identification number (VIN) of the vehicle, 17 characters." } } }, "update": { "name": "Update", - "description": "Fetches the last state of the vehicle of all your accounts, requesting an update from of the state from the car if possible.\n.", + "description": "Fetches the last state of the vehicle of all your accounts, requesting an update from of the state from the car if possible.", "fields": { "vin": { "name": "[%key:component::nissan_leaf::services::start_charge::fields::vin::name%]", diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 5b2dab50812..06f94e0566f 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", "iot_class": "local_polling", "loggers": ["nmap"], - "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.7"] + "requirements": ["netmap==0.7.0.2", "getmac==0.9.5", "aiooui==0.1.9"] } diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index ef660c7e991..3cbbea007b1 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -21,7 +21,7 @@ "config": { "step": { "user": { - "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32).", + "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP addresses (192.168.1.1), IP networks (192.168.0.0/24) or IP ranges (192.168.1.0-32).", "data": { "hosts": "Network addresses (comma separated) to scan", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", @@ -31,7 +31,7 @@ } }, "error": { - "invalid_hosts": "Invalid Hosts" + "invalid_hosts": "Invalid hosts" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 11013d471b5..9972d41ac7b 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -1 +1,45 @@ -"""The nmbs component.""" +"""The NMBS component.""" + +import logging + +from pyrail import iRail + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.SENSOR] + + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the NMBS component.""" + + api_client = iRail() + + hass.data.setdefault(DOMAIN, {}) + station_response = await hass.async_add_executor_job(api_client.get_stations) + if station_response == -1: + return False + hass.data[DOMAIN] = station_response["station"] + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up NMBS from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py new file mode 100644 index 00000000000..24ef8cd4995 --- /dev/null +++ b/homeassistant/components/nmbs/config_flow.py @@ -0,0 +1,180 @@ +"""Config flow for NMBS integration.""" + +from typing import Any + +from pyrail import iRail +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import Platform +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.selector import ( + BooleanSelector, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CONF_EXCLUDE_VIAS, + CONF_SHOW_ON_MAP, + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, +) + + +class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): + """NMBS config flow.""" + + def __init__(self) -> None: + """Initialize.""" + self.api_client = iRail() + self.stations: list[dict[str, Any]] = [] + + async def _fetch_stations(self) -> list[dict[str, Any]]: + """Fetch the stations.""" + stations_response = await self.hass.async_add_executor_job( + self.api_client.get_stations + ) + if stations_response == -1: + raise CannotConnect("The API is currently unavailable.") + return stations_response["station"] + + async def _fetch_stations_choices(self) -> list[SelectOptionDict]: + """Fetch the stations options.""" + + if len(self.stations) == 0: + self.stations = await self._fetch_stations() + + return [ + SelectOptionDict(value=station["id"], label=station["standardname"]) + for station in self.stations + ] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the step to setup a connection between 2 stations.""" + + try: + choices = await self._fetch_stations_choices() + except CannotConnect: + return self.async_abort(reason="api_unavailable") + + errors: dict = {} + if user_input is not None: + if user_input[CONF_STATION_FROM] == user_input[CONF_STATION_TO]: + errors["base"] = "same_station" + else: + [station_from] = [ + station + for station in self.stations + if station["id"] == user_input[CONF_STATION_FROM] + ] + [station_to] = [ + station + for station in self.stations + if station["id"] == user_input[CONF_STATION_TO] + ] + await self.async_set_unique_id( + f"{user_input[CONF_STATION_FROM]}_{user_input[CONF_STATION_TO]}" + ) + self._abort_if_unique_id_configured() + + config_entry_name = f"Train from {station_from['standardname']} to {station_to['standardname']}" + return self.async_create_entry( + title=config_entry_name, + data=user_input, + ) + + schema = vol.Schema( + { + vol.Required(CONF_STATION_FROM): SelectSelector( + SelectSelectorConfig( + options=choices, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(CONF_STATION_TO): SelectSelector( + SelectSelectorConfig( + options=choices, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_EXCLUDE_VIAS): BooleanSelector(), + vol.Optional(CONF_SHOW_ON_MAP): BooleanSelector(), + }, + ) + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Import configuration from yaml.""" + try: + self.stations = await self._fetch_stations() + except CannotConnect: + return self.async_abort(reason="api_unavailable") + + station_from = None + station_to = None + station_live = None + for station in self.stations: + if user_input[CONF_STATION_FROM] in ( + station["standardname"], + station["name"], + ): + station_from = station + if user_input[CONF_STATION_TO] in ( + station["standardname"], + station["name"], + ): + station_to = station + if CONF_STATION_LIVE in user_input and user_input[CONF_STATION_LIVE] in ( + station["standardname"], + station["name"], + ): + station_live = station + + if station_from is None or station_to is None: + return self.async_abort(reason="invalid_station") + if station_from == station_to: + return self.async_abort(reason="same_station") + + # config flow uses id and not the standard name + user_input[CONF_STATION_FROM] = station_from["id"] + user_input[CONF_STATION_TO] = station_to["id"] + + if station_live: + user_input[CONF_STATION_LIVE] = station_live["id"] + entity_registry = er.async_get(self.hass) + prefix = "live" + if entity_id := entity_registry.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}", + ): + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" + entity_registry.async_update_entity( + entity_id, new_unique_id=new_unique_id + ) + if entity_id := entity_registry.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"{prefix}_{station_live['name']}_{station_from['name']}_{station_to['name']}", + ): + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" + entity_registry.async_update_entity( + entity_id, new_unique_id=new_unique_id + ) + + return await self.async_step_user(user_input) + + +class CannotConnect(Exception): + """Error to indicate we cannot connect to NMBS.""" diff --git a/homeassistant/components/nmbs/const.py b/homeassistant/components/nmbs/const.py new file mode 100644 index 00000000000..fddb7365501 --- /dev/null +++ b/homeassistant/components/nmbs/const.py @@ -0,0 +1,36 @@ +"""The NMBS integration.""" + +from typing import Final + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +DOMAIN: Final = "nmbs" + +PLATFORMS: Final = [Platform.SENSOR] + +CONF_STATION_FROM = "station_from" +CONF_STATION_TO = "station_to" +CONF_STATION_LIVE = "station_live" +CONF_EXCLUDE_VIAS = "exclude_vias" +CONF_SHOW_ON_MAP = "show_on_map" + + +def find_station_by_name(hass: HomeAssistant, station_name: str): + """Find given station_name in the station list.""" + return next( + ( + s + for s in hass.data[DOMAIN] + if station_name in (s["standardname"], s["name"]) + ), + None, + ) + + +def find_station(hass: HomeAssistant, station_name: str): + """Find given station_id in the station list.""" + return next( + (s for s in hass.data[DOMAIN] if station_name in s["id"]), + None, + ) diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json index e17d1227bed..9016eff11f8 100644 --- a/homeassistant/components/nmbs/manifest.json +++ b/homeassistant/components/nmbs/manifest.json @@ -1,7 +1,8 @@ { "domain": "nmbs", "name": "NMBS", - "codeowners": ["@thibmaek"], + "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nmbs", "iot_class": "cloud_polling", "loggers": ["pyrail"], diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 6ccdc742430..85ae56144a0 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from pyrail import iRail import voluptuous as vol @@ -11,19 +12,33 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, + CONF_PLATFORM, CONF_SHOW_ON_MAP, UnitOfTime, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util +from .const import ( # noqa: F401 + CONF_EXCLUDE_VIAS, + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, + PLATFORMS, + find_station, + find_station_by_name, +) + _LOGGER = logging.getLogger(__name__) API_FAILURE = -1 @@ -33,11 +48,6 @@ DEFAULT_NAME = "NMBS" DEFAULT_ICON = "mdi:train" DEFAULT_ICON_ALERT = "mdi:alert-octagon" -CONF_STATION_FROM = "station_from" -CONF_STATION_TO = "station_to" -CONF_STATION_LIVE = "station_live" -CONF_EXCLUDE_VIAS = "exclude_vias" - PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_STATION_FROM): cv.string, @@ -73,33 +83,97 @@ def get_ride_duration(departure_time, arrival_time, delay=0): return duration_time + get_delay_in_minutes(delay) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the NMBS sensor with iRail API.""" - api_client = iRail() + if config[CONF_PLATFORM] == DOMAIN: + if CONF_SHOW_ON_MAP not in config: + config[CONF_SHOW_ON_MAP] = False + if CONF_EXCLUDE_VIAS not in config: + config[CONF_EXCLUDE_VIAS] = False - name = config[CONF_NAME] - show_on_map = config[CONF_SHOW_ON_MAP] - station_from = config[CONF_STATION_FROM] - station_to = config[CONF_STATION_TO] - station_live = config.get(CONF_STATION_LIVE) - excl_vias = config[CONF_EXCLUDE_VIAS] + station_types = [CONF_STATION_FROM, CONF_STATION_TO, CONF_STATION_LIVE] - sensors: list[SensorEntity] = [ - NMBSSensor(api_client, name, show_on_map, station_from, station_to, excl_vias) - ] + for station_type in station_types: + station = ( + find_station_by_name(hass, config[station_type]) + if station_type in config + else None + ) + if station is None and station_type in config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_station_not_found", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_station_not_found", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "NMBS", + "station_name": config[station_type], + "url": "/config/integrations/dashboard/add?domain=nmbs", + }, + ) + return - if station_live is not None: - sensors.append( - NMBSLiveBoard(api_client, station_live, station_from, station_to) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) ) - add_entities(sensors, True) + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "NMBS", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up NMBS sensor entities based on a config entry.""" + api_client = iRail() + + name = config_entry.data.get(CONF_NAME, None) + show_on_map = config_entry.data.get(CONF_SHOW_ON_MAP, False) + excl_vias = config_entry.data.get(CONF_EXCLUDE_VIAS, False) + + station_from = find_station(hass, config_entry.data[CONF_STATION_FROM]) + station_to = find_station(hass, config_entry.data[CONF_STATION_TO]) + + # setup the connection from station to station + # setup a disabled liveboard for both from and to station + async_add_entities( + [ + NMBSSensor( + api_client, name, show_on_map, station_from, station_to, excl_vias + ), + NMBSLiveBoard(api_client, station_from, station_from, station_to), + NMBSLiveBoard(api_client, station_to, station_from, station_to), + ] + ) class NMBSLiveBoard(SensorEntity): @@ -107,29 +181,39 @@ class NMBSLiveBoard(SensorEntity): _attr_attribution = "https://api.irail.be/" - def __init__(self, api_client, live_station, station_from, station_to): + def __init__( + self, + api_client: iRail, + live_station: dict[str, Any], + station_from: dict[str, Any], + station_to: dict[str, Any], + ) -> None: """Initialize the sensor for getting liveboard data.""" self._station = live_station self._api_client = api_client self._station_from = station_from self._station_to = station_to - self._attrs = {} - self._state = None + self._attrs: dict[str, Any] | None = {} + self._state: str | None = None + + self.entity_registry_enabled_default = False @property - def name(self): + def name(self) -> str: """Return the sensor default name.""" - return f"NMBS Live ({self._station})" + return f"Trains in {self._station['standardname']}" @property - def unique_id(self): - """Return a unique ID.""" - unique_id = f"{self._station}_{self._station_from}_{self._station_to}" + def unique_id(self) -> str: + """Return the unique ID.""" + unique_id = ( + f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}" + ) return f"nmbs_live_{unique_id}" @property - def icon(self): + def icon(self) -> str: """Return the default icon or an alert icon if delays.""" if self._attrs and int(self._attrs["delay"]) > 0: return DEFAULT_ICON_ALERT @@ -137,12 +221,12 @@ class NMBSLiveBoard(SensorEntity): return DEFAULT_ICON @property - def native_value(self): + def native_value(self) -> str | None: """Return sensor state.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the sensor attributes if data is available.""" if self._state is None or not self._attrs: return None @@ -155,7 +239,7 @@ class NMBSLiveBoard(SensorEntity): "departure_minutes": departure, "extra_train": int(self._attrs["isExtra"]) > 0, "vehicle_id": self._attrs["vehicle"], - "monitored_station": self._station, + "monitored_station": self._station["standardname"], } if delay > 0: @@ -166,7 +250,7 @@ class NMBSLiveBoard(SensorEntity): def update(self) -> None: """Set the state equal to the next departure.""" - liveboard = self._api_client.get_liveboard(self._station) + liveboard = self._api_client.get_liveboard(self._station["id"]) if liveboard == API_FAILURE: _LOGGER.warning("API failed in NMBSLiveBoard") @@ -195,8 +279,14 @@ class NMBSSensor(SensorEntity): _attr_native_unit_of_measurement = UnitOfTime.MINUTES def __init__( - self, api_client, name, show_on_map, station_from, station_to, excl_vias - ): + self, + api_client: iRail, + name: str, + show_on_map: bool, + station_from: dict[str, Any], + station_to: dict[str, Any], + excl_vias: bool, + ) -> None: """Initialize the NMBS connection sensor.""" self._name = name self._show_on_map = show_on_map @@ -205,16 +295,25 @@ class NMBSSensor(SensorEntity): self._station_to = station_to self._excl_vias = excl_vias - self._attrs = {} + self._attrs: dict[str, Any] | None = {} self._state = None @property - def name(self): + def unique_id(self) -> str: + """Return the unique ID.""" + unique_id = f"{self._station_from['id']}_{self._station_to['id']}" + + return f"nmbs_connection_{unique_id}" + + @property + def name(self) -> str: """Return the name of the sensor.""" + if self._name is None: + return f"Train from {self._station_from['standardname']} to {self._station_to['standardname']}" return self._name @property - def icon(self): + def icon(self) -> str: """Return the sensor default icon or an alert icon if any delay.""" if self._attrs: delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) @@ -224,7 +323,7 @@ class NMBSSensor(SensorEntity): return "mdi:train" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return sensor attributes if data is available.""" if self._state is None or not self._attrs: return None @@ -234,7 +333,7 @@ class NMBSSensor(SensorEntity): canceled = int(self._attrs["departure"]["canceled"]) attrs = { - "destination": self._station_to, + "destination": self._attrs["departure"]["station"], "direction": self._attrs["departure"]["direction"]["name"], "platform_arriving": self._attrs["arrival"]["platform"], "platform_departing": self._attrs["departure"]["platform"], @@ -271,12 +370,12 @@ class NMBSSensor(SensorEntity): return attrs @property - def native_value(self): + def native_value(self) -> int | None: """Return the state of the device.""" return self._state @property - def station_coordinates(self): + def station_coordinates(self) -> list[float]: """Get the lat, long coordinates for station.""" if self._state is None or not self._attrs: return [] @@ -286,7 +385,7 @@ class NMBSSensor(SensorEntity): return [latitude, longitude] @property - def is_via_connection(self): + def is_via_connection(self) -> bool: """Return whether the connection goes through another station.""" if not self._attrs: return False @@ -296,7 +395,7 @@ class NMBSSensor(SensorEntity): def update(self) -> None: """Set the state to the duration of a connection.""" connections = self._api_client.get_connections( - self._station_from, self._station_to + self._station_from["id"], self._station_to["id"] ) if connections == API_FAILURE: diff --git a/homeassistant/components/nmbs/strings.json b/homeassistant/components/nmbs/strings.json new file mode 100644 index 00000000000..3e7aa8d05bd --- /dev/null +++ b/homeassistant/components/nmbs/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "api_unavailable": "The API is currently unavailable.", + "same_station": "[%key:component::nmbs::config::error::same_station%]", + "invalid_station": "Invalid station." + }, + "error": { + "same_station": "Departure and arrival station can not be the same." + }, + "step": { + "user": { + "data": { + "station_from": "Departure station", + "station_to": "Arrival station", + "exclude_vias": "Direct connections only", + "show_on_map": "Display on map" + }, + "data_description": { + "station_from": "Station where the train departs", + "station_to": "Station where the train arrives", + "exclude_vias": "Exclude connections with transfers", + "show_on_map": "Show the station on the map" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue_station_not_found": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but there was an problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index f1e2f4a78f0..a089209cde5 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -82,7 +82,6 @@ class NoboZone(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = 1 # Need to poll to get preset change when in HVACMode.AUTO, so can't set _attr_should_poll = False - _enable_turn_on_off_backwards_compatibility = False def __init__(self, zone_id, hub: nobo, override_type) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py index 82db98e2148..77f4b263b54 100644 --- a/homeassistant/components/nordpool/__init__.py +++ b/homeassistant/components/nordpool/__init__.py @@ -5,18 +5,34 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .const import DOMAIN, PLATFORMS +from .const import CONF_AREAS, DOMAIN, LOGGER, PLATFORMS from .coordinator import NordPoolDataUpdateCoordinator +from .services import async_setup_services type NordPoolConfigEntry = ConfigEntry[NordPoolDataUpdateCoordinator] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> bool: + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Nord Pool service.""" + + async_setup_services(hass) + return True + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: NordPoolConfigEntry +) -> bool: """Set up Nord Pool from a config entry.""" - coordinator = NordPoolDataUpdateCoordinator(hass, entry) + await cleanup_device(hass, config_entry) + + coordinator = NordPoolDataUpdateCoordinator(hass, config_entry) await coordinator.fetch_data(dt_util.utcnow()) if not coordinator.last_update_success: raise ConfigEntryNotReady( @@ -24,13 +40,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> translation_key="initial_update_failed", translation_placeholders={"error": str(coordinator.last_exception)}, ) - entry.runtime_data = coordinator + config_entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: NordPoolConfigEntry +) -> bool: """Unload Nord Pool config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def cleanup_device( + hass: HomeAssistant, config_entry: NordPoolConfigEntry +) -> None: + """Cleanup device and entities.""" + device_reg = dr.async_get(hass) + + entries = dr.async_entries_for_config_entry(device_reg, config_entry.entry_id) + for area in config_entry.data[CONF_AREAS]: + for entry in entries: + if entry.identifiers == {(DOMAIN, area)}: + continue + + LOGGER.debug("Removing device %s", entry.name) + device_reg.async_update_device( + entry.id, remove_config_entry_id=config_entry.entry_id + ) diff --git a/homeassistant/components/nordpool/config_flow.py b/homeassistant/components/nordpool/config_flow.py index 1d75d825e47..b3b807badad 100644 --- a/homeassistant/components/nordpool/config_flow.py +++ b/homeassistant/components/nordpool/config_flow.py @@ -99,10 +99,10 @@ class NordpoolConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the reconfiguration step.""" + reconfigure_entry = self._get_reconfigure_entry() errors: dict[str, str] = {} if user_input: errors = await test_api(self.hass, user_input) - reconfigure_entry = self._get_reconfigure_entry() if not errors: return self.async_update_reload_and_abort( reconfigure_entry, data_updates=user_input @@ -110,6 +110,8 @@ class NordpoolConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reconfigure", - data_schema=DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, user_input or reconfigure_entry.data + ), errors=errors, ) diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index fa4e9ca2548..a6cfd40c323 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -9,6 +9,8 @@ from typing import TYPE_CHECKING from pynordpool import ( Currency, DeliveryPeriodData, + DeliveryPeriodEntry, + DeliveryPeriodsData, NordPoolClient, NordPoolEmptyResponseError, NordPoolError, @@ -28,7 +30,7 @@ if TYPE_CHECKING: from . import NordPoolConfigEntry -class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]): +class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): """A Nord Pool Data Update Coordinator.""" config_entry: NordPoolConfigEntry @@ -69,23 +71,53 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]): self.unsub = async_track_point_in_utc_time( self.hass, self.fetch_data, self.get_next_interval(dt_util.utcnow()) ) + data = await self.api_call() + if data and data.entries: + self.async_set_updated_data(data) + + async def api_call(self, retry: int = 3) -> DeliveryPeriodsData | None: + """Make api call to retrieve data with retry if failure.""" + data = None try: - data = await self.client.async_get_delivery_period( - dt_util.now(), + data = await self.client.async_get_delivery_periods( + [ + dt_util.now() - timedelta(days=1), + dt_util.now(), + dt_util.now() + timedelta(days=1), + ], Currency(self.config_entry.data[CONF_CURRENCY]), self.config_entry.data[CONF_AREAS], ) - except NordPoolEmptyResponseError as error: - LOGGER.debug("Empty response error: %s", error) - self.async_set_update_error(error) - return - except NordPoolResponseError as error: - LOGGER.debug("Response error: %s", error) - self.async_set_update_error(error) - return - except NordPoolError as error: + except ( + NordPoolResponseError, + NordPoolError, + ) as error: LOGGER.debug("Connection error: %s", error) self.async_set_update_error(error) - return - self.async_set_updated_data(data) + if data: + current_day = dt_util.utcnow().strftime("%Y-%m-%d") + for entry in data.entries: + if entry.requested_date == current_day: + LOGGER.debug("Data for current day found") + return data + + self.async_set_update_error(NordPoolEmptyResponseError("No current day data")) + return data + + def merge_price_entries(self) -> list[DeliveryPeriodEntry]: + """Return the merged price entries.""" + merged_entries: list[DeliveryPeriodEntry] = [] + for del_period in self.data.entries: + merged_entries.extend(del_period.entries) + return merged_entries + + def get_data_current_day(self) -> DeliveryPeriodData: + """Return the current day data.""" + current_day = dt_util.utcnow().strftime("%Y-%m-%d") + delivery_period: DeliveryPeriodData = self.data.entries[0] + for del_period in self.data.entries: + if del_period.requested_date == current_day: + delivery_period = del_period + break + return delivery_period diff --git a/homeassistant/components/nordpool/entity.py b/homeassistant/components/nordpool/entity.py index 32240aad12c..ec3264cd2e3 100644 --- a/homeassistant/components/nordpool/entity.py +++ b/homeassistant/components/nordpool/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -29,4 +29,5 @@ class NordpoolBaseEntity(CoordinatorEntity[NordPoolDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, area)}, name=f"Nord Pool {area}", + entry_type=DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/nordpool/icons.json b/homeassistant/components/nordpool/icons.json index 85434a2d09b..5a1a3df3d92 100644 --- a/homeassistant/components/nordpool/icons.json +++ b/homeassistant/components/nordpool/icons.json @@ -38,5 +38,10 @@ "default": "mdi:cash-multiple" } } + }, + "services": { + "get_prices_for_date": { + "service": "mdi:cash-multiple" + } } } diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json index bf093eb3ee9..b096d2bd506 100644 --- a/homeassistant/components/nordpool/manifest.json +++ b/homeassistant/components/nordpool/manifest.json @@ -7,6 +7,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pynordpool"], - "requirements": ["pynordpool==0.2.2"], + "quality_scale": "platinum", + "requirements": ["pynordpool==0.2.4"], "single_config_entry": true } diff --git a/homeassistant/components/nordpool/quality_scale.yaml b/homeassistant/components/nordpool/quality_scale.yaml new file mode 100644 index 00000000000..9c5160d0ccb --- /dev/null +++ b/homeassistant/components/nordpool/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: | + Entities doesn't subscribe to events. + dependency-transparency: done + action-setup: done + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: done + brands: done + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + This integration has no options flow. + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: + status: exempt + comment: | + No discovery, cloud service + stale-devices: + status: exempt + comment: | + This integration devices (services) will be removed with config entry if needed. + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: + status: exempt + comment: | + This integration has fixed devices. + discovery-update-info: + status: exempt + comment: | + No discovery + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + docs-use-cases: done + docs-supported-devices: + status: exempt + comment: | + Only service, no device + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py index e7e655a6657..30910f8e5f6 100644 --- a/homeassistant/components/nordpool/sensor.py +++ b/homeassistant/components/nordpool/sensor.py @@ -6,8 +6,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from pynordpool import DeliveryPeriodData - from homeassistant.components.sensor import ( EntityCategory, SensorDeviceClass, @@ -27,40 +25,89 @@ from .entity import NordpoolBaseEntity PARALLEL_UPDATES = 0 -def get_prices(data: DeliveryPeriodData) -> dict[str, tuple[float, float, float]]: +def validate_prices( + func: Callable[ + [NordpoolPriceSensor], dict[str, tuple[float | None, float, float | None]] + ], + entity: NordpoolPriceSensor, + area: str, + index: int, +) -> float | None: + """Validate and return.""" + if result := func(entity)[area][index]: + return result / 1000 + return None + + +def get_prices( + entity: NordpoolPriceSensor, +) -> dict[str, tuple[float | None, float, float | None]]: """Return previous, current and next prices. Output: {"SE3": (10.0, 10.5, 12.1)} """ + data = entity.coordinator.merge_price_entries() last_price_entries: dict[str, float] = {} current_price_entries: dict[str, float] = {} next_price_entries: dict[str, float] = {} current_time = dt_util.utcnow() previous_time = current_time - timedelta(hours=1) next_time = current_time + timedelta(hours=1) - price_data = data.entries - for entry in price_data: + LOGGER.debug("Price data: %s", data) + for entry in data: if entry.start <= current_time <= entry.end: current_price_entries = entry.entry if entry.start <= previous_time <= entry.end: last_price_entries = entry.entry if entry.start <= next_time <= entry.end: next_price_entries = entry.entry + LOGGER.debug( + "Last price %s, current price %s, next price %s", + last_price_entries, + current_price_entries, + next_price_entries, + ) result = {} for area, price in current_price_entries.items(): - result[area] = (last_price_entries[area], price, next_price_entries[area]) + result[area] = ( + last_price_entries.get(area), + price, + next_price_entries.get(area), + ) LOGGER.debug("Prices: %s", result) return result +def get_min_max_price( + entity: NordpoolPriceSensor, + func: Callable[[float, float], float], +) -> tuple[float, datetime, datetime]: + """Get the lowest price from the data.""" + data = entity.coordinator.get_data_current_day() + area = entity.area + price_data = data.entries + price: float = price_data[0].entry[area] + start: datetime = price_data[0].start + end: datetime = price_data[0].end + for entry in price_data: + for _area, _price in entry.entry.items(): + if _area == area and _price == func(price, _price): + price = _price + start = entry.start + end = entry.end + + return (price, start, end) + + def get_blockprices( - data: DeliveryPeriodData, + entity: NordpoolBlockPriceSensor, ) -> dict[str, dict[str, tuple[datetime, datetime, float, float, float]]]: """Return average, min and max for block prices. Output: {"SE3": {"Off-peak 1": (_datetime_, _datetime_, 9.3, 10.5, 12.1)}} """ + data = entity.coordinator.get_data_current_day() result: dict[str, dict[str, tuple[datetime, datetime, float, float, float]]] = {} block_prices = data.block_prices for entry in block_prices: @@ -83,14 +130,15 @@ def get_blockprices( class NordpoolDefaultSensorEntityDescription(SensorEntityDescription): """Describes Nord Pool default sensor entity.""" - value_fn: Callable[[DeliveryPeriodData], str | float | datetime | None] + value_fn: Callable[[NordpoolSensor], str | float | datetime | None] @dataclass(frozen=True, kw_only=True) class NordpoolPricesSensorEntityDescription(SensorEntityDescription): """Describes Nord Pool prices sensor entity.""" - value_fn: Callable[[tuple[float, float, float]], float | None] + value_fn: Callable[[NordpoolPriceSensor], float | None] + extra_fn: Callable[[NordpoolPriceSensor], dict[str, str] | None] @dataclass(frozen=True, kw_only=True) @@ -107,19 +155,19 @@ DEFAULT_SENSOR_TYPES: tuple[NordpoolDefaultSensorEntityDescription, ...] = ( key="updated_at", translation_key="updated_at", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: data.updated_at, + value_fn=lambda entity: entity.coordinator.get_data_current_day().updated_at, entity_category=EntityCategory.DIAGNOSTIC, ), NordpoolDefaultSensorEntityDescription( key="currency", translation_key="currency", - value_fn=lambda data: data.currency, + value_fn=lambda entity: entity.coordinator.get_data_current_day().currency, entity_category=EntityCategory.DIAGNOSTIC, ), NordpoolDefaultSensorEntityDescription( key="exchange_rate", translation_key="exchange_rate", - value_fn=lambda data: data.exchange_rate, + value_fn=lambda entity: entity.coordinator.get_data_current_day().exchange_rate, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -129,20 +177,43 @@ PRICES_SENSOR_TYPES: tuple[NordpoolPricesSensorEntityDescription, ...] = ( NordpoolPricesSensorEntityDescription( key="current_price", translation_key="current_price", - value_fn=lambda data: data[1] / 1000, + value_fn=lambda entity: validate_prices(get_prices, entity, entity.area, 1), + extra_fn=lambda entity: None, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ), NordpoolPricesSensorEntityDescription( key="last_price", translation_key="last_price", - value_fn=lambda data: data[0] / 1000, + value_fn=lambda entity: validate_prices(get_prices, entity, entity.area, 0), + extra_fn=lambda entity: None, suggested_display_precision=2, ), NordpoolPricesSensorEntityDescription( key="next_price", translation_key="next_price", - value_fn=lambda data: data[2] / 1000, + value_fn=lambda entity: validate_prices(get_prices, entity, entity.area, 2), + extra_fn=lambda entity: None, + suggested_display_precision=2, + ), + NordpoolPricesSensorEntityDescription( + key="lowest_price", + translation_key="lowest_price", + value_fn=lambda entity: get_min_max_price(entity, min)[0] / 1000, + extra_fn=lambda entity: { + "start": get_min_max_price(entity, min)[1].isoformat(), + "end": get_min_max_price(entity, min)[2].isoformat(), + }, + suggested_display_precision=2, + ), + NordpoolPricesSensorEntityDescription( + key="highest_price", + translation_key="highest_price", + value_fn=lambda entity: get_min_max_price(entity, max)[0] / 1000, + extra_fn=lambda entity: { + "start": get_min_max_price(entity, max)[1].isoformat(), + "end": get_min_max_price(entity, max)[2].isoformat(), + }, suggested_display_precision=2, ), ) @@ -205,11 +276,12 @@ async def async_setup_entry( """Set up Nord Pool sensor platform.""" coordinator = entry.runtime_data + current_day_data = entry.runtime_data.get_data_current_day() entities: list[NordpoolBaseEntity] = [] - currency = entry.runtime_data.data.currency + currency = current_day_data.currency - for area in get_prices(entry.runtime_data.data): + for area in current_day_data.area_average: LOGGER.debug("Setting up base sensors for area %s", area) entities.extend( NordpoolSensor(coordinator, description, area) @@ -226,16 +298,16 @@ async def async_setup_entry( NordpoolDailyAveragePriceSensor(coordinator, description, area, currency) for description in DAILY_AVERAGE_PRICES_SENSOR_TYPES ) - for block_name in get_blockprices(coordinator.data)[area]: + for block_prices in entry.runtime_data.get_data_current_day().block_prices: LOGGER.debug( "Setting up block price sensors for area %s with currency %s in block %s", area, currency, - block_name, + block_prices.name, ) entities.extend( NordpoolBlockPriceSensor( - coordinator, description, area, currency, block_name + coordinator, description, area, currency, block_prices.name ) for description in BLOCK_PRICES_SENSOR_TYPES ) @@ -250,7 +322,7 @@ class NordpoolSensor(NordpoolBaseEntity, SensorEntity): @property def native_value(self) -> str | float | datetime | None: """Return value of sensor.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn(self) class NordpoolPriceSensor(NordpoolBaseEntity, SensorEntity): @@ -272,9 +344,12 @@ class NordpoolPriceSensor(NordpoolBaseEntity, SensorEntity): @property def native_value(self) -> float | None: """Return value of sensor.""" - return self.entity_description.value_fn( - get_prices(self.coordinator.data)[self.area] - ) + return self.entity_description.value_fn(self) + + @property + def extra_state_attributes(self) -> dict[str, str] | None: + """Return the extra state attributes.""" + return self.entity_description.extra_fn(self) class NordpoolBlockPriceSensor(NordpoolBaseEntity, SensorEntity): @@ -302,7 +377,7 @@ class NordpoolBlockPriceSensor(NordpoolBaseEntity, SensorEntity): def native_value(self) -> float | datetime | None: """Return value of sensor.""" return self.entity_description.value_fn( - get_blockprices(self.coordinator.data)[self.area][self.block_name] + get_blockprices(self)[self.area][self.block_name] ) @@ -325,4 +400,5 @@ class NordpoolDailyAveragePriceSensor(NordpoolBaseEntity, SensorEntity): @property def native_value(self) -> float | None: """Return value of sensor.""" - return self.coordinator.data.area_average[self.area] / 1000 + data = self.coordinator.get_data_current_day() + return data.area_average[self.area] / 1000 diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py new file mode 100644 index 00000000000..872bd5b1e6b --- /dev/null +++ b/homeassistant/components/nordpool/services.py @@ -0,0 +1,129 @@ +"""Services for Nord Pool integration.""" + +from __future__ import annotations + +from datetime import date, datetime +import logging +from typing import TYPE_CHECKING + +from pynordpool import ( + AREAS, + Currency, + NordPoolAuthenticationError, + NordPoolEmptyResponseError, + NordPoolError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DATE +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ConfigEntrySelector +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonValueType + +if TYPE_CHECKING: + from . import NordPoolConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +ATTR_CONFIG_ENTRY = "config_entry" +ATTR_AREAS = "areas" +ATTR_CURRENCY = "currency" + +SERVICE_GET_PRICES_FOR_DATE = "get_prices_for_date" +SERVICE_GET_PRICES_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_DATE): cv.date, + vol.Optional(ATTR_AREAS): vol.All(vol.In(list(AREAS)), cv.ensure_list, [str]), + vol.Optional(ATTR_CURRENCY): vol.All( + cv.string, vol.In([currency.value for currency in Currency]) + ), + } +) + + +def get_config_entry(hass: HomeAssistant, entry_id: str) -> NordPoolConfigEntry: + """Return config entry.""" + if not (entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_found", + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + ) + return entry + + +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Nord Pool integration.""" + + async def get_prices_for_date(call: ServiceCall) -> ServiceResponse: + """Get price service.""" + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) + asked_date: date = call.data[ATTR_DATE] + client = entry.runtime_data.client + + areas: list[str] = entry.data[ATTR_AREAS] + if _areas := call.data.get(ATTR_AREAS): + areas = _areas + + currency: str = entry.data[ATTR_CURRENCY] + if _currency := call.data.get(ATTR_CURRENCY): + currency = _currency + + areas = [area.upper() for area in areas] + currency = currency.upper() + + try: + price_data = await client.async_get_delivery_period( + datetime.combine(asked_date, dt_util.utcnow().time()), + Currency(currency), + areas, + ) + except NordPoolAuthenticationError as error: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from error + except NordPoolEmptyResponseError as error: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="empty_response", + ) from error + except NordPoolError as error: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from error + + result: dict[str, JsonValueType] = {} + for area in areas: + result[area] = [ + { + "start": price_entry.start.isoformat(), + "end": price_entry.end.isoformat(), + "price": price_entry.entry[area], + } + for price_entry in price_data.entries + ] + return result + + hass.services.async_register( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + get_prices_for_date, + schema=SERVICE_GET_PRICES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/nordpool/services.yaml b/homeassistant/components/nordpool/services.yaml new file mode 100644 index 00000000000..dded8482c6f --- /dev/null +++ b/homeassistant/components/nordpool/services.yaml @@ -0,0 +1,48 @@ +get_prices_for_date: + fields: + config_entry: + required: true + selector: + config_entry: + integration: nordpool + date: + required: true + selector: + date: + areas: + selector: + select: + options: + - "EE" + - "LT" + - "LV" + - "AT" + - "BE" + - "FR" + - "GER" + - "NL" + - "PL" + - "DK1" + - "DK2" + - "FI" + - "NO1" + - "NO2" + - "NO3" + - "NO4" + - "NO5" + - "SE1" + - "SE2" + - "SE3" + - "SE4" + - "SYS" + mode: dropdown + currency: + selector: + select: + options: + - "DKK" + - "EUR" + - "NOK" + - "PLN" + - "SEK" + mode: dropdown diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index 1a4551fe61a..cc10a1a0640 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -12,12 +12,20 @@ "data": { "currency": "Currency", "areas": "Areas" + }, + "data_description": { + "currency": "Select currency to display prices in, EUR is the base currency.", + "areas": "Areas to display prices for according to Nordpool market areas." } }, "reconfigure": { "data": { "currency": "[%key:component::nordpool::config::step::user::data::currency%]", "areas": "[%key:component::nordpool::config::step::user::data::areas%]" + }, + "data_description": { + "currency": "[%key:component::nordpool::config::step::user::data_description::currency%]", + "areas": "[%key:component::nordpool::config::step::user::data_description::areas%]" } } } @@ -42,6 +50,28 @@ "next_price": { "name": "Next price" }, + "lowest_price": { + "name": "Lowest price", + "state_attributes": { + "start": { + "name": "Start time" + }, + "end": { + "name": "End time" + } + } + }, + "highest_price": { + "name": "Highest price", + "state_attributes": { + "start": { + "name": "[%key:component::nordpool::entity::sensor::lowest_price::state_attributes::start::name%]" + }, + "end": { + "name": "[%key:component::nordpool::entity::sensor::lowest_price::state_attributes::end::name%]" + } + } + }, "block_average": { "name": "{block} average" }, @@ -62,9 +92,48 @@ } } }, + "services": { + "get_prices_for_date": { + "name": "Get prices for date", + "description": "Retrieve the prices for a specific date.", + "fields": { + "config_entry": { + "name": "Select Nord Pool configuration entry", + "description": "Choose the configuration entry." + }, + "date": { + "name": "Date", + "description": "Only dates two months in the past and one day in the future is allowed." + }, + "areas": { + "name": "Areas", + "description": "One or multiple areas to get prices for. If left empty it will use the areas already configured." + }, + "currency": { + "name": "Currency", + "description": "Currency to get prices in. If left empty it will use the currency already configured." + } + } + } + }, "exceptions": { "initial_update_failed": { "message": "Initial update failed on startup with error {error}" + }, + "entry_not_found": { + "message": "The Nord Pool integration is not configured in Home Assistant." + }, + "entry_not_loaded": { + "message": "The Nord Pool integration is currently not loaded or disabled in Home Assistant." + }, + "authentication_error": { + "message": "There was an authentication error as you tried to retrieve data too far in the past." + }, + "empty_response": { + "message": "Nord Pool has not posted market prices for the provided date." + }, + "connection_error": { + "message": "There was a connection error connecting to the API. Try again later." } } } diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 0b7a25ced3e..7f41817a683 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -8,7 +8,7 @@ from functools import partial import logging from typing import Any, final, override -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol import homeassistant.components.persistent_notification as pn diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index db85827fc9b..8248c1b9b82 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -79,7 +79,6 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_preset_modes = PRESET_MODES - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, thermostat, temperature_unit): """Initialize the thermostat.""" diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index 4a9789c7e51..ac6771bb1bd 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -9,10 +9,10 @@ from pynuki.bridge import InvalidCredentialsException from requests.exceptions import RequestException import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_ENCRYPT_TOKEN, DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN from .helpers import CannotConnect, InvalidAuth, parse_id @@ -75,7 +75,7 @@ class NukiConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_validate(user_input) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Nuki bridge.""" await self.async_set_unique_id(discovery_info.hostname[12:].upper()) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index dc169fcb348..3e9d3448af2 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -10,7 +10,7 @@ import logging from math import ceil, floor from typing import TYPE_CHECKING, Any, Self, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -68,8 +68,8 @@ __all__ = [ "DEFAULT_MIN_VALUE", "DEFAULT_STEP", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "NumberDeviceClass", "NumberEntity", "NumberEntityDescription", @@ -384,6 +384,18 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ): return self.hass.config.units.temperature_unit + if (translation_key := self._unit_of_measurement_translation_key) and ( + unit_of_measurement + := self.platform.default_language_platform_translations.get(translation_key) + ): + if native_unit_of_measurement is not None: + raise ValueError( + f"Number entity {type(self)} from integration '{self.platform.platform_name}' " + f"has a translation key for unit_of_measurement '{unit_of_measurement}', " + f"but also has a native_unit_of_measurement '{native_unit_of_measurement}'" + ) + return unit_of_measurement + return native_unit_of_measurement @cached_property diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 7330b781e75..1a9c6c91ca7 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -3,7 +3,6 @@ from __future__ import annotations from enum import StrEnum -from functools import partial from typing import Final import voluptuous as vol @@ -41,12 +40,6 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.util.unit_conversion import ( BaseUnitConverter, TemperatureConverter, @@ -76,12 +69,6 @@ class NumberMode(StrEnum): SLIDER = "slider" -# MODE_* are deprecated as of 2021.12, use the NumberMode enum instead. -_DEPRECATED_MODE_AUTO: Final = DeprecatedConstantEnum(NumberMode.AUTO, "2025.1") -_DEPRECATED_MODE_BOX: Final = DeprecatedConstantEnum(NumberMode.BOX, "2025.1") -_DEPRECATED_MODE_SLIDER: Final = DeprecatedConstantEnum(NumberMode.SLIDER, "2025.1") - - class NumberDeviceClass(StrEnum): """Device class for numbers.""" @@ -176,7 +163,7 @@ class NumberDeviceClass(StrEnum): ENERGY = "energy" """Energy. - Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ ENERGY_STORAGE = "energy_storage" @@ -185,7 +172,7 @@ class NumberDeviceClass(StrEnum): Use this device class for sensors measuring stored energy, for example the amount of electric energy currently stored in a battery or the capacity of a battery. - Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ FREQUENCY = "frequency" @@ -293,7 +280,7 @@ class NumberDeviceClass(StrEnum): POWER = "power" """Power. - Unit of measurement: `W`, `kW`, `MW`, `GW`, `TW` + Unit of measurement: `mW`, `W`, `kW`, `MW`, `GW`, `TW`, `BTU/h` """ PRECIPITATION = "precipitation" @@ -376,7 +363,7 @@ class NumberDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV`, `µV` + Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV` """ VOLUME = "volume" @@ -480,7 +467,13 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, - NumberDeviceClass.POWER: {UnitOfPower.WATT, UnitOfPower.KILO_WATT}, + NumberDeviceClass.POWER: { + UnitOfPower.WATT, + UnitOfPower.KILO_WATT, + UnitOfPower.MEGA_WATT, + UnitOfPower.GIGA_WATT, + UnitOfPower.TERA_WATT, + }, NumberDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), NumberDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), NumberDeviceClass.PRESSURE: set(UnitOfPressure), @@ -519,10 +512,3 @@ UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = { NumberDeviceClass.TEMPERATURE: TemperatureConverter, NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter, } - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 966c51e98e9..b1b44966d14 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -9,7 +9,6 @@ from typing import Any from aionut import NUTError, NUTLoginError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -27,6 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import PyNUTData from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -95,7 +95,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self.reauth_entry: ConfigEntry | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered nut device.""" await self._async_handle_discovery_without_unique_id() diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index ec5905fc16c..83b8d340dc1 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -113,15 +113,15 @@ "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, "input_bypass_frequency": { "name": "Input bypass frequency" }, "input_bypass_phases": { "name": "Input bypass phases" }, - "input_bypass_realpower": { "name": "Current input bypass real power" }, + "input_bypass_realpower": { "name": "Input bypass real power" }, "input_bypass_l1_realpower": { - "name": "Current input bypass L1 real power" + "name": "Input bypass L1 real power" }, "input_bypass_l2_realpower": { - "name": "Current input bypass L2 real power" + "name": "Input bypass L2 real power" }, "input_bypass_l3_realpower": { - "name": "Current input bypass L3 real power" + "name": "Input bypass L3 real power" }, "input_current": { "name": "Input current" }, "input_l1_current": { "name": "Input L1 current" }, @@ -134,10 +134,10 @@ "input_l2_frequency": { "name": "Input L2 line frequency" }, "input_l3_frequency": { "name": "Input L3 line frequency" }, "input_phases": { "name": "Input phases" }, - "input_realpower": { "name": "Current input real power" }, - "input_l1_realpower": { "name": "Current input L1 real power" }, - "input_l2_realpower": { "name": "Current input L2 real power" }, - "input_l3_realpower": { "name": "Current input L3 real power" }, + "input_realpower": { "name": "Input real power" }, + "input_l1_realpower": { "name": "Input L1 real power" }, + "input_l2_realpower": { "name": "Input L2 real power" }, + "input_l3_realpower": { "name": "Input L3 real power" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, @@ -160,11 +160,11 @@ "output_l1_power_percent": { "name": "Output L1 power usage" }, "output_l3_power_percent": { "name": "Output L3 power usage" }, "output_power_nominal": { "name": "Nominal output power" }, - "output_realpower": { "name": "Current output real power" }, + "output_realpower": { "name": "Output real power" }, "output_realpower_nominal": { "name": "Nominal output real power" }, - "output_l1_realpower": { "name": "Current output L1 real power" }, - "output_l2_realpower": { "name": "Current output L2 real power" }, - "output_l3_realpower": { "name": "Current output L3 real power" }, + "output_l1_realpower": { "name": "Output L1 real power" }, + "output_l2_realpower": { "name": "Output L2 real power" }, + "output_l3_realpower": { "name": "Output L3 real power" }, "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, "output_l1_n_voltage": { "name": "Output L1-N voltage" }, @@ -183,9 +183,9 @@ "ups_id": { "name": "System identifier" }, "ups_load": { "name": "Load" }, "ups_load_high": { "name": "Overload setting" }, - "ups_power": { "name": "Current apparent power" }, + "ups_power": { "name": "Apparent power" }, "ups_power_nominal": { "name": "Nominal power" }, - "ups_realpower": { "name": "Current real power" }, + "ups_realpower": { "name": "Real power" }, "ups_realpower_nominal": { "name": "Nominal real power" }, "ups_shutdown": { "name": "Shutdown ability" }, "ups_start_auto": { "name": "Start on ac" }, diff --git a/homeassistant/components/nyt_games/quality_scale.yaml b/homeassistant/components/nyt_games/quality_scale.yaml new file mode 100644 index 00000000000..9f455bd4e2c --- /dev/null +++ b/homeassistant/components/nyt_games/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: + status: exempt + comment: | + This is handled by the coordinator. + integration-owner: done + log-when-unavailable: + status: done + comment: | + This is handled by the coordinator. + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration is a service and not discoverable. + discovery: + status: exempt + comment: | + This integration is a service and not discoverable. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: + status: done + comment: | + The entities are categorized well by using default category. + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: | + Games can't be "unplayed". + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index 559900db5d0..03f6348ebac 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -8,11 +8,11 @@ from typing import Any from pyobihai import PyObihai import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .connectivity import validate_auth from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN @@ -54,7 +54,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 2 discovery_schema: vol.Schema | None = None - _dhcp_discovery_info: dhcp.DhcpServiceInfo | None = None + _dhcp_discovery_info: DhcpServiceInfo | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -94,7 +94,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Obihai.""" diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 9bbf21d71fa..627ca999acd 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -12,7 +12,6 @@ from pyoctoprintapi import ApiError, OctoprintClient, OctoprintException import voluptuous as vol from yarl import URL -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, @@ -26,6 +25,8 @@ from homeassistant.const import ( from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import DOMAIN @@ -167,7 +168,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user(import_data) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery flow.""" uuid = discovery_info.properties["uuid"] @@ -193,7 +194,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle ssdp discovery flow.""" uuid = discovery_info.upnp["UDN"][5:] diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py index ff00b6c3420..c6d7373a002 100644 --- a/homeassistant/components/octoprint/coordinator.py +++ b/homeassistant/components/octoprint/coordinator.py @@ -80,7 +80,7 @@ class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): """Device info.""" unique_id = cast(str, self.config_entry.unique_id) configuration_url = URL.build( - scheme=self.config_entry.data[CONF_SSL] and "https" or "http", + scheme=(self.config_entry.data[CONF_SSL] and "https") or "http", host=self.config_entry.data[CONF_HOST], port=self.config_entry.data[CONF_PORT], path=self.config_entry.data[CONF_PATH], diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py index cf16f1ba87e..4cecb9ff195 100644 --- a/homeassistant/components/oem/climate.py +++ b/homeassistant/components/oem/climate.py @@ -73,7 +73,6 @@ class ThermostatDevice(ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, thermostat, name): """Initialize the device.""" diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py new file mode 100644 index 00000000000..8518e55c0a3 --- /dev/null +++ b/homeassistant/components/ohme/__init__.py @@ -0,0 +1,83 @@ +"""Set up ohme integration.""" + +from dataclasses import dataclass + +from ohme import ApiException, AuthException, OhmeApiClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, PLATFORMS +from .coordinator import ( + OhmeAdvancedSettingsCoordinator, + OhmeChargeSessionCoordinator, + OhmeDeviceInfoCoordinator, +) +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +type OhmeConfigEntry = ConfigEntry[OhmeRuntimeData] + + +@dataclass() +class OhmeRuntimeData: + """Dataclass to hold ohme coordinators.""" + + charge_session_coordinator: OhmeChargeSessionCoordinator + advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator + device_info_coordinator: OhmeDeviceInfoCoordinator + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Ohme integration.""" + async_setup_services(hass) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool: + """Set up Ohme from a config entry.""" + + client = OhmeApiClient(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) + + try: + await client.async_login() + + if not await client.async_update_device_info(): + raise ConfigEntryNotReady( + translation_key="device_info_failed", translation_domain=DOMAIN + ) + except AuthException as e: + raise ConfigEntryAuthFailed( + translation_key="auth_failed", translation_domain=DOMAIN + ) from e + except ApiException as e: + raise ConfigEntryNotReady( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + + coordinators = ( + OhmeChargeSessionCoordinator(hass, client), + OhmeAdvancedSettingsCoordinator(hass, client), + OhmeDeviceInfoCoordinator(hass, client), + ) + + for coordinator in coordinators: + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = OhmeRuntimeData(*coordinators) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py new file mode 100644 index 00000000000..0b0590428ce --- /dev/null +++ b/homeassistant/components/ohme/button.py @@ -0,0 +1,68 @@ +"""Platform for button.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from ohme import ApiException, ChargerStatus, OhmeApiClient + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OhmeConfigEntry +from .const import DOMAIN +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeButtonDescription(OhmeEntityDescription, ButtonEntityDescription): + """Class describing Ohme button entities.""" + + press_fn: Callable[[OhmeApiClient], Awaitable[None]] + + +BUTTON_DESCRIPTIONS = [ + OhmeButtonDescription( + key="approve", + translation_key="approve", + press_fn=lambda client: client.async_approve_charge(), + is_supported_fn=lambda client: client.is_capable("pluginsRequireApprovalMode"), + available_fn=lambda client: client.status is ChargerStatus.PENDING_APPROVAL, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up buttons.""" + coordinator = config_entry.runtime_data.charge_session_coordinator + + async_add_entities( + OhmeButton(coordinator, description) + for description in BUTTON_DESCRIPTIONS + if description.is_supported_fn(coordinator.client) + ) + + +class OhmeButton(OhmeEntity, ButtonEntity): + """Generic button for Ohme.""" + + entity_description: OhmeButtonDescription + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.entity_description.press_fn(self.coordinator.client) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ohme/config_flow.py b/homeassistant/components/ohme/config_flow.py new file mode 100644 index 00000000000..748ea558983 --- /dev/null +++ b/homeassistant/components/ohme/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for ohme integration.""" + +from collections.abc import Mapping +from typing import Any + +from ohme import ApiException, AuthException, OhmeApiClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + } +) + +REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + } +) + + +class OhmeConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """First config step.""" + + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) + + errors = await self._validate_account( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if not errors: + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + if user_input is not None: + errors = await self._validate_account( + reauth_entry.data[CONF_EMAIL], + user_input[CONF_PASSWORD], + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + description_placeholders={"email": reauth_entry.data[CONF_EMAIL]}, + errors=errors, + ) + + async def _validate_account(self, email: str, password: str) -> dict[str, str]: + """Validate Ohme account and return dict of errors.""" + errors: dict[str, str] = {} + client = OhmeApiClient( + email, + password, + ) + try: + await client.async_login() + except AuthException: + errors["base"] = "invalid_auth" + except ApiException: + errors["base"] = "unknown" + + return errors diff --git a/homeassistant/components/ohme/const.py b/homeassistant/components/ohme/const.py new file mode 100644 index 00000000000..308664ba0ad --- /dev/null +++ b/homeassistant/components/ohme/const.py @@ -0,0 +1,12 @@ +"""Component constants.""" + +from homeassistant.const import Platform + +DOMAIN = "ohme" +PLATFORMS = [ + Platform.BUTTON, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, +] diff --git a/homeassistant/components/ohme/coordinator.py b/homeassistant/components/ohme/coordinator.py new file mode 100644 index 00000000000..199eb7380a7 --- /dev/null +++ b/homeassistant/components/ohme/coordinator.py @@ -0,0 +1,79 @@ +"""Ohme coordinators.""" + +from abc import abstractmethod +from datetime import timedelta +import logging + +from ohme import ApiException, OhmeApiClient + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OhmeBaseCoordinator(DataUpdateCoordinator[None]): + """Base for all Ohme coordinators.""" + + client: OhmeApiClient + _default_update_interval: timedelta | None = timedelta(minutes=1) + coordinator_name: str = "" + + def __init__(self, hass: HomeAssistant, client: OhmeApiClient) -> None: + """Initialise coordinator.""" + super().__init__( + hass, + _LOGGER, + name="", + update_interval=self._default_update_interval, + ) + + self.name = f"Ohme {self.coordinator_name}" + self.client = client + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + try: + await self._internal_update_data() + except ApiException as e: + raise UpdateFailed( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + + @abstractmethod + async def _internal_update_data(self) -> None: + """Update coordinator data.""" + + +class OhmeChargeSessionCoordinator(OhmeBaseCoordinator): + """Coordinator to pull all updates from the API.""" + + coordinator_name = "Charge Sessions" + _default_update_interval = timedelta(seconds=30) + + async def _internal_update_data(self) -> None: + """Fetch data from API endpoint.""" + await self.client.async_get_charge_session() + + +class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator): + """Coordinator to pull settings and charger state from the API.""" + + coordinator_name = "Advanced Settings" + + async def _internal_update_data(self) -> None: + """Fetch data from API endpoint.""" + await self.client.async_get_advanced_settings() + + +class OhmeDeviceInfoCoordinator(OhmeBaseCoordinator): + """Coordinator to pull device info and charger settings from the API.""" + + coordinator_name = "Device Info" + _default_update_interval = timedelta(minutes=30) + + async def _internal_update_data(self) -> None: + """Fetch data from API endpoint.""" + await self.client.async_update_device_info() diff --git a/homeassistant/components/ohme/entity.py b/homeassistant/components/ohme/entity.py new file mode 100644 index 00000000000..38e281975a0 --- /dev/null +++ b/homeassistant/components/ohme/entity.py @@ -0,0 +1,60 @@ +"""Base class for entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from ohme import OhmeApiClient + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OhmeBaseCoordinator + + +@dataclass(frozen=True) +class OhmeEntityDescription(EntityDescription): + """Class describing Ohme entities.""" + + is_supported_fn: Callable[[OhmeApiClient], bool] = lambda _: True + available_fn: Callable[[OhmeApiClient], bool] = lambda _: True + + +class OhmeEntity(CoordinatorEntity[OhmeBaseCoordinator]): + """Base class for all Ohme entities.""" + + _attr_has_entity_name = True + entity_description: OhmeEntityDescription + + def __init__( + self, + coordinator: OhmeBaseCoordinator, + entity_description: OhmeEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self.entity_description = entity_description + + client = coordinator.client + self._attr_unique_id = f"{client.serial}_{entity_description.key}" + + device_info = client.device_info + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, client.serial)}, + name=device_info["name"], + manufacturer="Ohme", + model=device_info["model"], + sw_version=device_info["sw_version"], + serial_number=client.serial, + ) + + @property + def available(self) -> bool: + """Return if charger reporting as online.""" + return ( + super().available + and self.coordinator.client.available + and self.entity_description.available_fn(self.coordinator.client) + ) diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json new file mode 100644 index 00000000000..a6b04004833 --- /dev/null +++ b/homeassistant/components/ohme/icons.json @@ -0,0 +1,56 @@ +{ + "entity": { + "button": { + "approve": { + "default": "mdi:check-decagram" + } + }, + "number": { + "target_percentage": { + "default": "mdi:battery-heart" + } + }, + "sensor": { + "status": { + "default": "mdi:car", + "state": { + "unplugged": "mdi:power-plug-off", + "plugged_in": "mdi:power-plug", + "charging": "mdi:battery-charging-100", + "paused": "mdi:pause", + "pending_approval": "mdi:alert-decagram" + } + }, + "ct_current": { + "default": "mdi:gauge" + } + }, + "switch": { + "lock_buttons": { + "default": "mdi:lock", + "state": { + "off": "mdi:lock-open" + } + }, + "require_approval": { + "default": "mdi:check-decagram" + }, + "sleep_when_inactive": { + "default": "mdi:sleep", + "state": { + "off": "mdi:sleep-off" + } + } + }, + "time": { + "target_time": { + "default": "mdi:clock-end" + } + } + }, + "services": { + "list_charge_slots": { + "service": "mdi:clock-start" + } + } +} diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json new file mode 100644 index 00000000000..67c41550491 --- /dev/null +++ b/homeassistant/components/ohme/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ohme", + "name": "Ohme", + "codeowners": ["@dan-r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ohme/", + "integration_type": "device", + "iot_class": "cloud_polling", + "quality_scale": "silver", + "requirements": ["ohme==1.2.5"] +} diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py new file mode 100644 index 00000000000..d618d4a873b --- /dev/null +++ b/homeassistant/components/ohme/number.py @@ -0,0 +1,77 @@ +"""Platform for number.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from ohme import ApiException, OhmeApiClient + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OhmeConfigEntry +from .const import DOMAIN +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeNumberDescription(OhmeEntityDescription, NumberEntityDescription): + """Class describing Ohme number entities.""" + + set_fn: Callable[[OhmeApiClient, float], Awaitable[None]] + value_fn: Callable[[OhmeApiClient], float] + + +NUMBER_DESCRIPTION = [ + OhmeNumberDescription( + key="target_percentage", + translation_key="target_percentage", + value_fn=lambda client: client.target_soc, + set_fn=lambda client, value: client.async_set_target(target_percent=value), + native_min_value=0, + native_max_value=100, + native_step=1, + native_unit_of_measurement=PERCENTAGE, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up numbers.""" + coordinators = config_entry.runtime_data + coordinator = coordinators.charge_session_coordinator + + async_add_entities( + OhmeNumber(coordinator, description) + for description in NUMBER_DESCRIPTION + if description.is_supported_fn(coordinator.client) + ) + + +class OhmeNumber(OhmeEntity, NumberEntity): + """Generic number entity for Ohme.""" + + entity_description: OhmeNumberDescription + + @property + def native_value(self) -> float: + """Return the current value of the number.""" + return self.entity_description.value_fn(self.coordinator.client) + + async def async_set_native_value(self, value: float) -> None: + """Set the number value.""" + try: + await self.entity_description.set_fn(self.coordinator.client, value) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml new file mode 100644 index 00000000000..497d5ad32e5 --- /dev/null +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + This integration has no explicit subscriptions to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration has no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: | + All supported devices are cloud connected over mobile data. Discovery is not possible. + discovery-update-info: + status: exempt + comment: | + All supported devices are cloud connected over mobile data. Discovery is not possible. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration currently has no repairs. + stale-devices: todo + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py new file mode 100644 index 00000000000..230314cba83 --- /dev/null +++ b/homeassistant/components/ohme/sensor.py @@ -0,0 +1,119 @@ +"""Platform for sensor.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from ohme import ChargerStatus, OhmeApiClient + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OhmeConfigEntry +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription): + """Class describing Ohme sensor entities.""" + + value_fn: Callable[[OhmeApiClient], str | int | float] + + +SENSOR_CHARGE_SESSION = [ + OhmeSensorDescription( + key="status", + translation_key="status", + device_class=SensorDeviceClass.ENUM, + options=[e.value for e in ChargerStatus], + value_fn=lambda client: client.status.value, + ), + OhmeSensorDescription( + key="current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda client: client.power.amps, + ), + OhmeSensorDescription( + key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=1, + value_fn=lambda client: client.power.watts, + ), + OhmeSensorDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda client: client.energy, + ), + OhmeSensorDescription( + key="battery", + translation_key="vehicle_battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + suggested_display_precision=0, + value_fn=lambda client: client.battery, + ), +] + +SENSOR_ADVANCED_SETTINGS = [ + OhmeSensorDescription( + key="ct_current", + translation_key="ct_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda client: client.power.ct_amps, + is_supported_fn=lambda client: client.ct_connected, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors.""" + coordinators = config_entry.runtime_data + coordinator_map = [ + (SENSOR_CHARGE_SESSION, coordinators.charge_session_coordinator), + (SENSOR_ADVANCED_SETTINGS, coordinators.advanced_settings_coordinator), + ] + + async_add_entities( + OhmeSensor(coordinator, description) + for entities, coordinator in coordinator_map + for description in entities + if description.is_supported_fn(coordinator.client) + ) + + +class OhmeSensor(OhmeEntity, SensorEntity): + """Generic sensor for Ohme.""" + + entity_description: OhmeSensorDescription + + @property + def native_value(self) -> str | int | float: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.client) diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py new file mode 100644 index 00000000000..7d06b909d88 --- /dev/null +++ b/homeassistant/components/ohme/services.py @@ -0,0 +1,75 @@ +"""Ohme services.""" + +from typing import Final + +from ohme import OhmeApiClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import selector + +from .const import DOMAIN + +SERVICE_LIST_CHARGE_SLOTS = "list_charge_slots" +ATTR_CONFIG_ENTRY: Final = "config_entry" +SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + } +) + + +def __get_client(call: ServiceCall) -> OhmeApiClient: + """Get the client from the config entry.""" + entry_id: str = call.data[ATTR_CONFIG_ENTRY] + entry: ConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id) + + if not entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry": entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry": entry.title, + }, + ) + + return entry.runtime_data.charge_session_coordinator.client + + +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + + async def list_charge_slots( + service_call: ServiceCall, + ) -> ServiceResponse: + """List of charge slots.""" + client = __get_client(service_call) + + return {"slots": client.slots} + + hass.services.async_register( + DOMAIN, + SERVICE_LIST_CHARGE_SLOTS, + list_charge_slots, + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/ohme/services.yaml b/homeassistant/components/ohme/services.yaml new file mode 100644 index 00000000000..c5c8ee18138 --- /dev/null +++ b/homeassistant/components/ohme/services.yaml @@ -0,0 +1,7 @@ +list_charge_slots: + fields: + config_entry: + required: true + selector: + config_entry: + integration: ohme diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json new file mode 100644 index 00000000000..84f62ba65ab --- /dev/null +++ b/homeassistant/components/ohme/strings.json @@ -0,0 +1,110 @@ +{ + "config": { + "step": { + "user": { + "description": "Configure your Ohme account. If you signed up to Ohme with a third party account like Google, please reset your password via Ohme before configuring this integration.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Enter the email address associated with your Ohme account.", + "password": "Enter the password for your Ohme account" + } + }, + "reauth_confirm": { + "description": "Please update your password for {email}", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Enter the password for your Ohme account" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "services": { + "list_charge_slots": { + "name": "List charge slots", + "description": "Return a list of charge slots.", + "fields": { + "config_entry": { + "name": "Ohme account", + "description": "The Ohme config entry for which to return charge slots." + } + } + } + }, + "entity": { + "button": { + "approve": { + "name": "Approve charge" + } + }, + "number": { + "target_percentage": { + "name": "Target percentage" + } + }, + "sensor": { + "status": { + "name": "Status", + "state": { + "unplugged": "Unplugged", + "plugged_in": "Plugged in", + "charging": "Charging", + "paused": "[%key:common::state::paused%]", + "pending_approval": "Pending approval" + } + }, + "ct_current": { + "name": "CT current" + }, + "vehicle_battery": { + "name": "Vehicle battery" + } + }, + "switch": { + "lock_buttons": { + "name": "Lock buttons" + }, + "require_approval": { + "name": "Require approval" + }, + "sleep_when_inactive": { + "name": "Sleep when inactive" + } + }, + "time": { + "target_time": { + "name": "Target time" + } + } + }, + "exceptions": { + "auth_failed": { + "message": "Unable to login to Ohme" + }, + "device_info_failed": { + "message": "Unable to get Ohme device information" + }, + "api_failed": { + "message": "Error communicating with Ohme API" + }, + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + }, + "unloaded_config_entry": { + "message": "Invalid config entry provided. {config_entry} is not loaded." + } + } +} diff --git a/homeassistant/components/ohme/switch.py b/homeassistant/components/ohme/switch.py new file mode 100644 index 00000000000..d1eb1a80b56 --- /dev/null +++ b/homeassistant/components/ohme/switch.py @@ -0,0 +1,102 @@ +"""Platform for switch.""" + +from dataclasses import dataclass +from typing import Any + +from ohme import ApiException + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OhmeConfigEntry +from .const import DOMAIN +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeSwitchDescription(OhmeEntityDescription, SwitchEntityDescription): + """Class describing Ohme switch entities.""" + + configuration_key: str + + +SWITCH_DEVICE_INFO = [ + OhmeSwitchDescription( + key="lock_buttons", + translation_key="lock_buttons", + entity_category=EntityCategory.CONFIG, + is_supported_fn=lambda client: client.is_capable("buttonsLockable"), + configuration_key="buttonsLocked", + ), + OhmeSwitchDescription( + key="require_approval", + translation_key="require_approval", + entity_category=EntityCategory.CONFIG, + is_supported_fn=lambda client: client.is_capable("pluginsRequireApprovalMode"), + configuration_key="pluginsRequireApproval", + ), + OhmeSwitchDescription( + key="sleep_when_inactive", + translation_key="sleep_when_inactive", + entity_category=EntityCategory.CONFIG, + is_supported_fn=lambda client: client.is_capable("stealth"), + configuration_key="stealthEnabled", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches.""" + coordinators = config_entry.runtime_data + coordinator_map = [ + (SWITCH_DEVICE_INFO, coordinators.device_info_coordinator), + ] + + async_add_entities( + OhmeSwitch(coordinator, description) + for entities, coordinator in coordinator_map + for description in entities + if description.is_supported_fn(coordinator.client) + ) + + +class OhmeSwitch(OhmeEntity, SwitchEntity): + """Generic switch for Ohme.""" + + entity_description: OhmeSwitchDescription + + @property + def is_on(self) -> bool: + """Return the entity value to represent the entity state.""" + return self.coordinator.client.configuration_value( + self.entity_description.configuration_key + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._toggle(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._toggle(False) + + async def _toggle(self, on: bool) -> None: + """Toggle the switch.""" + try: + await self.coordinator.client.async_set_configuration_value( + {self.entity_description.configuration_key: on} + ) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ohme/time.py b/homeassistant/components/ohme/time.py new file mode 100644 index 00000000000..a7de913ef8e --- /dev/null +++ b/homeassistant/components/ohme/time.py @@ -0,0 +1,77 @@ +"""Platform for time.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import time + +from ohme import ApiException, OhmeApiClient + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OhmeConfigEntry +from .const import DOMAIN +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeTimeDescription(OhmeEntityDescription, TimeEntityDescription): + """Class describing Ohme time entities.""" + + set_fn: Callable[[OhmeApiClient, time], Awaitable[None]] + value_fn: Callable[[OhmeApiClient], time] + + +TIME_DESCRIPTION = [ + OhmeTimeDescription( + key="target_time", + translation_key="target_time", + value_fn=lambda client: time( + hour=client.target_time[0], minute=client.target_time[1] + ), + set_fn=lambda client, value: client.async_set_target( + target_time=(value.hour, value.minute) + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up time entities.""" + coordinators = config_entry.runtime_data + coordinator = coordinators.charge_session_coordinator + + async_add_entities( + OhmeTime(coordinator, description) + for description in TIME_DESCRIPTION + if description.is_supported_fn(coordinator.client) + ) + + +class OhmeTime(OhmeEntity, TimeEntity): + """Generic time entity for Ohme.""" + + entity_description: OhmeTimeDescription + + @property + def native_value(self) -> time: + """Return the current value of the time.""" + return self.entity_description.value_fn(self.coordinator.client) + + async def async_set_value(self, value: time) -> None: + """Set the time value.""" + try: + await self.entity_description.set_fn(self.coordinator.client, value) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 3bcba567803..6983db73cf4 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -28,12 +28,12 @@ from .const import ( _LOGGER = logging.getLogger(__name__) __all__ = [ - "CONF_URL", - "CONF_PROMPT", - "CONF_MODEL", - "CONF_MAX_HISTORY", - "CONF_NUM_CTX", "CONF_KEEP_ALIVE", + "CONF_MAX_HISTORY", + "CONF_MODEL", + "CONF_NUM_CTX", + "CONF_PROMPT", + "CONF_URL", "DOMAIN", ] diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 69c0a3d6296..857f0bff34a 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -61,7 +61,8 @@ MODEL_NAMES = [ # https://ollama.com/library "goliath", "granite-code", "granite3-dense", - "granite3-guardian" "granite3-moe", + "granite3-guardian", + "granite3-moe", "hermes3", "internlm2", "llama-guard3", diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 1a91c790d27..c0fbfae6444 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import intent, llm, template from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from .const import ( CONF_KEEP_ALIVE, @@ -141,7 +141,7 @@ class OllamaConversationEntity( settings = {**self.entry.data, **self.entry.options} client = self.hass.data[DOMAIN][self.entry.entry_id] - conversation_id = user_input.conversation_id or ulid.ulid_now() + conversation_id = user_input.conversation_id or ulid_util.ulid_now() model = settings[CONF_MODEL] intent_response = intent.IntentResponse(language=user_input.language) llm_api: llm.APIInstance | None = None diff --git a/homeassistant/components/ollama/manifest.json b/homeassistant/components/ollama/manifest.json index dca4c2dd6be..c3f7616ca16 100644 --- a/homeassistant/components/ollama/manifest.json +++ b/homeassistant/components/ollama/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/ollama", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["ollama==0.3.3"] + "requirements": ["ollama==0.4.7"] } diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 3c4aac2cd7d..c77d87d91b9 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -4,32 +4,40 @@ import logging from pyownet import protocol -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from .const import DOMAIN, PLATFORMS -from .onewirehub import CannotConnect, OneWireHub +from .const import DOMAIN +from .onewirehub import OneWireConfigEntry, OneWireHub _LOGGER = logging.getLogger(__name__) -type OneWireConfigEntry = ConfigEntry[OneWireHub] + +_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> bool: """Set up a 1-Wire proxy for a config entry.""" - onewire_hub = OneWireHub(hass) + onewire_hub = OneWireHub(hass, entry) try: - await onewire_hub.initialize(entry) + await onewire_hub.initialize() except ( - CannotConnect, # Failed to connect to the server + protocol.ConnError, # Failed to connect to the server protocol.OwnetError, # Connected to server, but failed to list the devices ) as exc: raise ConfigEntryNotReady from exc entry.runtime_data = onewire_hub - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + onewire_hub.schedule_scan_for_new_devices() entry.async_on_unload(entry.add_update_listener(options_update_listener)) @@ -50,7 +58,7 @@ async def async_unload_entry( hass: HomeAssistant, config_entry: OneWireConfigEntry ) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(config_entry, _PLATFORMS) async def options_update_listener( diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 5607fd7ed1d..60a1d165b15 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import timedelta import os from homeassistant.components.binary_sensor import ( @@ -12,12 +13,22 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OneWireConfigEntry from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) + +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) @dataclass(frozen=True) @@ -93,19 +104,28 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) ) - async_add_entities(entities, True) -def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireBinarySensorEntity]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] - - entities: list[OneWireBinarySensor] = [] - for device in onewire_hub.devices: + entities: list[OneWireBinarySensorEntity] = [] + for device in devices: family = device.family device_id = device.id device_type = device.type @@ -120,7 +140,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) entities.append( - OneWireBinarySensor( + OneWireBinarySensorEntity( description=description, device_id=device_id, device_file=device_file, @@ -132,7 +152,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: return entities -class OneWireBinarySensor(OneWireEntity, BinarySensorEntity): +class OneWireBinarySensorEntity(OneWireEntity, BinarySensorEntity): """Implementation of a 1-Wire binary sensor.""" entity_description: OneWireBinarySensorEntityDescription diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 3889db2a069..e40e99d0903 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -5,18 +5,16 @@ from __future__ import annotations from copy import deepcopy from typing import Any +from pyownet import protocol import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( DEFAULT_HOST, @@ -29,7 +27,7 @@ from .const import ( OPTION_ENTRY_SENSOR_PRECISION, PRECISION_MAPPING_FAMILY_28, ) -from .onewirehub import CannotConnect, OneWireHub +from .onewirehub import OneWireConfigEntry DATA_SCHEMA = vol.Schema( { @@ -39,70 +37,123 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - - hub = OneWireHub(hass) - - host = data[CONF_HOST] - port = data[CONF_PORT] - # Raises CannotConnect exception on failure - await hub.connect(host, port) - - # Return info that you want to store in the config entry. - return {"title": host} +async def validate_input( + hass: HomeAssistant, data: dict[str, Any], errors: dict[str, str] +) -> None: + """Validate the user input allows us to connect.""" + try: + await hass.async_add_executor_job( + protocol.proxy, data[CONF_HOST], data[CONF_PORT] + ) + except protocol.ConnError: + errors["base"] = "cannot_connect" class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): """Handle 1-Wire config flow.""" VERSION = 1 - - def __init__(self) -> None: - """Initialize 1-Wire config flow.""" - self.onewire_config: dict[str, Any] = {} + _discovery_data: dict[str, Any] async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle 1-Wire config flow start. - - Let user manually input configuration. - """ + """Handle 1-Wire config flow start.""" errors: dict[str, str] = {} if user_input: - # Prevent duplicate entries self._async_abort_entries_match( - { - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - } + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} ) - self.onewire_config.update(user_input) - - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - else: + await validate_input(self.hass, user_input, errors) + if not errors: return self.async_create_entry( - title=info["title"], data=self.onewire_config + title=user_input[CONF_HOST], data=user_input ) return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle 1-Wire reconfiguration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + + await validate_input(self.hass, user_input, errors) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, reconfigure_entry.data | (user_input or {}) + ), + description_placeholders={"name": reconfigure_entry.title}, + errors=errors, + ) + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle hassio discovery.""" + await self._async_handle_discovery_without_unique_id() + + self._discovery_data = { + "title": discovery_info.config["addon"], + CONF_HOST: discovery_info.config[CONF_HOST], + CONF_PORT: discovery_info.config[CONF_PORT], + } + return await self.async_step_discovery_confirm() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + await self._async_handle_discovery_without_unique_id() + + self._discovery_data = { + "title": discovery_info.name, + CONF_HOST: discovery_info.hostname, + CONF_PORT: discovery_info.port, + } + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + errors: dict[str, str] = {} + if user_input is not None: + data = { + CONF_HOST: self._discovery_data[CONF_HOST], + CONF_PORT: self._discovery_data[CONF_PORT], + } + await validate_input(self.hass, data, errors) + if not errors: + return self.async_create_entry( + title=self._discovery_data["title"], data=data + ) + + return self.async_show_form( + step_id="discovery_confirm", errors=errors, ) @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: OneWireConfigEntry, ) -> OnewireOptionsFlowHandler: """Get the options flow for this handler.""" return OnewireOptionsFlowHandler(config_entry) @@ -126,7 +177,7 @@ class OnewireOptionsFlowHandler(OptionsFlow): current_device: str """Friendly name of the currently selected device.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: OneWireConfigEntry) -> None: """Initialize options flow.""" self.options = deepcopy(dict(config_entry.options)) diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index a4f3ebe9a78..2ab44c47892 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -2,8 +2,6 @@ from __future__ import annotations -from homeassistant.const import Platform - DEFAULT_HOST = "localhost" DEFAULT_PORT = 4304 @@ -54,9 +52,3 @@ MANUFACTURER_EDS = "Embedded Data Systems" READ_MODE_BOOL = "bool" READ_MODE_FLOAT = "float" READ_MODE_INT = "int" - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.SENSOR, - Platform.SWITCH, -] diff --git a/homeassistant/components/onewire/diagnostics.py b/homeassistant/components/onewire/diagnostics.py index 523bb4e2580..48426cf3b5b 100644 --- a/homeassistant/components/onewire/diagnostics.py +++ b/homeassistant/components/onewire/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import OneWireConfigEntry +from .onewirehub import OneWireConfigEntry TO_REDACT = {CONF_HOST} diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index bbf36deaaa0..2ea21aca488 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -54,6 +54,7 @@ class OneWireEntity(Entity): """Return the state attributes of the entity.""" return { "device_file": self._device_file, + # raw_value attribute is deprecated and can be removed in 2025.8 "raw_value": self._value_raw, } @@ -84,4 +85,4 @@ class OneWireEntity(Entity): elif self.entity_description.read_mode == READ_MODE_BOOL: self._state = int(self._value_raw) == 1 else: - self._state = round(self._value_raw, 1) + self._state = self._value_raw diff --git a/homeassistant/components/onewire/manifest.json b/homeassistant/components/onewire/manifest.json index 4f3cb5d04ab..844c4c1afb9 100644 --- a/homeassistant/components/onewire/manifest.json +++ b/homeassistant/components/onewire/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyownet"], - "requirements": ["pyownet==0.10.0.post1"] + "requirements": ["pyownet==0.10.0.post1"], + "zeroconf": ["_owserver._tcp.local."] } diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index 2dc617ba039..a8d8dd06034 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -2,26 +2,20 @@ from __future__ import annotations +from datetime import datetime, timedelta import logging import os -from typing import TYPE_CHECKING from pyownet import protocol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NAME, - ATTR_VIA_DEVICE, - CONF_HOST, - CONF_PORT, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import ATTR_VIA_DEVICE, CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.signal_type import SignalType from .const import ( DEVICE_SUPPORT, @@ -42,8 +36,15 @@ DEVICE_MANUFACTURER = { "EF": MANUFACTURER_HOBBYBOARDS, } +_DEVICE_SCAN_INTERVAL = timedelta(minutes=5) _LOGGER = logging.getLogger(__name__) +type OneWireConfigEntry = ConfigEntry[OneWireHub] + +SIGNAL_NEW_DEVICE_CONNECTED = SignalType["OneWireHub", list[OWDeviceDescription]]( + f"{DOMAIN}_new_device_connected" +) + def _is_known_device(device_family: str, device_type: str | None) -> bool: """Check if device family/type is known to the library.""" @@ -55,116 +56,119 @@ def _is_known_device(device_family: str, device_type: str | None) -> bool: class OneWireHub: """Hub to communicate with server.""" - def __init__(self, hass: HomeAssistant) -> None: + owproxy: protocol._Proxy + devices: list[OWDeviceDescription] + + def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None: """Initialize.""" - self.hass = hass - self.owproxy: protocol._Proxy | None = None - self.devices: list[OWDeviceDescription] | None = None + self._hass = hass + self._config_entry = config_entry - async def connect(self, host: str, port: int) -> None: - """Connect to the server.""" - try: - self.owproxy = await self.hass.async_add_executor_job( - protocol.proxy, host, port - ) - except protocol.ConnError as exc: - raise CannotConnect from exc + def _initialize(self) -> None: + """Connect to the server, and discover connected devices. - async def initialize(self, config_entry: ConfigEntry) -> None: - """Initialize a config entry.""" - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] + Needs to be run in executor. + """ + host = self._config_entry.data[CONF_HOST] + port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) - await self.connect(host, port) - await self.discover_devices() - if TYPE_CHECKING: - assert self.devices - # Register discovered devices on Hub - device_registry = dr.async_get(self.hass) - for device in self.devices: - device_info: DeviceInfo = device.device_info + self.owproxy = protocol.proxy(host, port) + self.devices = _discover_devices(self.owproxy) + + async def initialize(self) -> None: + """Initialize a config entry.""" + await self._hass.async_add_executor_job(self._initialize) + self._populate_device_registry(self.devices) + + @callback + def _populate_device_registry(self, devices: list[OWDeviceDescription]) -> None: + """Populate the device registry.""" + device_registry = dr.async_get(self._hass) + for device in devices: device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers=device_info[ATTR_IDENTIFIERS], - manufacturer=device_info[ATTR_MANUFACTURER], - model=device_info[ATTR_MODEL], - name=device_info[ATTR_NAME], - via_device=device_info.get(ATTR_VIA_DEVICE), + config_entry_id=self._config_entry.entry_id, + **device.device_info, ) - async def discover_devices(self) -> None: - """Discover all devices.""" - if self.devices is None: - self.devices = await self.hass.async_add_executor_job( - self._discover_devices + def schedule_scan_for_new_devices(self) -> None: + """Schedule a regular scan of the bus for new devices.""" + self._config_entry.async_on_unload( + async_track_time_interval( + self._hass, self._scan_for_new_devices, _DEVICE_SCAN_INTERVAL + ) + ) + + async def _scan_for_new_devices(self, _: datetime) -> None: + """Scan the bus for new devices.""" + devices = await self._hass.async_add_executor_job( + _discover_devices, self.owproxy + ) + existing_device_ids = [device.id for device in self.devices] + new_devices = [ + device for device in devices if device.id not in existing_device_ids + ] + if new_devices: + self.devices.extend(new_devices) + self._populate_device_registry(new_devices) + async_dispatcher_send( + self._hass, SIGNAL_NEW_DEVICE_CONNECTED, self, new_devices ) - def _discover_devices( - self, path: str = "/", parent_id: str | None = None - ) -> list[OWDeviceDescription]: - """Discover all server devices.""" - devices: list[OWDeviceDescription] = [] - assert self.owproxy - for device_path in self.owproxy.dir(path): - device_id = os.path.split(os.path.split(device_path)[0])[1] - device_family = self.owproxy.read(f"{device_path}family").decode() - _LOGGER.debug("read `%sfamily`: %s", device_path, device_family) - device_type = self._get_device_type(device_path) - if not _is_known_device(device_family, device_type): - _LOGGER.warning( - "Ignoring unknown device family/type (%s/%s) found for device %s", - device_family, - device_type, - device_id, + +def _discover_devices( + owproxy: protocol._Proxy, path: str = "/", parent_id: str | None = None +) -> list[OWDeviceDescription]: + """Discover all server devices.""" + devices: list[OWDeviceDescription] = [] + for device_path in owproxy.dir(path): + device_id = os.path.split(os.path.split(device_path)[0])[1] + device_family = owproxy.read(f"{device_path}family").decode() + _LOGGER.debug("read `%sfamily`: %s", device_path, device_family) + device_type = _get_device_type(owproxy, device_path) + if not _is_known_device(device_family, device_type): + _LOGGER.warning( + "Ignoring unknown device family/type (%s/%s) found for device %s", + device_family, + device_type, + device_id, + ) + continue + device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + manufacturer=DEVICE_MANUFACTURER.get(device_family, MANUFACTURER_MAXIM), + model=device_type, + model_id=device_type, + name=device_id, + serial_number=device_id[3:], + ) + if parent_id: + device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_id) + device = OWDeviceDescription( + device_info=device_info, + id=device_id, + family=device_family, + path=device_path, + type=device_type, + ) + devices.append(device) + if device_branches := DEVICE_COUPLERS.get(device_family): + for branch in device_branches: + devices += _discover_devices( + owproxy, f"{device_path}{branch}", device_id ) - continue - device_info: DeviceInfo = { - ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, - ATTR_MANUFACTURER: DEVICE_MANUFACTURER.get( - device_family, MANUFACTURER_MAXIM - ), - ATTR_MODEL: device_type, - ATTR_NAME: device_id, - } - if parent_id: - device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_id) - device = OWDeviceDescription( - device_info=device_info, - id=device_id, - family=device_family, - path=device_path, - type=device_type, - ) - devices.append(device) - if device_branches := DEVICE_COUPLERS.get(device_family): - for branch in device_branches: - devices += self._discover_devices( - f"{device_path}{branch}", device_id - ) - return devices - - def _get_device_type(self, device_path: str) -> str | None: - """Get device model.""" - if TYPE_CHECKING: - assert self.owproxy - try: - device_type = self.owproxy.read(f"{device_path}type").decode() - except protocol.ProtocolError as exc: - _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) - return None - _LOGGER.debug("read `%stype`: %s", device_path, device_type) - if device_type == "EDS": - device_type = self.owproxy.read(f"{device_path}device_type").decode() - _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type) - if TYPE_CHECKING: - assert isinstance(device_type, str) - return device_type + return devices -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidPath(HomeAssistantError): - """Error to indicate the path is invalid.""" +def _get_device_type(owproxy: protocol._Proxy, device_path: str) -> str | None: + """Get device model.""" + try: + device_type: str = owproxy.read(f"{device_path}type").decode() + except protocol.ProtocolError as exc: + _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) + return None + _LOGGER.debug("read `%stype`: %s", device_path, device_type) + if device_type == "EDS": + device_type = owproxy.read(f"{device_path}device_type").decode() + _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type) + return device_type diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml new file mode 100644 index 00000000000..d46ed69f0d6 --- /dev/null +++ b/homeassistant/components/onewire/quality_scale.yaml @@ -0,0 +1,126 @@ +rules: + ## Bronze + config-flow: + status: todo + comment: missing data_description on options flow + test-before-configure: done + unique-config-entry: + status: done + comment: unique ID is not available, but duplicates are prevented based on host/port + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: entities do not subscribe to events + dependency-transparency: + status: todo + comment: The package is not built and published inside a CI pipeline + action-setup: + status: exempt + comment: No service actions currently available + common-modules: + status: done + comment: base entity available, but no coordinator + docs-high-level-description: + status: todo + comment: Under review + docs-installation-instructions: + status: todo + comment: Under review + docs-removal-instructions: + status: todo + comment: Under review + docs-actions: + status: todo + comment: Under review + brands: done + + ## Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No service actions currently available + reauthentication-flow: + status: exempt + comment: Local polling without authentication + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: + status: todo + comment: Under review + docs-configuration-parameters: + status: todo + comment: Under review + + ## Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: + status: done + comment: hassio and mDNS/zeroconf discovery implemented + stale-devices: + status: done + comment: > + Manual removal, as it is not possible to distinguish + between a flaky device and a device that has been removed + diagnostics: + status: todo + comment: config-entry diagnostics level available, might be nice to have device-level diagnostics + exception-translations: + status: todo + comment: Under review + icon-translations: + status: exempt + comment: It doesn't make sense to override defaults + reconfiguration-flow: done + dynamic-devices: + status: done + comment: The bus is scanned for new devices at regular interval + discovery-update-info: + status: todo + comment: Under review + repair-issues: + status: exempt + comment: No repairs available + docs-use-cases: + status: todo + comment: Under review + docs-supported-devices: + status: todo + comment: Under review + docs-supported-functions: + status: todo + comment: Under review + docs-data-update: + status: todo + comment: Under review + docs-known-limitations: + status: todo + comment: Under review + docs-troubleshooting: + status: todo + comment: Under review + docs-examples: + status: todo + comment: Under review + + ## Platinum + async-dependency: + status: todo + comment: The dependency is not async + inject-websession: + status: exempt + comment: No websession + strict-typing: + status: todo + comment: The dependency is not typed diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py new file mode 100644 index 00000000000..7a26ecdbb31 --- /dev/null +++ b/homeassistant/components/onewire/select.py @@ -0,0 +1,110 @@ +"""Support for 1-Wire environment select entities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import os + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import READ_MODE_INT +from .entity import OneWireEntity, OneWireEntityDescription +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) + +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) + + +@dataclass(frozen=True) +class OneWireSelectEntityDescription(OneWireEntityDescription, SelectEntityDescription): + """Class describing OneWire select entities.""" + + +ENTITY_DESCRIPTIONS: dict[str, tuple[OneWireEntityDescription, ...]] = { + "28": ( + OneWireSelectEntityDescription( + key="tempres", + entity_category=EntityCategory.CONFIG, + read_mode=READ_MODE_INT, + options=["9", "10", "11", "12"], + translation_key="tempres", + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OneWireConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up 1-Wire platform.""" + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) + ) + + +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireSelectEntity]: + """Get a list of entities.""" + entities: list[OneWireSelectEntity] = [] + + for device in devices: + family = device.family + device_id = device.id + device_info = device.device_info + + if family not in ENTITY_DESCRIPTIONS: + continue + for description in ENTITY_DESCRIPTIONS[family]: + device_file = os.path.join(os.path.split(device.path)[0], description.key) + entities.append( + OneWireSelectEntity( + description=description, + device_id=device_id, + device_file=device_file, + device_info=device_info, + owproxy=onewire_hub.owproxy, + ) + ) + + return entities + + +class OneWireSelectEntity(OneWireEntity, SelectEntity): + """Implementation of a 1-Wire switch.""" + + entity_description: OneWireSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return str(self._state) + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self._write_value(option.encode("ascii")) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index c9030cab8ea..1c4047abf0a 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping import dataclasses +from datetime import timedelta import logging import os from types import MappingProxyType @@ -25,10 +26,10 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import OneWireConfigEntry from .const import ( DEVICE_KEYS_0_3, DEVICE_KEYS_A_B, @@ -39,7 +40,17 @@ from .const import ( READ_MODE_INT, ) from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) + +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) @dataclasses.dataclass(frozen=True) @@ -233,7 +244,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { "1D": tuple( OneWireSensorEntityDescription( key=f"counter.{device_key}", - native_unit_of_measurement="count", read_mode=READ_MODE_INT, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="counter_id", @@ -353,22 +363,35 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data, config_entry.options + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + # note: we have to go through the executor as SENSOR platform + # makes extra calls to the hub during device listing + entities = await hass.async_add_executor_job( + get_entities, hub, devices, config_entry.options + ) + async_add_entities(entities, True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) ) - async_add_entities(entities, True) def get_entities( - onewire_hub: OneWireHub, options: MappingProxyType[str, Any] -) -> list[OneWireSensor]: + onewire_hub: OneWireHub, + devices: list[OWDeviceDescription], + options: MappingProxyType[str, Any], +) -> list[OneWireSensorEntity]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] - - entities: list[OneWireSensor] = [] - assert onewire_hub.owproxy - for device in onewire_hub.devices: + entities: list[OneWireSensorEntity] = [] + for device in devices: family = device.family device_type = device.type device_id = device.id @@ -422,7 +445,7 @@ def get_entities( ) continue entities.append( - OneWireSensor( + OneWireSensorEntity( description=description, device_id=device_id, device_file=device_file, @@ -433,7 +456,7 @@ def get_entities( return entities -class OneWireSensor(OneWireEntity, SensorEntity): +class OneWireSensorEntity(OneWireEntity, SensorEntity): """Implementation of a 1-Wire sensor.""" entity_description: OneWireSensorEntityDescription diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 68585c3203f..9613a927f8d 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -1,21 +1,34 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::onewire::config::step::user::data_description::host%]", + "port": "[%key:component::onewire::config::step::user::data_description::port%]" + }, + "description": "Update OWServer configuration for {name}" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "The hostname or IP address of your 1-Wire device." + "host": "The hostname or IP address of your OWServer instance.", + "port": "The port of your OWServer instance (default is 4304)." }, - "title": "Set server details" + "title": "Set OWServer instance details" } } }, @@ -28,6 +41,17 @@ "name": "Hub short on branch {id}" } }, + "select": { + "tempres": { + "name": "Temperature resolution", + "state": { + "9": "9 bits (0.5°C, fastest, up to 93.75ms)", + "10": "10 bits (0.25°C, up to 187.5ms)", + "11": "11 bits (0.125°C, up to 375ms)", + "12": "12 bits (0.0625°C, slowest, up to 750ms)" + } + } + }, "sensor": { "counter_id": { "name": "Counter {id}" diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index ec0bc44e03f..7215b1ec020 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -3,18 +3,29 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import timedelta import os from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OneWireConfigEntry from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) + +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) @dataclass(frozen=True) @@ -153,20 +164,29 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) ) - async_add_entities(entities, True) -def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireSwitchEntity]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] + entities: list[OneWireSwitchEntity] = [] - entities: list[OneWireSwitch] = [] - - for device in onewire_hub.devices: + for device in devices: family = device.family device_type = device.type device_id = device.id @@ -184,7 +204,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) entities.append( - OneWireSwitch( + OneWireSwitchEntity( description=description, device_id=device_id, device_file=device_file, @@ -196,7 +216,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: return entities -class OneWireSwitch(OneWireEntity, SwitchEntity): +class OneWireSwitchEntity(OneWireEntity, SwitchEntity): """Implementation of a 1-Wire switch.""" entity_description: OneWireSwitchEntityDescription diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index a8ced6fae64..228748d5257 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -4,6 +4,7 @@ import logging from typing import Any import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ( SOURCE_RECONFIGURE, @@ -14,6 +15,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import section from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, @@ -24,6 +26,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, TextSelector, ) +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_RECEIVER_MAX_VOLUME, @@ -47,9 +50,13 @@ INPUT_SOURCES_ALL_MEANINGS = [ input_source.value_meaning for input_source in InputSource ] STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) -STEP_CONFIGURE_SCHEMA = vol.Schema( +STEP_RECONFIGURE_SCHEMA = vol.Schema( { vol.Required(OPTION_VOLUME_RESOLUTION): vol.In(VOLUME_RESOLUTION_ALLOWED), + } +) +STEP_CONFIGURE_SCHEMA = STEP_RECONFIGURE_SCHEMA.extend( + { vol.Required(OPTION_INPUT_SOURCES): SelectSelector( SelectSelectorConfig( options=INPUT_SOURCES_ALL_MEANINGS, @@ -165,61 +172,101 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): ), ) + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle flow initialized by SSDP discovery.""" + _LOGGER.debug("Config flow start ssdp: %s", discovery_info) + + if udn := discovery_info.ssdp_udn: + udn_parts = udn.split(":") + if len(udn_parts) == 2: + uuid = udn_parts[1] + last_uuid_section = uuid.split("-")[-1].upper() + await self.async_set_unique_id(last_uuid_section) + self._abort_if_unique_id_configured() + + if discovery_info.ssdp_location is None: + _LOGGER.error("SSDP location is None") + return self.async_abort(reason="unknown") + + host = URL(discovery_info.ssdp_location).host + + if host is None: + _LOGGER.error("SSDP host is None") + return self.async_abort(reason="unknown") + + try: + info = await async_interview(host) + except OSError: + _LOGGER.exception("Unexpected exception interviewing host %s", host) + return self.async_abort(reason="unknown") + + if info is None: + _LOGGER.debug("SSDP eiscp is None: %s", host) + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(info.identifier) + self._abort_if_unique_id_configured(updates={CONF_HOST: info.host}) + + self._receiver_info = info + + title_string = f"{info.model_name} ({info.host})" + self.context["title_placeholders"] = {"name": title_string} + return await self.async_step_configure_receiver() + async def async_step_configure_receiver( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the configuration of a single receiver.""" errors = {} - entry = None - entry_options = None + reconfigure_entry = None + schema = STEP_CONFIGURE_SCHEMA if self.source == SOURCE_RECONFIGURE: - entry = self._get_reconfigure_entry() - entry_options = entry.options + schema = STEP_RECONFIGURE_SCHEMA + reconfigure_entry = self._get_reconfigure_entry() if user_input is not None: - source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] - if not source_meanings: + volume_resolution = user_input[OPTION_VOLUME_RESOLUTION] + + if reconfigure_entry is not None: + entry_options = reconfigure_entry.options + result = self.async_update_reload_and_abort( + reconfigure_entry, + data={ + CONF_HOST: self._receiver_info.host, + }, + options={ + OPTION_VOLUME_RESOLUTION: volume_resolution, + OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], + OPTION_INPUT_SOURCES: entry_options[OPTION_INPUT_SOURCES], + }, + ) + + _LOGGER.debug("Reconfigured receiver, result: %s", result) + return result + + input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] + if not input_source_meanings: errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" else: - sources_store: dict[str, str] = {} - for source_meaning in source_meanings: - source = InputSource.from_meaning(source_meaning) + input_sources_store: dict[str, str] = {} + for input_source_meaning in input_source_meanings: + input_source = InputSource.from_meaning(input_source_meaning) + input_sources_store[input_source.value] = input_source_meaning - source_name = source_meaning - if entry_options is not None: - source_name = entry_options[OPTION_INPUT_SOURCES].get( - source.value, source_name - ) - sources_store[source.value] = source_name - - volume_resolution = user_input[OPTION_VOLUME_RESOLUTION] - - if entry_options is None: - result = self.async_create_entry( - title=self._receiver_info.model_name, - data={ - CONF_HOST: self._receiver_info.host, - }, - options={ - OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, - OPTION_INPUT_SOURCES: sources_store, - }, - ) - else: - assert entry is not None - result = self.async_update_reload_and_abort( - entry, - data={ - CONF_HOST: self._receiver_info.host, - }, - options={ - OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], - OPTION_INPUT_SOURCES: sources_store, - }, - ) + result = self.async_create_entry( + title=self._receiver_info.model_name, + data={ + CONF_HOST: self._receiver_info.host, + }, + options={ + OPTION_VOLUME_RESOLUTION: volume_resolution, + OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, + OPTION_INPUT_SOURCES: input_sources_store, + }, + ) _LOGGER.debug("Configured receiver, result: %s", result) return result @@ -228,12 +275,13 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): suggested_values = user_input if suggested_values is None: - if entry_options is None: + if reconfigure_entry is None: suggested_values = { OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, OPTION_INPUT_SOURCES: [], } else: + entry_options = reconfigure_entry.options suggested_values = { OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], OPTION_INPUT_SOURCES: [ @@ -244,9 +292,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="configure_receiver", - data_schema=self.add_suggested_values_to_schema( - STEP_CONFIGURE_SCHEMA, suggested_values - ), + data_schema=self.add_suggested_values_to_schema(schema, suggested_values), errors=errors, description_placeholders={ "name": f"{self._receiver_info.model_name} ({self._receiver_info.host})" @@ -315,57 +361,107 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Return the options flow.""" - return OnkyoOptionsFlowHandler(config_entry) + return OnkyoOptionsFlowHandler() + + +OPTIONS_STEP_INIT_SCHEMA = vol.Schema( + { + vol.Required(OPTION_MAX_VOLUME): NumberSelector( + NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX) + ), + vol.Required(OPTION_INPUT_SOURCES): SelectSelector( + SelectSelectorConfig( + options=INPUT_SOURCES_ALL_MEANINGS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } +) class OnkyoOptionsFlowHandler(OptionsFlow): """Handle an options flow for Onkyo.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - sources_store: dict[str, str] = config_entry.options[OPTION_INPUT_SOURCES] - self._input_sources = {InputSource(k): v for k, v in sources_store.items()} + _data: dict[str, Any] + _input_sources: dict[InputSource, str] async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" + errors = {} + + entry_options = self.config_entry.options + if user_input is not None: - sources_store: dict[str, str] = {} - for source_meaning, source_name in user_input.items(): - if source_meaning in INPUT_SOURCES_ALL_MEANINGS: - source = InputSource.from_meaning(source_meaning) - sources_store[source.value] = source_name + self._input_sources = {} + for input_source_meaning in user_input[OPTION_INPUT_SOURCES]: + input_source = InputSource.from_meaning(input_source_meaning) + input_source_name = entry_options[OPTION_INPUT_SOURCES].get( + input_source.value, input_source_meaning + ) + self._input_sources[input_source] = input_source_name + + if not self._input_sources: + errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" + else: + self._data = { + OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], + OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], + } + + return await self.async_step_names() + + suggested_values = user_input + if suggested_values is None: + suggested_values = { + OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], + OPTION_INPUT_SOURCES: [ + InputSource(input_source).value_meaning + for input_source in entry_options[OPTION_INPUT_SOURCES] + ], + } + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONS_STEP_INIT_SCHEMA, suggested_values + ), + errors=errors, + ) + + async def async_step_names( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure names.""" + if user_input is not None: + input_sources_store: dict[str, str] = {} + for input_source_meaning, input_source_name in user_input[ + "input_sources" + ].items(): + input_source = InputSource.from_meaning(input_source_meaning) + input_sources_store[input_source.value] = input_source_name return self.async_create_entry( data={ - OPTION_VOLUME_RESOLUTION: self.config_entry.options[ - OPTION_VOLUME_RESOLUTION - ], - OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], - OPTION_INPUT_SOURCES: sources_store, + **self._data, + OPTION_INPUT_SOURCES: input_sources_store, } ) schema_dict: dict[Any, Selector] = {} - max_volume: float = self.config_entry.options[OPTION_MAX_VOLUME] - schema_dict[vol.Required(OPTION_MAX_VOLUME, default=max_volume)] = ( - NumberSelector( - NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX) - ) - ) - - for source, source_name in self._input_sources.items(): - schema_dict[vol.Required(source.value_meaning, default=source_name)] = ( - TextSelector() - ) + for input_source, input_source_name in self._input_sources.items(): + schema_dict[ + vol.Required(input_source.value_meaning, default=input_source_name) + ] = TextSelector() return self.async_show_form( - step_id="init", - data_schema=vol.Schema(schema_dict), + step_id="names", + data_schema=vol.Schema( + {vol.Required("input_sources"): section(vol.Schema(schema_dict))} + ), ) diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json index 0e75404b3eb..6f37fb61b44 100644 --- a/homeassistant/components/onkyo/manifest.json +++ b/homeassistant/components/onkyo/manifest.json @@ -1,11 +1,49 @@ { "domain": "onkyo", "name": "Onkyo", - "codeowners": ["@arturpragacz"], + "codeowners": ["@arturpragacz", "@eclair4151"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/onkyo", "integration_type": "device", "iot_class": "local_push", "loggers": ["pyeiscp"], - "requirements": ["pyeiscp==0.0.7"] + "requirements": ["pyeiscp==0.0.7"], + "ssdp": [ + { + "manufacturer": "ONKYO", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "manufacturer": "ONKYO", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "manufacturer": "ONKYO", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3" + }, + { + "manufacturer": "Onkyo & Pioneer Corporation", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "manufacturer": "Onkyo & Pioneer Corporation", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "manufacturer": "Onkyo & Pioneer Corporation", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3" + }, + { + "manufacturer": "Pioneer", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "manufacturer": "Pioneer", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "manufacturer": "Pioneer", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3" + } + ] } diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 41e36a7f237..97a82fc8a1a 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from functools import cache import logging from typing import Any, Literal @@ -19,6 +20,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -111,6 +113,7 @@ AUDIO_INFORMATION_MAPPING = [ "precision_quartz_lock_system", "auto_phase_control_delay", "auto_phase_control_phase", + "upmix_mode", ] VIDEO_INFORMATION_MAPPING = [ @@ -123,13 +126,21 @@ VIDEO_INFORMATION_MAPPING = [ "output_color_schema", "output_color_depth", "picture_mode", + "input_hdr", ] ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" -type InputLibValue = str | tuple[str, ...] +type LibValue = str | tuple[str, ...] -def _input_lib_cmds(zone: str) -> dict[InputSource, InputLibValue]: +def _get_single_lib_value(value: LibValue) -> str: + if isinstance(value, str): + return value + return value[0] + + +@cache +def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]: match zone: case "main": cmds = PYEISCP_COMMANDS["main"]["SLI"] @@ -140,7 +151,7 @@ def _input_lib_cmds(zone: str) -> dict[InputSource, InputLibValue]: case "zone4": cmds = PYEISCP_COMMANDS["zone4"]["SL4"] - result: dict[InputSource, InputLibValue] = {} + result: dict[InputSource, LibValue] = {} for k, v in cmds["values"].items(): try: source = InputSource(k) @@ -151,6 +162,11 @@ def _input_lib_cmds(zone: str) -> dict[InputSource, InputLibValue]: return result +@cache +def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]: + return {value: key for key, value in _input_source_lib_mappings(zone).items()} + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -162,7 +178,7 @@ async def async_setup_platform( source_mapping: dict[str, InputSource] = {} for zone in ZONES: - for source, source_lib in _input_lib_cmds(zone).items(): + for source, source_lib in _input_source_lib_mappings(zone).items(): if isinstance(source_lib, str): source_mapping.setdefault(source_lib, source) else: @@ -351,14 +367,18 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._volume_resolution = volume_resolution self._max_volume = max_volume - self._name_mapping = sources - self._reverse_name_mapping = {value: key for key, value in sources.items()} - self._lib_mapping = _input_lib_cmds(zone) - self._reverse_lib_mapping = { - value: key for key, value in self._lib_mapping.items() + self._source_lib_mapping = _input_source_lib_mappings(zone) + self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone) + self._source_mapping = { + key: value + for key, value in sources.items() + if key in self._source_lib_mapping + } + self._rev_source_mapping = { + value: key for key, value in self._source_mapping.items() } - self._attr_source_list = list(sources.values()) + self._attr_source_list = list(self._rev_source_mapping) self._attr_extra_state_attributes = {} async def async_added_to_hass(self) -> None: @@ -407,7 +427,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): """ # HA_VOL * (MAX VOL / 100) * VOL_RESOLUTION self._update_receiver( - "volume", int(volume * (self._max_volume / 100) * self._volume_resolution) + "volume", round(volume * (self._max_volume / 100) * self._volume_resolution) ) async def async_volume_up(self) -> None: @@ -427,12 +447,18 @@ class OnkyoMediaPlayer(MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" - if self.source_list and source in self.source_list: - source_lib = self._lib_mapping[self._reverse_name_mapping[source]] - if isinstance(source_lib, str): - source_lib_single = source_lib - else: - source_lib_single = source_lib[0] + if not self.source_list or source not in self.source_list: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_source", + translation_placeholders={ + "invalid_source": source, + "entity_id": self.entity_id, + }, + ) + + source_lib = self._source_lib_mapping[self._rev_source_mapping[source]] + source_lib_single = _get_single_lib_value(source_lib) self._update_receiver( "input-selector" if self._zone == "main" else "selector", source_lib_single ) @@ -446,7 +472,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): ) -> None: """Play radio station by preset number.""" if self.source is not None: - source = self._reverse_name_mapping[self.source] + source = self._rev_source_mapping[self.source] if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: self._update_receiver("preset", media_id) @@ -518,15 +544,17 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self.async_write_ha_state() @callback - def _parse_source(self, source_lib: InputLibValue) -> None: - source = self._reverse_lib_mapping[source_lib] - if source in self._name_mapping: - self._attr_source = self._name_mapping[source] + def _parse_source(self, source_lib: LibValue) -> None: + source = self._rev_source_lib_mapping[source_lib] + if source in self._source_mapping: + self._attr_source = self._source_mapping[source] return source_meaning = source.value_meaning _LOGGER.error( - 'Input source "%s" not in source list: %s', source_meaning, self.entity_id + 'Input source "%s" is invalid for entity: %s', + source_meaning, + self.entity_id, ) self._attr_source = source_meaning diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml new file mode 100644 index 00000000000..cdcf88e72d7 --- /dev/null +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -0,0 +1,83 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: exempt + comment: | + This integration uses a push API. No polling required. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: + status: todo + comment: | + Coverage is 100%, but the tests need to be improved. + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: done + comment: | + Currently we store created entities in hass.data. That should be removed in the future. + entity-unique-id: done + has-entity-name: todo + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: todo + diagnostics: todo + discovery: todo + discovery-update-info: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration has a fixed single device. + entity-category: done + entity-device-class: todo + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration is not making any HTTP requests. + strict-typing: + status: todo + comment: | + The library is not fully typed yet. diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index 1b0eadcc45e..b3b14efec44 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -10,24 +10,34 @@ "manual": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of the receiver." } }, "eiscp_discovery": { "data": { "device": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "device": "Select the receiver to configure." } }, "configure_receiver": { "description": "Configure {name}", "data": { - "volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume", - "input_sources": "List of input sources supported by the receiver" + "volume_resolution": "Volume resolution", + "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]" + }, + "data_description": { + "volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume.", + "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "empty_input_source_list": "Input source list cannot be empty", + "empty_input_source_list": "[%key:component::onkyo::options::error::empty_input_source_list%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -42,9 +52,25 @@ "step": { "init": { "data": { - "max_volume": "Maximum volume limit (%)" + "max_volume": "Maximum volume limit (%)", + "input_sources": "Input sources" + }, + "data_description": { + "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value.", + "input_sources": "List of input sources supported by the receiver." + } + }, + "names": { + "sections": { + "input_sources": { + "name": "Input source names", + "description": "Mappings of receiver's input sources to their names." + } } } + }, + "error": { + "empty_input_source_list": "Input source list cannot be empty" } }, "issues": { @@ -56,5 +82,10 @@ "title": "The Onkyo YAML configuration import failed", "description": "Configuring Onkyo using YAML is being removed but there was a connection error when importing your YAML configuration for host {host}.\n\nEnsure the connection to the receiver works and restart Home Assistant to try again or remove the Onkyo YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." } + }, + "exceptions": { + "invalid_source": { + "message": "Cannot select input source \"{invalid_source}\" for entity: {entity_id}." + } } } diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 66e566af0bf..f645444f9c6 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -11,11 +11,11 @@ from urllib.parse import urlparse from onvif.util import is_auth_error, stringify_onvif_error import voluptuous as vol from wsdiscovery.discovery import ThreadedWSDiscovery as WSDiscovery +from wsdiscovery.qname import QName from wsdiscovery.scope import Scope from wsdiscovery.service import Service from zeep.exceptions import Fault -from homeassistant.components import dhcp from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, @@ -39,6 +39,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_DEVICE_ID, @@ -58,16 +59,22 @@ CONF_MANUAL_INPUT = "Manually configure ONVIF device" def wsdiscovery() -> list[Service]: """Get ONVIF Profile S devices from network.""" - discovery = WSDiscovery(ttl=4) + discovery = WSDiscovery(ttl=4, relates_to=True) try: discovery.start() return discovery.searchServices( - scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")] + types=[ + QName( + "http://www.onvif.org/ver10/network/wsdl", + "NetworkVideoTransmitter", + "dp0", + ) + ], + scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")], + timeout=10, ) finally: discovery.stop() - # Stop the threads started by WSDiscovery since otherwise there is a leak. - discovery._stopThreads() # noqa: SLF001 async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]: @@ -170,7 +177,7 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" hass = self.hass diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f51b1b74686..f15f6637ab9 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -263,16 +263,22 @@ class ONVIFDevice: LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name) return - cam_date = dt.datetime( - cdate.Date.Year, - cdate.Date.Month, - cdate.Date.Day, - cdate.Time.Hour, - cdate.Time.Minute, - cdate.Time.Second, - 0, - tzone, - ) + try: + cam_date = dt.datetime( + cdate.Date.Year, + cdate.Date.Month, + cdate.Date.Day, + cdate.Time.Hour, + cdate.Time.Minute, + cdate.Time.Second, + 0, + tzone, + ) + except ValueError as err: + LOGGER.warning( + "%s: Could not parse date/time from camera: %s", self.name, err + ) + return cam_date_utc = cam_date.astimezone(dt_util.UTC) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 4b5335f1eb6..b7b34f7be9f 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -252,9 +252,9 @@ class PullPointManager: async def async_start(self) -> bool: """Start pullpoint subscription.""" - assert ( - self.state == PullPointManagerState.STOPPED - ), "PullPoint manager already started" + assert self.state == PullPointManagerState.STOPPED, ( + "PullPoint manager already started" + ) LOGGER.debug("%s: Starting PullPoint manager", self._name) if not await self._async_start_pullpoint(): self.state = PullPointManagerState.FAILED @@ -501,9 +501,9 @@ class WebHookManager: async def async_start(self) -> bool: """Start polling events.""" LOGGER.debug("%s: Starting webhook manager", self._name) - assert ( - self.state == WebHookManagerState.STOPPED - ), "Webhook manager already started" + assert self.state == WebHookManagerState.STOPPED, ( + "Webhook manager already started" + ) assert self._webhook_url is None, "Webhook already registered" self._async_register_webhook() if not await self._async_start_webhook(): diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index d03073dcfd3..78df5130aed 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -1,12 +1,12 @@ { "domain": "onvif", "name": "ONVIF", - "codeowners": ["@hunterjm"], + "codeowners": ["@hunterjm", "@jterrace"], "config_flow": true, "dependencies": ["ffmpeg"], "dhcp": [{ "registered_devices": true }], "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.12", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.2.5", "WSDiscovery==2.1.2"] } diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 57bd8a974db..6eb1d001796 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +import dataclasses import datetime from typing import Any @@ -370,6 +371,85 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: return None +_TAPO_EVENT_TEMPLATES: dict[str, Event] = { + "IsVehicle": Event( + uid="", + name="Vehicle Detection", + platform="binary_sensor", + device_class="motion", + ), + "IsPeople": Event( + uid="", name="Person Detection", platform="binary_sensor", device_class="motion" + ), + "IsPet": Event( + uid="", name="Pet Detection", platform="binary_sensor", device_class="motion" + ), + "IsLineCross": Event( + uid="", + name="Line Detector Crossed", + platform="binary_sensor", + device_class="motion", + ), + "IsTamper": Event( + uid="", name="Tamper Detection", platform="binary_sensor", device_class="tamper" + ), + "IsIntrusion": Event( + uid="", + name="Intrusion Detection", + platform="binary_sensor", + device_class="safety", + ), +} + + +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Intrusion") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/LineCross") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/People") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Tamper") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/TpSmartEvent") +@PARSERS.register("tns1:RuleEngine/PeopleDetector/People") +@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent") +async def async_parse_tplink_detector(uid: str, msg) -> Event | None: + """Handle parsing tplink smart event messages. + + Topic: tns1:RuleEngine/CellMotionDetector/Intrusion + Topic: tns1:RuleEngine/CellMotionDetector/LineCross + Topic: tns1:RuleEngine/CellMotionDetector/People + Topic: tns1:RuleEngine/CellMotionDetector/Tamper + Topic: tns1:RuleEngine/CellMotionDetector/TpSmartEvent + Topic: tns1:RuleEngine/PeopleDetector/People + Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent + """ + try: + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value + + for item in payload.Data.SimpleItem: + event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None) + if event_template is None: + continue + + return dataclasses.replace( + event_template, + uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + value=item.Value == "true", + ) + + except (AttributeError, KeyError): + return None + + return None + + @PARSERS.register("tns1:RuleEngine/MyRuleDetector/PeopleDetect") async def async_parse_person_detector(uid: str, msg) -> Event | None: """Handle parsing event message. diff --git a/homeassistant/components/open_meteo/__init__.py b/homeassistant/components/open_meteo/__init__.py index 6deb63904ff..34495d4bd0b 100644 --- a/homeassistant/components/open_meteo/__init__.py +++ b/homeassistant/components/open_meteo/__init__.py @@ -2,82 +2,27 @@ from __future__ import annotations -from open_meteo import ( - DailyParameters, - Forecast, - HourlyParameters, - OpenMeteo, - OpenMeteoError, - PrecipitationUnit, - TemperatureUnit, - WindSpeedUnit, -) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .coordinator import OpenMeteoConfigEntry, OpenMeteoDataUpdateCoordinator PLATFORMS = [Platform.WEATHER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OpenMeteoConfigEntry) -> bool: """Set up Open-Meteo from a config entry.""" - session = async_get_clientsession(hass) - open_meteo = OpenMeteo(session=session) - async def async_update_forecast() -> Forecast: - if (zone := hass.states.get(entry.data[CONF_ZONE])) is None: - raise UpdateFailed(f"Zone '{entry.data[CONF_ZONE]}' not found") - - try: - return await open_meteo.forecast( - latitude=zone.attributes[ATTR_LATITUDE], - longitude=zone.attributes[ATTR_LONGITUDE], - current_weather=True, - daily=[ - DailyParameters.PRECIPITATION_SUM, - DailyParameters.TEMPERATURE_2M_MAX, - DailyParameters.TEMPERATURE_2M_MIN, - DailyParameters.WEATHER_CODE, - DailyParameters.WIND_DIRECTION_10M_DOMINANT, - DailyParameters.WIND_SPEED_10M_MAX, - ], - hourly=[ - HourlyParameters.PRECIPITATION, - HourlyParameters.TEMPERATURE_2M, - HourlyParameters.WEATHER_CODE, - ], - precipitation_unit=PrecipitationUnit.MILLIMETERS, - temperature_unit=TemperatureUnit.CELSIUS, - timezone="UTC", - wind_speed_unit=WindSpeedUnit.KILOMETERS_PER_HOUR, - ) - except OpenMeteoError as err: - raise UpdateFailed("Open-Meteo API communication error") from err - - coordinator: DataUpdateCoordinator[Forecast] = DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name=f"{DOMAIN}_{entry.data[CONF_ZONE]}", - update_interval=SCAN_INTERVAL, - update_method=async_update_forecast, - ) + coordinator = OpenMeteoDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OpenMeteoConfigEntry) -> bool: """Unload Open-Meteo config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/open_meteo/coordinator.py b/homeassistant/components/open_meteo/coordinator.py new file mode 100644 index 00000000000..9e2f262db78 --- /dev/null +++ b/homeassistant/components/open_meteo/coordinator.py @@ -0,0 +1,73 @@ +"""DataUpdateCoordinator for the Open-Meteo integration.""" + +from __future__ import annotations + +from open_meteo import ( + DailyParameters, + Forecast, + HourlyParameters, + OpenMeteo, + OpenMeteoError, + PrecipitationUnit, + TemperatureUnit, + WindSpeedUnit, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + +type OpenMeteoConfigEntry = ConfigEntry[OpenMeteoDataUpdateCoordinator] + + +class OpenMeteoDataUpdateCoordinator(DataUpdateCoordinator[Forecast]): + """A Open-Meteo Data Update Coordinator.""" + + config_entry: OpenMeteoConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: OpenMeteoConfigEntry) -> None: + """Initialize the Open-Meteo coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{config_entry.data[CONF_ZONE]}", + update_interval=SCAN_INTERVAL, + ) + session = async_get_clientsession(hass) + self.open_meteo = OpenMeteo(session=session) + + async def _async_update_data(self) -> Forecast: + """Fetch data from Sensibo.""" + if (zone := self.hass.states.get(self.config_entry.data[CONF_ZONE])) is None: + raise UpdateFailed(f"Zone '{self.config_entry.data[CONF_ZONE]}' not found") + + try: + return await self.open_meteo.forecast( + latitude=zone.attributes[ATTR_LATITUDE], + longitude=zone.attributes[ATTR_LONGITUDE], + current_weather=True, + daily=[ + DailyParameters.PRECIPITATION_SUM, + DailyParameters.TEMPERATURE_2M_MAX, + DailyParameters.TEMPERATURE_2M_MIN, + DailyParameters.WEATHER_CODE, + DailyParameters.WIND_DIRECTION_10M_DOMINANT, + DailyParameters.WIND_SPEED_10M_MAX, + ], + hourly=[ + HourlyParameters.PRECIPITATION, + HourlyParameters.TEMPERATURE_2M, + HourlyParameters.WEATHER_CODE, + ], + precipitation_unit=PrecipitationUnit.MILLIMETERS, + temperature_unit=TemperatureUnit.CELSIUS, + timezone="UTC", + wind_speed_unit=WindSpeedUnit.KILOMETERS_PER_HOUR, + ) + except OpenMeteoError as err: + raise UpdateFailed("Open-Meteo API communication error") from err diff --git a/homeassistant/components/open_meteo/diagnostics.py b/homeassistant/components/open_meteo/diagnostics.py index 0ce9f4fcf3d..44bf7d60e24 100644 --- a/homeassistant/components/open_meteo/diagnostics.py +++ b/homeassistant/components/open_meteo/diagnostics.py @@ -4,15 +4,11 @@ from __future__ import annotations from typing import Any -from open_meteo import Forecast - from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .coordinator import OpenMeteoConfigEntry TO_REDACT = { CONF_LATITUDE, @@ -21,8 +17,8 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: OpenMeteoConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator[Forecast] = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return async_redact_data(coordinator.data.to_dict(), TO_REDACT) diff --git a/homeassistant/components/open_meteo/manifest.json b/homeassistant/components/open_meteo/manifest.json index abdb59a48d0..a2f2a724ad5 100644 --- a/homeassistant/components/open_meteo/manifest.json +++ b/homeassistant/components/open_meteo/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/open_meteo", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["open-meteo==0.3.1"] + "requirements": ["open-meteo==0.3.2"] } diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index a2be81f0928..51ee91de083 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import datetime, time + from open_meteo import Forecast as OpenMeteoForecast from homeassistant.components.weather import ( @@ -15,7 +17,6 @@ from homeassistant.components.weather import ( SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPrecipitationDepth, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -24,15 +25,16 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from .const import DOMAIN, WMO_TO_HA_CONDITION_MAP +from .coordinator import OpenMeteoConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenMeteoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Open-Meteo weather entity based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities([OpenMeteoWeatherEntity(entry=entry, coordinator=coordinator)]) @@ -53,7 +55,7 @@ class OpenMeteoWeatherEntity( def __init__( self, *, - entry: ConfigEntry, + entry: OpenMeteoConfigEntry, coordinator: DataUpdateCoordinator[OpenMeteoForecast], ) -> None: """Initialize Open-Meteo weather entity.""" @@ -107,8 +109,9 @@ class OpenMeteoWeatherEntity( daily = self.coordinator.data.daily for index, date in enumerate(self.coordinator.data.daily.time): + _datetime = datetime.combine(date=date, time=time(0), tzinfo=dt_util.UTC) forecast = Forecast( - datetime=date.isoformat(), + datetime=_datetime.isoformat(), ) if daily.weathercode is not None: @@ -155,12 +158,14 @@ class OpenMeteoWeatherEntity( today = dt_util.utcnow() hourly = self.coordinator.data.hourly - for index, datetime in enumerate(self.coordinator.data.hourly.time): - if dt_util.as_utc(datetime) < today: + for index, _datetime in enumerate(self.coordinator.data.hourly.time): + if _datetime.tzinfo is None: + _datetime = _datetime.replace(tzinfo=dt_util.UTC) + if _datetime < today: continue forecast = Forecast( - datetime=datetime.isoformat(), + datetime=_datetime.isoformat(), ) if hourly.weather_code is not None: diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 9c73766c8d4..c89574bf3bd 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -2,7 +2,7 @@ from collections.abc import Callable import json -from typing import Any, Literal +from typing import Any, Literal, cast import openai from openai._types import NOT_GIVEN @@ -11,10 +11,8 @@ from openai.types.chat import ( ChatCompletionMessage, ChatCompletionMessageParam, ChatCompletionMessageToolCallParam, - ChatCompletionSystemMessageParam, ChatCompletionToolMessageParam, ChatCompletionToolParam, - ChatCompletionUserMessageParam, ) from openai.types.chat.chat_completion_message_tool_call_param import Function from openai.types.shared_params import FunctionDefinition @@ -26,10 +24,9 @@ from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import device_registry as dr, intent, llm, template +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid from . import OpenAIConfigEntry from .const import ( @@ -73,6 +70,30 @@ def _format_tool( return ChatCompletionToolParam(type="function", function=tool_spec) +def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessageParam: + """Convert from class to TypedDict.""" + tool_calls: list[ChatCompletionMessageToolCallParam] = [] + if message.tool_calls: + tool_calls = [ + ChatCompletionMessageToolCallParam( + id=tool_call.id, + function=Function( + arguments=tool_call.function.arguments, + name=tool_call.function.name, + ), + type=tool_call.type, + ) + for tool_call in message.tool_calls + ] + param = ChatCompletionAssistantMessageParam( + role=message.role, + content=message.content, + ) + if tool_calls: + param["tool_calls"] = tool_calls + return param + + class OpenAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -84,7 +105,6 @@ class OpenAIConversationEntity( def __init__(self, entry: OpenAIConfigEntry) -> None: """Initialize the agent.""" self.entry = entry - self.history: dict[str, list[ChatCompletionMessageParam]] = {} self._attr_unique_id = entry.entry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, @@ -123,114 +143,56 @@ class OpenAIConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" + async with conversation.async_get_chat_session( + self.hass, user_input + ) as session: + return await self._async_call_api(user_input, session) + + async def _async_call_api( + self, + user_input: conversation.ConversationInput, + session: conversation.ChatSession[ChatCompletionMessageParam], + ) -> conversation.ConversationResult: + """Call the API.""" options = self.entry.options - intent_response = intent.IntentResponse(language=user_input.language) - llm_api: llm.APIInstance | None = None - tools: list[ChatCompletionToolParam] | None = None - user_name: str | None = None - llm_context = llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ) - - if options.get(CONF_LLM_HASS_API): - try: - llm_api = await llm.async_get_api( - self.hass, - options[CONF_LLM_HASS_API], - llm_context, - ) - except HomeAssistantError as err: - LOGGER.error("Error getting LLM API: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Error preparing LLM API", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=user_input.conversation_id - ) - tools = [ - _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools - ] - - if user_input.conversation_id is None: - conversation_id = ulid.ulid_now() - messages = [] - - elif user_input.conversation_id in self.history: - conversation_id = user_input.conversation_id - messages = self.history[conversation_id] - - else: - # Conversation IDs are ULIDs. We generate a new one if not provided. - # If an old OLID is passed in, we will generate a new one to indicate - # a new conversation was started. If the user picks their own, they - # want to track a conversation and we respect it. - try: - ulid.ulid_to_bytes(user_input.conversation_id) - conversation_id = ulid.ulid_now() - except ValueError: - conversation_id = user_input.conversation_id - - messages = [] - - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - ): - user_name = user.name try: - prompt_parts = [ - template.Template( - llm.BASE_PROMPT - + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) + await session.async_update_llm_data( + DOMAIN, + user_input, + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + tools: list[ChatCompletionToolParam] | None = None + if session.llm_api: + tools = [ + _format_tool(tool, session.llm_api.custom_serializer) + for tool in session.llm_api.tools ] - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem with my template", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - if llm_api: - prompt_parts.append(llm_api.api_prompt) - - prompt = "\n".join(prompt_parts) - - # Create a copy of the variable because we attach it to the trace - messages = [ - ChatCompletionSystemMessageParam(role="system", content=prompt), - *messages[1:], - ChatCompletionUserMessageParam(role="user", content=user_input.text), - ] + messages: list[ChatCompletionMessageParam] = [] + for message in session.async_get_messages(user_input.agent_id): + if message.native is not None and message.agent_id == user_input.agent_id: + messages.append(message.native) + else: + messages.append( + cast( + ChatCompletionMessageParam, + {"role": message.role, "content": message.content}, + ) + ) LOGGER.debug("Prompt: %s", messages) LOGGER.debug("Tools: %s", tools) trace.async_conversation_trace_append( trace.ConversationTraceEventType.AGENT_DETAIL, - {"messages": messages, "tools": llm_api.tools if llm_api else None}, + { + "messages": session.messages, + "tools": session.llm_api.tools if session.llm_api else None, + }, ) client = self.entry.runtime_data @@ -245,7 +207,7 @@ class OpenAIConversationEntity( max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - user=conversation_id, + user=session.conversation_id, ) except openai.OpenAIError as err: LOGGER.error("Error talking to OpenAI: %s", err) @@ -255,44 +217,26 @@ class OpenAIConversationEntity( "Sorry, I had a problem talking to OpenAI", ) return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + response=intent_response, conversation_id=session.conversation_id ) LOGGER.debug("Response %s", result) response = result.choices[0].message + messages.append(_message_convert(response)) - def message_convert( - message: ChatCompletionMessage, - ) -> ChatCompletionMessageParam: - """Convert from class to TypedDict.""" - tool_calls: list[ChatCompletionMessageToolCallParam] = [] - if message.tool_calls: - tool_calls = [ - ChatCompletionMessageToolCallParam( - id=tool_call.id, - function=Function( - arguments=tool_call.function.arguments, - name=tool_call.function.name, - ), - type=tool_call.type, - ) - for tool_call in message.tool_calls - ] - param = ChatCompletionAssistantMessageParam( - role=message.role, - content=message.content, - ) - if tool_calls: - param["tool_calls"] = tool_calls - return param + session.async_add_message( + conversation.ChatMessage( + role=response.role, + agent_id=user_input.agent_id, + content=response.content or "", + native=messages[-1], + ), + ) - messages.append(message_convert(response)) - tool_calls = response.tool_calls - - if not tool_calls or not llm_api: + if not response.tool_calls or not session.llm_api: break - for tool_call in tool_calls: + for tool_call in response.tool_calls: tool_input = llm.ToolInput( tool_name=tool_call.function.name, tool_args=json.loads(tool_call.function.arguments), @@ -302,7 +246,7 @@ class OpenAIConversationEntity( ) try: - tool_response = await llm_api.async_call_tool(tool_input) + tool_response = await session.llm_api.async_call_tool(tool_input) except (HomeAssistantError, vol.Invalid) as e: tool_response = {"error": type(e).__name__} if str(e): @@ -316,13 +260,19 @@ class OpenAIConversationEntity( content=json.dumps(tool_response), ) ) - - self.history[conversation_id] = messages + session.async_add_message( + conversation.ChatMessage( + role="native", + agent_id=user_input.agent_id, + content="", + native=messages[-1], + ) + ) intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_speech(response.content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + response=intent_response, conversation_id=session.conversation_id ) async def _async_entry_update_listener( diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index fcbdc996ce5..9b70246117c 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.35.7"] + "requirements": ["openai==1.59.9"] } diff --git a/homeassistant/components/openhome/config_flow.py b/homeassistant/components/openhome/config_flow.py index b495819211b..9cd6a79f012 100644 --- a/homeassistant/components/openhome/config_flow.py +++ b/homeassistant/components/openhome/config_flow.py @@ -3,13 +3,13 @@ import logging from typing import Any -from homeassistant.components.ssdp import ( +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN, SsdpServiceInfo, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME from .const import DOMAIN diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index c9143c977ce..8c903c90bbb 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -67,11 +67,9 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[ ] -def catch_request_errors[_OpenhomeDeviceT: OpenhomeDevice, **_P, _R]() -> ( - Callable[ - [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R] - ] -): +def catch_request_errors[_OpenhomeDeviceT: OpenhomeDevice, **_P, _R]() -> Callable[ + [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R] +]: """Catch TimeoutError, aiohttp.ClientError, UpnpError errors.""" def call_wrapper( diff --git a/homeassistant/components/openhome/strings.json b/homeassistant/components/openhome/strings.json index b13fb997b7f..f4b15e52e7c 100644 --- a/homeassistant/components/openhome/strings.json +++ b/homeassistant/components/openhome/strings.json @@ -1,12 +1,12 @@ { "services": { "invoke_pin": { - "name": "Invoke PIN", - "description": "Invokes a pin on the specified device.", + "name": "Play pin", + "description": "Starts playing content pinned on the specified device.", "fields": { "pin": { - "name": "PIN", - "description": "Which pin to invoke." + "name": "Pin ID", + "description": "ID of the pinned content." } } } diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 5ce9d808b21..8c92c70ab49 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -47,6 +47,7 @@ from .const import ( CONF_CLIMATE, CONF_FLOOR_TEMP, CONF_PRECISION, + CONF_TEMPORARY_OVRD_MODE, CONNECTION_TIMEOUT, DATA_GATEWAYS, DATA_OPENTHERM_GW, @@ -105,6 +106,7 @@ PLATFORMS = [ async def options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" gateway = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][entry.data[CONF_ID]] + gateway.options = entry.options async_dispatcher_send(hass, gateway.options_update_signal, entry) @@ -469,7 +471,7 @@ class OpenThermGatewayHub: self.device_path = config_entry.data[CONF_DEVICE] self.hub_id = config_entry.data[CONF_ID] self.name = config_entry.data[CONF_NAME] - self.climate_config = config_entry.options + self.options = config_entry.options self.config_entry_id = config_entry.entry_id self.update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_update" self.options_update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_options_update" @@ -565,3 +567,9 @@ class OpenThermGatewayHub: def connected(self): """Report whether or not we are connected to the gateway.""" return self.gateway.connection.connected + + async def set_room_setpoint(self, temp) -> float: + """Set the room temperature setpoint on the gateway. Return the new temperature.""" + return await self.gateway.set_target_temp( + temp, self.options.get(CONF_TEMPORARY_OVRD_MODE, True) + ) diff --git a/homeassistant/components/opentherm_gw/button.py b/homeassistant/components/opentherm_gw/button.py index bac50295199..00b91ad33e0 100644 --- a/homeassistant/components/opentherm_gw/button.py +++ b/homeassistant/components/opentherm_gw/button.py @@ -16,7 +16,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import OpenThermGatewayHub -from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW, GATEWAY_DEVICE_DESCRIPTION +from .const import ( + DATA_GATEWAYS, + DATA_OPENTHERM_GW, + GATEWAY_DEVICE_DESCRIPTION, + THERMOSTAT_DEVICE_DESCRIPTION, +) from .entity import OpenThermEntity, OpenThermEntityDescription @@ -30,6 +35,12 @@ class OpenThermButtonEntityDescription( BUTTON_DESCRIPTIONS: tuple[OpenThermButtonEntityDescription, ...] = ( + OpenThermButtonEntityDescription( + key="cancel_room_setpoint_override", + translation_key="cancel_room_setpoint_override", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + action=lambda hub: hub.set_room_setpoint(0), + ), OpenThermButtonEntityDescription( key="restart_button", device_class=ButtonDeviceClass.RESTART, diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index 6edfeb35ec3..e8aa99f7325 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -28,7 +28,6 @@ from . import OpenThermGatewayHub from .const import ( CONF_READ_PRECISION, CONF_SET_PRECISION, - CONF_TEMPORARY_OVRD_MODE, DATA_GATEWAYS, DATA_OPENTHERM_GW, THERMOSTAT_DEVICE_DESCRIPTION, @@ -86,7 +85,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): _away_mode_b: int | None = None _away_state_a = False _away_state_b = False - _enable_turn_on_off_backwards_compatibility = False + _target_temperature: float | None = None _new_target_temperature: float | None = None entity_description: OpenThermClimateEntityDescription @@ -102,14 +101,12 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): if CONF_READ_PRECISION in options: self._attr_precision = options[CONF_READ_PRECISION] self._attr_target_temperature_step = options.get(CONF_SET_PRECISION) - self.temporary_ovrd_mode = options.get(CONF_TEMPORARY_OVRD_MODE, True) @callback def update_options(self, entry): """Update climate entity options.""" self._attr_precision = entry.options[CONF_READ_PRECISION] self._attr_target_temperature_step = entry.options[CONF_SET_PRECISION] - self.temporary_ovrd_mode = entry.options[CONF_TEMPORARY_OVRD_MODE] self.async_write_ha_state() async def async_added_to_hass(self) -> None: @@ -195,7 +192,5 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): temp = float(kwargs[ATTR_TEMPERATURE]) if temp == self.target_temperature: return - self._new_target_temperature = await self._gateway.gateway.set_target_temp( - temp, self.temporary_ovrd_mode - ) + self._new_target_temperature = await self._gateway.set_room_setpoint(temp) self.async_write_ha_state() diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 834168eb113..405af126c03 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -15,7 +15,7 @@ }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "id_exists": "Gateway id already exists", + "id_exists": "Gateway ID already exists", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" } @@ -158,6 +158,11 @@ "name": "Programmed change has priority over override" } }, + "button": { + "cancel_room_setpoint_override": { + "name": "Cancel room setpoint override" + } + }, "select": { "gpio_mode_n": { "name": "GPIO {gpio_id} mode", @@ -374,13 +379,13 @@ "fields": { "gateway_id": { "name": "Gateway ID", - "description": "The gateway_id of the OpenTherm Gateway." + "description": "The ID of the OpenTherm Gateway." } } }, "set_central_heating_ovrd": { "name": "Set central heating override", - "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a set_control_setpoint action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the set_control_setpoint action with temperature value 0. You will only need this if you are writing your own software thermostat.\n.", + "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a set_control_setpoint action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the set_control_setpoint action with temperature value 0. You will only need this if you are writing your own software thermostat.", "fields": { "gateway_id": { "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", @@ -412,7 +417,7 @@ }, "set_control_setpoint": { "name": "Set control set point", - "description": "Sets the central heating control setpoint override on the gateway. You will only need this if you are writing your own software thermostat.\n.", + "description": "Sets the central heating control setpoint override on the gateway. You will only need this if you are writing your own software thermostat.", "fields": { "gateway_id": { "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", @@ -420,7 +425,7 @@ }, "temperature": { "name": "Temperature", - "description": "The central heating setpoint to set on the gateway. Values between 0 and 90 are accepted, but not all boilers support this range. A value of 0 disables the central heating setpoint override.\n." + "description": "The central heating setpoint to set on the gateway. Values between 0 and 90 are accepted, but not all boilers support this range. A value of 0 disables the central heating setpoint override." } } }, @@ -434,7 +439,7 @@ }, "dhw_override": { "name": "Domestic hot water override", - "description": "Control the domestic hot water enable option. If the boiler has been configured to let the room unit control when to keep a small amount of water preheated, this command can influence that. Value should be 0 or 1 to enable the override in off or on state, or \"A\" to disable the override.\n." + "description": "Control the domestic hot water enable option. If the boiler has been configured to let the room unit control when to keep a small amount of water preheated, this command can influence that. Value should be 0 or 1 to enable the override in off or on state, or \"A\" to disable the override." } } }, @@ -448,7 +453,7 @@ }, "temperature": { "name": "Temperature", - "description": "The domestic hot water setpoint to set on the gateway. Not all boilers support this feature. Values between 0 and 90 are accepted, but not all boilers support this range. Check the values of the slave_dhw_min_setp and slave_dhw_max_setp sensors to see the supported range on your boiler.\n." + "description": "The domestic hot water setpoint to set on the gateway. Not all boilers support this feature. Values between 0 and 90 are accepted, but not all boilers support this range. Check the values of the slave_dhw_min_setp and slave_dhw_max_setp sensors to see the supported range on your boiler." } } }, @@ -466,7 +471,7 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "Mode to set on the GPIO pin. Values 0 through 6 are accepted for both GPIOs, 7 is only accepted for GPIO \"B\". See https://www.home-assistant.io/integrations/opentherm_gw/#gpio-modes for an explanation of the values.\n." + "description": "Mode to set on the GPIO pin. Values 0 through 6 are accepted for both GPIOs, 7 is only accepted for GPIO \"B\". See https://www.home-assistant.io/integrations/opentherm_gw/#gpio-modes for an explanation of the values." } } }, @@ -484,13 +489,13 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "The function to assign to the LED. See https://www.home-assistant.io/integrations/opentherm_gw/#led-modes for an explanation of the values.\n." + "description": "The function to assign to the LED. See https://www.home-assistant.io/integrations/opentherm_gw/#led-modes for an explanation of the values." } } }, "set_max_modulation": { "name": "Set max modulation", - "description": "Overrides the maximum relative modulation level. You will only need this if you are writing your own software thermostat.\n.", + "description": "Overrides the maximum relative modulation level. You will only need this if you are writing your own software thermostat.", "fields": { "gateway_id": { "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", @@ -498,13 +503,13 @@ }, "level": { "name": "Level", - "description": "The modulation level to provide to the gateway. Provide a value of -1 to clear the override and forward the value from the thermostat again.\n." + "description": "The modulation level to provide to the gateway. Provide a value of -1 to clear the override and forward the value from the thermostat again." } } }, "set_outside_temperature": { "name": "Set outside temperature", - "description": "Provides an outside temperature to the thermostat. If your thermostat is unable to display an outside temperature and does not support OTC (Outside Temperature Correction), this has no effect.\n.", + "description": "Provides an outside temperature to the thermostat. If your thermostat is unable to display an outside temperature and does not support OTC (Outside Temperature Correction), this has no effect.", "fields": { "gateway_id": { "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", @@ -512,7 +517,7 @@ }, "temperature": { "name": "Temperature", - "description": "The temperature to provide to the thermostat. Values between -40.0 and 64.0 will be accepted, but not all thermostats can display the full range. Any value above 64.0 will clear a previously configured value (suggestion: 99)\n." + "description": "The temperature to provide to the thermostat. Values between -40.0 and 64.0 will be accepted, but not all thermostats can display the full range. Any value above 64.0 will clear a previously configured value (suggestion: 99)." } } }, diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py index 1a34d0547aa..136a1a4e57a 100644 --- a/homeassistant/components/opower/__init__.py +++ b/homeassistant/components/opower/__init__.py @@ -6,27 +6,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN from .coordinator import OpowerCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type OpowerConfigEntry = ConfigEntry[OpowerCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool: """Set up Opower from a config entry.""" coordinator = OpowerCoordinator(hass, entry.data) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 629dce0823c..f6f3524d630 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -5,9 +5,11 @@ import logging from types import MappingProxyType from typing import Any, cast +import aiohttp from opower import ( Account, AggregateType, + CannotConnect, CostRead, Forecast, InvalidAuth, @@ -27,7 +29,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, Unit from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN @@ -80,8 +82,16 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): # assume previous session has expired and re-login. await self.api.async_login() except InvalidAuth as err: + _LOGGER.error("Error during login: %s", err) raise ConfigEntryAuthFailed from err - forecasts: list[Forecast] = await self.api.async_get_forecast() + except CannotConnect as err: + _LOGGER.error("Error during login: %s", err) + raise UpdateFailed(f"Error during login: {err}") from err + try: + forecasts: list[Forecast] = await self.api.async_get_forecast() + except aiohttp.ClientError as err: + _LOGGER.error("Error getting forecasts: %s", err) + raise _LOGGER.debug("Updating sensor data with: %s", forecasts) # Because Opower provides historical usage/cost with a delay of a couple of days # we need to insert data into statistics. @@ -90,7 +100,12 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): async def _insert_statistics(self) -> None: """Insert Opower statistics.""" - for account in await self.api.async_get_accounts(): + try: + accounts = await self.api.async_get_accounts() + except aiohttp.ClientError as err: + _LOGGER.error("Error getting accounts: %s", err) + raise + for account in accounts: id_prefix = "_".join( ( self.api.utility.subdomain(), @@ -252,9 +267,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): start = datetime.fromtimestamp(start_time, tz=tz) - timedelta(days=30) end = dt_util.now(tz) _LOGGER.debug("Getting monthly cost reads: %s - %s", start, end) - cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.BILL, start, end - ) + try: + cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.BILL, start, end + ) + except aiohttp.ClientError as err: + _LOGGER.error("Error getting monthly cost reads: %s", err) + raise _LOGGER.debug("Got %s monthly cost reads", len(cost_reads)) if account.read_resolution == ReadResolution.BILLING: return cost_reads @@ -267,9 +286,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): assert start start = max(start, end - timedelta(days=3 * 365)) _LOGGER.debug("Getting daily cost reads: %s - %s", start, end) - daily_cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.DAY, start, end - ) + try: + daily_cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.DAY, start, end + ) + except aiohttp.ClientError as err: + _LOGGER.error("Error getting daily cost reads: %s", err) + raise _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) _update_with_finer_cost_reads(cost_reads, daily_cost_reads) if account.read_resolution == ReadResolution.DAY: @@ -281,9 +304,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): assert start start = max(start, end - timedelta(days=2 * 30)) _LOGGER.debug("Getting hourly cost reads: %s - %s", start, end) - hourly_cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.HOUR, start, end - ) + try: + hourly_cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.HOUR, start, end + ) + except aiohttp.ClientError as err: + _LOGGER.error("Error getting hourly cost reads: %s", err) + raise _LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads)) _update_with_finer_cost_reads(cost_reads, hourly_cost_reads) _LOGGER.debug("Got %s cost reads", len(cost_reads)) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 593e4cf34b8..bd68cc84d13 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.8.6"] + "requirements": ["opower==0.8.7"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 05a22dfbf1b..7f8eb22d1e6 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -21,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import OpowerConfigEntry from .const import DOMAIN from .coordinator import OpowerCoordinator @@ -183,11 +183,13 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: OpowerConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Opower sensor.""" - coordinator: OpowerCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[OpowerSensor] = [] forecasts = coordinator.data.values() for forecast in forecasts: diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py index a4aa98bbf69..da2993d1996 100644 --- a/homeassistant/components/opple/light.py +++ b/homeassistant/components/opple/light.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, LightEntity, @@ -20,10 +20,6 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_mired_to_kelvin as mired_to_kelvin, -) _LOGGER = logging.getLogger(__name__) @@ -58,6 +54,8 @@ class OppleLight(LightEntity): _attr_color_mode = ColorMode.COLOR_TEMP _attr_supported_color_modes = {ColorMode.COLOR_TEMP} + _attr_min_color_temp_kelvin = 3000 # 333 Mireds + _attr_max_color_temp_kelvin = 5700 # 175 Mireds def __init__(self, name, host): """Initialize an Opple light.""" @@ -67,7 +65,6 @@ class OppleLight(LightEntity): self._name = name self._is_on = None self._brightness = None - self._color_temp = None @property def available(self) -> bool: @@ -94,21 +91,6 @@ class OppleLight(LightEntity): """Return the brightness of the light.""" return self._brightness - @property - def color_temp(self): - """Return the color temperature of this light.""" - return kelvin_to_mired(self._color_temp) - - @property - def min_mireds(self): - """Return minimum supported color temperature.""" - return 175 - - @property - def max_mireds(self): - """Return maximum supported color temperature.""" - return 333 - def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" _LOGGER.debug("Turn on light %s %s", self._device.ip, kwargs) @@ -118,9 +100,11 @@ class OppleLight(LightEntity): if ATTR_BRIGHTNESS in kwargs and self.brightness != kwargs[ATTR_BRIGHTNESS]: self._device.brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_COLOR_TEMP in kwargs and self.color_temp != kwargs[ATTR_COLOR_TEMP]: - color_temp = mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) - self._device.color_temperature = color_temp + if ( + ATTR_COLOR_TEMP_KELVIN in kwargs + and self.color_temp_kelvin != kwargs[ATTR_COLOR_TEMP_KELVIN] + ): + self._device.color_temperature = kwargs[ATTR_COLOR_TEMP_KELVIN] def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" @@ -136,7 +120,7 @@ class OppleLight(LightEntity): prev_available == self.available and self._is_on == self._device.power_on and self._brightness == self._device.brightness - and self._color_temp == self._device.color_temperature + and self._attr_color_temp_kelvin == self._device.color_temperature ): return @@ -146,7 +130,7 @@ class OppleLight(LightEntity): self._is_on = self._device.power_on self._brightness = self._device.brightness - self._color_temp = self._device.color_temperature + self._attr_color_temp_kelvin = self._device.color_temperature if not self.is_on: _LOGGER.debug("Update light %s success: power off", self._device.ip) @@ -155,5 +139,5 @@ class OppleLight(LightEntity): "Update light %s success: power on brightness %s color temperature %s", self._device.ip, self._brightness, - self._color_temp, + self._attr_color_temp_kelvin, ) diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index b8f95c021fa..ca23265048f 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -2,15 +2,15 @@ "config": { "step": { "user": { - "title": "OSO Energy Auth", - "description": "Enter the generated 'Subscription Key' for your account at 'https://portal.osoenergy.no/'", + "title": "OSO Energy auth", + "description": "Enter the 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } }, "reauth": { - "title": "OSO Energy Auth", - "description": "Generate and enter a new 'Subscription Key' for your account at 'https://portal.osoenergy.no/'.", + "title": "OSO Energy auth", + "description": "Enter a new 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } @@ -95,11 +95,11 @@ "services": { "get_profile": { "name": "Get heater profile", - "description": "Get the temperature profile of water heater" + "description": "Gets the temperature profile for water heater" }, "set_profile": { "name": "Set heater profile", - "description": "Set the temperature profile of water heater", + "description": "Sets the temperature profile for water heater", "fields": { "hour_00": { "name": "00:00", @@ -201,7 +201,7 @@ }, "set_v40_min": { "name": "Set v40 min", - "description": "Set the minimum quantity of water at 40°C for a heater", + "description": "Sets the minimum quantity of water at 40°C for a heater", "fields": { "v40_min": { "name": "V40 Min", @@ -211,7 +211,7 @@ }, "turn_off": { "name": "Turn off heating", - "description": "Turn off heating for one hour or until min temperature is reached", + "description": "Turns off heating for one hour or until min temperature is reached", "fields": { "until_temp_limit": { "name": "Until temperature limit", @@ -221,7 +221,7 @@ }, "turn_on": { "name": "Turn on heating", - "description": "Turn on heating for one hour or until max temperature is reached", + "description": "Turns on heating for one hour or until max temperature is reached", "fields": { "until_temp_limit": { "name": "Until temperature limit", diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py index 0254c478b42..6ddd392af7b 100644 --- a/homeassistant/components/osramlightify/light.py +++ b/homeassistant/components/osramlightify/light.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -191,10 +191,7 @@ class Luminary(LightEntity): self._effect_list = [] self._is_on = False self._available = True - self._min_mireds = None - self._max_mireds = None self._brightness = None - self._color_temp = None self._rgb_color = None self._device_attributes = None @@ -256,11 +253,6 @@ class Luminary(LightEntity): """Return last hs color value set.""" return color_util.color_RGB_to_hs(*self._rgb_color) - @property - def color_temp(self): - """Return the color temperature.""" - return self._color_temp - @property def brightness(self): """Return brightness of the luminary (0..255).""" @@ -276,16 +268,6 @@ class Luminary(LightEntity): """List of supported effects.""" return self._effect_list - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return self._min_mireds - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return self._max_mireds - @property def unique_id(self): """Return a unique ID.""" @@ -326,12 +308,10 @@ class Luminary(LightEntity): self._rgb_color = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) self._luminary.set_rgb(*self._rgb_color, transition) - if ATTR_COLOR_TEMP in kwargs: - self._color_temp = kwargs[ATTR_COLOR_TEMP] - self._luminary.set_temperature( - int(color_util.color_temperature_mired_to_kelvin(self._color_temp)), - transition, - ) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] + self._attr_color_temp_kelvin = color_temp_kelvin + self._luminary.set_temperature(color_temp_kelvin, transition) self._is_on = True if ATTR_BRIGHTNESS in kwargs: @@ -362,10 +342,10 @@ class Luminary(LightEntity): self._attr_supported_features = self._get_supported_features() self._effect_list = self._get_effect_list() if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._min_mireds = color_util.color_temperature_kelvin_to_mired( + self._attr_max_color_temp_kelvin = ( self._luminary.max_temp() or DEFAULT_KELVIN ) - self._max_mireds = color_util.color_temperature_kelvin_to_mired( + self._attr_min_color_temp_kelvin = ( self._luminary.min_temp() or DEFAULT_KELVIN ) if len(self._attr_supported_color_modes) == 1: @@ -380,9 +360,7 @@ class Luminary(LightEntity): self._brightness = int(self._luminary.lum() * 2.55) if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._color_temp = color_util.color_temperature_kelvin_to_mired( - self._luminary.temp() or DEFAULT_KELVIN - ) + self._attr_color_temp_kelvin = self._luminary.temp() or DEFAULT_KELVIN if ColorMode.HS in self._attr_supported_color_modes: self._rgb_color = self._luminary.rgb() diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index ca0faa160f0..f4029f4aa9e 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.6.0"] + "requirements": ["python-otbr-api==2.7.0"] } diff --git a/homeassistant/components/otp/config_flow.py b/homeassistant/components/otp/config_flow.py index 33f63a04d68..8ddae9204c6 100644 --- a/homeassistant/components/otp/config_flow.py +++ b/homeassistant/components/otp/config_flow.py @@ -82,17 +82,6 @@ class TOTPConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import config from yaml.""" - - await self.async_set_unique_id(import_data[CONF_TOKEN]) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=import_data.get(CONF_NAME, DEFAULT_NAME), - data=import_data, - ) - async def async_step_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 4119d02da8b..255bc0ded34 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -5,59 +5,20 @@ from __future__ import annotations import time import pyotp -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_TOKEN -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN TIME_STEP = 30 # Default time step assumed by Google Authenticator -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_TOKEN): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the OTP sensor.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - breaks_in_ha_version="2025.1.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "One-Time Password (OTP)", - }, - ) - await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index ce877e15261..51efb52e55d 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -41,20 +41,24 @@ from .const import ( PLATFORMS, UPDATE_INTERVAL, UPDATE_INTERVAL_ALL_ASSUMED_STATE, + UPDATE_INTERVAL_LOCAL, ) from .coordinator import OverkizDataUpdateCoordinator @dataclass class HomeAssistantOverkizData: - """Overkiz data stored in the Home Assistant data object.""" + """Overkiz data stored in the runtime data object.""" coordinator: OverkizDataUpdateCoordinator platforms: defaultdict[Platform, list[Device]] scenarios: list[Scenario] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type OverkizDataConfigEntry = ConfigEntry[HomeAssistantOverkizData] + + +async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) -> bool: """Set up Overkiz from a config entry.""" client: OverkizClient | None = None api_type = entry.data.get(CONF_API_TYPE, APIType.CLOUD) @@ -113,17 +117,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if coordinator.is_stateless: LOGGER.debug( - ( - "All devices have an assumed state. Update interval has been reduced" - " to: %s" - ), + "All devices have an assumed state. Update interval has been reduced to: %s", UPDATE_INTERVAL_ALL_ASSUMED_STATE, ) - coordinator.update_interval = UPDATE_INTERVAL_ALL_ASSUMED_STATE + coordinator.set_update_interval(UPDATE_INTERVAL_ALL_ASSUMED_STATE) + + if api_type == APIType.LOCAL: + LOGGER.debug( + "Devices connect via Local API. Update interval has been reduced to: %s", + UPDATE_INTERVAL_LOCAL, + ) + coordinator.set_update_interval(UPDATE_INTERVAL_LOCAL) platforms: defaultdict[Platform, list[Device]] = defaultdict(list) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantOverkizData( + entry.runtime_data = HomeAssistantOverkizData( coordinator=coordinator, platforms=platforms, scenarios=scenarios ) @@ -162,17 +170,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: OverkizDataConfigEntry +) -> bool: """Unload a config entry.""" - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def _async_migrate_entries( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: OverkizDataConfigEntry ) -> bool: """Migrate old entries to new unique IDs.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py index bdbf4d0cc8d..90c135291c3 100644 --- a/homeassistant/components/overkiz/alarm_control_panel.py +++ b/homeassistant/components/overkiz/alarm_control_panel.py @@ -16,14 +16,12 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantOverkizData -from .const import DOMAIN +from . import OverkizDataConfigEntry from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity @@ -210,11 +208,11 @@ SUPPORTED_DEVICES = {description.key: description for description in ALARM_DESCR async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz alarm control panel from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( OverkizAlarmControlPanel( diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 57df3cd4e09..3a75cd77c2f 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -14,12 +14,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantOverkizData -from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES +from . import OverkizDataConfigEntry +from .const import IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity @@ -143,11 +142,11 @@ SUPPORTED_STATES = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz binary sensors from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[OverkizBinarySensor] = [] for device in data.coordinator.data.values(): diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py index 5a1116aeeb5..92711ac8ca8 100644 --- a/homeassistant/components/overkiz/button.py +++ b/homeassistant/components/overkiz/button.py @@ -4,17 +4,20 @@ from __future__ import annotations from dataclasses import dataclass -from pyoverkiz.enums import OverkizCommand +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam from pyoverkiz.types import StateType as OverkizStateType -from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantOverkizData -from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES +from . import OverkizDataConfigEntry +from .const import IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity @@ -28,41 +31,48 @@ class OverkizButtonDescription(ButtonEntityDescription): BUTTON_DESCRIPTIONS: list[OverkizButtonDescription] = [ # My Position (cover, light) OverkizButtonDescription( - key="my", + key=OverkizCommand.MY, name="My position", icon="mdi:star", ), # Identify OverkizButtonDescription( - key="identify", # startIdentify and identify are reversed... Swap this when fixed in API. + key=OverkizCommand.IDENTIFY, # startIdentify and identify are reversed... Swap this when fixed in API. name="Start identify", icon="mdi:human-greeting-variant", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), OverkizButtonDescription( - key="stopIdentify", + key=OverkizCommand.STOP_IDENTIFY, name="Stop identify", icon="mdi:human-greeting-variant", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), OverkizButtonDescription( - key="startIdentify", # startIdentify and identify are reversed... Swap this when fixed in API. + key=OverkizCommand.START_IDENTIFY, # startIdentify and identify are reversed... Swap this when fixed in API. name="Identify", icon="mdi:human-greeting-variant", entity_category=EntityCategory.DIAGNOSTIC, + device_class=ButtonDeviceClass.IDENTIFY, ), # RTDIndoorSiren / RTDOutdoorSiren - OverkizButtonDescription(key="dingDong", name="Ding dong", icon="mdi:bell-ring"), - OverkizButtonDescription(key="bip", name="Bip", icon="mdi:bell-ring"), OverkizButtonDescription( - key="fastBipSequence", name="Fast bip sequence", icon="mdi:bell-ring" + key=OverkizCommand.DING_DONG, name="Ding dong", icon="mdi:bell-ring" + ), + OverkizButtonDescription(key=OverkizCommand.BIP, name="Bip", icon="mdi:bell-ring"), + OverkizButtonDescription( + key=OverkizCommand.FAST_BIP_SEQUENCE, + name="Fast bip sequence", + icon="mdi:bell-ring", + ), + OverkizButtonDescription( + key=OverkizCommand.RING, name="Ring", icon="mdi:bell-ring" ), - OverkizButtonDescription(key="ring", name="Ring", icon="mdi:bell-ring"), # DynamicScreen (ogp:blind) uses goToAlias (id 1: favorite1) instead of 'my' OverkizButtonDescription( - key="goToAlias", + key=OverkizCommand.GO_TO_ALIAS, press_args="1", name="My position", icon="mdi:star", @@ -72,6 +82,14 @@ BUTTON_DESCRIPTIONS: list[OverkizButtonDescription] = [ name="Toggle", icon="mdi:sync", ), + # SmokeSensor + OverkizButtonDescription( + key=OverkizCommand.CHECK_EVENT_TRIGGER, + press_args=OverkizCommandParam.SHORT, + name="Test", + icon="mdi:smoke-detector", + entity_category=EntityCategory.DIAGNOSTIC, + ), ] SUPPORTED_COMMANDS = { @@ -81,11 +99,11 @@ SUPPORTED_COMMANDS = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz button from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[ButtonEntity] = [] for device in data.coordinator.data.values(): diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py index 97840df7a41..3276a1979cc 100644 --- a/homeassistant/components/overkiz/climate/__init__.py +++ b/homeassistant/components/overkiz/climate/__init__.py @@ -7,14 +7,12 @@ from enum import StrEnum, unique from pyoverkiz.enums import Protocol from pyoverkiz.enums.ui import UIWidget -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .. import HomeAssistantOverkizData -from ..const import DOMAIN +from .. import OverkizDataConfigEntry from .atlantic_electrical_heater import AtlanticElectricalHeater from .atlantic_electrical_heater_with_adjustable_temperature_setpoint import ( AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint, @@ -27,8 +25,10 @@ from .atlantic_pass_apc_heat_pump_main_component import ( from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl from .atlantic_pass_apc_zone_control_zone import AtlanticPassAPCZoneControlZone +from .evo_home_controller import EvoHomeController from .hitachi_air_to_air_heat_pump_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI from .hitachi_air_to_air_heat_pump_ovp import HitachiAirToAirHeatPumpOVP +from .hitachi_air_to_water_heating_zone import HitachiAirToWaterHeatingZone from .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface from .somfy_thermostat import SomfyThermostat from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface @@ -53,6 +53,8 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: AtlanticHeatRecoveryVentilation, UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, + UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: HitachiAirToWaterHeatingZone, + UIWidget.EVO_HOME_CONTROLLER: EvoHomeController, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, @@ -79,11 +81,11 @@ WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz climate from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data # Match devices based on the widget. entities_based_on_widget: list[Entity] = [ diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py index ce9857f9d8c..059e64ef55d 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py @@ -54,7 +54,6 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 64a7dc1e645..93c7d03293b 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -76,7 +76,6 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py index e49fc4358e9..0b5ba3ffcc7 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py @@ -46,7 +46,6 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator @@ -85,12 +84,15 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): ) @property - def target_temperature(self) -> None: - """Return the temperature.""" - if self.hvac_mode == HVACMode.AUTO: - self.executor.select_state(OverkizState.IO_EFFECTIVE_TEMPERATURE_SETPOINT) - else: - self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE) + def target_temperature(self) -> float | None: + """Return the target temperature.""" + state = ( + OverkizState.IO_EFFECTIVE_TEMPERATURE_SETPOINT + if self.hvac_mode == HVACMode.AUTO + else OverkizState.CORE_TARGET_TEMPERATURE + ) + + return cast(float, self.executor.select_state(state)) @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py b/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py index f1d96b5687b..bb84fa76f22 100644 --- a/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py +++ b/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py @@ -55,7 +55,6 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py index 1cd13205b13..800516e4bda 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py @@ -41,7 +41,6 @@ class AtlanticPassAPCHeatPumpMainComponent(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py index 3da2ccc922b..3df31fb44fc 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py @@ -92,7 +92,6 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py index 7fbab821b8d..7846b058619 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py @@ -31,7 +31,6 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): _attr_supported_features = ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py index 5ba9dabe038..eff1d5fa130 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py @@ -5,7 +5,7 @@ from __future__ import annotations from asyncio import sleep from typing import Any, cast -from propcache import cached_property +from propcache.api import cached_property from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from homeassistant.components.climate import ( diff --git a/homeassistant/components/overkiz/climate/evo_home_controller.py b/homeassistant/components/overkiz/climate/evo_home_controller.py new file mode 100644 index 00000000000..272acbb13b9 --- /dev/null +++ b/homeassistant/components/overkiz/climate/evo_home_controller.py @@ -0,0 +1,101 @@ +"""Support for EvoHomeController.""" + +from datetime import timedelta + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import UnitOfTemperature +import homeassistant.util.dt as dt_util + +from ..entity import OverkizDataUpdateCoordinator, OverkizEntity + +PRESET_DAY_OFF = "day-off" +PRESET_HOLIDAYS = "holidays" + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + OverkizCommandParam.AUTO: HVACMode.AUTO, + OverkizCommandParam.OFF: HVACMode.OFF, +} +HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} + +OVERKIZ_TO_PRESET_MODES: dict[str, str] = { + OverkizCommandParam.DAY_OFF: PRESET_DAY_OFF, + OverkizCommandParam.HOLIDAYS: PRESET_HOLIDAYS, +} +PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} + + +class EvoHomeController(OverkizEntity, ClimateEntity): + """Representation of EvoHomeController device.""" + + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TURN_OFF + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + if self._attr_device_info: + self._attr_device_info["manufacturer"] = "EvoHome" + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if state := self.device.states.get(OverkizState.RAMSES_RAMSES_OPERATING_MODE): + operating_mode = state.value_as_str + + if operating_mode in OVERKIZ_TO_HVAC_MODES: + return OVERKIZ_TO_HVAC_MODES[operating_mode] + + if operating_mode in OVERKIZ_TO_PRESET_MODES: + return HVACMode.OFF + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_OPERATING_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] + ) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if ( + state := self.device.states[OverkizState.RAMSES_RAMSES_OPERATING_MODE] + ) and state.value_as_str in OVERKIZ_TO_PRESET_MODES: + return OVERKIZ_TO_PRESET_MODES[state.value_as_str] + + return PRESET_NONE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_DAY_OFF: + today_end_of_day = dt_util.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + timedelta(days=1) + time_interval = today_end_of_day + + if preset_mode == PRESET_HOLIDAYS: + one_week_from_now = dt_util.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + timedelta(days=7) + time_interval = one_week_from_now + + await self.executor.async_execute_command( + OverkizCommand.SET_OPERATING_MODE, + PRESET_MODES_TO_OVERKIZ[preset_mode], + time_interval.strftime("%Y/%m/%d %H:%M"), + ) diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py index efdae2165a9..41da90f1ce8 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py @@ -91,7 +91,6 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): _attr_target_temperature_step = 1.0 _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py index b31ecf91ec0..f60cbbeca2b 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py @@ -95,7 +95,6 @@ class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity): _attr_target_temperature_step = 1.0 _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py new file mode 100644 index 00000000000..c5465128bba --- /dev/null +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py @@ -0,0 +1,123 @@ +"""Support for HitachiAirToWaterHeatingZone.""" + +from __future__ import annotations + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..const import DOMAIN +from ..entity import OverkizDataUpdateCoordinator, OverkizEntity + +OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { + OverkizCommandParam.MANU: HVACMode.HEAT, + OverkizCommandParam.AUTO: HVACMode.AUTO, +} + +HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} + +OVERKIZ_TO_PRESET_MODE: dict[str, str] = { + OverkizCommandParam.COMFORT: PRESET_COMFORT, + OverkizCommandParam.ECO: PRESET_ECO, +} + +PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()} + + +class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): + """Representation of HitachiAirToWaterHeatingZone.""" + + _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] + _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_min_temp = 5.0 + _attr_max_temp = 35.0 + _attr_precision = 0.1 + _attr_target_temperature_step = 0.5 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + if self._attr_device_info: + self._attr_device_info["manufacturer"] = "Hitachi" + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if ( + state := self.device.states[OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1] + ) and state.value_as_str: + return OVERKIZ_TO_HVAC_MODE[state.value_as_str] + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_AUTO_MANU_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] + ) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if ( + state := self.device.states[OverkizState.MODBUS_YUTAKI_TARGET_MODE] + ) and state.value_as_str: + return OVERKIZ_TO_PRESET_MODE[state.value_as_str] + + return PRESET_NONE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_TARGET_MODE, PRESET_MODE_TO_OVERKIZ[preset_mode] + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + current_temperature = self.device.states[ + OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1 + ] + + if current_temperature: + return current_temperature.value_as_float + + return None + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + target_temperature = self.device.states[ + OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1 + ] + + if target_temperature: + return target_temperature.value_as_float + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = cast(float, kwargs.get(ATTR_TEMPERATURE)) + + await self.executor.async_execute_command( + OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, float(temperature) + ) diff --git a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py index acc761664ec..5ca17f9b6b1 100644 --- a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py @@ -82,7 +82,6 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 _attr_max_temp = 26.0 - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate/somfy_thermostat.py b/homeassistant/components/overkiz/climate/somfy_thermostat.py index 829a3bad03b..d2aa1658302 100644 --- a/homeassistant/components/overkiz/climate/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate/somfy_thermostat.py @@ -57,15 +57,11 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 @@ -83,11 +79,12 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" - return OVERKIZ_TO_HVAC_MODES[ - cast( - str, self.executor.select_state(OverkizState.CORE_DEROGATION_ACTIVATION) - ) - ] + if derogation_activation := self.executor.select_state( + OverkizState.CORE_DEROGATION_ACTIVATION + ): + return OVERKIZ_TO_HVAC_MODES[cast(str, derogation_activation)] + + return HVACMode.AUTO @property def preset_mode(self) -> str: @@ -97,9 +94,10 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): else: state_key = OverkizState.SOMFY_THERMOSTAT_DEROGATION_HEATING_MODE - state = cast(str, self.executor.select_state(state_key)) + if state := self.executor.select_state(state_key): + return OVERKIZ_TO_PRESET_MODES[OverkizCommandParam(cast(str, state))] - return OVERKIZ_TO_PRESET_MODES[OverkizCommandParam(state)] + return PRESET_NONE @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py index e2165e8b6c6..54c00b33167 100644 --- a/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py @@ -56,7 +56,6 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index 471a13d0de2..af955e5fb95 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -23,7 +23,6 @@ from pyoverkiz.obfuscate import obfuscate_id from pyoverkiz.utils import generate_local_server, is_overkiz_gateway import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -34,6 +33,8 @@ from homeassistant.const import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER @@ -76,7 +77,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): for gateway in gateways: if is_overkiz_gateway(gateway.id): gateway_id = gateway.id - await self.async_set_unique_id(gateway_id) + await self.async_set_unique_id(gateway_id, raise_on_progress=False) return user_input @@ -151,9 +152,11 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): except BadCredentialsException as exception: # If authentication with CozyTouch auth server is valid, but token is invalid # for Overkiz API server, the hardware is not supported. - if user_input[CONF_HUB] == Server.ATLANTIC_COZYTOUCH and not isinstance( - exception, CozyTouchBadCredentialsException - ): + if user_input[CONF_HUB] in { + Server.ATLANTIC_COZYTOUCH, + Server.SAUTER_COZYTOUCH, + Server.THERMOR_COZYTOUCH, + } and not isinstance(exception, CozyTouchBadCredentialsException): description_placeholders["unsupported_device"] = "CozyTouch" errors["base"] = "unsupported_hardware" else: @@ -271,7 +274,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" hostname = discovery_info.hostname @@ -282,7 +285,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): return await self._process_discovery(gateway_id) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle ZeroConf discovery.""" properties = discovery_info.properties diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index a90260e0f0f..7f5f4ad85bd 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -44,6 +44,7 @@ DEFAULT_SERVER: Final = Server.SOMFY_EUROPE DEFAULT_HOST: Final = "gateway-xxxx-xxxx-xxxx.local:8443" UPDATE_INTERVAL: Final = timedelta(seconds=30) +UPDATE_INTERVAL_LOCAL: Final = timedelta(seconds=5) UPDATE_INTERVAL_ALL_ASSUMED_STATE: Final = timedelta(minutes=60) PLATFORMS: list[Platform] = [ @@ -101,7 +102,9 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) + UIWidget.EVO_HOME_CONTROLLER: Platform.CLIMATE, # widgetName, uiClass is EvoHome (not supported) UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.HITACHI_DHW: Platform.WATER_HEATER, # widgetName, uiClass is HitachiHeatingSystem (not supported) UIWidget.MY_FOX_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.MY_FOX_SECURITY_CAMERA: Platform.SWITCH, # widgetName, uiClass is Camera (not supported) @@ -141,8 +144,8 @@ OVERKIZ_UNIT_TO_HA: dict[str, str] = { MeasuredValueType.ELECTRICAL_POWER_IN_W: UnitOfPower.WATT, MeasuredValueType.ELECTRIC_CURRENT_IN_AMPERE: UnitOfElectricCurrent.AMPERE, MeasuredValueType.ELECTRIC_CURRENT_IN_MILLI_AMPERE: UnitOfElectricCurrent.MILLIAMPERE, - MeasuredValueType.ENERGY_IN_CAL: "cal", - MeasuredValueType.ENERGY_IN_KCAL: "kcal", + MeasuredValueType.ENERGY_IN_CAL: UnitOfEnergy.CALORIE, + MeasuredValueType.ENERGY_IN_KCAL: UnitOfEnergy.KILO_CALORIE, MeasuredValueType.FLOW_IN_LITRE_PER_SECOND: f"{UnitOfVolume.LITERS}/{UnitOfTime.SECONDS}", MeasuredValueType.FLOW_IN_METER_CUBE_PER_HOUR: UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, MeasuredValueType.FLOW_IN_METER_CUBE_PER_SECOND: f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.SECONDS}", diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 17068d26b7c..484ef138cf7 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -26,7 +26,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.decorator import Registry -from .const import DOMAIN, LOGGER, UPDATE_INTERVAL +from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER EVENT_HANDLERS: Registry[ str, Callable[[OverkizDataUpdateCoordinator, Event], Coroutine[Any, Any, None]] @@ -36,6 +36,8 @@ EVENT_HANDLERS: Registry[ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): """Class to manage fetching data from Overkiz platform.""" + _default_update_interval: timedelta + def __init__( self, hass: HomeAssistant, @@ -45,7 +47,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): client: OverkizClient, devices: list[Device], places: Place | None, - update_interval: timedelta | None = None, + update_interval: timedelta, config_entry_id: str, ) -> None: """Initialize global data updater.""" @@ -59,12 +61,17 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): self.data = {} self.client = client self.devices: dict[str, Device] = {d.device_url: d for d in devices} - self.is_stateless = all( - device.protocol in (Protocol.RTS, Protocol.INTERNAL) for device in devices - ) self.executions: dict[str, dict[str, str]] = {} self.areas = self._places_to_area(places) if places else None self.config_entry_id = config_entry_id + self._default_update_interval = update_interval + + self.is_stateless = all( + device.protocol in (Protocol.RTS, Protocol.INTERNAL) + for device in devices + if device.widget not in IGNORED_OVERKIZ_DEVICES + and device.ui_class not in IGNORED_OVERKIZ_DEVICES + ) async def _async_update_data(self) -> dict[str, Device]: """Fetch Overkiz data via event listener.""" @@ -102,8 +109,9 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): if event_handler := EVENT_HANDLERS.get(event.name): await event_handler(self, event) + # Restore the default update interval if no executions are pending if not self.executions: - self.update_interval = UPDATE_INTERVAL + self.update_interval = self._default_update_interval return self.devices @@ -124,6 +132,11 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): return areas + def set_update_interval(self, update_interval: timedelta) -> None: + """Set the update interval and store this value.""" + self.update_interval = update_interval + self._default_update_interval = update_interval + @EVENT_HANDLERS.register(EventName.DEVICE_AVAILABLE) async def on_device_available( diff --git a/homeassistant/components/overkiz/cover/__init__.py b/homeassistant/components/overkiz/cover/__init__.py index f9df3256253..38c02eba1bb 100644 --- a/homeassistant/components/overkiz/cover/__init__.py +++ b/homeassistant/components/overkiz/cover/__init__.py @@ -2,23 +2,23 @@ from pyoverkiz.enums import OverkizCommand, UIClass -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .. import HomeAssistantOverkizData -from ..const import DOMAIN +from .. import OverkizDataConfigEntry from .awning import Awning from .generic_cover import OverkizGenericCover from .vertical_cover import LowSpeedCover, VerticalCover async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: OverkizDataConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz covers from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[OverkizGenericCover] = [ Awning(device.device_url, data.coordinator) diff --git a/homeassistant/components/overkiz/diagnostics.py b/homeassistant/components/overkiz/diagnostics.py index 427230b9c82..dae0c6c59cf 100644 --- a/homeassistant/components/overkiz/diagnostics.py +++ b/homeassistant/components/overkiz/diagnostics.py @@ -7,20 +7,18 @@ from typing import Any from pyoverkiz.enums import APIType from pyoverkiz.obfuscate import obfuscate_id -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from . import HomeAssistantOverkizData -from .const import CONF_API_TYPE, CONF_HUB, DOMAIN +from . import OverkizDataConfigEntry +from .const import CONF_API_TYPE, CONF_HUB async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: OverkizDataConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - entry_data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] - client = entry_data.coordinator.client + client = entry.runtime_data.coordinator.client data = { "setup": await client.get_diagnostic_data(), @@ -39,11 +37,10 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: OverkizDataConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" - entry_data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] - client = entry_data.coordinator.client + client = entry.runtime_data.coordinator.client device_url = min(device.identifiers)[1] diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index 02829eaf1a3..220c6fe7cb2 100644 --- a/homeassistant/components/overkiz/executor.py +++ b/homeassistant/components/overkiz/executor.py @@ -6,7 +6,7 @@ from typing import Any, cast from urllib.parse import urlparse from pyoverkiz.enums import OverkizCommand, Protocol -from pyoverkiz.exceptions import OverkizException +from pyoverkiz.exceptions import BaseOverkizException from pyoverkiz.models import Command, Device, StateDefinition from pyoverkiz.types import StateType as OverkizStateType @@ -105,7 +105,7 @@ class OverkizExecutor: "Home Assistant", ) # Catch Overkiz exceptions to support `continue_on_error` functionality - except OverkizException as exception: + except BaseOverkizException as exception: raise HomeAssistantError(exception) from exception # ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here diff --git a/homeassistant/components/overkiz/light.py b/homeassistant/components/overkiz/light.py index 18d724dd63a..933d4cf695b 100644 --- a/homeassistant/components/overkiz/light.py +++ b/homeassistant/components/overkiz/light.py @@ -12,24 +12,22 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantOverkizData -from .const import DOMAIN +from . import OverkizDataConfigEntry from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz lights from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( OverkizLight(device.device_url, data.coordinator) diff --git a/homeassistant/components/overkiz/lock.py b/homeassistant/components/overkiz/lock.py index 2494903d076..1c073d2f9aa 100644 --- a/homeassistant/components/overkiz/lock.py +++ b/homeassistant/components/overkiz/lock.py @@ -7,23 +7,21 @@ from typing import Any from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantOverkizData -from .const import DOMAIN +from . import OverkizDataConfigEntry from .entity import OverkizEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz locks from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( OverkizLock(device.device_url, data.coordinator) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 8c750aec6bd..eda39821d5c 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -1,14 +1,7 @@ { "domain": "overkiz", "name": "Overkiz", - "codeowners": [ - "@imicknl", - "@vlebourl", - "@tetienne", - "@nyroDev", - "@tronix117", - "@alexfp14" - ], + "codeowners": ["@imicknl"], "config_flow": true, "dhcp": [ { @@ -20,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.15.0"], + "requirements": ["pyoverkiz==1.15.5"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 494d430c393..0e03e822424 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -14,13 +14,12 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantOverkizData -from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES +from . import OverkizDataConfigEntry +from .const import IGNORED_OVERKIZ_DEVICES from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity @@ -191,11 +190,11 @@ SUPPORTED_STATES = {description.key: description for description in NUMBER_DESCR async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz number from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[OverkizNumber] = [] for device in data.coordinator.data.values(): diff --git a/homeassistant/components/overkiz/scene.py b/homeassistant/components/overkiz/scene.py index 8cbbb9dbe5d..4533ed3245c 100644 --- a/homeassistant/components/overkiz/scene.py +++ b/homeassistant/components/overkiz/scene.py @@ -8,21 +8,19 @@ from pyoverkiz.client import OverkizClient from pyoverkiz.models import Scenario from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantOverkizData -from .const import DOMAIN +from . import OverkizDataConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz scenes from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( OverkizScene(scene, data.coordinator.client) for scene in data.scenarios diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index 83cdc9c4f2b..ac467eaaa7a 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -8,13 +8,12 @@ from dataclasses import dataclass from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantOverkizData -from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES +from . import OverkizDataConfigEntry +from .const import IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity @@ -129,11 +128,11 @@ SUPPORTED_STATES = {description.key: description for description in SELECT_DESCR async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz select from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[OverkizSelect] = [] for device in data.coordinator.data.values(): diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 5c54a1bd383..81a9ab41d2d 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, @@ -34,7 +33,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import HomeAssistantOverkizData +from . import OverkizDataConfigEntry from .const import ( DOMAIN, IGNORED_OVERKIZ_DEVICES, @@ -423,7 +422,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ OverkizSensorDescription( key=OverkizState.CORE_REMAINING_HOT_WATER, name="Warm water remaining", - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.VOLUME_STORAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfVolume.LITERS, ), @@ -458,6 +457,24 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), + # HitachiHeatingSystem/HitachiAirToWaterHeatingZone + OverkizSensorDescription( + key=OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1, + name="Room ambient temperature", + native_value=lambda value: cast(float, value), + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + # HitachiHeatingSystem/HitachiAirToWaterMainComponent + OverkizSensorDescription( + key=OverkizState.MODBUS_OUTDOOR_AMBIENT_TEMPERATURE, + name="Outdoor ambient temperature", + native_value=lambda value: cast(int, value), + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), ] SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCRIPTIONS} @@ -465,11 +482,11 @@ SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCR async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz sensors from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[SensorEntity] = [] for device in data.coordinator.data.values(): @@ -517,8 +534,7 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): # This is probably incorrect and should be fixed in a follow up PR. # To ensure measurement sensors do not get an `unknown` state on # a falsy value (e.g. 0 or 0.0) we also check the state_class. - or self.state_class != SensorStateClass.MEASUREMENT - and not state.value + or (self.state_class != SensorStateClass.MEASUREMENT and not state.value) ): return None diff --git a/homeassistant/components/overkiz/siren.py b/homeassistant/components/overkiz/siren.py index a7ba41e2fef..f7246e50ec0 100644 --- a/homeassistant/components/overkiz/siren.py +++ b/homeassistant/components/overkiz/siren.py @@ -10,23 +10,21 @@ from homeassistant.components.siren import ( SirenEntity, SirenEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantOverkizData -from .const import DOMAIN +from . import OverkizDataConfigEntry from .entity import OverkizEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz sirens from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( OverkizSiren(device.device_url, data.coordinator) diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index a756df4d0d6..0c564a003d6 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -6,12 +6,18 @@ "description": "Select your server. The Overkiz platform is used by various vendors like Somfy (Connexoon / TaHoma), Hitachi (Hi Kumo) and Atlantic (Cozytouch).", "data": { "hub": "Server" + }, + "data_description": { + "hub": "Select the mobile app that you use to control your devices." } }, "local_or_cloud": { - "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices and scenarios are not supported in local API.", + "description": "Choose how you want to connect to your gateway.", "data": { "api_type": "API type" + }, + "data_description": { + "api_type": "Local API is only supported by TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices and scenarios are **not** available via the local API." } }, "cloud": { @@ -19,15 +25,25 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The username of your cloud account (app).", + "password": "The password of your cloud account (app)." } }, "local": { - "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network. \n\n After activation, enter your application credentials and change the host to include your gateway-pin or enter the IP address of your gateway.", + "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\nAfter activation, enter your application credentials and change the host to include your Gateway PIN or enter the IP address of your gateway.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your Overkiz hub.", + "username": "The username of your cloud account (app).", + "password": "The password of your cloud account (app).", + "verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname." } } }, diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index ac3ea351559..c921dbab776 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -15,13 +15,11 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantOverkizData -from .const import DOMAIN +from . import OverkizDataConfigEntry from .entity import OverkizDescriptiveEntity @@ -111,11 +109,11 @@ SUPPORTED_DEVICES = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz switch from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( OverkizSwitch( diff --git a/homeassistant/components/overkiz/water_heater.py b/homeassistant/components/overkiz/water_heater.py deleted file mode 100644 index 99bfb279e4c..00000000000 --- a/homeassistant/components/overkiz/water_heater.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Support for Overkiz water heater devices.""" - -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import HomeAssistantOverkizData -from .const import DOMAIN -from .entity import OverkizEntity -from .water_heater_entities import ( - CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY, - WIDGET_TO_WATER_HEATER_ENTITY, -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Overkiz DHW from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] - entities: list[OverkizEntity] = [] - - for device in data.platforms[Platform.WATER_HEATER]: - if device.controllable_name in CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY: - entities.append( - CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY[device.controllable_name]( - device.device_url, data.coordinator - ) - ) - elif device.widget in WIDGET_TO_WATER_HEATER_ENTITY: - entities.append( - WIDGET_TO_WATER_HEATER_ENTITY[device.widget]( - device.device_url, data.coordinator - ) - ) - - async_add_entities(entities) diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py index 1fb5e5696bd..1dd1d596a33 100644 --- a/homeassistant/components/overkiz/water_heater/__init__.py +++ b/homeassistant/components/overkiz/water_heater/__init__.py @@ -4,13 +4,11 @@ from __future__ import annotations from pyoverkiz.enums.ui import UIWidget -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .. import HomeAssistantOverkizData -from ..const import DOMAIN +from .. import OverkizDataConfigEntry from ..entity import OverkizEntity from .atlantic_domestic_hot_water_production_mlb_component import ( AtlanticDomesticHotWaterProductionMBLComponent, @@ -22,11 +20,11 @@ from .hitachi_dhw import HitachiDHW async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OverkizDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Overkiz DHW from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[OverkizEntity] = [] for device in data.platforms[Platform.WATER_HEATER]: diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py index 1b2a1e218d4..8ba2c1678c2 100644 --- a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py +++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py @@ -13,6 +13,7 @@ from homeassistant.components.water_heater import ( WaterHeaterEntityFeature, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.util import dt as dt_util from .. import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -153,11 +154,11 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE async def async_turn_away_mode_on(self) -> None: """Turn away mode on. - This requires the start date and the end date to be also set. + This requires the start date and the end date to be also set, and those dates have to match the device datetime. The API accepts setting dates in the format of the core:DateTimeState state for the DHW - {'day': 11, 'hour': 21, 'minute': 12, 'month': 7, 'second': 53, 'weekday': 3, 'year': 2024}) - The dict is then passed as an away mode start date, and then as an end date, but with the year incremented by 1, - so the away mode is getting turned on for the next year. + {'day': 11, 'hour': 21, 'minute': 12, 'month': 7, 'second': 53, 'weekday': 3, 'year': 2024} + The dict is then passed as an actual device date, the away mode start date, and then as an end date, + but with the year incremented by 1, so the away mode is getting turned on for the next year. The weekday number seems to have no effect so the calculation of the future date's weekday number is redundant, but possible via homeassistant dt_util to form both start and end dates dictionaries from scratch based on datetime.now() and datetime.timedelta into the future. @@ -167,13 +168,19 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE With `refresh_afterwards=False` on the first commands, and `refresh_afterwards=True` only the last command, the API is not choking and the transition is smooth without the unavailability state. """ - now_date = cast( - dict, - self.executor.select_state(OverkizState.CORE_DATETIME), - ) + now = dt_util.now() + now_date = { + "month": now.month, + "hour": now.hour, + "year": now.year, + "weekday": now.weekday(), + "day": now.day, + "minute": now.minute, + "second": now.second, + } await self.executor.async_execute_command( - OverkizCommand.SET_ABSENCE_MODE, - OverkizCommandParam.PROG, + OverkizCommand.SET_DATE_TIME, + now_date, refresh_afterwards=False, ) await self.executor.async_execute_command( @@ -183,7 +190,11 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE await self.executor.async_execute_command( OverkizCommand.SET_ABSENCE_END_DATE, now_date, refresh_afterwards=False ) - + await self.executor.async_execute_command( + OverkizCommand.SET_ABSENCE_MODE, + OverkizCommandParam.PROG, + refresh_afterwards=False, + ) await self.coordinator.async_refresh() async def async_turn_away_mode_off(self) -> None: diff --git a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py index abd3f40adc2..f5a9e3d4a7e 100644 --- a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py +++ b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py @@ -64,10 +64,8 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity): for param, mode in OVERKIZ_TO_OPERATION_MODE.items(): # Filter only for mode allowed by this device # or allow all if no mode definition found - if ( - not state_mode_definition - or state_mode_definition.values - and param in state_mode_definition.values + if not state_mode_definition or ( + state_mode_definition.values and param in state_mode_definition.values ): self.operation_mode_to_overkiz[mode] = param self._attr_operation_list.append(param) diff --git a/homeassistant/components/overkiz/water_heater/hitachi_dhw.py b/homeassistant/components/overkiz/water_heater/hitachi_dhw.py index dc2a93a8d2f..988c66afdb0 100644 --- a/homeassistant/components/overkiz/water_heater/hitachi_dhw.py +++ b/homeassistant/components/overkiz/water_heater/hitachi_dhw.py @@ -48,8 +48,10 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity): def current_temperature(self) -> float | None: """Return the current temperature.""" current_temperature = self.device.states[OverkizState.CORE_DHW_TEMPERATURE] - if current_temperature: - return current_temperature.value_as_float + + if current_temperature and current_temperature.value_as_int: + return float(current_temperature.value_as_int) + return None @property @@ -58,13 +60,14 @@ class HitachiDHW(OverkizEntity, WaterHeaterEntity): target_temperature = self.device.states[ OverkizState.MODBUS_CONTROL_DHW_SETTING_TEMPERATURE ] - if target_temperature: - return target_temperature.value_as_float + + if target_temperature and target_temperature.value_as_int: + return float(target_temperature.value_as_int) + return None async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - await self.executor.async_execute_command( OverkizCommand.SET_CONTROL_DHW_SETTING_TEMPERATURE, int(kwargs[ATTR_TEMPERATURE]), diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py new file mode 100644 index 00000000000..597d44f66cf --- /dev/null +++ b/homeassistant/components/overseerr/__init__.py @@ -0,0 +1,182 @@ +"""The Overseerr integration.""" + +from __future__ import annotations + +import json +from typing import cast + +from aiohttp.hdrs import METH_POST +from aiohttp.web_request import Request +from aiohttp.web_response import Response +from python_overseerr import OverseerrConnectionError + +from homeassistant.components import cloud +from homeassistant.components.webhook import ( + async_generate_url, + async_register, + async_unregister, +) +from homeassistant.const import CONF_WEBHOOK_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.http import HomeAssistantView +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, EVENT_KEY, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS +from .coordinator import OverseerrConfigEntry, OverseerrCoordinator +from .services import setup_services + +PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR] +CONF_CLOUDHOOK_URL = "cloudhook_url" + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Overseerr component.""" + setup_services(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> bool: + """Set up Overseerr from a config entry.""" + + coordinator = OverseerrCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + webhook_manager = OverseerrWebhookManager(hass, entry) + + try: + await webhook_manager.register_webhook() + except OverseerrConnectionError: + LOGGER.error("Failed to register Overseerr webhook") + + entry.async_on_unload(webhook_manager.unregister_webhook) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> None: + """Cleanup when entry is removed.""" + if cloud.async_active_subscription(hass): + try: + LOGGER.debug( + "Removing Overseerr cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) + except cloud.CloudNotAvailable: + pass + + +class OverseerrWebhookManager: + """Overseerr webhook manager.""" + + def __init__(self, hass: HomeAssistant, entry: OverseerrConfigEntry) -> None: + """Initialize Overseerr webhook manager.""" + self.hass = hass + self.entry = entry + self.client = entry.runtime_data.client + + @property + def webhook_urls(self) -> list[str]: + """Return webhook URLs.""" + urls = [ + async_generate_url( + self.hass, self.entry.data[CONF_WEBHOOK_ID], prefer_external=external + ) + for external in (False, True) + ] + res = [] + for url in urls: + if url not in res: + res.append(url) + if CONF_CLOUDHOOK_URL in self.entry.data: + res.append(self.entry.data[CONF_CLOUDHOOK_URL]) + return res + + async def register_webhook(self) -> None: + """Register webhook.""" + async_register( + self.hass, + DOMAIN, + self.entry.title, + self.entry.data[CONF_WEBHOOK_ID], + self.handle_webhook, + allowed_methods=[METH_POST], + ) + if not await self.check_need_change(): + self.entry.runtime_data.push = True + return + for url in self.webhook_urls: + if await self.test_and_set_webhook(url): + return + LOGGER.info("Failed to register Overseerr webhook") + if cloud.async_active_subscription(self.hass): + LOGGER.info("Trying to register a cloudhook URL") + url = await _async_cloudhook_generate_url(self.hass, self.entry) + if await self.test_and_set_webhook(url): + return + LOGGER.error("Failed to register Overseerr cloudhook") + + async def check_need_change(self) -> bool: + """Check if webhook needs to be changed.""" + current_config = await self.client.get_webhook_notification_config() + return ( + not current_config.enabled + or current_config.options.webhook_url not in self.webhook_urls + or current_config.options.json_payload != json.loads(JSON_PAYLOAD) + or current_config.types != REGISTERED_NOTIFICATIONS + ) + + async def test_and_set_webhook(self, url: str) -> bool: + """Test and set webhook.""" + if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD): + LOGGER.debug("Setting Overseerr webhook to %s", url) + await self.client.set_webhook_notification_config( + enabled=True, + types=REGISTERED_NOTIFICATIONS, + webhook_url=url, + json_payload=JSON_PAYLOAD, + ) + self.entry.runtime_data.push = True + return True + return False + + async def handle_webhook( + self, hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response: + """Handle webhook.""" + data = await request.json() + LOGGER.debug("Received webhook payload: %s", data) + if data["notification_type"].startswith("MEDIA"): + await self.entry.runtime_data.async_refresh() + async_dispatcher_send(hass, EVENT_KEY, data) + return HomeAssistantView.json({"message": "ok"}) + + async def unregister_webhook(self) -> None: + """Unregister webhook.""" + async_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + + +async def _async_cloudhook_generate_url( + hass: HomeAssistant, entry: OverseerrConfigEntry +) -> str: + """Generate the full URL for a webhook_id.""" + if CONF_CLOUDHOOK_URL not in entry.data: + webhook_id = entry.data[CONF_WEBHOOK_ID] + webhook_url = await cloud.async_create_cloudhook(hass, webhook_id) + data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} + hass.config_entries.async_update_entry(entry, data=data) + return webhook_url + return cast(str, entry.data[CONF_CLOUDHOOK_URL]) diff --git a/homeassistant/components/overseerr/config_flow.py b/homeassistant/components/overseerr/config_flow.py new file mode 100644 index 00000000000..9a8bdd1676f --- /dev/null +++ b/homeassistant/components/overseerr/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for Overseerr.""" + +from collections.abc import Mapping +from typing import Any + +from python_overseerr import ( + OverseerrAuthenticationError, + OverseerrClient, + OverseerrError, +) +import voluptuous as vol +from yarl import URL + +from homeassistant.components.webhook import async_generate_id +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_WEBHOOK_ID, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN): + """Overseerr config flow.""" + + async def _check_connection( + self, host: str, port: int, ssl: bool, api_key: str + ) -> str | None: + """Check if we can connect to the Overseerr instance.""" + client = OverseerrClient( + host, + port, + api_key, + ssl=ssl, + session=async_get_clientsession(self.hass), + ) + try: + await client.get_request_count() + except OverseerrAuthenticationError: + return "invalid_auth" + except OverseerrError: + return "cannot_connect" + return None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input: + url = URL(user_input[CONF_URL]) + if (host := url.host) is None: + errors[CONF_URL] = "invalid_host" + else: + self._async_abort_entries_match({CONF_HOST: host}) + port = url.port + assert port + error = await self._check_connection( + host, port, url.scheme == "https", user_input[CONF_API_KEY] + ) + if error: + errors["base"] = error + else: + if self.source == SOURCE_USER: + return self.async_create_entry( + title="Overseerr", + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: url.scheme == "https", + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_WEBHOOK_ID: async_generate_id(), + }, + ) + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, + data={ + **reconfigure_entry.data, + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: url.scheme == "https", + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_URL): str, vol.Required(CONF_API_KEY): str} + ), + errors=errors, + ) + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-auth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-auth confirmation.""" + errors: dict[str, str] = {} + if user_input: + entry = self._get_reauth_entry() + error = await self._check_connection( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_SSL], + user_input[CONF_API_KEY], + ) + if error: + errors["base"] = error + else: + return self.async_update_reload_and_abort( + entry, + data={**entry.data, CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user() diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py new file mode 100644 index 00000000000..5c33ca3fcec --- /dev/null +++ b/homeassistant/components/overseerr/const.py @@ -0,0 +1,48 @@ +"""Constants for the overseerr integration.""" + +import logging + +from python_overseerr.models import NotificationType + +DOMAIN = "overseerr" +LOGGER = logging.getLogger(__package__) + +REQUESTS = "requests" + +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_STATUS = "status" +ATTR_SORT_ORDER = "sort_order" +ATTR_REQUESTED_BY = "requested_by" + +EVENT_KEY = f"{DOMAIN}_event" + +REGISTERED_NOTIFICATIONS = ( + NotificationType.REQUEST_PENDING_APPROVAL + | NotificationType.REQUEST_APPROVED + | NotificationType.REQUEST_DECLINED + | NotificationType.REQUEST_AVAILABLE + | NotificationType.REQUEST_PROCESSING_FAILED + | NotificationType.REQUEST_AUTOMATICALLY_APPROVED +) +JSON_PAYLOAD = ( + '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}' + '}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":' + '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_idd\\":\\"{{media_tmdbid}}\\",\\"t' + 'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k' + '\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id' + '}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna' + 'me\\":\\"{{requestedBy_username}}\\",\\"requested_by_avatar\\":\\"{{requestedBy_a' + 'vatar}}\\",\\"requested_by_settings_discord_id\\":\\"{{requestedBy_settings_disco' + 'rdId}}\\",\\"requested_by_settings_telegram_chat_id\\":\\"{{requestedBy_settings_' + 'telegramChatId}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_' + 'type\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",\\"reporte' + 'd_by_email\\":\\"{{reportedBy_email}}\\",\\"reported_by_username\\":\\"{{reported' + 'By_username}}\\",\\"reported_by_avatar\\":\\"{{reportedBy_avatar}}\\",\\"reported' + '_by_settings_discord_id\\":\\"{{reportedBy_settings_discordId}}\\",\\"reported_by' + '_settings_telegram_chat_id\\":\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{' + 'comment}}\\":{\\"comment_message\\":\\"{{comment_message}}\\",\\"commented_by_ema' + 'il\\":\\"{{commentedBy_email}}\\",\\"commented_by_username\\":\\"{{commentedBy_us' + 'ername}}\\",\\"commented_by_avatar\\":\\"{{commentedBy_avatar}}\\",\\"commented_b' + 'y_settings_discord_id\\":\\"{{commentedBy_settings_discordId}}\\",\\"commented_by' + '_settings_telegram_chat_id\\":\\"{{commentedBy_settings_telegramChatId}}\\"}}"' +) diff --git a/homeassistant/components/overseerr/coordinator.py b/homeassistant/components/overseerr/coordinator.py new file mode 100644 index 00000000000..2149dcbec7c --- /dev/null +++ b/homeassistant/components/overseerr/coordinator.py @@ -0,0 +1,66 @@ +"""Define an object to coordinate fetching Overseerr data.""" + +from datetime import timedelta + +from python_overseerr import ( + OverseerrAuthenticationError, + OverseerrClient, + OverseerrConnectionError, + RequestCount, +) +from yarl import URL + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +type OverseerrConfigEntry = ConfigEntry[OverseerrCoordinator] + + +class OverseerrCoordinator(DataUpdateCoordinator[RequestCount]): + """Class to manage fetching Overseerr data.""" + + config_entry: OverseerrConfigEntry + + def __init__(self, hass: HomeAssistant, entry: OverseerrConfigEntry) -> None: + """Initialize.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=timedelta(minutes=5), + ) + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + ssl = entry.data[CONF_SSL] + self.client = OverseerrClient( + host, + port, + entry.data[CONF_API_KEY], + ssl=ssl, + session=async_get_clientsession(hass), + ) + self.url = URL.build(host=host, port=port, scheme="https" if ssl else "http") + self.push = False + + async def _async_update_data(self) -> RequestCount: + """Fetch data from API endpoint.""" + try: + return await self.client.get_request_count() + except OverseerrAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from err + except OverseerrConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/overseerr/diagnostics.py b/homeassistant/components/overseerr/diagnostics.py new file mode 100644 index 00000000000..d45e1441e23 --- /dev/null +++ b/homeassistant/components/overseerr/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for Overseerr.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import CONF_CLOUDHOOK_URL +from .coordinator import OverseerrConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: OverseerrConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + has_cloudhooks = CONF_CLOUDHOOK_URL in entry.data + + data = entry.runtime_data + + return { + "has_cloudhooks": has_cloudhooks, + "coordinator_data": asdict(data.data), + } diff --git a/homeassistant/components/overseerr/entity.py b/homeassistant/components/overseerr/entity.py new file mode 100644 index 00000000000..937ad52f7ec --- /dev/null +++ b/homeassistant/components/overseerr/entity.py @@ -0,0 +1,23 @@ +"""Base entity for Overseerr.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OverseerrCoordinator + + +class OverseerrEntity(CoordinatorEntity[OverseerrCoordinator]): + """Defines a base Overseerr entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: OverseerrCoordinator, key: str) -> None: + """Initialize Overseerr entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + entry_type=DeviceEntryType.SERVICE, + configuration_url=coordinator.url, + ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{key}" diff --git a/homeassistant/components/overseerr/event.py b/homeassistant/components/overseerr/event.py new file mode 100644 index 00000000000..589a80c5404 --- /dev/null +++ b/homeassistant/components/overseerr/event.py @@ -0,0 +1,125 @@ +"""Support for Overseerr events.""" + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN, EVENT_KEY +from .coordinator import OverseerrConfigEntry, OverseerrCoordinator +from .entity import OverseerrEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OverseerrEventEntityDescription(EventEntityDescription): + """Describes Overseerr config event entity.""" + + nullable_fields: list[str] + + +EVENTS: tuple[OverseerrEventEntityDescription, ...] = ( + OverseerrEventEntityDescription( + key="media", + translation_key="last_media_event", + event_types=[ + "pending", + "approved", + "available", + "failed", + "declined", + "auto_approved", + ], + nullable_fields=["comment", "issue"], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OverseerrConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Overseerr sensor entities based on a config entry.""" + + coordinator = entry.runtime_data + ent_reg = er.async_get(hass) + + event_entities_setup_before = ent_reg.async_get_entity_id( + Platform.EVENT, DOMAIN, f"{entry.entry_id}-media" + ) + + if coordinator.push or event_entities_setup_before: + async_add_entities( + OverseerrEvent(coordinator, description) for description in EVENTS + ) + + +class OverseerrEvent(OverseerrEntity, EventEntity): + """Defines a Overseerr event entity.""" + + entity_description: OverseerrEventEntityDescription + + def __init__( + self, + coordinator: OverseerrCoordinator, + description: OverseerrEventEntityDescription, + ) -> None: + """Initialize Overseerr event entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + self._attr_available = True + + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect(self.hass, EVENT_KEY, self._handle_update) + ) + + async def _handle_update(self, event: dict[str, Any]) -> None: + """Handle incoming event.""" + event_type = event["notification_type"].lower() + if event_type.split("_")[0] == self.entity_description.key: + self._attr_entity_picture = event.get("image") + self._trigger_event( + event_type[6:], + parse_event(event, self.entity_description.nullable_fields), + ) + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + if super().available != self._attr_available: + self._attr_available = super().available + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._attr_available and self.coordinator.push + + +def parse_event(event: dict[str, Any], nullable_fields: list[str]) -> dict[str, Any]: + """Parse event.""" + event.pop("notification_type") + event.pop("image") + for field in nullable_fields: + event.pop(field) + if (media := event.get("media")) is not None: + for field in ("status", "status4k"): + media[field] = media[field].lower() + for field in ("tmdb_id", "tvdb_id"): + if (value := media.get(field)) != "": + media[field] = int(value) + else: + media[field] = None + if (request := event.get("request")) is not None: + request["request_id"] = int(request["request_id"]) + return event diff --git a/homeassistant/components/overseerr/icons.json b/homeassistant/components/overseerr/icons.json new file mode 100644 index 00000000000..af18836680b --- /dev/null +++ b/homeassistant/components/overseerr/icons.json @@ -0,0 +1,37 @@ +{ + "entity": { + "sensor": { + "total_requests": { + "default": "mdi:forum" + }, + "movie_requests": { + "default": "mdi:movie-open" + }, + "tv_requests": { + "default": "mdi:television-box" + }, + "pending_requests": { + "default": "mdi:clock" + }, + "declined_requests": { + "default": "mdi:movie-open-off" + }, + "processing_requests": { + "default": "mdi:sync" + }, + "available_requests": { + "default": "mdi:message-bulleted" + } + }, + "event": { + "last_media_event": { + "default": "mdi:multimedia" + } + } + }, + "services": { + "get_requests": { + "service": "mdi:multimedia" + } + } +} diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json new file mode 100644 index 00000000000..396b9d7000b --- /dev/null +++ b/homeassistant/components/overseerr/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "overseerr", + "name": "Overseerr", + "after_dependencies": ["cloud"], + "codeowners": ["@joostlek"], + "config_flow": true, + "dependencies": ["http", "webhook"], + "documentation": "https://www.home-assistant.io/integrations/overseerr", + "integration_type": "service", + "iot_class": "local_push", + "quality_scale": "platinum", + "requirements": ["python-overseerr==0.6.0"] +} diff --git a/homeassistant/components/overseerr/quality_scale.yaml b/homeassistant/components/overseerr/quality_scale.yaml new file mode 100644 index 00000000000..7afbcd6aa07 --- /dev/null +++ b/homeassistant/components/overseerr/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: done + entity-unavailable: + status: done + comment: Handled by the coordinator + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by the coordinator + parallel-updates: done + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration does not support discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + This integration has a fixed single device. + entity-category: done + entity-device-class: + status: exempt + comment: | + This integration has no relevant device class to use. + entity-disabled-by-default: + status: exempt + comment: | + This integration has no unpopular entities to disable. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: | + This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/overseerr/sensor.py b/homeassistant/components/overseerr/sensor.py new file mode 100644 index 00000000000..2daaa3de0cb --- /dev/null +++ b/homeassistant/components/overseerr/sensor.py @@ -0,0 +1,107 @@ +"""Support for Overseerr sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from python_overseerr import RequestCount + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import REQUESTS +from .coordinator import OverseerrConfigEntry, OverseerrCoordinator +from .entity import OverseerrEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OverseerrSensorEntityDescription(SensorEntityDescription): + """Describes Overseerr config sensor entity.""" + + value_fn: Callable[[RequestCount], int] + + +SENSORS: tuple[OverseerrSensorEntityDescription, ...] = ( + OverseerrSensorEntityDescription( + key="total_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.total, + ), + OverseerrSensorEntityDescription( + key="movie_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.movie, + ), + OverseerrSensorEntityDescription( + key="tv_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.tv, + ), + OverseerrSensorEntityDescription( + key="pending_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.pending, + ), + OverseerrSensorEntityDescription( + key="declined_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.declined, + ), + OverseerrSensorEntityDescription( + key="processing_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.processing, + ), + OverseerrSensorEntityDescription( + key="available_requests", + native_unit_of_measurement=REQUESTS, + state_class=SensorStateClass.TOTAL, + value_fn=lambda count: count.available, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OverseerrConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Overseerr sensor entities based on a config entry.""" + + coordinator = entry.runtime_data + async_add_entities( + OverseerrSensor(coordinator, description) for description in SENSORS + ) + + +class OverseerrSensor(OverseerrEntity, SensorEntity): + """Defines an Overseerr sensor.""" + + entity_description: OverseerrSensorEntityDescription + + def __init__( + self, + coordinator: OverseerrCoordinator, + description: OverseerrSensorEntityDescription, + ) -> None: + """Initialize airgradient sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + self._attr_translation_key = description.key + + @property + def native_value(self) -> int: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py new file mode 100644 index 00000000000..4631e578af8 --- /dev/null +++ b/homeassistant/components/overseerr/services.py @@ -0,0 +1,115 @@ +"""Define services for the Overseerr integration.""" + +from dataclasses import asdict +from typing import Any, cast + +from python_overseerr import OverseerrClient, OverseerrConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.util.json import JsonValueType + +from .const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_REQUESTED_BY, + ATTR_SORT_ORDER, + ATTR_STATUS, + DOMAIN, + LOGGER, +) +from .coordinator import OverseerrConfigEntry + +SERVICE_GET_REQUESTS = "get_requests" +SERVICE_GET_REQUESTS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Optional(ATTR_STATUS): vol.In( + ["approved", "pending", "available", "processing", "unavailable", "failed"] + ), + vol.Optional(ATTR_SORT_ORDER): vol.In(["added", "modified"]), + vol.Optional(ATTR_REQUESTED_BY): int, + } +) + + +def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> OverseerrConfigEntry: + """Get the Overseerr config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return cast(OverseerrConfigEntry, entry) + + +async def get_media( + client: OverseerrClient, media_type: str, identifier: int +) -> dict[str, Any]: + """Get media details.""" + media = {} + try: + if media_type == "movie": + media = asdict(await client.get_movie_details(identifier)) + if media_type == "tv": + media = asdict(await client.get_tv_details(identifier)) + except OverseerrConnectionError: + LOGGER.error("Could not find data for %s %s", media_type, identifier) + return {} + media["media_info"].pop("requests") + return media + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Overseerr integration.""" + + async def async_get_requests(call: ServiceCall) -> ServiceResponse: + """Get requests made to Overseerr.""" + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + client = entry.runtime_data.client + kwargs: dict[str, Any] = {} + if status := call.data.get(ATTR_STATUS): + kwargs["status"] = status + if sort_order := call.data.get(ATTR_SORT_ORDER): + kwargs["sort"] = sort_order + if requested_by := call.data.get(ATTR_REQUESTED_BY): + kwargs["requested_by"] = requested_by + try: + requests = await client.get_requests(**kwargs) + except OverseerrConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + result: list[dict[str, Any]] = [] + for request in requests: + req = asdict(request) + assert request.media.tmdb_id + req["media"] = await get_media( + client, request.media.media_type, request.media.tmdb_id + ) + result.append(req) + + return {"requests": cast(list[JsonValueType], result)} + + hass.services.async_register( + DOMAIN, + SERVICE_GET_REQUESTS, + async_get_requests, + schema=SERVICE_GET_REQUESTS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/overseerr/services.yaml b/homeassistant/components/overseerr/services.yaml new file mode 100644 index 00000000000..c7593fc5aee --- /dev/null +++ b/homeassistant/components/overseerr/services.yaml @@ -0,0 +1,30 @@ +get_requests: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: overseerr + status: + selector: + select: + options: + - approved + - pending + - available + - processing + - unavailable + - failed + translation_key: request_status + sort_order: + selector: + select: + options: + - added + - modified + translation_key: request_sort_order + requested_by: + selector: + number: + min: 0 + mode: box diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json new file mode 100644 index 00000000000..5053bcedc41 --- /dev/null +++ b/homeassistant/components/overseerr/strings.json @@ -0,0 +1,132 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "The URL of the Overseerr instance.", + "api_key": "The API key of the Overseerr instance." + } + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::overseerr::config::step::user::data_description::api_key%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "The provided URL is not a valid host." + } + }, + "entity": { + "event": { + "last_media_event": { + "name": "Last media event", + "state_attributes": { + "event_type": { + "state": { + "pending": "Pending", + "approved": "Approved", + "available": "Available", + "failed": "Failed", + "declined": "Declined", + "auto_approved": "Auto-approved" + } + } + } + } + }, + "sensor": { + "total_requests": { + "name": "Total requests" + }, + "movie_requests": { + "name": "Movie requests" + }, + "tv_requests": { + "name": "TV requests" + }, + "pending_requests": { + "name": "Pending requests" + }, + "declined_requests": { + "name": "Declined requests" + }, + "processing_requests": { + "name": "Processing requests" + }, + "available_requests": { + "name": "Available requests" + } + } + }, + "exceptions": { + "connection_error": { + "message": "Error connecting to the Overseerr instance: {error}" + }, + "auth_error": { + "message": "Invalid API key." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + } + }, + "services": { + "get_requests": { + "name": "Get requests", + "description": "Get media requests from Overseerr.", + "fields": { + "config_entry_id": { + "name": "Overseerr instance", + "description": "The Overseerr instance to get requests from." + }, + "status": { + "name": "Request status", + "description": "Filter the requests by status." + }, + "sort_order": { + "name": "Sort order", + "description": "Sort the requests by added or modified date." + }, + "requested_by": { + "name": "Requested by", + "description": "Filter the requests by the user id that requested them." + } + } + } + }, + "selector": { + "request_status": { + "options": { + "approved": "Approved", + "pending": "Pending", + "available": "Available", + "processing": "Processing", + "unavailable": "Unavailable", + "failed": "Failed" + } + }, + "request_sort_order": { + "options": { + "added": "Added", + "modified": "Modified" + } + } + } +} diff --git a/homeassistant/components/palazzetti/__init__.py b/homeassistant/components/palazzetti/__init__.py index 4bea4434496..dbf1baa0c28 100644 --- a/homeassistant/components/palazzetti/__init__.py +++ b/homeassistant/components/palazzetti/__init__.py @@ -7,7 +7,12 @@ from homeassistant.core import HomeAssistant from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.CLIMATE, + Platform.NUMBER, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: PalazzettiConfigEntry) -> bool: diff --git a/homeassistant/components/palazzetti/button.py b/homeassistant/components/palazzetti/button.py new file mode 100644 index 00000000000..cd4765576ed --- /dev/null +++ b/homeassistant/components/palazzetti/button.py @@ -0,0 +1,52 @@ +"""Support for Palazzetti buttons.""" + +from __future__ import annotations + +from pypalazzetti.exceptions import CommunicationError + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PalazzettiConfigEntry +from .const import DOMAIN +from .coordinator import PalazzettiDataUpdateCoordinator +from .entity import PalazzettiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PalazzettiConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Palazzetti button platform.""" + + coordinator = config_entry.runtime_data + if coordinator.client.has_fan_silent: + async_add_entities([PalazzettiSilentButtonEntity(coordinator)]) + + +class PalazzettiSilentButtonEntity(PalazzettiEntity, ButtonEntity): + """Representation of a Palazzetti Silent button.""" + + _attr_translation_key = "silent" + + def __init__( + self, + coordinator: PalazzettiDataUpdateCoordinator, + ) -> None: + """Initialize a Palazzetti Silent button.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}-silent" + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.set_fan_silent() + except CommunicationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py index 356f3a7306f..0722b97e4b7 100644 --- a/homeassistant/components/palazzetti/climate.py +++ b/homeassistant/components/palazzetti/climate.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PalazzettiConfigEntry -from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES, FAN_SILENT +from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES from .coordinator import PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity @@ -57,8 +57,6 @@ class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity): self._attr_fan_modes = list( map(str, range(client.fan_speed_min, client.fan_speed_max + 1)) ) - if client.has_fan_silent: - self._attr_fan_modes.insert(0, FAN_SILENT) if client.has_fan_high: self._attr_fan_modes.append(FAN_HIGH) if client.has_fan_auto: @@ -124,15 +122,13 @@ class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity): @property def fan_mode(self) -> str | None: """Return the fan mode.""" - api_state = self.coordinator.client.fan_speed + api_state = self.coordinator.client.current_fan_speed() return FAN_MODES[api_state] async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" try: - if fan_mode == FAN_SILENT: - await self.coordinator.client.set_fan_silent() - elif fan_mode == FAN_HIGH: + if fan_mode == FAN_HIGH: await self.coordinator.client.set_fan_high() elif fan_mode == FAN_AUTO: await self.coordinator.client.set_fan_auto() diff --git a/homeassistant/components/palazzetti/config_flow.py b/homeassistant/components/palazzetti/config_flow.py index fe892b6624d..91762216ff5 100644 --- a/homeassistant/components/palazzetti/config_flow.py +++ b/homeassistant/components/palazzetti/config_flow.py @@ -6,10 +6,10 @@ from pypalazzetti.client import PalazzettiClient from pypalazzetti.exceptions import CommunicationError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, LOGGER @@ -53,7 +53,7 @@ class PalazzettiConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" diff --git a/homeassistant/components/palazzetti/const.py b/homeassistant/components/palazzetti/const.py index 4cb8b1f14a6..1b68cf99f9d 100644 --- a/homeassistant/components/palazzetti/const.py +++ b/homeassistant/components/palazzetti/const.py @@ -4,6 +4,8 @@ from datetime import timedelta import logging from typing import Final +from homeassistant.helpers.typing import StateType + DOMAIN: Final = "palazzetti" PALAZZETTI: Final = "Palazzetti" LOGGER = logging.getLogger(__package__) @@ -16,4 +18,54 @@ ERROR_CANNOT_CONNECT = "cannot_connect" FAN_SILENT: Final = "silent" FAN_HIGH: Final = "high" FAN_AUTO: Final = "auto" -FAN_MODES: Final = [FAN_SILENT, "1", "2", "3", "4", "5", FAN_HIGH, FAN_AUTO] +FAN_MODES: Final = ["0", "1", "2", "3", "4", "5", FAN_HIGH, FAN_AUTO] + +STATUS_TO_HA: Final[dict[StateType, str]] = { + 0: "off", + 1: "off_timer", + 2: "test_fire", + 3: "heatup", + 4: "fueling", + 5: "ign_test", + 6: "burning", + 7: "burning_mod", + 8: "unknown", + 9: "cool_fluid", + 10: "fire_stop", + 11: "clean_fire", + 12: "cooling", + 50: "cleanup", + 51: "ecomode", + 241: "chimney_alarm", + 243: "grate_error", + 244: "pellet_water_error", + 245: "t05_error", + 247: "hatch_door_open", + 248: "pressure_error", + 249: "main_probe_failure", + 250: "flue_probe_failure", + 252: "exhaust_temp_high", + 253: "pellet_finished", + 501: "off", + 502: "fueling", + 503: "ign_test", + 504: "burning", + 505: "firewood_finished", + 506: "cooling", + 507: "clean_fire", + 1000: "general_error", + 1001: "general_error", + 1239: "door_open", + 1240: "temp_too_high", + 1241: "cleaning_warning", + 1243: "fuel_error", + 1244: "pellet_water_error", + 1245: "t05_error", + 1247: "hatch_door_open", + 1248: "pressure_error", + 1249: "main_probe_failure", + 1250: "flue_probe_failure", + 1252: "exhaust_temp_high", + 1253: "pellet_finished", + 1508: "general_error", +} diff --git a/homeassistant/components/palazzetti/icons.json b/homeassistant/components/palazzetti/icons.json new file mode 100644 index 00000000000..c20a9572618 --- /dev/null +++ b/homeassistant/components/palazzetti/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "button": { + "silent": { + "default": "mdi:volume-mute" + } + } + } +} diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 05a5d260b50..41e8e0fb4de 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.14"] + "requirements": ["pypalazzetti==0.1.19"] } diff --git a/homeassistant/components/palazzetti/number.py b/homeassistant/components/palazzetti/number.py new file mode 100644 index 00000000000..2b303f71fd6 --- /dev/null +++ b/homeassistant/components/palazzetti/number.py @@ -0,0 +1,124 @@ +"""Number platform for Palazzetti settings.""" + +from __future__ import annotations + +from pypalazzetti.exceptions import CommunicationError, ValidationError +from pypalazzetti.fan import FanType + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PalazzettiConfigEntry +from .const import DOMAIN +from .coordinator import PalazzettiDataUpdateCoordinator +from .entity import PalazzettiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PalazzettiConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Palazzetti number platform.""" + + entities: list[PalazzettiEntity] = [ + PalazzettiCombustionPowerEntity(config_entry.runtime_data) + ] + + if config_entry.runtime_data.client.has_fan(FanType.LEFT): + entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.LEFT)) + + if config_entry.runtime_data.client.has_fan(FanType.RIGHT): + entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.RIGHT)) + + async_add_entities(entities) + + +class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity): + """Representation of Palazzetti number entity for Combustion power.""" + + _attr_translation_key = "combustion_power" + _attr_device_class = NumberDeviceClass.POWER_FACTOR + _attr_native_min_value = 1 + _attr_native_max_value = 5 + _attr_native_step = 1 + + def __init__( + self, + coordinator: PalazzettiDataUpdateCoordinator, + ) -> None: + """Initialize the Palazzetti number entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}-combustion_power" + + @property + def native_value(self) -> float: + """Return the state of the setting entity.""" + return self.coordinator.client.power_mode + + async def async_set_native_value(self, value: float) -> None: + """Update the setting.""" + try: + await self.coordinator.client.set_power_mode(int(value)) + except CommunicationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + except ValidationError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_combustion_power", + translation_placeholders={ + "value": str(value), + }, + ) from err + + await self.coordinator.async_request_refresh() + + +class PalazzettiFanEntity(PalazzettiEntity, NumberEntity): + """Representation of Palazzetti number entity for Combustion power.""" + + _attr_device_class = NumberDeviceClass.WIND_SPEED + _attr_native_step = 1 + + def __init__( + self, coordinator: PalazzettiDataUpdateCoordinator, fan: FanType + ) -> None: + """Initialize the Palazzetti number entity.""" + super().__init__(coordinator) + self.fan = fan + + self._attr_translation_key = f"fan_{str.lower(fan.name)}_speed" + self._attr_native_min_value = coordinator.client.min_fan_speed(fan) + self._attr_native_max_value = coordinator.client.max_fan_speed(fan) + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}-fan_{str.lower(fan.name)}_speed" + ) + + @property + def native_value(self) -> float: + """Return the state of the setting entity.""" + return self.coordinator.client.current_fan_speed(self.fan) + + async def async_set_native_value(self, value: float) -> None: + """Update the setting.""" + try: + await self.coordinator.client.set_fan_speed(int(value), self.fan) + except CommunicationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + except ValidationError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "name": str.lower(self.fan.name), + "value": str(value), + }, + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml index 493b2595117..ff8461ad193 100644 --- a/homeassistant/components/palazzetti/quality_scale.yaml +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -15,8 +15,8 @@ rules: comment: | This integration does not register actions. docs-high-level-description: done - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -35,7 +35,7 @@ rules: status: exempt comment: | This integration does not have configuration. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -51,12 +51,12 @@ rules: discovery-update-info: done discovery: done docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | @@ -67,9 +67,7 @@ rules: entity-translations: done exception-translations: done icon-translations: - status: exempt - comment: | - This integration does not have custom icons. + status: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/palazzetti/sensor.py b/homeassistant/components/palazzetti/sensor.py index ead2b236b17..11462201f4e 100644 --- a/homeassistant/components/palazzetti/sensor.py +++ b/homeassistant/components/palazzetti/sensor.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import PalazzettiConfigEntry +from .const import STATUS_TO_HA from .coordinator import PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity @@ -23,10 +24,19 @@ class PropertySensorEntityDescription(SensorEntityDescription): """Describes a Palazzetti sensor entity that is read from a `PalazzettiClient` property.""" client_property: str + property_map: dict[StateType, str] | None = None presence_flag: None | str = None PROPERTY_SENSOR_DESCRIPTIONS: list[PropertySensorEntityDescription] = [ + PropertySensorEntityDescription( + key="status", + device_class=SensorDeviceClass.ENUM, + translation_key="status", + client_property="status", + property_map=STATUS_TO_HA, + options=list(STATUS_TO_HA.values()), + ), PropertySensorEntityDescription( key="pellet_quantity", device_class=SensorDeviceClass.WEIGHT, @@ -103,4 +113,11 @@ class PalazzettiSensor(PalazzettiEntity, SensorEntity): def native_value(self) -> StateType: """Return the state value of the sensor.""" - return getattr(self.coordinator.client, self.entity_description.client_property) + raw_value = getattr( + self.coordinator.client, self.entity_description.client_property + ) + + if self.entity_description.property_map: + return self.entity_description.property_map[raw_value] + + return raw_value diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index 435ec0aab85..501ee777fe9 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -27,14 +27,25 @@ "invalid_fan_mode": { "message": "Fan mode {value} is invalid." }, + "invalid_fan_speed": { + "message": "Fan {name} speed {value} is invalid." + }, "invalid_target_temperature": { "message": "Target temperature {value} is invalid." }, + "invalid_combustion_power": { + "message": "Combustion power {value} is invalid." + }, "cannot_connect": { "message": "Could not connect to the device." } }, "entity": { + "button": { + "silent": { + "name": "Silent" + } + }, "climate": { "palazzetti": { "state_attributes": { @@ -48,7 +59,54 @@ } } }, + "number": { + "combustion_power": { + "name": "Combustion power" + }, + "fan_left_speed": { + "name": "Left fan speed" + }, + "fan_right_speed": { + "name": "Right fan speed" + } + }, "sensor": { + "status": { + "name": "Status", + "state": { + "off": "Off", + "off_timer": "Timer-regulated switch off", + "test_fire": "Ignition test", + "heatup": "Pellet feed", + "fueling": "Ignition", + "ign_test": "Fuel check", + "burning": "Operating", + "burning_mod": "Operating - Modulating", + "unknown": "Unknown", + "cool_fluid": "Stand-by", + "fire_stop": "Switch off", + "clean_fire": "Burn pot cleaning", + "cooling": "Cooling in progress", + "cleanup": "Final cleaning", + "ecomode": "Ecomode", + "chimney_alarm": "Chimney alarm", + "grate_error": "Grate error", + "pellet_water_error": "Pellet probe or return water error", + "t05_error": "T05 error disconnected or faulty probe", + "hatch_door_open": "Feed hatch or door open", + "pressure_error": "Safety pressure switch error", + "main_probe_failure": "Main probe failure", + "flue_probe_failure": "Flue gas probe failure", + "exhaust_temp_high": "Too high exhaust gas temperature", + "pellet_finished": "Pellets finished or ignition failed", + "firewood_finished": "Firewood finished", + "general_error": "General error", + "door_open": "Door open", + "temp_too_high": "Temperature too high", + "cleaning_warning": "Cleaning warning", + "fuel_error": "Fuel error" + } + }, "pellet_quantity": { "name": "Pellet quantity" }, diff --git a/homeassistant/components/pandora/manifest.json b/homeassistant/components/pandora/manifest.json index e7d8946fb38..e67dbac27db 100644 --- a/homeassistant/components/pandora/manifest.json +++ b/homeassistant/components/pandora/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pexpect", "ptyprocess"], "quality_scale": "legacy", - "requirements": ["pexpect==4.6.0"] + "requirements": ["pexpect==4.9.0"] } diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py index f781f366173..0b2f5b7055f 100644 --- a/homeassistant/components/pandora/media_player.py +++ b/homeassistant/components/pandora/media_player.py @@ -8,6 +8,7 @@ import os import re import shutil import signal +from typing import cast import pexpect @@ -26,7 +27,7 @@ from homeassistant.const import ( SERVICE_VOLUME_DOWN, SERVICE_VOLUME_UP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -58,7 +59,7 @@ def setup_platform( # Make sure we end the pandora subprocess on exit in case user doesn't # power it down. - def _stop_pianobar(_event): + def _stop_pianobar(_event: Event) -> None: pandora.turn_off() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_pianobar) @@ -80,7 +81,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): | MediaPlayerEntityFeature.PLAY ) - def __init__(self, name): + def __init__(self, name: str) -> None: """Initialize the Pandora device.""" self._attr_name = name self._attr_state = MediaPlayerState.OFF @@ -91,13 +92,13 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._attr_source_list = [] self._time_remaining = 0 self._attr_media_duration = 0 - self._pianobar = None + self._pianobar: pexpect.spawn[str] | None = None def turn_on(self) -> None: """Turn the media player on.""" if self.state != MediaPlayerState.OFF: return - self._pianobar = pexpect.spawn("pianobar") + self._pianobar = pexpect.spawn("pianobar", encoding="utf-8") _LOGGER.debug("Started pianobar subprocess") mode = self._pianobar.expect( ["Receiving new playlist", "Select station:", "Email:"] @@ -134,8 +135,9 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._pianobar.terminate() except pexpect.exceptions.TIMEOUT: # kill the process group - os.killpg(os.getpgid(self._pianobar.pid), signal.SIGTERM) - _LOGGER.debug("Killed Pianobar subprocess") + if (pid := self._pianobar.pid) is not None: + os.killpg(os.getpgid(pid), signal.SIGTERM) + _LOGGER.debug("Killed Pianobar subprocess") self._pianobar = None self._attr_state = MediaPlayerState.OFF self.schedule_update_ha_state() @@ -173,13 +175,15 @@ class PandoraMediaPlayer(MediaPlayerEntity): _LOGGER.warning("Station %s is not in list", source) return _LOGGER.debug("Setting station %s, %d", source, station_index) + assert self._pianobar is not None self._send_station_list_command() self._pianobar.sendline(f"{station_index}") self._pianobar.expect("\r\n") self._attr_state = MediaPlayerState.PLAYING - def _send_station_list_command(self): + def _send_station_list_command(self) -> None: """Send a station list command.""" + assert self._pianobar is not None self._pianobar.send("s") try: self._pianobar.expect("Select station:", timeout=1) @@ -189,7 +193,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._pianobar.send("s") self._pianobar.expect("Select station:") - def update_playing_status(self): + def update_playing_status(self) -> None: """Query pianobar for info about current media_title, station.""" response = self._query_for_playing_status() if not response: @@ -198,14 +202,15 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._update_current_song(response) self._update_song_position() - def _query_for_playing_status(self): + def _query_for_playing_status(self) -> str | None: """Query system for info about current track.""" + assert self._pianobar is not None self._clear_buffer() self._pianobar.send("i") try: match_idx = self._pianobar.expect( [ - rb"(\d\d):(\d\d)/(\d\d):(\d\d)", + r"(\d\d):(\d\d)/(\d\d):(\d\d)", "No song playing", "Select station", "Receiving new playlist", @@ -218,21 +223,22 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._log_match() if match_idx == 1: # idle. - response = None - elif match_idx == 2: + return None + if match_idx == 2: # stuck on a station selection dialog. Clear it. _LOGGER.warning("On unexpected station list page") self._pianobar.sendcontrol("m") # press enter self._pianobar.sendcontrol("m") # do it again b/c an 'i' got in - response = self.update_playing_status() - elif match_idx == 3: + self.update_playing_status() + return None + if match_idx == 3: _LOGGER.debug("Received new playlist list") - response = self.update_playing_status() - else: - response = self._pianobar.before.decode("utf-8") - return response + self.update_playing_status() + return None - def _update_current_station(self, response): + return self._pianobar.before + + def _update_current_station(self, response: str) -> None: """Update current station.""" if station_match := re.search(STATION_PATTERN, response): self._attr_source = station_match.group(1) @@ -240,7 +246,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): else: _LOGGER.warning("No station match") - def _update_current_song(self, response): + def _update_current_song(self, response: str) -> None: """Update info about current song.""" if song_match := re.search(CURRENT_SONG_PATTERN, response): ( @@ -253,19 +259,20 @@ class PandoraMediaPlayer(MediaPlayerEntity): _LOGGER.warning("No song match") @util.Throttle(MIN_TIME_BETWEEN_UPDATES) - def _update_song_position(self): + def _update_song_position(self) -> None: """Get the song position and duration. It's hard to predict whether or not the music will start during init so we have to detect state by checking the ticker. """ + assert self._pianobar is not None ( cur_minutes, cur_seconds, total_minutes, total_seconds, - ) = self._pianobar.match.groups() + ) = cast(re.Match[str], self._pianobar.match).groups() time_remaining = int(cur_minutes) * 60 + int(cur_seconds) self._attr_media_duration = int(total_minutes) * 60 + int(total_seconds) @@ -275,8 +282,9 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._attr_state = MediaPlayerState.PAUSED self._time_remaining = time_remaining - def _log_match(self): + def _log_match(self) -> None: """Log grabbed values from console.""" + assert self._pianobar is not None _LOGGER.debug( "Before: %s\nMatch: %s\nAfter: %s", repr(self._pianobar.before), @@ -284,22 +292,25 @@ class PandoraMediaPlayer(MediaPlayerEntity): repr(self._pianobar.after), ) - def _send_pianobar_command(self, service_cmd): + def _send_pianobar_command(self, service_cmd: str) -> None: """Send a command to Pianobar.""" + assert self._pianobar is not None command = CMD_MAP.get(service_cmd) _LOGGER.debug("Sending pinaobar command %s for %s", command, service_cmd) if command is None: _LOGGER.warning("Command %s not supported yet", service_cmd) + return self._clear_buffer() self._pianobar.sendline(command) - def _update_stations(self): + def _update_stations(self) -> None: """List defined Pandora stations.""" + assert self._pianobar is not None self._send_station_list_command() - station_lines = self._pianobar.before.decode("utf-8") + station_lines = self._pianobar.before or "" _LOGGER.debug("Getting stations: %s", station_lines) self._attr_source_list = [] - for line in station_lines.split("\r\n"): + for line in station_lines.splitlines(): if match := re.search(r"\d+\).....(.+)", line): station = match.group(1).strip() _LOGGER.debug("Found station %s", station) @@ -309,12 +320,13 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._pianobar.sendcontrol("m") # press enter with blank line self._pianobar.sendcontrol("m") # do it twice in case an 'i' got in - def _clear_buffer(self): + def _clear_buffer(self) -> None: """Clear buffer from pexpect. This is necessary because there are a bunch of 00:00 in the buffer """ + assert self._pianobar is not None try: while not self._pianobar.expect(".+", timeout=0.1): pass @@ -324,7 +336,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): pass -def _pianobar_exists(): +def _pianobar_exists() -> bool: """Verify that Pianobar is properly installed.""" pianobar_exe = shutil.which("pianobar") if pianobar_exe: diff --git a/homeassistant/components/peblar/__init__.py b/homeassistant/components/peblar/__init__.py new file mode 100644 index 00000000000..bf1b3ef7e66 --- /dev/null +++ b/homeassistant/components/peblar/__init__.py @@ -0,0 +1,89 @@ +"""Integration for Peblar EV chargers.""" + +from __future__ import annotations + +import asyncio + +from aiohttp import CookieJar +from peblar import ( + AccessMode, + Peblar, + PeblarAuthenticationError, + PeblarConnectionError, + PeblarError, +) + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .coordinator import ( + PeblarConfigEntry, + PeblarDataUpdateCoordinator, + PeblarRuntimeData, + PeblarUserConfigurationDataUpdateCoordinator, + PeblarVersionDataUpdateCoordinator, +) + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool: + """Set up Peblar from a config entry.""" + + # Set up connection to the Peblar charger + peblar = Peblar( + host=entry.data[CONF_HOST], + session=async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)), + ) + try: + await peblar.login(password=entry.data[CONF_PASSWORD]) + system_information = await peblar.system_information() + api = await peblar.rest_api(enable=True, access_mode=AccessMode.READ_WRITE) + except PeblarConnectionError as err: + raise ConfigEntryNotReady("Could not connect to Peblar charger") from err + except PeblarAuthenticationError as err: + raise ConfigEntryAuthFailed from err + except PeblarError as err: + raise ConfigEntryNotReady( + "Unknown error occurred while connecting to Peblar charger" + ) from err + + # Setup the data coordinators + meter_coordinator = PeblarDataUpdateCoordinator(hass, entry, api) + user_configuration_coordinator = PeblarUserConfigurationDataUpdateCoordinator( + hass, entry, peblar + ) + version_coordinator = PeblarVersionDataUpdateCoordinator(hass, entry, peblar) + await asyncio.gather( + meter_coordinator.async_config_entry_first_refresh(), + user_configuration_coordinator.async_config_entry_first_refresh(), + version_coordinator.async_config_entry_first_refresh(), + ) + + # Store the runtime data + entry.runtime_data = PeblarRuntimeData( + data_coordinator=meter_coordinator, + system_information=system_information, + user_configuration_coordinator=user_configuration_coordinator, + version_coordinator=version_coordinator, + ) + + # Forward the setup to the platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool: + """Unload Peblar config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/peblar/binary_sensor.py b/homeassistant/components/peblar/binary_sensor.py new file mode 100644 index 00000000000..e8e5095f050 --- /dev/null +++ b/homeassistant/components/peblar/binary_sensor.py @@ -0,0 +1,77 @@ +"""Support for Peblar binary sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import PeblarConfigEntry, PeblarData, PeblarDataUpdateCoordinator +from .entity import PeblarEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PeblarBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Peblar binary sensor entities.""" + + is_on_fn: Callable[[PeblarData], bool] + + +DESCRIPTIONS = [ + PeblarBinarySensorEntityDescription( + key="active_error_codes", + translation_key="active_error_codes", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + is_on_fn=lambda x: bool(x.system.active_error_codes), + ), + PeblarBinarySensorEntityDescription( + key="active_warning_codes", + translation_key="active_warning_codes", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + is_on_fn=lambda x: bool(x.system.active_warning_codes), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PeblarConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Peblar binary sensor based on a config entry.""" + async_add_entities( + PeblarBinarySensorEntity( + entry=entry, + coordinator=entry.runtime_data.data_coordinator, + description=description, + ) + for description in DESCRIPTIONS + ) + + +class PeblarBinarySensorEntity( + PeblarEntity[PeblarDataUpdateCoordinator], + BinarySensorEntity, +): + """Defines a Peblar binary sensor entity.""" + + entity_description: PeblarBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return state of the binary sensor.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/peblar/button.py b/homeassistant/components/peblar/button.py new file mode 100644 index 00000000000..22150c82649 --- /dev/null +++ b/homeassistant/components/peblar/button.py @@ -0,0 +1,79 @@ +"""Support for Peblar button.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from peblar import Peblar + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import PeblarConfigEntry, PeblarUserConfigurationDataUpdateCoordinator +from .entity import PeblarEntity +from .helpers import peblar_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class PeblarButtonEntityDescription(ButtonEntityDescription): + """Describe a Peblar button.""" + + press_fn: Callable[[Peblar], Awaitable[Any]] + + +DESCRIPTIONS = [ + PeblarButtonEntityDescription( + key="identify", + device_class=ButtonDeviceClass.IDENTIFY, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + press_fn=lambda x: x.identify(), + ), + PeblarButtonEntityDescription( + key="reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + press_fn=lambda x: x.reboot(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PeblarConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Peblar buttons based on a config entry.""" + async_add_entities( + PeblarButtonEntity( + entry=entry, + coordinator=entry.runtime_data.user_configuration_coordinator, + description=description, + ) + for description in DESCRIPTIONS + ) + + +class PeblarButtonEntity( + PeblarEntity[PeblarUserConfigurationDataUpdateCoordinator], + ButtonEntity, +): + """Defines an Peblar button.""" + + entity_description: PeblarButtonEntityDescription + + @peblar_exception_handler + async def async_press(self) -> None: + """Trigger button press on the Peblar device.""" + await self.entity_description.press_fn(self.coordinator.peblar) diff --git a/homeassistant/components/peblar/config_flow.py b/homeassistant/components/peblar/config_flow.py new file mode 100644 index 00000000000..b9b42cd6ca5 --- /dev/null +++ b/homeassistant/components/peblar/config_flow.py @@ -0,0 +1,244 @@ +"""Config flow to configure the Peblar integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiohttp import CookieJar +from peblar import Peblar, PeblarAuthenticationError, PeblarConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DOMAIN, LOGGER + + +class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Peblar config flow.""" + + VERSION = 1 + + _discovery_info: ZeroconfServiceInfo + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + peblar = Peblar( + host=user_input[CONF_HOST], + session=async_create_clientsession( + self.hass, cookie_jar=CookieJar(unsafe=True) + ), + ) + try: + await peblar.login(password=user_input[CONF_PASSWORD]) + info = await peblar.system_information() + except PeblarAuthenticationError: + errors[CONF_PASSWORD] = "invalid_auth" + except PeblarConnectionError: + errors[CONF_HOST] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + info.product_serial_number, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Peblar", data=user_input) + else: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=user_input.get(CONF_HOST) + ): TextSelector(TextSelectorConfig(autocomplete="off")), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of a Peblar device.""" + errors = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + peblar = Peblar( + host=user_input[CONF_HOST], + session=async_create_clientsession( + self.hass, cookie_jar=CookieJar(unsafe=True) + ), + ) + try: + await peblar.login(password=user_input[CONF_PASSWORD]) + info = await peblar.system_information() + except PeblarAuthenticationError: + errors[CONF_PASSWORD] = "invalid_auth" + except PeblarConnectionError: + errors[CONF_HOST] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info.product_serial_number) + self._abort_if_unique_id_mismatch(reason="different_device") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=user_input, + ) + + host = reconfigure_entry.data[CONF_HOST] + if user_input is not None: + host = user_input[CONF_HOST] + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=host): TextSelector( + TextSelectorConfig(autocomplete="off") + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery of a Peblar device.""" + if not (sn := discovery_info.properties.get("sn")): + return self.async_abort(reason="no_serial_number") + + await self.async_set_unique_id(sn) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self._discovery_info = discovery_info + self.context.update( + { + "title_placeholders": { + "name": discovery_info.name.replace("._http._tcp.local.", "") + }, + "configuration_url": f"http://{discovery_info.host}", + }, + ) + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by zeroconf.""" + errors = {} + + if user_input is not None: + peblar = Peblar( + host=self._discovery_info.host, + session=async_create_clientsession( + self.hass, cookie_jar=CookieJar(unsafe=True) + ), + ) + try: + await peblar.login(password=user_input[CONF_PASSWORD]) + except PeblarAuthenticationError: + errors[CONF_PASSWORD] = "invalid_auth" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Peblar", + data={ + CONF_HOST: self._discovery_info.host, + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={ + "hostname": self._discovery_info.name.replace("._http._tcp.local.", ""), + "host": self._discovery_info.host, + }, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle initiation of re-authentication with a Peblar device.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication with a Peblar device.""" + errors = {} + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + peblar = Peblar( + host=reauth_entry.data[CONF_HOST], + session=async_create_clientsession( + self.hass, cookie_jar=CookieJar(unsafe=True) + ), + ) + try: + await peblar.login(password=user_input[CONF_PASSWORD]) + except PeblarAuthenticationError: + errors[CONF_PASSWORD] = "invalid_auth" + except PeblarConnectionError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data={ + CONF_HOST: reauth_entry.data[CONF_HOST], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py new file mode 100644 index 00000000000..58fcc9b85da --- /dev/null +++ b/homeassistant/components/peblar/const.py @@ -0,0 +1,42 @@ +"""Constants for the Peblar integration.""" + +from __future__ import annotations + +import logging +from typing import Final + +from peblar import ChargeLimiter, CPState + +DOMAIN: Final = "peblar" + +LOGGER = logging.getLogger(__package__) + +PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT = { + ChargeLimiter.CHARGING_CABLE: "charging_cable", + ChargeLimiter.CURRENT_LIMITER: "current_limiter", + ChargeLimiter.DYNAMIC_LOAD_BALANCING: "dynamic_load_balancing", + ChargeLimiter.EXTERNAL_POWER_LIMIT: "external_power_limit", + ChargeLimiter.GROUP_LOAD_BALANCING: "group_load_balancing", + ChargeLimiter.HARDWARE_LIMITATION: "hardware_limitation", + ChargeLimiter.HIGH_TEMPERATURE: "high_temperature", + ChargeLimiter.HOUSEHOLD_POWER_LIMIT: "household_power_limit", + ChargeLimiter.INSTALLATION_LIMIT: "installation_limit", + ChargeLimiter.LOCAL_MODBUS_API: "local_modbus_api", + ChargeLimiter.LOCAL_REST_API: "local_rest_api", + ChargeLimiter.LOCAL_SCHEDULED_CHARGING: "local_scheduled_charging", + ChargeLimiter.OCPP_SMART_CHARGING: "ocpp_smart_charging", + ChargeLimiter.OVERCURRENT_PROTECTION: "overcurrent_protection", + ChargeLimiter.PHASE_IMBALANCE: "phase_imbalance", + ChargeLimiter.POWER_FACTOR: "power_factor", + ChargeLimiter.SOLAR_CHARGING: "solar_charging", +} + +PEBLAR_CP_STATE_TO_HOME_ASSISTANT = { + CPState.CHARGING_SUSPENDED: "suspended", + CPState.CHARGING_VENTILATION: "charging", + CPState.CHARGING: "charging", + CPState.ERROR: "error", + CPState.FAULT: "fault", + CPState.INVALID: "invalid", + CPState.NO_EV_CONNECTED: "no_ev_connected", +} diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py new file mode 100644 index 00000000000..058f2aefb3b --- /dev/null +++ b/homeassistant/components/peblar/coordinator.py @@ -0,0 +1,184 @@ +"""Data update coordinator for Peblar EV chargers.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, Concatenate + +from peblar import ( + Peblar, + PeblarApi, + PeblarAuthenticationError, + PeblarConnectionError, + PeblarError, + PeblarEVInterface, + PeblarMeter, + PeblarSystem, + PeblarSystemInformation, + PeblarUserConfiguration, + PeblarVersions, +) + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +@dataclass(kw_only=True) +class PeblarRuntimeData: + """Class to hold runtime data.""" + + data_coordinator: PeblarDataUpdateCoordinator + system_information: PeblarSystemInformation + user_configuration_coordinator: PeblarUserConfigurationDataUpdateCoordinator + version_coordinator: PeblarVersionDataUpdateCoordinator + + +type PeblarConfigEntry = ConfigEntry[PeblarRuntimeData] + + +@dataclass(kw_only=True, frozen=True) +class PeblarVersionInformation: + """Class to hold version information.""" + + current: PeblarVersions + available: PeblarVersions + + +@dataclass(kw_only=True) +class PeblarData: + """Class to hold active charging related information of Peblar. + + This is data that needs to be polled and updated at a relatively high + frequency in order for this integration to function correctly. + All this data is updated at the same time by a single coordinator. + """ + + ev: PeblarEVInterface + meter: PeblarMeter + system: PeblarSystem + + +def _coordinator_exception_handler[ + _DataUpdateCoordinatorT: PeblarDataUpdateCoordinator + | PeblarVersionDataUpdateCoordinator + | PeblarUserConfigurationDataUpdateCoordinator, + **_P, +]( + func: Callable[Concatenate[_DataUpdateCoordinatorT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_DataUpdateCoordinatorT, _P], Coroutine[Any, Any, Any]]: + """Handle exceptions within the update handler of a coordinator.""" + + async def handler( + self: _DataUpdateCoordinatorT, *args: _P.args, **kwargs: _P.kwargs + ) -> Any: + try: + return await func(self, *args, **kwargs) + except PeblarAuthenticationError as error: + if self.config_entry and self.config_entry.state is ConfigEntryState.LOADED: + # This is not the first refresh, so let's reload + # the config entry to ensure we trigger a re-authentication + # flow (or recover in case of API token changes). + self.hass.config_entries.async_schedule_reload( + self.config_entry.entry_id + ) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from error + except PeblarConnectionError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(error)}, + ) from error + except PeblarError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"error": str(error)}, + ) from error + + return handler + + +class PeblarVersionDataUpdateCoordinator( + DataUpdateCoordinator[PeblarVersionInformation] +): + """Class to manage fetching Peblar version information.""" + + def __init__( + self, hass: HomeAssistant, entry: PeblarConfigEntry, peblar: Peblar + ) -> None: + """Initialize the coordinator.""" + self.peblar = peblar + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"Peblar {entry.title} version", + update_interval=timedelta(hours=2), + ) + + @_coordinator_exception_handler + async def _async_update_data(self) -> PeblarVersionInformation: + """Fetch data from the Peblar device.""" + return PeblarVersionInformation( + current=await self.peblar.current_versions(), + available=await self.peblar.available_versions(), + ) + + +class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]): + """Class to manage fetching Peblar active data.""" + + def __init__( + self, hass: HomeAssistant, entry: PeblarConfigEntry, api: PeblarApi + ) -> None: + """Initialize the coordinator.""" + self.api = api + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"Peblar {entry.title} meter", + update_interval=timedelta(seconds=10), + ) + + @_coordinator_exception_handler + async def _async_update_data(self) -> PeblarData: + """Fetch data from the Peblar device.""" + return PeblarData( + ev=await self.api.ev_interface(), + meter=await self.api.meter(), + system=await self.api.system(), + ) + + +class PeblarUserConfigurationDataUpdateCoordinator( + DataUpdateCoordinator[PeblarUserConfiguration] +): + """Class to manage fetching Peblar user configuration data.""" + + def __init__( + self, hass: HomeAssistant, entry: PeblarConfigEntry, peblar: Peblar + ) -> None: + """Initialize the coordinator.""" + self.peblar = peblar + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"Peblar {entry.title} user configuration", + update_interval=timedelta(minutes=5), + ) + + @_coordinator_exception_handler + async def _async_update_data(self) -> PeblarUserConfiguration: + """Fetch data from the Peblar device.""" + return await self.peblar.user_configuration() diff --git a/homeassistant/components/peblar/diagnostics.py b/homeassistant/components/peblar/diagnostics.py new file mode 100644 index 00000000000..a8c7423f79a --- /dev/null +++ b/homeassistant/components/peblar/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for Peblar.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import PeblarConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: PeblarConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "system_information": entry.runtime_data.system_information.to_dict(), + "user_configuration": entry.runtime_data.user_configuration_coordinator.data.to_dict(), + "ev": entry.runtime_data.data_coordinator.data.ev.to_dict(), + "meter": entry.runtime_data.data_coordinator.data.meter.to_dict(), + "system": entry.runtime_data.data_coordinator.data.system.to_dict(), + "versions": { + "available": entry.runtime_data.version_coordinator.data.available.to_dict(), + "current": entry.runtime_data.version_coordinator.data.current.to_dict(), + }, + } diff --git a/homeassistant/components/peblar/entity.py b/homeassistant/components/peblar/entity.py new file mode 100644 index 00000000000..ecfd3e8232b --- /dev/null +++ b/homeassistant/components/peblar/entity.py @@ -0,0 +1,55 @@ +"""Base entity for the Peblar integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN +from .coordinator import PeblarConfigEntry + + +class PeblarEntity[_DataUpdateCoordinatorT: DataUpdateCoordinator[Any]]( + CoordinatorEntity[_DataUpdateCoordinatorT] +): + """Defines a Peblar entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + *, + entry: PeblarConfigEntry, + coordinator: _DataUpdateCoordinatorT, + description: EntityDescription, + ) -> None: + """Initialize the Peblar entity.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self._attr_unique_id = f"{entry.unique_id}_{description.key}" + + system_information = entry.runtime_data.system_information + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{entry.data[CONF_HOST]}", + connections={ + (dr.CONNECTION_NETWORK_MAC, system_information.ethernet_mac_address), + (dr.CONNECTION_NETWORK_MAC, system_information.wlan_mac_address), + }, + identifiers={ + (DOMAIN, entry.runtime_data.system_information.product_serial_number) + }, + manufacturer=system_information.product_vendor_name, + model=system_information.product_model_name, + model_id=system_information.product_number, + name="Peblar EV Charger", + serial_number=system_information.product_serial_number, + sw_version=entry.runtime_data.version_coordinator.data.current.firmware, + ) diff --git a/homeassistant/components/peblar/helpers.py b/homeassistant/components/peblar/helpers.py new file mode 100644 index 00000000000..cc1eb228803 --- /dev/null +++ b/homeassistant/components/peblar/helpers.py @@ -0,0 +1,55 @@ +"""Helpers for Peblar.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate + +from peblar import PeblarAuthenticationError, PeblarConnectionError, PeblarError + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .entity import PeblarEntity + + +def peblar_exception_handler[_PeblarEntityT: PeblarEntity, **_P]( + func: Callable[Concatenate[_PeblarEntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_PeblarEntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Peblar calls to handle exceptions. + + A decorator that wraps the passed in function, catches Peblar errors. + """ + + async def handler( + self: _PeblarEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + try: + await func(self, *args, **kwargs) + self.coordinator.async_update_listeners() + + except PeblarAuthenticationError as error: + # Reload the config entry to trigger reauth flow + self.hass.config_entries.async_schedule_reload( + self.coordinator.config_entry.entry_id + ) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from error + + except PeblarConnectionError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(error)}, + ) from error + + except PeblarError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"error": str(error)}, + ) from error + + return handler diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json new file mode 100644 index 00000000000..6244945077b --- /dev/null +++ b/homeassistant/components/peblar/icons.json @@ -0,0 +1,49 @@ +{ + "entity": { + "binary_sensor": { + "active_error_codes": { + "default": "mdi:alert" + }, + "active_warning_codes": { + "default": "mdi:alert" + } + }, + "number": { + "charge_current_limit": { + "default": "mdi:speedometer" + } + }, + "select": { + "smart_charging": { + "default": "mdi:lightning-bolt", + "state": { + "fast_solar": "mdi:solar-power", + "pure_solar": "mdi:solar-power-variant", + "scheduled": "mdi:calendar-clock", + "smart_solar": "mdi:solar-power" + } + } + }, + "sensor": { + "cp_state": { + "default": "mdi:ev-plug-type2" + }, + "charge_current_limit_source": { + "default": "mdi:arrow-collapse-up" + }, + "uptime": { + "default": "mdi:timer" + } + }, + "switch": { + "force_single_phase": { + "default": "mdi:power-cycle" + } + }, + "update": { + "customization": { + "default": "mdi:palette" + } + } + } +} diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json new file mode 100644 index 00000000000..e2ae96de988 --- /dev/null +++ b/homeassistant/components/peblar/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "peblar", + "name": "Peblar", + "codeowners": ["@frenck"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/peblar", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "platinum", + "requirements": ["peblar==0.4.0"], + "zeroconf": [{ "type": "_http._tcp.local.", "name": "pblr-*" }] +} diff --git a/homeassistant/components/peblar/number.py b/homeassistant/components/peblar/number.py new file mode 100644 index 00000000000..1a7cec43295 --- /dev/null +++ b/homeassistant/components/peblar/number.py @@ -0,0 +1,102 @@ +"""Support for Peblar numbers.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from peblar import PeblarApi + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import ( + PeblarConfigEntry, + PeblarData, + PeblarDataUpdateCoordinator, + PeblarRuntimeData, +) +from .entity import PeblarEntity +from .helpers import peblar_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class PeblarNumberEntityDescription(NumberEntityDescription): + """Describe a Peblar number.""" + + native_max_value_fn: Callable[[PeblarRuntimeData], int] + set_value_fn: Callable[[PeblarApi, float], Awaitable[Any]] + value_fn: Callable[[PeblarData], int | None] + + +DESCRIPTIONS = [ + PeblarNumberEntityDescription( + key="charge_current_limit", + translation_key="charge_current_limit", + device_class=NumberDeviceClass.CURRENT, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=6, + native_max_value_fn=lambda x: x.user_configuration_coordinator.data.user_defined_charge_limit_current, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + set_value_fn=lambda x, v: x.ev_interface(charge_current_limit=int(v) * 1000), + value_fn=lambda x: round(x.ev.charge_current_limit / 1000), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PeblarConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Peblar number based on a config entry.""" + async_add_entities( + PeblarNumberEntity( + entry=entry, + coordinator=entry.runtime_data.data_coordinator, + description=description, + ) + for description in DESCRIPTIONS + ) + + +class PeblarNumberEntity( + PeblarEntity[PeblarDataUpdateCoordinator], + NumberEntity, +): + """Defines a Peblar number.""" + + entity_description: PeblarNumberEntityDescription + + def __init__( + self, + entry: PeblarConfigEntry, + coordinator: PeblarDataUpdateCoordinator, + description: PeblarNumberEntityDescription, + ) -> None: + """Initialize the Peblar entity.""" + super().__init__(entry=entry, coordinator=coordinator, description=description) + self._attr_native_max_value = description.native_max_value_fn( + entry.runtime_data + ) + + @property + def native_value(self) -> int | None: + """Return the number value.""" + return self.entity_description.value_fn(self.coordinator.data) + + @peblar_exception_handler + async def async_set_native_value(self, value: float) -> None: + """Change to new number value.""" + await self.entity_description.set_value_fn(self.coordinator.api, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/peblar/quality_scale.yaml b/homeassistant/components/peblar/quality_scale.yaml new file mode 100644 index 00000000000..91f9bb7af55 --- /dev/null +++ b/homeassistant/components/peblar/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have any configuration parameters. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + This integration connects to a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: exempt + comment: | + The coordinator needs translation when the update failed. + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + This integration does not raise any repairable issues. + stale-devices: + status: exempt + comment: | + This integration connects to a single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/peblar/select.py b/homeassistant/components/peblar/select.py new file mode 100644 index 00000000000..a2a0997a797 --- /dev/null +++ b/homeassistant/components/peblar/select.py @@ -0,0 +1,82 @@ +"""Support for Peblar selects.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from peblar import Peblar, PeblarUserConfiguration, SmartChargingMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import PeblarConfigEntry, PeblarUserConfigurationDataUpdateCoordinator +from .entity import PeblarEntity +from .helpers import peblar_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class PeblarSelectEntityDescription(SelectEntityDescription): + """Class describing Peblar select entities.""" + + current_fn: Callable[[PeblarUserConfiguration], str | None] + select_fn: Callable[[Peblar, str], Awaitable[Any]] + + +DESCRIPTIONS = [ + PeblarSelectEntityDescription( + key="smart_charging", + translation_key="smart_charging", + entity_category=EntityCategory.CONFIG, + options=[ + "default", + "fast_solar", + "pure_solar", + "scheduled", + "smart_solar", + ], + current_fn=lambda x: x.smart_charging.value if x.smart_charging else None, + select_fn=lambda x, mode: x.smart_charging(SmartChargingMode(mode)), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PeblarConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Peblar select based on a config entry.""" + async_add_entities( + PeblarSelectEntity( + entry=entry, + coordinator=entry.runtime_data.user_configuration_coordinator, + description=description, + ) + for description in DESCRIPTIONS + ) + + +class PeblarSelectEntity( + PeblarEntity[PeblarUserConfigurationDataUpdateCoordinator], + SelectEntity, +): + """Defines a Peblar select entity.""" + + entity_description: PeblarSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return self.entity_description.current_fn(self.coordinator.data) + + @peblar_exception_handler + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.select_fn(self.coordinator.peblar, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/peblar/sensor.py b/homeassistant/components/peblar/sensor.py new file mode 100644 index 00000000000..e655253d75c --- /dev/null +++ b/homeassistant/components/peblar/sensor.py @@ -0,0 +1,256 @@ +"""Support for Peblar sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from peblar import PeblarUserConfiguration + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.dt import utcnow + +from .const import ( + PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT, + PEBLAR_CP_STATE_TO_HOME_ASSISTANT, +) +from .coordinator import PeblarConfigEntry, PeblarData, PeblarDataUpdateCoordinator +from .entity import PeblarEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PeblarSensorDescription(SensorEntityDescription): + """Describe a Peblar sensor.""" + + has_fn: Callable[[PeblarUserConfiguration], bool] = lambda _: True + value_fn: Callable[[PeblarData], datetime | int | str | None] + + +DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( + PeblarSensorDescription( + key="cp_state", + translation_key="cp_state", + device_class=SensorDeviceClass.ENUM, + options=list(PEBLAR_CP_STATE_TO_HOME_ASSISTANT.values()), + value_fn=lambda x: PEBLAR_CP_STATE_TO_HOME_ASSISTANT[x.ev.cp_state], + ), + PeblarSensorDescription( + key="charge_current_limit_source", + translation_key="charge_current_limit_source", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT.values()), + value_fn=lambda x: PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT[ + x.ev.charge_current_limit_source + ], + ), + PeblarSensorDescription( + key="current_total", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda x: x.meter.current_total, + ), + PeblarSensorDescription( + key="current_phase_1", + translation_key="current_phase_1", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda x: x.connected_phases >= 2, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda x: x.meter.current_phase_1, + ), + PeblarSensorDescription( + key="current_phase_2", + translation_key="current_phase_2", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda x: x.connected_phases >= 2, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda x: x.meter.current_phase_2, + ), + PeblarSensorDescription( + key="current_phase_3", + translation_key="current_phase_3", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda x: x.connected_phases == 3, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda x: x.meter.current_phase_3, + ), + PeblarSensorDescription( + key="energy_session", + translation_key="energy_session", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda x: x.meter.energy_session, + ), + PeblarSensorDescription( + key="energy_total", + translation_key="energy_total", + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda x: x.meter.energy_total, + ), + PeblarSensorDescription( + key="power_total", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.meter.power_total, + ), + PeblarSensorDescription( + key="power_phase_1", + translation_key="power_phase_1", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda x: x.connected_phases >= 2, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.meter.power_phase_1, + ), + PeblarSensorDescription( + key="power_phase_2", + translation_key="power_phase_2", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda x: x.connected_phases >= 2, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.meter.power_phase_2, + ), + PeblarSensorDescription( + key="power_phase_3", + translation_key="power_phase_3", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda x: x.connected_phases == 3, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.meter.power_phase_3, + ), + PeblarSensorDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda x: x.connected_phases == 1, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.meter.voltage_phase_1, + ), + PeblarSensorDescription( + key="voltage_phase_1", + translation_key="voltage_phase_1", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda x: x.connected_phases >= 2, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.meter.voltage_phase_1, + ), + PeblarSensorDescription( + key="voltage_phase_2", + translation_key="voltage_phase_2", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda x: x.connected_phases >= 2, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.meter.voltage_phase_2, + ), + PeblarSensorDescription( + key="voltage_phase_3", + translation_key="voltage_phase_3", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=lambda x: x.connected_phases == 3, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.meter.voltage_phase_3, + ), + PeblarSensorDescription( + key="uptime", + translation_key="uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda x: ( + utcnow().replace(microsecond=0) - timedelta(seconds=x.system.uptime) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PeblarConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Peblar sensors based on a config entry.""" + async_add_entities( + PeblarSensorEntity( + entry=entry, + coordinator=entry.runtime_data.data_coordinator, + description=description, + ) + for description in DESCRIPTIONS + if description.has_fn(entry.runtime_data.user_configuration_coordinator.data) + ) + + +class PeblarSensorEntity(PeblarEntity[PeblarDataUpdateCoordinator], SensorEntity): + """Defines a Peblar sensor.""" + + entity_description: PeblarSensorDescription + + @property + def native_value(self) -> datetime | int | str | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json new file mode 100644 index 00000000000..a33667fa533 --- /dev/null +++ b/homeassistant/components/peblar/strings.json @@ -0,0 +1,177 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "different_device": "The information entered is from a different Peblar EV charger.", + "no_serial_number": "The discovered Peblar device did not provide a serial number.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::peblar::config::step::user::data_description::password%]" + }, + "description": "Reauthenticate with your Peblar EV charger.\n\nTo do so, you will need to enter your new password you use to log in to the Peblar EV charger's web interface." + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "[%key:component::peblar::config::step::user::data_description::host%]", + "password": "[%key:component::peblar::config::step::user::data_description::password%]" + }, + "description": "Reconfigure your Peblar EV charger.\n\nThis allows you to change the IP address of your Peblar EV charger and the password you use to log in to its web interface." + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Peblar EV charger on your home network.", + "password": "The same password as you use to log in to the Peblar EV charger's local web interface." + }, + "description": "Set up your Peblar EV charger to integrate with Home Assistant.\n\nTo do so, you will need to get the IP address of your Peblar EV charger and the password you use to log in to its web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant." + }, + "zeroconf_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::peblar::config::step::user::data_description::password%]" + }, + "description": "Set up your Peblar EV charger {hostname}, on IP address {host}, to integrate with Home Assistant\n\nTo do so, you will need the password you use to log in to the Peblar EV charger's web interface.\n\nHome Assistant will automatically configure your Peblar EV charger for use with Home Assistant." + } + } + }, + "entity": { + "binary_sensor": { + "active_error_codes": { + "name": "Active errors" + }, + "active_warning_codes": { + "name": "Active warnings" + } + }, + "number": { + "charge_current_limit": { + "name": "Charge limit" + } + }, + "select": { + "smart_charging": { + "name": "Smart charging", + "state": { + "default": "Default", + "fast_solar": "Fast solar", + "pure_solar": "Pure solar", + "scheduled": "Scheduled", + "smart_solar": "Smart solar" + } + } + }, + "sensor": { + "charge_current_limit_source": { + "name": "Limit source", + "state": { + "charging_cable": "Charging cable", + "current_limiter": "Current limiter", + "dynamic_load_balancing": "Dynamic load balancing", + "external_power_limit": "External power limit", + "group_load_balancing": "Group load balancing", + "hardware_limitation": "Hardware limitation", + "high_temperature": "High temperature", + "household_power_limit": "Household power limit", + "installation_limit": "Installation limit", + "local_modbus_api": "Modbus API", + "local_rest_api": "REST API", + "local_scheduled_charging": "Scheduled charging", + "ocpp_smart_charging": "OCPP smart charging", + "overcurrent_protection": "Overcurrent protection", + "phase_imbalance": "Phase imbalance", + "power_factor": "Power factor", + "solar_charging": "Solar charging" + } + }, + "cp_state": { + "name": "State", + "state": { + "charging": "Charging", + "error": "Error", + "fault": "Fault", + "invalid": "Invalid", + "no_ev_connected": "No EV connected", + "suspended": "Suspended" + } + }, + "current_phase_1": { + "name": "Current phase 1" + }, + "current_phase_2": { + "name": "Current phase 2" + }, + "current_phase_3": { + "name": "Current phase 3" + }, + "energy_session": { + "name": "Session energy" + }, + "energy_total": { + "name": "Lifetime energy" + }, + "power_phase_1": { + "name": "Power phase 1" + }, + "power_phase_2": { + "name": "Power phase 2" + }, + "power_phase_3": { + "name": "Power phase 3" + }, + "uptime": { + "name": "Uptime" + }, + "voltage_phase_1": { + "name": "Voltage phase 1" + }, + "voltage_phase_2": { + "name": "Voltage phase 2" + }, + "voltage_phase_3": { + "name": "Voltage phase 3" + } + }, + "switch": { + "force_single_phase": { + "name": "Force single phase" + } + }, + "update": { + "customization": { + "name": "Customization" + } + } + }, + "exceptions": { + "authentication_error": { + "message": "An authentication failure occurred while communicating with the Peblar EV charger." + }, + "communication_error": { + "message": "An error occurred while communicating with the Peblar EV charger: {error}" + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Peblar EV charger: {error}" + } + } +} diff --git a/homeassistant/components/peblar/switch.py b/homeassistant/components/peblar/switch.py new file mode 100644 index 00000000000..e56c2fcdaec --- /dev/null +++ b/homeassistant/components/peblar/switch.py @@ -0,0 +1,92 @@ +"""Support for Peblar selects.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from peblar import PeblarApi + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import ( + PeblarConfigEntry, + PeblarData, + PeblarDataUpdateCoordinator, + PeblarRuntimeData, +) +from .entity import PeblarEntity +from .helpers import peblar_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class PeblarSwitchEntityDescription(SwitchEntityDescription): + """Class describing Peblar switch entities.""" + + has_fn: Callable[[PeblarRuntimeData], bool] = lambda x: True + is_on_fn: Callable[[PeblarData], bool] + set_fn: Callable[[PeblarApi, bool], Awaitable[Any]] + + +DESCRIPTIONS = [ + PeblarSwitchEntityDescription( + key="force_single_phase", + translation_key="force_single_phase", + entity_category=EntityCategory.CONFIG, + has_fn=lambda x: ( + x.data_coordinator.data.system.force_single_phase_allowed + and x.user_configuration_coordinator.data.connected_phases > 1 + ), + is_on_fn=lambda x: x.ev.force_single_phase, + set_fn=lambda x, on: x.ev_interface(force_single_phase=on), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PeblarConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Peblar switch based on a config entry.""" + async_add_entities( + PeblarSwitchEntity( + entry=entry, + coordinator=entry.runtime_data.data_coordinator, + description=description, + ) + for description in DESCRIPTIONS + if description.has_fn(entry.runtime_data) + ) + + +class PeblarSwitchEntity( + PeblarEntity[PeblarDataUpdateCoordinator], + SwitchEntity, +): + """Defines a Peblar switch entity.""" + + entity_description: PeblarSwitchEntityDescription + + @property + def is_on(self) -> bool: + """Return state of the switch.""" + return self.entity_description.is_on_fn(self.coordinator.data) + + @peblar_exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.set_fn(self.coordinator.api, True) + await self.coordinator.async_request_refresh() + + @peblar_exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.set_fn(self.coordinator.api, False) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py new file mode 100644 index 00000000000..58c2fbdc899 --- /dev/null +++ b/homeassistant/components/peblar/update.py @@ -0,0 +1,86 @@ +"""Support for Peblar updates.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import ( + PeblarConfigEntry, + PeblarVersionDataUpdateCoordinator, + PeblarVersionInformation, +) +from .entity import PeblarEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class PeblarUpdateEntityDescription(UpdateEntityDescription): + """Describe an Peblar update entity.""" + + available_fn: Callable[[PeblarVersionInformation], str | None] + has_fn: Callable[[PeblarVersionInformation], bool] = lambda _: True + installed_fn: Callable[[PeblarVersionInformation], str | None] + + +DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = ( + PeblarUpdateEntityDescription( + key="firmware", + device_class=UpdateDeviceClass.FIRMWARE, + installed_fn=lambda x: x.current.firmware, + has_fn=lambda x: x.available.firmware is not None, + available_fn=lambda x: x.available.firmware, + ), + PeblarUpdateEntityDescription( + key="customization", + translation_key="customization", + available_fn=lambda x: x.available.customization, + has_fn=lambda x: x.available.customization is not None, + installed_fn=lambda x: x.current.customization, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PeblarConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Peblar update based on a config entry.""" + async_add_entities( + PeblarUpdateEntity( + entry=entry, + coordinator=entry.runtime_data.version_coordinator, + description=description, + ) + for description in DESCRIPTIONS + if description.has_fn(entry.runtime_data.version_coordinator.data) + ) + + +class PeblarUpdateEntity( + PeblarEntity[PeblarVersionDataUpdateCoordinator], + UpdateEntity, +): + """Defines a Peblar update entity.""" + + entity_description: PeblarUpdateEntityDescription + + @property + def installed_version(self) -> str | None: + """Version currently installed and in use.""" + return self.entity_description.installed_fn(self.coordinator.data) + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + return self.entity_description.available_fn(self.coordinator.data) diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index 2c465342493..30e5f4d2a38 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -5,10 +5,12 @@ from __future__ import annotations import logging from aiopegelonline import PegelOnline +from aiopegelonline.const import CONNECT_ERRORS from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION @@ -28,7 +30,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) _LOGGER.debug("Setting up station with uuid %s", station_uuid) api = PegelOnline(async_get_clientsession(hass)) - station = await api.async_get_station_details(station_uuid) + try: + station = await api.async_get_station_details(station_uuid) + except CONNECT_ERRORS as err: + raise ConfigEntryNotReady("Failed to connect") from err coordinator = PegelOnlineDataUpdateCoordinator(hass, entry.title, api, station) diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json index 443e8c58467..0a0f31532b1 100644 --- a/homeassistant/components/pegel_online/manifest.json +++ b/homeassistant/components/pegel_online/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopegelonline"], - "requirements": ["aiopegelonline==0.1.0"] + "requirements": ["aiopegelonline==0.1.1"] } diff --git a/homeassistant/components/persistent_notification/strings.json b/homeassistant/components/persistent_notification/strings.json index b9a4ae4f10f..e6c3d3b7775 100644 --- a/homeassistant/components/persistent_notification/strings.json +++ b/homeassistant/components/persistent_notification/strings.json @@ -21,17 +21,17 @@ }, "dismiss": { "name": "Dismiss", - "description": "Removes a notification from the notifications panel.", + "description": "Deletes a notification from the notifications panel.", "fields": { "notification_id": { "name": "[%key:component::persistent_notification::services::create::fields::notification_id::name%]", - "description": "ID of the notification to be removed." + "description": "ID of the notification to be deleted." } } }, "dismiss_all": { "name": "Dismiss all", - "description": "Removes all notifications from the notifications panel." + "description": "Deletes all notifications from the notifications panel." } } } diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index b793f4b33ae..856e07bb2ee 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -280,7 +280,7 @@ class PersonStorageCollection(collection.DictStorageCollection): return data @callback - def _get_suggested_id(self, info: dict) -> str: + def _get_suggested_id(self, info: dict[str, str]) -> str: """Suggest an ID based on the config.""" return info[CONF_NAME] diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 503883e9326..4cf5133e700 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -18,7 +18,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="ads_blocked_today", translation_key="ads_blocked_today", - native_unit_of_measurement="ads", ), SensorEntityDescription( key="ads_percentage_today", @@ -28,38 +27,20 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="clients_ever_seen", translation_key="clients_ever_seen", - native_unit_of_measurement="clients", ), SensorEntityDescription( - key="dns_queries_today", - translation_key="dns_queries_today", - native_unit_of_measurement="queries", + key="dns_queries_today", translation_key="dns_queries_today" ), SensorEntityDescription( key="domains_being_blocked", translation_key="domains_being_blocked", - native_unit_of_measurement="domains", ), + SensorEntityDescription(key="queries_cached", translation_key="queries_cached"), SensorEntityDescription( - key="queries_cached", - translation_key="queries_cached", - native_unit_of_measurement="queries", - ), - SensorEntityDescription( - key="queries_forwarded", - translation_key="queries_forwarded", - native_unit_of_measurement="queries", - ), - SensorEntityDescription( - key="unique_clients", - translation_key="unique_clients", - native_unit_of_measurement="clients", - ), - SensorEntityDescription( - key="unique_domains", - translation_key="unique_domains", - native_unit_of_measurement="domains", + key="queries_forwarded", translation_key="queries_forwarded" ), + SensorEntityDescription(key="unique_clients", translation_key="unique_clients"), + SensorEntityDescription(key="unique_domains", translation_key="unique_domains"), ) diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index b76b61f1903..9e1d5948a09 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -41,31 +41,39 @@ }, "sensor": { "ads_blocked_today": { - "name": "Ads blocked today" + "name": "Ads blocked today", + "unit_of_measurement": "ads" }, "ads_percentage_today": { "name": "Ads percentage blocked today" }, "clients_ever_seen": { - "name": "Seen clients" + "name": "Seen clients", + "unit_of_measurement": "clients" }, "dns_queries_today": { - "name": "DNS queries today" + "name": "DNS queries today", + "unit_of_measurement": "queries" }, "domains_being_blocked": { - "name": "Domains blocked" + "name": "Domains blocked", + "unit_of_measurement": "domains" }, "queries_cached": { - "name": "DNS queries cached" + "name": "DNS queries cached", + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::dns_queries_today::unit_of_measurement%]" }, "queries_forwarded": { - "name": "DNS queries forwarded" + "name": "DNS queries forwarded", + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::dns_queries_today::unit_of_measurement%]" }, "unique_clients": { - "name": "DNS unique clients" + "name": "DNS unique clients", + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::clients_ever_seen::unit_of_measurement%]" }, "unique_domains": { - "name": "DNS unique domains" + "name": "DNS unique domains", + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::domains_being_blocked::unit_of_measurement%]" } }, "update": { diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 9548029209b..4c8281f21de 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -67,8 +67,8 @@ async def validate_input(hass: HomeAssistant, data): # Return the validation result address = ( - f'{user_data["address"]["street"]} {user_data["address"]["house_number"]}' - f'{user_data["address"]["house_number_ext"]}' + f"{user_data['address']['street']} {user_data['address']['house_number']}" + f"{user_data['address']['house_number_ext']}" ) return auth_token, { "title": address, diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index c367d5ec548..b3979580990 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -79,7 +79,10 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): """Get the address that identifies the Picnic service.""" if self._user_address is None: address = self.picnic_api_client.get_user()["address"] - self._user_address = f'{address["street"]} {address["house_number"]}{address["house_number_ext"]}' + self._user_address = ( + f"{address['street']} " + f"{address['house_number']}{address['house_number_ext']}" + ) return self._user_address diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index 82ebf4532da..996faa99c5b 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -160,7 +160,7 @@ class PingDataSubProcess(PingData): ) if pinger: - with suppress(TypeError): + with suppress(TypeError, ProcessLookupError): await pinger.kill() # type: ignore[func-returns-value] del pinger diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py index 670ccffaea7..02072b6cb43 100644 --- a/homeassistant/components/pioneer/media_player.py +++ b/homeassistant/components/pioneer/media_player.py @@ -3,9 +3,9 @@ from __future__ import annotations import logging -import telnetlib # pylint: disable=deprecated-module from typing import Final +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.media_player import ( diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 0716b3606af..7f9c2545032 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -203,7 +203,7 @@ class PlexServer: config_entry_update_needed = True else: # pylint: disable-next=raise-missing-from - raise Unauthorized( # noqa: TRY200 + raise Unauthorized( # noqa: B904 "New certificate cannot be validated" " with provided token" ) @@ -425,9 +425,7 @@ class PlexServer: client = resource.connect(timeout=3) _LOGGER.debug("Resource connection successful to plex.tv: %s", client) except NotFound: - _LOGGER.error( - "Resource connection failed to plex.tv: %s", resource.name - ) + _LOGGER.info("Resource connection failed to plex.tv: %s", resource.name) else: client.proxyThroughServer(value=False, server=self._plex_server) self._client_device_cache[client.machineIdentifier] = client diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index 7d1b9ceac8a..a100103b029 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -83,7 +83,7 @@ def migrate_sensor_entities( # Migrating opentherm_outdoor_temperature # to opentherm_outdoor_air_temperature sensor for device_id, device in coordinator.data.devices.items(): - if device.get("dev_class") != "heater_central": + if device["dev_class"] != "heater_central": continue old_unique_id = f"{device_id}-outdoor_temperature" diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index fb271ea7264..539fa243d6c 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -23,6 +23,9 @@ from .entity import PlugwiseEntity SEVERITIES = ["other", "info", "warning", "error"] +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -34,7 +37,6 @@ class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( PlugwiseBinarySensorEntityDescription( key="low_battery", - translation_key="low_battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -56,7 +58,6 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( PlugwiseBinarySensorEntityDescription( key="flame_state", translation_key="flame_state", - name="Flame state", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( diff --git a/homeassistant/components/plugwise/button.py b/homeassistant/components/plugwise/button.py index 078d31bea12..8a05ede3496 100644 --- a/homeassistant/components/plugwise/button.py +++ b/homeassistant/components/plugwise/button.py @@ -13,6 +13,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 46b4bff250a..3caed1e7bc2 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PlugwiseConfigEntry @@ -24,6 +24,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -39,11 +41,19 @@ async def async_setup_entry( if not coordinator.new_devices: return - async_add_entities( - PlugwiseClimateEntity(coordinator, device_id) - for device_id in coordinator.new_devices - if coordinator.data.devices[device_id]["dev_class"] in MASTER_THERMOSTATS - ) + if coordinator.data.gateway["smile_name"] == "Adam": + async_add_entities( + PlugwiseClimateEntity(coordinator, device_id) + for device_id in coordinator.new_devices + if coordinator.data.devices[device_id]["dev_class"] == "climate" + ) + else: + async_add_entities( + PlugwiseClimateEntity(coordinator, device_id) + for device_id in coordinator.new_devices + if coordinator.data.devices[device_id]["dev_class"] + in MASTER_THERMOSTATS + ) _add_entities() entry.async_on_unload(coordinator.async_add_listener(_add_entities)) @@ -52,11 +62,9 @@ async def async_setup_entry( class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): """Representation of a Plugwise thermostat.""" - _attr_has_entity_name = True _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN - _enable_turn_on_off_backwards_compatibility = False _previous_mode: str = "heating" @@ -67,17 +75,20 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): ) -> None: """Set up the Plugwise API.""" super().__init__(coordinator, device_id) - self._attr_extra_state_attributes = {} self._attr_unique_id = f"{device_id}-climate" - self.cdr_gateway = coordinator.data.gateway - gateway_id: str = coordinator.data.gateway["gateway_id"] - self.gateway_data = coordinator.data.devices[gateway_id] + + self._devices = coordinator.data.devices + self._gateway = coordinator.data.gateway + gateway_id: str = self._gateway["gateway_id"] + self._gateway_data = self._devices[gateway_id] + + self._location = device_id + if (location := self.device.get("location")) is not None: + self._location = location + # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if ( - self.cdr_gateway["cooling_present"] - and self.cdr_gateway["smile_name"] != "Adam" - ): + if self._gateway["cooling_present"] and self._gateway["smile_name"] != "Adam": self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) @@ -103,10 +114,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): """ # When no cooling available, _previous_mode is always heating if ( - "regulation_modes" in self.gateway_data - and "cooling" in self.gateway_data["regulation_modes"] + "regulation_modes" in self._gateway_data + and "cooling" in self._gateway_data["regulation_modes"] ): - mode = self.gateway_data["select_regulation_mode"] + mode = self._gateway_data["select_regulation_mode"] if mode in ("cooling", "heating"): self._previous_mode = mode @@ -153,17 +164,17 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): def hvac_modes(self) -> list[HVACMode]: """Return a list of available HVACModes.""" hvac_modes: list[HVACMode] = [] - if "regulation_modes" in self.gateway_data: + if "regulation_modes" in self._gateway_data: hvac_modes.append(HVACMode.OFF) if "available_schedules" in self.device: hvac_modes.append(HVACMode.AUTO) - if self.cdr_gateway["cooling_present"]: - if "regulation_modes" in self.gateway_data: - if self.gateway_data["select_regulation_mode"] == "cooling": + if self._gateway["cooling_present"]: + if "regulation_modes" in self._gateway_data: + if self._gateway_data["select_regulation_mode"] == "cooling": hvac_modes.append(HVACMode.COOL) - if self.gateway_data["select_regulation_mode"] == "heating": + if self._gateway_data["select_regulation_mode"] == "heating": hvac_modes.append(HVACMode.HEAT) else: hvac_modes.append(HVACMode.HEAT_COOL) @@ -177,23 +188,8 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): """Return the current running hvac operation if supported.""" # Keep track of the previous action-mode self._previous_action_mode(self.coordinator) - - # Adam provides the hvac_action for each thermostat - if (control_state := self.device.get("control_state")) == "cooling": - return HVACAction.COOLING - if control_state == "heating": - return HVACAction.HEATING - if control_state == "preheating": - return HVACAction.PREHEATING - if control_state == "off": - return HVACAction.IDLE - - heater: str = self.coordinator.data.gateway["heater_id"] - heater_data = self.coordinator.data.devices[heater] - if heater_data["binary_sensors"]["heating_state"]: - return HVACAction.HEATING - if heater_data["binary_sensors"].get("cooling_state", False): - return HVACAction.COOLING + if (action := self.device.get("control_state")) is not None: + return HVACAction(action) return HVACAction.IDLE @@ -213,22 +209,24 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): if ATTR_TARGET_TEMP_LOW in kwargs: data["setpoint_low"] = kwargs.get(ATTR_TARGET_TEMP_LOW) - for temperature in data.values(): - if temperature is None or not ( - self._attr_min_temp <= temperature <= self._attr_max_temp - ): - raise ValueError("Invalid temperature change requested") - if mode := kwargs.get(ATTR_HVAC_MODE): await self.async_set_hvac_mode(mode) - await self.coordinator.api.set_temperature(self.device["location"], data) + await self.coordinator.api.set_temperature(self._location, data) @plugwise_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the hvac mode.""" if hvac_mode not in self.hvac_modes: - raise HomeAssistantError("Unsupported hvac_mode") + hvac_modes = ", ".join(self.hvac_modes) + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_hvac_mode_requested", + translation_placeholders={ + "hvac_mode": hvac_mode, + "hvac_modes": hvac_modes, + }, + ) if hvac_mode == self.hvac_mode: return @@ -237,7 +235,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): await self.coordinator.api.set_regulation_mode(hvac_mode) else: await self.coordinator.api.set_schedule_state( - self.device["location"], + self._location, "on" if hvac_mode == HVACMode.AUTO else "off", ) if self.hvac_mode == HVACMode.OFF: @@ -246,4 +244,4 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): @plugwise_command async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" - await self.coordinator.api.set_preset(self.device["location"], preset_mode) + await self.coordinator.api.set_preset(self._location, preset_mode) diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 57abb1ccb86..a94000934eb 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any, Self from plugwise import Smile @@ -15,7 +16,6 @@ from plugwise.exceptions import ( ) import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import ( ATTR_CONFIGURATION_URL, @@ -28,6 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( DEFAULT_PORT, @@ -41,8 +42,16 @@ from .const import ( ZEROCONF_MAP, ) +_LOGGER = logging.getLogger(__name__) -def base_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema: +SMILE_RECONF_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +def smile_user_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema: """Generate base schema for gateways.""" schema = vol.Schema({vol.Required(CONF_PASSWORD): str}) @@ -50,6 +59,7 @@ def base_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema: schema = schema.extend( { vol.Required(CONF_HOST): str, + # Port under investigation for removal (hence not added in #132878) vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, vol.Required(CONF_USERNAME, default=SMILE): vol.In( {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH} @@ -63,7 +73,7 @@ def base_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema: async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile: """Validate whether the user input allows us to connect to the gateway. - Data has the keys from base_schema() with values provided by the user. + Data has the keys from the schema with values provided by the user. """ websession = async_get_clientsession(hass, verify_ssl=False) api = Smile( @@ -77,6 +87,32 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile: return api +async def verify_connection( + hass: HomeAssistant, user_input: dict[str, Any] +) -> tuple[Smile | None, dict[str, str]]: + """Verify and return the gateway connection or an error.""" + errors: dict[str, str] = {} + + try: + return (await validate_input(hass, user_input), errors) + except ConnectionFailedError: + errors[CONF_BASE] = "cannot_connect" + except InvalidAuthentication: + errors[CONF_BASE] = "invalid_auth" + except InvalidSetupError: + errors[CONF_BASE] = "invalid_setup" + except (InvalidXMLError, ResponseError): + errors[CONF_BASE] = "response_error" + except UnsupportedDeviceError: + errors[CONF_BASE] = "unsupported" + except Exception: + _LOGGER.exception( + "Unknown exception while verifying connection with your Plugwise Smile" + ) + errors[CONF_BASE] = "unknown" + return (None, errors) + + class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Plugwise Smile.""" @@ -166,30 +202,56 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_PORT] = self.discovery_info.port user_input[CONF_USERNAME] = self._username - try: - api = await validate_input(self.hass, user_input) - except ConnectionFailedError: - errors[CONF_BASE] = "cannot_connect" - except InvalidAuthentication: - errors[CONF_BASE] = "invalid_auth" - except InvalidSetupError: - errors[CONF_BASE] = "invalid_setup" - except (InvalidXMLError, ResponseError): - errors[CONF_BASE] = "response_error" - except UnsupportedDeviceError: - errors[CONF_BASE] = "unsupported" - except Exception: # noqa: BLE001 - errors[CONF_BASE] = "unknown" - else: + api, errors = await verify_connection(self.hass, user_input) + if api: await self.async_set_unique_id( - api.smile_hostname or api.gateway_id, raise_on_progress=False + api.smile_hostname or api.gateway_id, + raise_on_progress=False, ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=api.smile_name, data=user_input) return self.async_show_form( step_id=SOURCE_USER, - data_schema=base_schema(self.discovery_info), + data_schema=smile_user_schema(self.discovery_info), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + + reconfigure_entry = self._get_reconfigure_entry() + + if user_input: + # Keep current username and password + full_input = { + CONF_HOST: user_input.get(CONF_HOST), + CONF_PORT: reconfigure_entry.data.get(CONF_PORT), + CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME), + CONF_PASSWORD: reconfigure_entry.data.get(CONF_PASSWORD), + } + + api, errors = await verify_connection(self.hass, full_input) + if api: + await self.async_set_unique_id( + api.smile_hostname or api.gateway_id, + raise_on_progress=False, + ) + self._abort_if_unique_id_mismatch(reason="not_the_same_smile") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=full_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=SMILE_RECONF_SCHEMA, + suggested_values=reconfigure_entry.data, + ), + description_placeholders={"title": reconfigure_entry.title}, errors=errors, ) diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index b897a8bf833..7ac0cc21c51 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -64,30 +64,41 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): version = await self.api.connect() self._connected = isinstance(version, Version) if self._connected: - self.api.get_all_devices() + self.api.get_all_gateway_entities() async def _async_update_data(self) -> PlugwiseData: """Fetch data from Plugwise.""" - data = PlugwiseData({}, {}) try: if not self._connected: await self._connect() data = await self.api.async_update() except ConnectionFailedError as err: - raise UpdateFailed("Failed to connect") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="failed_to_connect", + ) from err except InvalidAuthentication as err: - raise ConfigEntryError("Authentication failed") from err + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err except (InvalidXMLError, ResponseError) as err: raise UpdateFailed( - "Invalid XML data, or error indication received from the Plugwise Adam/Smile/Stretch" + translation_domain=DOMAIN, + translation_key="invalid_xml_data", ) from err except PlugwiseError as err: - raise UpdateFailed("Data incomplete or missing") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="data_incomplete_or_missing", + ) from err except UnsupportedDeviceError as err: - raise ConfigEntryError("Device with unsupported firmware") from err - else: - self._async_add_remove_devices(data, self.config_entry) + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="unsupported_firmware", + ) from err + self._async_add_remove_devices(data, self.config_entry) return data def _async_add_remove_devices(self, data: PlugwiseData, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/plugwise/diagnostics.py b/homeassistant/components/plugwise/diagnostics.py index 9d15ea4fe28..47ff7d1a9fb 100644 --- a/homeassistant/components/plugwise/diagnostics.py +++ b/homeassistant/components/plugwise/diagnostics.py @@ -15,6 +15,6 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator = entry.runtime_data return { - "gateway": coordinator.data.gateway, "devices": coordinator.data.devices, + "gateway": coordinator.data.gateway, } diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index e24f3d1e1bb..3f63abaff43 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from plugwise.constants import DeviceData +from plugwise.constants import GwEntityData from homeassistant.const import ATTR_NAME, ATTR_VIA_DEVICE, CONF_HOST from homeassistant.helpers.device_registry import ( @@ -74,11 +74,6 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): ) @property - def device(self) -> DeviceData: + def device(self) -> GwEntityData: """Return data for this device.""" return self.coordinator.data.devices[self._dev_id] - - async def async_added_to_hass(self) -> None: - """Subscribe to updates.""" - self._handle_coordinator_update() - await super().async_added_to_hass() diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 9f11433d8d3..ae60d4d7452 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -1,12 +1,12 @@ { "domain": "plugwise", "name": "Plugwise", - "codeowners": ["@CoMPaTech", "@bouwew", "@frenck"], + "codeowners": ["@CoMPaTech", "@bouwew"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plugwise", "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==1.5.2"], + "requirements": ["plugwise==1.6.4"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 06db5faa55b..1d0b1382c24 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -20,6 +20,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class PlugwiseNumberEntityDescription(NumberEntityDescription): @@ -91,12 +93,12 @@ class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity): ) -> None: """Initiate Plugwise Number.""" super().__init__(coordinator, device_id) - self.device_id = device_id - self.entity_description = description - self._attr_unique_id = f"{device_id}-{description.key}" self._attr_mode = NumberMode.BOX self._attr_native_max_value = self.device[description.key]["upper_bound"] self._attr_native_min_value = self.device[description.key]["lower_bound"] + self._attr_unique_id = f"{device_id}-{description.key}" + self.device_id = device_id + self.entity_description = description native_step = self.device[description.key]["resolution"] if description.key != "temperature_offset": diff --git a/homeassistant/components/plugwise/quality_scale.yaml b/homeassistant/components/plugwise/quality_scale.yaml new file mode 100644 index 00000000000..a7b955b4713 --- /dev/null +++ b/homeassistant/components/plugwise/quality_scale.yaml @@ -0,0 +1,83 @@ +rules: + ## Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: done + dependency-transparency: done + action-setup: + status: exempt + comment: Plugwise integration has no custom actions + common-modules: done + docs-high-level-description: + status: todo + comment: Rewrite top section, docs PR prepared waiting for 36087 merge + docs-installation-instructions: + status: todo + comment: Docs PR 36087 + docs-removal-instructions: done + docs-actions: done + brands: done + ## Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: + status: exempt + comment: The hubs have a hardcoded `Smile ID` printed on the sticker used as password, it can not be changed + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: + status: todo + comment: Docs PR 36087 (partial) + todo rewrite generically (PR prepared) + docs-configuration-parameters: + status: exempt + comment: Plugwise has no options flow + ## Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: done + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: done + discovery-update-info: done + repair-issues: + status: exempt + comment: This integration does not have repairs + docs-use-cases: + status: todo + comment: Check for completeness, PR prepared waiting for 36087 merge + docs-supported-devices: + status: todo + comment: The list is there but could be improved for readability, PR prepared waiting for 36087 merge + docs-supported-functions: + status: todo + comment: Check for completeness, PR prepared waiting for 36087 merge + docs-data-update: done + docs-known-limitations: + status: todo + comment: Partial in 36087 but could be more elaborate + docs-troubleshooting: + status: todo + comment: Check for completeness, PR prepared waiting for 36087 merge + docs-examples: + status: todo + comment: Check for completeness, PR prepared waiting for 36087 merge + ## Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index b7d4a0a1ded..ff268d8eded 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -10,11 +10,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PlugwiseConfigEntry -from .const import LOCATION, SelectOptionsType, SelectType +from .const import SelectOptionsType, SelectType from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class PlugwiseSelectEntityDescription(SelectEntityDescription): @@ -89,8 +91,12 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): ) -> None: """Initialise the selector.""" super().__init__(coordinator, device_id) - self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" + self.entity_description = entity_description + + self._location = device_id + if (location := self.device.get("location")) is not None: + self._location = location @property def current_option(self) -> str: @@ -106,8 +112,8 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change to the selected entity option. - self.device[LOCATION] and STATE_ON are required for the thermostat-schedule select. + self._location and STATE_ON are required for the thermostat-schedule select. """ await self.coordinator.api.set_select( - self.entity_description.key, self.device[LOCATION], option, STATE_ON + self.entity_description.key, self._location, option, STATE_ON ) diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index ae5b4e6ed91..14b42682376 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -31,6 +31,9 @@ from . import PlugwiseConfigEntry from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class PlugwiseSensorEntityDescription(SensorEntityDescription): @@ -439,8 +442,8 @@ class PlugwiseSensorEntity(PlugwiseEntity, SensorEntity): ) -> None: """Initialise the sensor.""" super().__init__(coordinator, device_id) - self.entity_description = description self._attr_unique_id = f"{device_id}-{description.key}" + self.entity_description = description @property def native_value(self) -> int | float: diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index c09323f458b..d16b38df992 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -1,17 +1,31 @@ { "config": { "step": { + "reconfigure": { + "description": "Update configuration for {title}.", + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::plugwise::config::step::user::data_description::host%]", + "port": "[%key:component::plugwise::config::step::user::data_description::port%]" + } + }, "user": { "title": "Connect to the Smile", "description": "Please enter", "data": { - "password": "Smile ID", "host": "[%key:common::config_flow::data::ip%]", + "password": "Smile ID", "port": "[%key:common::config_flow::data::port%]", "username": "Smile Username" }, "data_description": { - "host": "Leave empty if using Auto Discovery" + "password": "The Smile ID printed on the label on the back of your Adam, Smile-T, or P1.", + "host": "The hostname or IP-address of your Smile. You can find it in your router or the Plugwise App.", + "port": "By default your Smile uses port 80, normally you should not have to change this.", + "username": "Default is `smile`, or `stretch` for the legacy Stretch." } } }, @@ -25,14 +39,13 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "anna_with_adam": "Both Anna and Adam detected. Add your Adam instead of your Anna" + "anna_with_adam": "Both Anna and Adam detected. Add your Adam instead of your Anna", + "not_the_same_smile": "The configured Smile ID does not match the Smile ID on the requested IP address.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { "binary_sensor": { - "low_battery": { - "name": "Battery state" - }, "compressor_state": { "name": "Compressor state" }, @@ -284,5 +297,28 @@ "name": "Relay" } } + }, + "exceptions": { + "authentication_failed": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "data_incomplete_or_missing": { + "message": "Data incomplete or missing." + }, + "error_communicating_with_api": { + "message": "Error communicating with API: {error}." + }, + "failed_to_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "invalid_xml_data": { + "message": "[%key:component::plugwise::config::error::response_error%]" + }, + "unsupported_firmware": { + "message": "[%key:component::plugwise::config::error::unsupported%]" + }, + "unsupported_hvac_mode_requested": { + "message": "Unsupported mode {hvac_mode} requested, valid modes are: {hvac_modes}." + } } } diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index a134ab5b044..ea6d6f18b7f 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -21,6 +21,8 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class PlugwiseSwitchEntityDescription(SwitchEntityDescription): @@ -48,7 +50,6 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( PlugwiseSwitchEntityDescription( key="cooling_ena_switch", translation_key="cooling_ena_switch", - name="Cooling", entity_category=EntityCategory.CONFIG, ), ) @@ -93,8 +94,8 @@ class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity): ) -> None: """Set up the Plugwise API.""" super().__init__(coordinator, device_id) - self.entity_description = description self._attr_unique_id = f"{device_id}-{description.key}" + self.entity_description = description @property def is_on(self) -> bool: diff --git a/homeassistant/components/plugwise/util.py b/homeassistant/components/plugwise/util.py index d998711f2b9..c830e5f69f3 100644 --- a/homeassistant/components/plugwise/util.py +++ b/homeassistant/components/plugwise/util.py @@ -7,6 +7,7 @@ from plugwise.exceptions import PlugwiseException from homeassistant.exceptions import HomeAssistantError +from .const import DOMAIN from .entity import PlugwiseEntity @@ -24,10 +25,14 @@ def plugwise_command[_PlugwiseEntityT: PlugwiseEntity, **_P, _R]( ) -> _R: try: return await func(self, *args, **kwargs) - except PlugwiseException as error: + except PlugwiseException as err: raise HomeAssistantError( - f"Error communicating with API: {error}" - ) from error + translation_domain=DOMAIN, + translation_key="error_communicating_with_api", + translation_placeholders={ + "error": str(err), + }, + ) from err finally: await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/powerfox/__init__.py b/homeassistant/components/powerfox/__init__.py new file mode 100644 index 00000000000..243f3aacc4f --- /dev/null +++ b/homeassistant/components/powerfox/__init__.py @@ -0,0 +1,55 @@ +"""The Powerfox integration.""" + +from __future__ import annotations + +import asyncio + +from powerfox import Powerfox, PowerfoxConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import PowerfoxDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxDataUpdateCoordinator]] + + +async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> bool: + """Set up Powerfox from a config entry.""" + client = Powerfox( + username=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) + + try: + devices = await client.all_devices() + except PowerfoxConnectionError as err: + await client.close() + raise ConfigEntryNotReady from err + + coordinators: list[PowerfoxDataUpdateCoordinator] = [ + PowerfoxDataUpdateCoordinator(hass, client, device) for device in devices + ] + + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators + ] + ) + + entry.runtime_data = coordinators + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/powerfox/config_flow.py b/homeassistant/components/powerfox/config_flow.py new file mode 100644 index 00000000000..dd17badf881 --- /dev/null +++ b/homeassistant/components/powerfox/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for Powerfox integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from powerfox import Powerfox, PowerfoxAuthenticationError, PowerfoxConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } +) + + +class PowerfoxConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Powerfox.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) + client = Powerfox( + username=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + await client.all_devices() + except PowerfoxAuthenticationError: + errors["base"] = "invalid_auth" + except PowerfoxConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data={ + CONF_EMAIL: user_input[CONF_EMAIL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=STEP_USER_DATA_SCHEMA, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication flow for Powerfox.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication flow for Powerfox.""" + errors = {} + + reauth_entry = self._get_reauth_entry() + if user_input is not None: + client = Powerfox( + username=reauth_entry.data[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + await client.all_devices() + except PowerfoxAuthenticationError: + errors["base"] = "invalid_auth" + except PowerfoxConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={"email": reauth_entry.data[CONF_EMAIL]}, + data_schema=STEP_REAUTH_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure Powerfox configuration.""" + errors = {} + + reconfigure_entry = self._get_reconfigure_entry() + if user_input is not None: + client = Powerfox( + username=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + await client.all_devices() + except PowerfoxAuthenticationError: + errors["base"] = "invalid_auth" + except PowerfoxConnectionError: + errors["base"] = "cannot_connect" + else: + if reconfigure_entry.data[CONF_EMAIL] != user_input[CONF_EMAIL]: + self._async_abort_entries_match( + {CONF_EMAIL: user_input[CONF_EMAIL]} + ) + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/powerfox/const.py b/homeassistant/components/powerfox/const.py new file mode 100644 index 00000000000..0970e8a1b66 --- /dev/null +++ b/homeassistant/components/powerfox/const.py @@ -0,0 +1,11 @@ +"""Constants for the Powerfox integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "powerfox" +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/powerfox/coordinator.py b/homeassistant/components/powerfox/coordinator.py new file mode 100644 index 00000000000..a4a26759b69 --- /dev/null +++ b/homeassistant/components/powerfox/coordinator.py @@ -0,0 +1,50 @@ +"""Coordinator for Powerfox integration.""" + +from __future__ import annotations + +from powerfox import ( + Device, + Powerfox, + PowerfoxAuthenticationError, + PowerfoxConnectionError, + PowerfoxNoDataError, + Poweropti, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + + +class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]): + """Class to manage fetching Powerfox data from the API.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: Powerfox, + device: Device, + ) -> None: + """Initialize global Powerfox data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + self.device = device + + async def _async_update_data(self) -> Poweropti: + """Fetch data from Powerfox API.""" + try: + return await self.client.device(device_id=self.device.id) + except PowerfoxAuthenticationError as err: + raise ConfigEntryAuthFailed(err) from err + except (PowerfoxConnectionError, PowerfoxNoDataError) as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/powerfox/diagnostics.py b/homeassistant/components/powerfox/diagnostics.py new file mode 100644 index 00000000000..4c6b0f8c6eb --- /dev/null +++ b/homeassistant/components/powerfox/diagnostics.py @@ -0,0 +1,74 @@ +"""Support for Powerfox diagnostics.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from powerfox import HeatMeter, PowerMeter, WaterMeter + +from homeassistant.core import HomeAssistant + +from . import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: PowerfoxConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Powerfox config entry.""" + powerfox_data: list[PowerfoxDataUpdateCoordinator] = entry.runtime_data + + return { + "devices": [ + { + **( + { + "power_meter": { + "outdated": coordinator.data.outdated, + "timestamp": datetime.strftime( + coordinator.data.timestamp, "%Y-%m-%d %H:%M:%S" + ), + "power": coordinator.data.power, + "energy_usage": coordinator.data.energy_usage, + "energy_return": coordinator.data.energy_return, + "energy_usage_high_tariff": coordinator.data.energy_usage_high_tariff, + "energy_usage_low_tariff": coordinator.data.energy_usage_low_tariff, + } + } + if isinstance(coordinator.data, PowerMeter) + else {} + ), + **( + { + "water_meter": { + "outdated": coordinator.data.outdated, + "timestamp": datetime.strftime( + coordinator.data.timestamp, "%Y-%m-%d %H:%M:%S" + ), + "cold_water": coordinator.data.cold_water, + "warm_water": coordinator.data.warm_water, + } + } + if isinstance(coordinator.data, WaterMeter) + else {} + ), + **( + { + "heat_meter": { + "outdated": coordinator.data.outdated, + "timestamp": datetime.strftime( + coordinator.data.timestamp, "%Y-%m-%d %H:%M:%S" + ), + "total_energy": coordinator.data.total_energy, + "delta_energy": coordinator.data.delta_energy, + "total_volume": coordinator.data.total_volume, + "delta_volume": coordinator.data.delta_volume, + } + } + if isinstance(coordinator.data, HeatMeter) + else {} + ), + } + for coordinator in powerfox_data + ], + } diff --git a/homeassistant/components/powerfox/entity.py b/homeassistant/components/powerfox/entity.py new file mode 100644 index 00000000000..0ab7200ffe8 --- /dev/null +++ b/homeassistant/components/powerfox/entity.py @@ -0,0 +1,32 @@ +"""Generic entity for Powerfox.""" + +from __future__ import annotations + +from powerfox import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PowerfoxDataUpdateCoordinator + + +class PowerfoxEntity(CoordinatorEntity[PowerfoxDataUpdateCoordinator]): + """Base entity for Powerfox.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PowerfoxDataUpdateCoordinator, + device: Device, + ) -> None: + """Initialize Powerfox entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.id)}, + manufacturer="Powerfox", + model=device.type.human_readable, + name=device.name, + serial_number=device.id, + ) diff --git a/homeassistant/components/powerfox/manifest.json b/homeassistant/components/powerfox/manifest.json new file mode 100644 index 00000000000..3938eb01a1b --- /dev/null +++ b/homeassistant/components/powerfox/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "powerfox", + "name": "Powerfox", + "codeowners": ["@klaasnicolaas"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/powerfox", + "iot_class": "cloud_polling", + "quality_scale": "silver", + "requirements": ["powerfox==1.2.1"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "powerfox*" + } + ] +} diff --git a/homeassistant/components/powerfox/quality_scale.yaml b/homeassistant/components/powerfox/quality_scale.yaml new file mode 100644 index 00000000000..f72d25c3684 --- /dev/null +++ b/homeassistant/components/powerfox/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: | + This integration uses a coordinator to handle updates. + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration is connecting to a cloud service. + discovery: + status: done + comment: | + It can find poweropti devices via zeroconf, and will start a normal user flow. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have any entities that should disabled by default. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: | + There is no need for icon translations. + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/powerfox/sensor.py b/homeassistant/components/powerfox/sensor.py new file mode 100644 index 00000000000..6505139fcd9 --- /dev/null +++ b/homeassistant/components/powerfox/sensor.py @@ -0,0 +1,189 @@ +"""Sensors for Powerfox integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from powerfox import Device, HeatMeter, PowerMeter, WaterMeter + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PowerfoxConfigEntry +from .coordinator import PowerfoxDataUpdateCoordinator +from .entity import PowerfoxEntity + + +@dataclass(frozen=True, kw_only=True) +class PowerfoxSensorEntityDescription[T: (PowerMeter, WaterMeter, HeatMeter)]( + SensorEntityDescription +): + """Describes Poweropti sensor entity.""" + + value_fn: Callable[[T], float | int | None] + + +SENSORS_POWER: tuple[PowerfoxSensorEntityDescription[PowerMeter], ...] = ( + PowerfoxSensorEntityDescription[PowerMeter]( + key="power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda meter: meter.power, + ), + PowerfoxSensorEntityDescription[PowerMeter]( + key="energy_usage", + translation_key="energy_usage", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.energy_usage, + ), + PowerfoxSensorEntityDescription[PowerMeter]( + key="energy_usage_low_tariff", + translation_key="energy_usage_low_tariff", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.energy_usage_low_tariff, + ), + PowerfoxSensorEntityDescription[PowerMeter]( + key="energy_usage_high_tariff", + translation_key="energy_usage_high_tariff", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.energy_usage_high_tariff, + ), + PowerfoxSensorEntityDescription[PowerMeter]( + key="energy_return", + translation_key="energy_return", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.energy_return, + ), +) + + +SENSORS_WATER: tuple[PowerfoxSensorEntityDescription[WaterMeter], ...] = ( + PowerfoxSensorEntityDescription[WaterMeter]( + key="cold_water", + translation_key="cold_water", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.cold_water, + ), + PowerfoxSensorEntityDescription[WaterMeter]( + key="warm_water", + translation_key="warm_water", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.warm_water, + ), +) + +SENSORS_HEAT: tuple[PowerfoxSensorEntityDescription[HeatMeter], ...] = ( + PowerfoxSensorEntityDescription[HeatMeter]( + key="heat_total_energy", + translation_key="heat_total_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.total_energy, + ), + PowerfoxSensorEntityDescription[HeatMeter]( + key="heat_delta_energy", + translation_key="heat_delta_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda meter: meter.delta_energy, + ), + PowerfoxSensorEntityDescription[HeatMeter]( + key="heat_total_volume", + translation_key="heat_total_volume", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.total_volume, + ), + PowerfoxSensorEntityDescription[HeatMeter]( + key="heat_delta_volume", + translation_key="heat_delta_volume", + suggested_display_precision=2, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.WATER, + value_fn=lambda meter: meter.delta_volume, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PowerfoxConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Powerfox sensors based on a config entry.""" + entities: list[SensorEntity] = [] + for coordinator in entry.runtime_data: + if isinstance(coordinator.data, PowerMeter): + entities.extend( + PowerfoxSensorEntity( + coordinator=coordinator, + description=description, + device=coordinator.device, + ) + for description in SENSORS_POWER + if description.value_fn(coordinator.data) is not None + ) + if isinstance(coordinator.data, WaterMeter): + entities.extend( + PowerfoxSensorEntity( + coordinator=coordinator, + description=description, + device=coordinator.device, + ) + for description in SENSORS_WATER + ) + if isinstance(coordinator.data, HeatMeter): + entities.extend( + PowerfoxSensorEntity( + coordinator=coordinator, + description=description, + device=coordinator.device, + ) + for description in SENSORS_HEAT + ) + async_add_entities(entities) + + +class PowerfoxSensorEntity(PowerfoxEntity, SensorEntity): + """Defines a powerfox power meter sensor.""" + + entity_description: PowerfoxSensorEntityDescription + + def __init__( + self, + coordinator: PowerfoxDataUpdateCoordinator, + device: Device, + description: PowerfoxSensorEntityDescription, + ) -> None: + """Initialize Powerfox power meter sensor.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{device.id}_{description.key}" + + @property + def native_value(self) -> float | int | None: + """Return the state of the entity.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/powerfox/strings.json b/homeassistant/components/powerfox/strings.json new file mode 100644 index 00000000000..cb068a212c2 --- /dev/null +++ b/homeassistant/components/powerfox/strings.json @@ -0,0 +1,82 @@ +{ + "config": { + "step": { + "user": { + "description": "Connect to your Powerfox account to get information about your energy, heat or water consumption.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address of your Powerfox account.", + "password": "The password of your Powerfox account." + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The password for {email} is no longer valid.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::powerfox::config::step::user::data_description::password%]" + } + }, + "reconfigure": { + "title": "Reconfigure your Powerfox account", + "description": "Powerfox is already configured. Would you like to reconfigure it?", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::powerfox::config::step::user::data_description::email%]", + "password": "[%key:component::powerfox::config::step::user::data_description::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, + "entity": { + "sensor": { + "energy_usage": { + "name": "Energy usage" + }, + "energy_usage_low_tariff": { + "name": "Energy usage low tariff" + }, + "energy_usage_high_tariff": { + "name": "Energy usage high tariff" + }, + "energy_return": { + "name": "Energy return" + }, + "cold_water": { + "name": "Cold water" + }, + "warm_water": { + "name": "Warm water" + }, + "heat_total_energy": { + "name": "Total energy" + }, + "heat_delta_energy": { + "name": "Delta energy" + }, + "heat_total_volume": { + "name": "Total volume" + }, + "heat_delta_volume": { + "name": "Delta volume" + } + } + } +} diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 6a2522ac43b..d84452c0443 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -14,6 +14,7 @@ from tesla_powerwall import ( Powerwall, PowerwallUnreachableError, ) +from yarl import URL from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry @@ -25,7 +26,14 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.network import is_ip_address -from .const import DOMAIN, POWERWALL_API_CHANGED, POWERWALL_COORDINATOR, UPDATE_INTERVAL +from .const import ( + AUTH_COOKIE_KEY, + CONFIG_ENTRY_COOKIE, + DOMAIN, + POWERWALL_API_CHANGED, + POWERWALL_COORDINATOR, + UPDATE_INTERVAL, +) from .models import ( PowerwallBaseInfo, PowerwallConfigEntry, @@ -52,6 +60,8 @@ class PowerwallDataManager: self, hass: HomeAssistant, power_wall: Powerwall, + cookie_jar: CookieJar, + entry: PowerwallConfigEntry, ip_address: str, password: str | None, runtime_data: PowerwallRuntimeData, @@ -62,6 +72,8 @@ class PowerwallDataManager: self.password = password self.runtime_data = runtime_data self.power_wall = power_wall + self.cookie_jar = cookie_jar + self.entry = entry @property def api_changed(self) -> int: @@ -72,7 +84,9 @@ class PowerwallDataManager: """Recreate the login on auth failure.""" if self.power_wall.is_authenticated(): await self.power_wall.logout() + # Always use the password when recreating the login await self.power_wall.login(self.password or "") + self.save_auth_cookie() async def async_update_data(self) -> PowerwallData: """Fetch data from API endpoint.""" @@ -116,41 +130,74 @@ class PowerwallDataManager: return data raise RuntimeError("unreachable") + @callback + def save_auth_cookie(self) -> None: + """Save the auth cookie.""" + for cookie in self.cookie_jar: + if cookie.key == AUTH_COOKIE_KEY: + self.hass.config_entries.async_update_entry( + self.entry, + data={**self.entry.data, CONFIG_ENTRY_COOKIE: cookie.value}, + ) + _LOGGER.debug("Saved auth cookie") + break + async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> bool: """Set up Tesla Powerwall from a config entry.""" ip_address: str = entry.data[CONF_IP_ADDRESS] password: str | None = entry.data.get(CONF_PASSWORD) + + cookie_jar: CookieJar = CookieJar(unsafe=True) + use_auth_cookie: bool = False + # Try to reuse the auth cookie + auth_cookie_value: str | None = entry.data.get(CONFIG_ENTRY_COOKIE) + if auth_cookie_value: + cookie_jar.update_cookies( + {AUTH_COOKIE_KEY: auth_cookie_value}, + URL(f"http://{ip_address}"), + ) + _LOGGER.debug("Using existing auth cookie") + use_auth_cookie = True + http_session = async_create_clientsession( - hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + hass, verify_ssl=False, cookie_jar=cookie_jar ) async with AsyncExitStack() as stack: power_wall = Powerwall(ip_address, http_session=http_session, verify_ssl=False) stack.push_async_callback(power_wall.close) - try: - base_info = await _login_and_fetch_base_info( - power_wall, ip_address, password - ) + for tries in range(2): + try: + base_info = await _login_and_fetch_base_info( + power_wall, ip_address, password, use_auth_cookie + ) - # Cancel closing power_wall on success - stack.pop_all() - except (TimeoutError, PowerwallUnreachableError) as err: - raise ConfigEntryNotReady from err - except MissingAttributeError as err: - # The error might include some important information about what exactly changed. - _LOGGER.error("The powerwall api has changed: %s", str(err)) - persistent_notification.async_create( - hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE - ) - return False - except AccessDeniedError as err: - _LOGGER.debug("Authentication failed", exc_info=err) - raise ConfigEntryAuthFailed from err - except ApiError as err: - raise ConfigEntryNotReady from err + # Cancel closing power_wall on success + stack.pop_all() + break + except (TimeoutError, PowerwallUnreachableError) as err: + raise ConfigEntryNotReady from err + except MissingAttributeError as err: + # The error might include some important information about what exactly changed. + _LOGGER.error("The powerwall api has changed: %s", str(err)) + persistent_notification.async_create( + hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE + ) + return False + except AccessDeniedError as err: + if use_auth_cookie and tries == 0: + _LOGGER.debug( + "Authentication failed with cookie, retrying with password" + ) + use_auth_cookie = False + continue + _LOGGER.debug("Authentication failed", exc_info=err) + raise ConfigEntryAuthFailed from err + except ApiError as err: + raise ConfigEntryNotReady from err gateway_din = base_info.gateway_din if entry.unique_id is not None and is_ip_address(entry.unique_id): @@ -163,7 +210,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> api_instance=power_wall, ) - manager = PowerwallDataManager(hass, power_wall, ip_address, password, runtime_data) + manager = PowerwallDataManager( + hass, + power_wall, + cookie_jar, + entry, + ip_address, + password, + runtime_data, + ) + manager.save_auth_cookie() coordinator = DataUpdateCoordinator( hass, @@ -213,10 +269,11 @@ async def async_migrate_entity_unique_ids( async def _login_and_fetch_base_info( - power_wall: Powerwall, host: str, password: str | None + power_wall: Powerwall, host: str, password: str | None, use_auth_cookie: bool ) -> PowerwallBaseInfo: """Login to the powerwall and fetch the base info.""" - if password is not None: + # Login step is skipped if password is None or if we are using the auth cookie + if not (password is None or use_auth_cookie): await power_wall.login(password) return await _call_base_info(power_wall, host) diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 0c39392ca19..b082016e562 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -17,7 +17,6 @@ from tesla_powerwall import ( ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -28,10 +27,11 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.network import is_ip_address from . import async_last_update_was_successful -from .const import DOMAIN +from .const import CONFIG_ENTRY_COOKIE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -116,7 +116,7 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN): ) and not await _powerwall_is_reachable(ip_address, password) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self.ip_address = discovery_info.ip @@ -257,8 +257,10 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN): {CONF_IP_ADDRESS: reauth_entry.data[CONF_IP_ADDRESS], **user_input} ) if not errors: + # We have a new valid connection, old cookie is no longer valid + user_input[CONFIG_ENTRY_COOKIE] = None return self.async_update_reload_and_abort( - reauth_entry, data_updates=user_input + reauth_entry, data_updates={**user_input, CONFIG_ENTRY_COOKIE: None} ) self.context["title_placeholders"] = { diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py index bb3a6c2355e..186a1221a87 100644 --- a/homeassistant/components/powerwall/const.py +++ b/homeassistant/components/powerwall/const.py @@ -18,3 +18,6 @@ ATTR_IS_ACTIVE = "is_active" MODEL = "PowerWall 2" MANUFACTURER = "Tesla" + +CONFIG_ENTRY_COOKIE = "cookie" +AUTH_COOKIE_KEY = "AuthCookie" diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 9423d65b0fc..28506e2a60c 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from operator import attrgetter, methodcaller -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import TYPE_CHECKING from tesla_powerwall import GridState, MeterResponse, MeterType @@ -35,14 +35,12 @@ from .models import BatteryResponse, PowerwallConfigEntry, PowerwallRuntimeData _METER_DIRECTION_EXPORT = "export" _METER_DIRECTION_IMPORT = "import" -_ValueParamT = TypeVar("_ValueParamT") -_ValueT = TypeVar("_ValueT", bound=float | int | str | None) +type _ValueType = float | int | str | None @dataclass(frozen=True, kw_only=True) -class PowerwallSensorEntityDescription( - SensorEntityDescription, - Generic[_ValueParamT, _ValueT], +class PowerwallSensorEntityDescription[_ValueParamT, _ValueT: _ValueType]( + SensorEntityDescription ): """Describes Powerwall entity.""" @@ -389,7 +387,7 @@ class PowerWallImportSensor(PowerWallEnergyDirectionSensor): return meter.get_energy_imported() -class PowerWallBatterySensor(BatteryEntity, SensorEntity, Generic[_ValueT]): +class PowerWallBatterySensor[_ValueT: _ValueType](BatteryEntity, SensorEntity): """Representation of an Powerwall Battery sensor.""" entity_description: PowerwallSensorEntityDescription[BatteryResponse, _ValueT] diff --git a/homeassistant/components/private_ble_device/config_flow.py b/homeassistant/components/private_ble_device/config_flow.py index c7311e8691b..90340bc70fa 100644 --- a/homeassistant/components/private_ble_device/config_flow.py +++ b/homeassistant/components/private_ble_device/config_flow.py @@ -20,8 +20,7 @@ CONF_IRK = "irk" def _parse_irk(irk: str) -> bytes | None: - if irk.startswith("irk:"): - irk = irk[4:] + irk = irk.removeprefix("irk:") if irk.endswith("="): try: diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 6759cdda0f0..2ab736b02d3 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.20.0"] + "requirements": ["bluetooth-data-tools==1.22.0"] } diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 389e3384ad9..9b2b9736574 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -436,10 +436,6 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall) # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - if sys.version_info >= (3, 13): - raise HomeAssistantError( - "Memory profiling is not supported on Python 3.13. Please use Python 3.12." - ) from guppy import hpy # pylint: disable=import-outside-toplevel start_time = int(time.time() * 1000000) diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json index 8d2814c8c7f..814b00a16d4 100644 --- a/homeassistant/components/profiler/manifest.json +++ b/homeassistant/components/profiler/manifest.json @@ -7,7 +7,7 @@ "quality_scale": "internal", "requirements": [ "pyprof2calltree==1.4.5", - "guppy3==3.1.4.post1;python_version<'3.13'", + "guppy3==3.1.5", "objgraph==3.5.0" ], "single_config_entry": true diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py index 1bf23befbdb..4d090f4d0c1 100644 --- a/homeassistant/components/progettihwsw/__init__.py +++ b/homeassistant/components/progettihwsw/__init__.py @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up ProgettiHWSW Automation from a config entry.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = ProgettiHWSWAPI( - f'{entry.data["host"]}:{entry.data["port"]}' + f"{entry.data['host']}:{entry.data['port']}" ) # Check board validation again to load new values to API. diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 2202678da9b..2e5ea221dca 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -19,7 +19,7 @@ DATA_SCHEMA = vol.Schema( async def validate_input(hass: HomeAssistant, data): """Validate the user host input.""" - api_instance = ProgettiHWSWAPI(f'{data["host"]}:{data["port"]}') + api_instance = ProgettiHWSWAPI(f"{data['host']}:{data['port']}") is_valid = await api_instance.check_board() if not is_valid: diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py index 18b974800a3..be7d394993a 100644 --- a/homeassistant/components/proliphix/climate.py +++ b/homeassistant/components/proliphix/climate.py @@ -61,7 +61,6 @@ class ProliphixThermostat(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - _enable_turn_on_off_backwards_compatibility = False def __init__(self, pdp): """Initialize the thermostat.""" diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index c243bf90dc0..ab012847bba 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Callable -from contextlib import suppress +from dataclasses import astuple, dataclass import logging import string from typing import Any, cast @@ -158,6 +159,22 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +@dataclass(frozen=True, slots=True) +class MetricNameWithLabelValues: + """Class to represent a metric with its label values. + + The prometheus client library doesn't easily allow us to get back the + information we put into it. Specifically, it is very expensive to query + which label values have been set for metrics. + + This class is used to hold a bit of data we need to efficiently remove + labelsets from metrics. + """ + + metric_name: str + label_values: tuple[str, ...] + + class PrometheusMetrics: """Model all of the metrics which should be exposed to Prometheus.""" @@ -191,6 +208,9 @@ class PrometheusMetrics: else: self.metrics_prefix = "" self._metrics: dict[str, MetricWrapperBase] = {} + self._metrics_by_entity_id: dict[str, set[MetricNameWithLabelValues]] = ( + defaultdict(set) + ) self._climate_units = climate_units def handle_state_changed_event(self, event: Event[EventStateChangedData]) -> None: @@ -202,10 +222,12 @@ class PrometheusMetrics: _LOGGER.debug("Filtered out entity %s", state.entity_id) return - if (old_state := event.data.get("old_state")) is not None and ( - old_friendly_name := old_state.attributes.get(ATTR_FRIENDLY_NAME) + if ( + old_state := event.data.get("old_state") + ) is not None and old_state.attributes.get( + ATTR_FRIENDLY_NAME ) != state.attributes.get(ATTR_FRIENDLY_NAME): - self._remove_labelsets(old_state.entity_id, old_friendly_name) + self._remove_labelsets(old_state.entity_id) self.handle_state(state) @@ -215,30 +237,32 @@ class PrometheusMetrics: _LOGGER.debug("Handling state update for %s", entity_id) labels = self._labels(state) - state_change = self._metric( - "state_change", prometheus_client.Counter, "The number of state changes" - ) - state_change.labels(**labels).inc() - entity_available = self._metric( + self._metric( + "state_change", + prometheus_client.Counter, + "The number of state changes", + labels, + ).inc() + + self._metric( "entity_available", prometheus_client.Gauge, "Entity is available (not in the unavailable or unknown state)", - ) - entity_available.labels(**labels).set(float(state.state not in IGNORED_STATES)) + labels, + ).set(float(state.state not in IGNORED_STATES)) - last_updated_time_seconds = self._metric( + self._metric( "last_updated_time_seconds", prometheus_client.Gauge, "The last_updated timestamp", - ) - last_updated_time_seconds.labels(**labels).set(state.last_updated.timestamp()) + labels, + ).set(state.last_updated.timestamp()) if state.state in IGNORED_STATES: self._remove_labelsets( entity_id, - None, - {state_change, entity_available, last_updated_time_seconds}, + {"state_change", "entity_available", "last_updated_time_seconds"}, ) else: domain, _ = hacore.split_entity_id(entity_id) @@ -274,67 +298,68 @@ class PrometheusMetrics: def _remove_labelsets( self, entity_id: str, - friendly_name: str | None = None, - ignored_metrics: set[MetricWrapperBase] | None = None, + ignored_metric_names: set[str] | None = None, ) -> None: """Remove labelsets matching the given entity id from all non-ignored metrics.""" - if ignored_metrics is None: - ignored_metrics = set() - for metric in list(self._metrics.values()): - if metric in ignored_metrics: + if ignored_metric_names is None: + ignored_metric_names = set() + metric_set = self._metrics_by_entity_id[entity_id] + removed_metrics = set() + for metric in metric_set: + metric_name, label_values = astuple(metric) + if metric_name in ignored_metric_names: continue - for sample in cast(list[prometheus_client.Metric], metric.collect())[ - 0 - ].samples: - if sample.labels["entity"] == entity_id and ( - not friendly_name or sample.labels["friendly_name"] == friendly_name - ): - _LOGGER.debug( - "Removing labelset from %s for entity_id: %s", - sample.name, - entity_id, - ) - with suppress(KeyError): - metric.remove(*sample.labels.values()) + + _LOGGER.debug( + "Removing labelset %s from %s for entity_id: %s", + label_values, + metric_name, + entity_id, + ) + removed_metrics.add(metric) + self._metrics[metric_name].remove(*label_values) + metric_set -= removed_metrics + if not metric_set: + del self._metrics_by_entity_id[entity_id] def _handle_attributes(self, state: State) -> None: for key, value in state.attributes.items(): - metric = self._metric( + try: + value = float(value) + except (ValueError, TypeError): + continue + + self._metric( f"{state.domain}_attr_{key.lower()}", prometheus_client.Gauge, f"{key} attribute of {state.domain} entity", - ) - - try: - value = float(value) - metric.labels(**self._labels(state)).set(value) - except (ValueError, TypeError): - pass + self._labels(state), + ).set(value) def _metric[_MetricBaseT: MetricWrapperBase]( self, - metric: str, + metric_name: str, factory: type[_MetricBaseT], documentation: str, - extra_labels: list[str] | None = None, + labels: dict[str, str], ) -> _MetricBaseT: - labels = ["entity", "friendly_name", "domain"] - if extra_labels is not None: - labels.extend(extra_labels) - try: - return cast(_MetricBaseT, self._metrics[metric]) + metric = cast(_MetricBaseT, self._metrics[metric_name]) except KeyError: full_metric_name = self._sanitize_metric_name( - f"{self.metrics_prefix}{metric}" + f"{self.metrics_prefix}{metric_name}" ) - self._metrics[metric] = factory( + self._metrics[metric_name] = factory( full_metric_name, documentation, - labels, + labels.keys(), registry=prometheus_client.REGISTRY, ) - return cast(_MetricBaseT, self._metrics[metric]) + metric = cast(_MetricBaseT, self._metrics[metric_name]) + self._metrics_by_entity_id[labels["entity"]].add( + MetricNameWithLabelValues(metric_name, tuple(labels.values())) + ) + return metric.labels(**labels) @staticmethod def _sanitize_metric_name(metric: str) -> str: @@ -356,67 +381,90 @@ class PrometheusMetrics: return value @staticmethod - def _labels(state: State) -> dict[str, Any]: - return { + def _labels( + state: State, + extra_labels: dict[str, str] | None = None, + ) -> dict[str, Any]: + if extra_labels is None: + extra_labels = {} + labels = { "entity": state.entity_id, "domain": state.domain, "friendly_name": state.attributes.get(ATTR_FRIENDLY_NAME), } + if not labels.keys().isdisjoint(extra_labels.keys()): + conflicting_keys = labels.keys() & extra_labels.keys() + raise ValueError( + f"extra_labels contains conflicting keys: {conflicting_keys}" + ) + return labels | extra_labels def _battery(self, state: State) -> None: - if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is not None: - metric = self._metric( - "battery_level_percent", - prometheus_client.Gauge, - "Battery level as a percentage of its capacity", - ) - try: - value = float(battery_level) - metric.labels(**self._labels(state)).set(value) - except ValueError: - pass + if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is None: + return + + try: + value = float(battery_level) + except ValueError: + return + + self._metric( + "battery_level_percent", + prometheus_client.Gauge, + "Battery level as a percentage of its capacity", + self._labels(state), + ).set(value) def _handle_binary_sensor(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + self._metric( "binary_sensor_state", prometheus_client.Gauge, "State of the binary sensor (0/1)", - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _handle_input_boolean(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + self._metric( "input_boolean_state", prometheus_client.Gauge, "State of the input boolean (0/1)", - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _numeric_handler(self, state: State, domain: str, title: str) -> None: + if (value := self.state_as_number(state)) is None: + return + if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): metric = self._metric( f"{domain}_state_{unit}", prometheus_client.Gauge, f"State of the {title} measured in {unit}", + self._labels(state), ) else: metric = self._metric( f"{domain}_state", prometheus_client.Gauge, f"State of the {title}", + self._labels(state), ) - if (value := self.state_as_number(state)) is not None: - if ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfTemperature.FAHRENHEIT - ): - value = TemperatureConverter.convert( - value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS - ) - metric.labels(**self._labels(state)).set(value) + if ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT + ): + value = TemperatureConverter.convert( + value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS + ) + + metric.set(value) def _handle_input_number(self, state: State) -> None: self._numeric_handler(state, "input_number", "input number") @@ -425,88 +473,99 @@ class PrometheusMetrics: self._numeric_handler(state, "number", "number") def _handle_device_tracker(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + self._metric( "device_tracker_state", prometheus_client.Gauge, "State of the device tracker (0/1)", - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _handle_person(self, state: State) -> None: - metric = self._metric( - "person_state", prometheus_client.Gauge, "State of the person (0/1)" - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + if (value := self.state_as_number(state)) is None: + return + + self._metric( + "person_state", + prometheus_client.Gauge, + "State of the person (0/1)", + self._labels(state), + ).set(value) def _handle_cover(self, state: State) -> None: - metric = self._metric( - "cover_state", - prometheus_client.Gauge, - "State of the cover (0/1)", - ["state"], - ) - cover_states = [STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING] for cover_state in cover_states: - metric.labels(**dict(self._labels(state), state=cover_state)).set( - float(cover_state == state.state) + metric = self._metric( + "cover_state", + prometheus_client.Gauge, + "State of the cover (0/1)", + self._labels(state, {"state": cover_state}), ) + metric.set(float(cover_state == state.state)) position = state.attributes.get(ATTR_CURRENT_POSITION) if position is not None: - position_metric = self._metric( + self._metric( "cover_position", prometheus_client.Gauge, "Position of the cover (0-100)", - ) - position_metric.labels(**self._labels(state)).set(float(position)) + self._labels(state), + ).set(float(position)) tilt_position = state.attributes.get(ATTR_CURRENT_TILT_POSITION) if tilt_position is not None: - tilt_position_metric = self._metric( + self._metric( "cover_tilt_position", prometheus_client.Gauge, "Tilt Position of the cover (0-100)", - ) - tilt_position_metric.labels(**self._labels(state)).set(float(tilt_position)) + self._labels(state), + ).set(float(tilt_position)) def _handle_light(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + brightness = state.attributes.get(ATTR_BRIGHTNESS) + if state.state == STATE_ON and brightness is not None: + value = float(brightness) / 255.0 + value = value * 100 + + self._metric( "light_brightness_percent", prometheus_client.Gauge, "Light brightness percentage (0..100)", - ) - - if (value := self.state_as_number(state)) is not None: - brightness = state.attributes.get(ATTR_BRIGHTNESS) - if state.state == STATE_ON and brightness is not None: - value = float(brightness) / 255.0 - value = value * 100 - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _handle_lock(self, state: State) -> None: - metric = self._metric( - "lock_state", prometheus_client.Gauge, "State of the lock (0/1)" - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + if (value := self.state_as_number(state)) is None: + return + + self._metric( + "lock_state", + prometheus_client.Gauge, + "State of the lock (0/1)", + self._labels(state), + ).set(value) def _handle_climate_temp( self, state: State, attr: str, metric_name: str, metric_description: str ) -> None: - if (temp := state.attributes.get(attr)) is not None: - if self._climate_units == UnitOfTemperature.FAHRENHEIT: - temp = TemperatureConverter.convert( - temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS - ) - metric = self._metric( - metric_name, - prometheus_client.Gauge, - metric_description, + if (temp := state.attributes.get(attr)) is None: + return + + if self._climate_units == UnitOfTemperature.FAHRENHEIT: + temp = TemperatureConverter.convert( + temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS ) - metric.labels(**self._labels(state)).set(temp) + self._metric( + metric_name, + prometheus_client.Gauge, + metric_description, + self._labels(state), + ).set(temp) def _handle_climate(self, state: State) -> None: self._handle_climate_temp( @@ -535,90 +594,75 @@ class PrometheusMetrics: ) if current_action := state.attributes.get(ATTR_HVAC_ACTION): - metric = self._metric( - "climate_action", - prometheus_client.Gauge, - "HVAC action", - ["action"], - ) for action in HVACAction: - metric.labels(**dict(self._labels(state), action=action.value)).set( - float(action == current_action) - ) + self._metric( + "climate_action", + prometheus_client.Gauge, + "HVAC action", + self._labels(state, {"action": action.value}), + ).set(float(action == current_action)) current_mode = state.state available_modes = state.attributes.get(ATTR_HVAC_MODES) if current_mode and available_modes: - metric = self._metric( - "climate_mode", - prometheus_client.Gauge, - "HVAC mode", - ["mode"], - ) for mode in available_modes: - metric.labels(**dict(self._labels(state), mode=mode)).set( - float(mode == current_mode) - ) + self._metric( + "climate_mode", + prometheus_client.Gauge, + "HVAC mode", + self._labels(state, {"mode": mode}), + ).set(float(mode == current_mode)) preset_mode = state.attributes.get(ATTR_PRESET_MODE) available_preset_modes = state.attributes.get(ATTR_PRESET_MODES) if preset_mode and available_preset_modes: - preset_metric = self._metric( - "climate_preset_mode", - prometheus_client.Gauge, - "Preset mode enum", - ["mode"], - ) for mode in available_preset_modes: - preset_metric.labels(**dict(self._labels(state), mode=mode)).set( - float(mode == preset_mode) - ) + self._metric( + "climate_preset_mode", + prometheus_client.Gauge, + "Preset mode enum", + self._labels(state, {"mode": mode}), + ).set(float(mode == preset_mode)) fan_mode = state.attributes.get(ATTR_FAN_MODE) available_fan_modes = state.attributes.get(ATTR_FAN_MODES) if fan_mode and available_fan_modes: - fan_mode_metric = self._metric( - "climate_fan_mode", - prometheus_client.Gauge, - "Fan mode enum", - ["mode"], - ) for mode in available_fan_modes: - fan_mode_metric.labels(**dict(self._labels(state), mode=mode)).set( - float(mode == fan_mode) - ) + self._metric( + "climate_fan_mode", + prometheus_client.Gauge, + "Fan mode enum", + self._labels(state, {"mode": mode}), + ).set(float(mode == fan_mode)) def _handle_humidifier(self, state: State) -> None: humidifier_target_humidity_percent = state.attributes.get(ATTR_HUMIDITY) if humidifier_target_humidity_percent: - metric = self._metric( + self._metric( "humidifier_target_humidity_percent", prometheus_client.Gauge, "Target Relative Humidity", - ) - metric.labels(**self._labels(state)).set(humidifier_target_humidity_percent) + self._labels(state), + ).set(humidifier_target_humidity_percent) - metric = self._metric( - "humidifier_state", - prometheus_client.Gauge, - "State of the humidifier (0/1)", - ) if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._metric( + "humidifier_state", + prometheus_client.Gauge, + "State of the humidifier (0/1)", + self._labels(state), + ).set(value) current_mode = state.attributes.get(ATTR_MODE) available_modes = state.attributes.get(ATTR_AVAILABLE_MODES) if current_mode and available_modes: - metric = self._metric( - "humidifier_mode", - prometheus_client.Gauge, - "Humidifier Mode", - ["mode"], - ) for mode in available_modes: - metric.labels(**dict(self._labels(state), mode=mode)).set( - float(mode == current_mode) - ) + self._metric( + "humidifier_mode", + prometheus_client.Gauge, + "Humidifier Mode", + self._labels(state, {"mode": mode}), + ).set(float(mode == current_mode)) def _handle_sensor(self, state: State) -> None: unit = self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) @@ -628,22 +672,24 @@ class PrometheusMetrics: if metric is not None: break - if metric is not None: + if metric is not None and (value := self.state_as_number(state)) is not None: documentation = "State of the sensor" if unit: documentation = f"Sensor data measured in {unit}" - _metric = self._metric(metric, prometheus_client.Gauge, documentation) - - if (value := self.state_as_number(state)) is not None: - if ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfTemperature.FAHRENHEIT - ): - value = TemperatureConverter.convert( - value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS - ) - _metric.labels(**self._labels(state)).set(value) + if ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT + ): + value = TemperatureConverter.convert( + value, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS + ) + self._metric( + metric, + prometheus_client.Gauge, + documentation, + self._labels(state), + ).set(value) self._battery(state) @@ -702,114 +748,107 @@ class PrometheusMetrics: return units.get(unit, default) def _handle_switch(self, state: State) -> None: - metric = self._metric( - "switch_state", prometheus_client.Gauge, "State of the switch (0/1)" - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._metric( + "switch_state", + prometheus_client.Gauge, + "State of the switch (0/1)", + self._labels(state), + ).set(value) self._handle_attributes(state) def _handle_fan(self, state: State) -> None: - metric = self._metric( - "fan_state", prometheus_client.Gauge, "State of the fan (0/1)" - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._metric( + "fan_state", + prometheus_client.Gauge, + "State of the fan (0/1)", + self._labels(state), + ).set(value) fan_speed_percent = state.attributes.get(ATTR_PERCENTAGE) if fan_speed_percent is not None: - fan_speed_metric = self._metric( + self._metric( "fan_speed_percent", prometheus_client.Gauge, "Fan speed percent (0-100)", - ) - fan_speed_metric.labels(**self._labels(state)).set(float(fan_speed_percent)) + self._labels(state), + ).set(float(fan_speed_percent)) fan_is_oscillating = state.attributes.get(ATTR_OSCILLATING) if fan_is_oscillating is not None: - fan_oscillating_metric = self._metric( + self._metric( "fan_is_oscillating", prometheus_client.Gauge, "Whether the fan is oscillating (0/1)", - ) - fan_oscillating_metric.labels(**self._labels(state)).set( - float(fan_is_oscillating) - ) + self._labels(state), + ).set(float(fan_is_oscillating)) fan_preset_mode = state.attributes.get(ATTR_PRESET_MODE) available_modes = state.attributes.get(ATTR_PRESET_MODES) if fan_preset_mode and available_modes: - fan_preset_metric = self._metric( - "fan_preset_mode", - prometheus_client.Gauge, - "Fan preset mode enum", - ["mode"], - ) for mode in available_modes: - fan_preset_metric.labels(**dict(self._labels(state), mode=mode)).set( - float(mode == fan_preset_mode) - ) + self._metric( + "fan_preset_mode", + prometheus_client.Gauge, + "Fan preset mode enum", + self._labels(state, {"mode": mode}), + ).set(float(mode == fan_preset_mode)) fan_direction = state.attributes.get(ATTR_DIRECTION) - if fan_direction is not None: - fan_direction_metric = self._metric( + if fan_direction in {DIRECTION_FORWARD, DIRECTION_REVERSE}: + self._metric( "fan_direction_reversed", prometheus_client.Gauge, "Fan direction reversed (bool)", - ) - if fan_direction == DIRECTION_FORWARD: - fan_direction_metric.labels(**self._labels(state)).set(0) - elif fan_direction == DIRECTION_REVERSE: - fan_direction_metric.labels(**self._labels(state)).set(1) + self._labels(state), + ).set(float(fan_direction == DIRECTION_REVERSE)) def _handle_zwave(self, state: State) -> None: self._battery(state) def _handle_automation(self, state: State) -> None: - metric = self._metric( + self._metric( "automation_triggered_count", prometheus_client.Counter, "Count of times an automation has been triggered", - ) - - metric.labels(**self._labels(state)).inc() + self._labels(state), + ).inc() def _handle_counter(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + self._metric( "counter_value", prometheus_client.Gauge, "Value of counter entities", - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _handle_update(self, state: State) -> None: - metric = self._metric( + if (value := self.state_as_number(state)) is None: + return + + self._metric( "update_state", prometheus_client.Gauge, "Update state, indicating if an update is available (0/1)", - ) - if (value := self.state_as_number(state)) is not None: - metric.labels(**self._labels(state)).set(value) + self._labels(state), + ).set(value) def _handle_alarm_control_panel(self, state: State) -> None: current_state = state.state if current_state: - metric = self._metric( - "alarm_control_panel_state", - prometheus_client.Gauge, - "State of the alarm control panel (0/1)", - ["state"], - ) - for alarm_state in AlarmControlPanelState: - metric.labels(**dict(self._labels(state), state=alarm_state.value)).set( - float(alarm_state.value == current_state) - ) + self._metric( + "alarm_control_panel_state", + prometheus_client.Gauge, + "State of the alarm control panel (0/1)", + self._labels(state, {"state": alarm_state.value}), + ).set(float(alarm_state.value == current_state)) class PrometheusView(HomeAssistantView): diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index e73eddf3cdd..6925b9e2133 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", "quality_scale": "legacy", - "requirements": ["Pillow==11.0.0"] + "requirements": ["Pillow==11.1.0"] } diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 0ada2885fa7..2ccf086071a 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -28,7 +28,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util import location +from homeassistant.util import location as location_util from homeassistant.util.json import JsonObjectType, load_json_object from .config_flow import PlayStation4FlowHandler # noqa: F401 @@ -103,7 +103,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Migrate Version 1 -> Version 2: New region codes. if version == 1: - loc = await location.async_detect_location_info(async_get_clientsession(hass)) + loc = await location_util.async_detect_location_info( + async_get_clientsession(hass) + ) if loc: country = COUNTRYCODE_NAMES.get(loc.country_code) if country in COUNTRIES: diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index 877fb595fc0..4e3f8f08e39 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import location +from homeassistant.util import location as location_util from .const import ( CONFIG_ENTRY_VERSION, @@ -54,7 +54,7 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): self.region = None self.pin: str | None = None self.m_device = None - self.location: location.LocationInfo | None = None + self.location: location_util.LocationInfo | None = None self.device_list: list[str] = [] async def async_step_user( @@ -190,7 +190,7 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): # Try to find region automatically. if not self.location: - self.location = await location.async_detect_location_info( + self.location = await location_util.async_detect_location_info( async_get_clientsession(self.hass) ) if self.location: diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index 6b1d4cd690b..778fa0215fb 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -21,7 +21,7 @@ "ip_address": "[%key:common::config_flow::data::ip%]" }, "data_description": { - "code": "On your PlayStation 4 console, go to **Settings**. Then, go to **Mobile App Connection Settings** and select **Add Device** to get the pin." + "code": "On your PlayStation 4 console, go to **Settings**. Then, go to **Mobile App Connection Settings** and select **Add Device** to get the PIN." } } }, diff --git a/homeassistant/components/pure_energie/config_flow.py b/homeassistant/components/pure_energie/config_flow.py index a2bbb671ff7..0dcb1a9ab13 100644 --- a/homeassistant/components/pure_energie/config_flow.py +++ b/homeassistant/components/pure_energie/config_flow.py @@ -7,11 +7,11 @@ from typing import Any from gridnet import Device, GridNet, GridNetConnectionError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -58,7 +58,7 @@ class PureEnergieFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.discovered_host = discovery_info.host diff --git a/homeassistant/components/purpleair/diagnostics.py b/homeassistant/components/purpleair/diagnostics.py index a3b3af857fb..f7c44b7e9b2 100644 --- a/homeassistant/components/purpleair/diagnostics.py +++ b/homeassistant/components/purpleair/diagnostics.py @@ -37,7 +37,7 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { "entry": entry.as_dict(), - "data": coordinator.data.dict(), + "data": coordinator.data.model_dump(), }, TO_REDACT, ) diff --git a/homeassistant/components/purpleair/manifest.json b/homeassistant/components/purpleair/manifest.json index cf74365d6d8..87cb375c347 100644 --- a/homeassistant/components/purpleair/manifest.json +++ b/homeassistant/components/purpleair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/purpleair", "iot_class": "cloud_polling", - "requirements": ["aiopurpleair==2022.12.1"] + "requirements": ["aiopurpleair==2023.12.0"] } diff --git a/homeassistant/components/purpleair/strings.json b/homeassistant/components/purpleair/strings.json index b082e088ba2..006093f3545 100644 --- a/homeassistant/components/purpleair/strings.json +++ b/homeassistant/components/purpleair/strings.json @@ -6,7 +6,7 @@ "data": { "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", - "distance": "Search Radius" + "distance": "Search radius" }, "data_description": { "latitude": "The latitude around which to search for sensors", @@ -53,7 +53,7 @@ "options": { "step": { "add_sensor": { - "title": "Add Sensor", + "title": "Add sensor", "description": "[%key:component::purpleair::config::step::by_coordinates::description%]", "data": { "latitude": "[%key:common::config_flow::data::latitude%]", @@ -67,7 +67,7 @@ } }, "choose_sensor": { - "title": "Choose Sensor to Add", + "title": "Choose sensor to add", "description": "[%key:component::purpleair::config::step::choose_sensor::description%]", "data": { "sensor_index": "[%key:component::purpleair::config::step::choose_sensor::data::sensor_index%]" @@ -84,9 +84,9 @@ } }, "remove_sensor": { - "title": "Remove Sensor", + "title": "Remove sensor", "data": { - "sensor_device_id": "Sensor Name" + "sensor_device_id": "Sensor name" }, "data_description": { "sensor_device_id": "The sensor to remove" diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index bc96bc5061d..9dbdad53bcb 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pvoutput", "integration_type": "device", "iot_class": "cloud_polling", - "requirements": ["pvo==2.1.1"] + "requirements": ["pvo==2.2.0"] } diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index 8b85dfa29a4..f07db509630 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo """Set up pyLoad from a config entry.""" url = ( - f"{"https" if entry.data[CONF_SSL] else "http"}://" + f"{'https' if entry.data[CONF_SSL] else 'http'}://" f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/" ) diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 3e6cbd33bb3..5df11711d6f 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -30,7 +30,7 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) -from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -83,7 +83,7 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non ) url = ( - f"{"https" if user_input[CONF_SSL] else "http"}://" + f"{'https' if user_input[CONF_SSL] else 'http'}://" f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/" ) pyload = PyLoadAPI( @@ -120,7 +120,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - title = user_input.pop(CONF_NAME, DEFAULT_NAME) + title = DEFAULT_NAME return self.async_create_entry(title=title, data=user_input) return self.async_show_form( @@ -131,25 +131,6 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import config from yaml.""" - - config = { - CONF_NAME: import_data.get(CONF_NAME), - CONF_HOST: import_data.get(CONF_HOST, DEFAULT_HOST), - CONF_PASSWORD: import_data.get(CONF_PASSWORD, ""), - CONF_PORT: import_data.get(CONF_PORT, DEFAULT_PORT), - CONF_SSL: import_data.get(CONF_SSL, False), - CONF_USERNAME: import_data.get(CONF_USERNAME, ""), - CONF_VERIFY_SSL: False, - } - - result = await self.async_step_user(config) - - if errors := result.get("errors"): - return self.async_abort(reason=errors["base"]) - return result - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/pyload/const.py b/homeassistant/components/pyload/const.py index a0b66687bd0..e802152df16 100644 --- a/homeassistant/components/pyload/const.py +++ b/homeassistant/components/pyload/const.py @@ -2,12 +2,9 @@ DOMAIN = "pyload" -DEFAULT_HOST = "localhost" DEFAULT_NAME = "pyLoad" DEFAULT_PORT = 8000 -ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=pyload"} - MANUFACTURER = "pyLoad Team" SERVICE_NAME = "pyLoad" diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py index a1b29b46260..38f681d30d5 100644 --- a/homeassistant/components/pyload/sensor.py +++ b/homeassistant/components/pyload/sensor.py @@ -6,43 +6,19 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -import voluptuous as vol - from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_HOST, - CONF_MONITORED_VARIABLES, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_SSL, - CONF_USERNAME, - UnitOfDataRate, - UnitOfInformation, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -import homeassistant.helpers.config_validation as cv +from homeassistant.const import UnitOfDataRate, UnitOfInformation +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType from . import PyLoadConfigEntry -from .const import ( - DEFAULT_HOST, - DEFAULT_NAME, - DEFAULT_PORT, - DOMAIN, - ISSUE_PLACEHOLDER, - UNIT_DOWNLOADS, -) +from .const import UNIT_DOWNLOADS from .coordinator import PyLoadData from .entity import BasePyLoadEntity @@ -106,63 +82,6 @@ SENSOR_DESCRIPTIONS: tuple[PyLoadSensorEntityDescription, ...] = ( ), ) -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_MONITORED_VARIABLES, default=["speed"]): vol.All( - cv.ensure_list, [vol.In(PyLoadSensorEntity)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_USERNAME): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import config from yaml.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - if ( - result.get("type") == FlowResultType.CREATE_ENTRY - or result.get("reason") == "already_configured" - ): - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - breaks_in_ha_version="2025.1.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "pyLoad", - }, - ) - elif error := result.get("reason"): - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{error}", - breaks_in_ha_version="2025.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{error}", - translation_placeholders=ISSUE_PLACEHOLDER, - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 4ae4c4fee67..0fd9b4befcf 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -105,19 +105,5 @@ "service_call_auth_exception": { "message": "Unable to send command to pyLoad due to an authentication error, try again later" } - }, - "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "title": "The pyLoad YAML configuration import failed", - "description": "Configuring pyLoad using YAML is being removed but there was a connection error when trying to import the YAML configuration.\n\nPlease verify that you have a stable internet connection and restart Home Assistant to try again or remove the pyLoad YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, - "deprecated_yaml_import_issue_invalid_auth": { - "title": "The pyLoad YAML configuration import failed", - "description": "Configuring pyLoad using YAML is being removed but there was an authentication error when trying to import the YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the pyLoad YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, - "deprecated_yaml_import_issue_unknown": { - "title": "The pyLoad YAML configuration import failed", - "description": "Configuring pyLoad using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the pyLoad YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - } } } diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 70e9c5b0d29..dbd1a5dce4b 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -1,5 +1,6 @@ """Component to allow running Python scripts.""" +from collections.abc import Callable, Mapping, Sequence import datetime import glob import logging @@ -7,6 +8,7 @@ from numbers import Number import operator import os import time +import types from typing import Any from RestrictedPython import ( @@ -167,6 +169,20 @@ IOPERATOR_TO_OPERATOR = { } +def guarded_import( + name: str, + globals: Mapping[str, object] | None = None, + locals: Mapping[str, object] | None = None, + fromlist: Sequence[str] = (), + level: int = 0, +) -> types.ModuleType: + """Guard imports.""" + # Allow import of _strptime needed by datetime.datetime.strptime + if name == "_strptime": + return __import__(name, globals, locals, fromlist, level) + raise ImportError(f"Not allowed to import {name}") + + def guarded_inplacevar(op: str, target: Any, operand: Any) -> Any: """Implement augmented-assign (+=, -=, etc.) operators for restricted code. @@ -181,7 +197,12 @@ def guarded_inplacevar(op: str, target: Any, operand: Any) -> Any: @bind_hass -def execute_script(hass, name, data=None, return_response=False): +def execute_script( + hass: HomeAssistant, + name: str, + data: dict[str, Any] | None = None, + return_response: bool = False, +) -> dict | None: """Execute a script.""" filename = f"{name}.py" raise_if_invalid_filename(filename) @@ -191,7 +212,13 @@ def execute_script(hass, name, data=None, return_response=False): @bind_hass -def execute(hass, filename, source, data=None, return_response=False): +def execute( + hass: HomeAssistant, + filename: str, + source: Any, + data: dict[str, Any] | None = None, + return_response: bool = False, +) -> dict | None: """Execute Python source.""" compiled = compile_restricted_exec(source, filename=filename) @@ -207,31 +234,25 @@ def execute(hass, filename, source, data=None, return_response=False): "Warning loading script %s: %s", filename, ", ".join(compiled.warnings) ) - def protected_getattr(obj, name, default=None): + def protected_getattr(obj: object, name: str, default: Any = None) -> Any: """Restricted method to get attributes.""" if name.startswith("async_"): raise ScriptError("Not allowed to access async methods") if ( - obj is hass - and name not in ALLOWED_HASS - or obj is hass.bus - and name not in ALLOWED_EVENTBUS - or obj is hass.states - and name not in ALLOWED_STATEMACHINE - or obj is hass.services - and name not in ALLOWED_SERVICEREGISTRY - or obj is dt_util - and name not in ALLOWED_DT_UTIL - or obj is datetime - and name not in ALLOWED_DATETIME - or isinstance(obj, TimeWrapper) - and name not in ALLOWED_TIME + (obj is hass and name not in ALLOWED_HASS) + or (obj is hass.bus and name not in ALLOWED_EVENTBUS) + or (obj is hass.states and name not in ALLOWED_STATEMACHINE) + or (obj is hass.services and name not in ALLOWED_SERVICEREGISTRY) + or (obj is dt_util and name not in ALLOWED_DT_UTIL) + or (obj is datetime and name not in ALLOWED_DATETIME) + or (isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME) ): raise ScriptError(f"Not allowed to access {obj.__class__.__name__}.{name}") return getattr(obj, name, default) extra_builtins = { + "__import__": guarded_import, "datetime": datetime, "sorted": sorted, "time": TimeWrapper(), @@ -299,10 +320,10 @@ def execute(hass, filename, source, data=None, return_response=False): class StubPrinter: """Class to handle printing inside scripts.""" - def __init__(self, _getattr_): + def __init__(self, _getattr_: Callable) -> None: """Initialize our printer.""" - def _call_print(self, *objects, **kwargs): + def _call_print(self, *objects: object, **kwargs: Any) -> None: """Print text.""" _LOGGER.warning("Don't use print() inside scripts. Use logger.info() instead") @@ -313,7 +334,7 @@ class TimeWrapper: # Class variable, only going to warn once per Home Assistant run warned = False - def sleep(self, *args, **kwargs): + def sleep(self, *args: Any, **kwargs: Any) -> None: """Sleep method that warns once.""" if not TimeWrapper.warned: TimeWrapper.warned = True @@ -323,12 +344,12 @@ class TimeWrapper: time.sleep(*args, **kwargs) - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> Any: """Fetch an attribute from Time module.""" attribute = getattr(time, attr) if callable(attribute): - def wrapper(*args, **kw): + def wrapper(*args: Any, **kw: Any) -> Any: """Wrap to return callable method if callable.""" return attribute(*args, **kw) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index abc23f39975..67eb856bb83 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -100,13 +100,11 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( QBittorrentSensorEntityDescription( key=SENSOR_TYPE_ALL_TORRENTS, translation_key="all_torrents", - native_unit_of_measurement="torrents", value_fn=lambda coordinator: count_torrents_in_states(coordinator, []), ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_ACTIVE_TORRENTS, translation_key="active_torrents", - native_unit_of_measurement="torrents", value_fn=lambda coordinator: count_torrents_in_states( coordinator, ["downloading", "uploading"] ), @@ -114,7 +112,6 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( QBittorrentSensorEntityDescription( key=SENSOR_TYPE_INACTIVE_TORRENTS, translation_key="inactive_torrents", - native_unit_of_measurement="torrents", value_fn=lambda coordinator: count_torrents_in_states( coordinator, ["stalledDL", "stalledUP"] ), @@ -122,7 +119,6 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( QBittorrentSensorEntityDescription( key=SENSOR_TYPE_PAUSED_TORRENTS, translation_key="paused_torrents", - native_unit_of_measurement="torrents", value_fn=lambda coordinator: count_torrents_in_states( coordinator, ["pausedDL", "pausedUP"] ), diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 88015dad5c3..9c9ee371737 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -36,16 +36,20 @@ } }, "active_torrents": { - "name": "Active torrents" + "name": "Active torrents", + "unit_of_measurement": "torrents" }, "inactive_torrents": { - "name": "Inactive torrents" + "name": "Inactive torrents", + "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]" }, "paused_torrents": { - "name": "Paused torrents" + "name": "Paused torrents", + "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]" }, "all_torrents": { - "name": "All torrents" + "name": "All torrents", + "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]" } }, "switch": { diff --git a/homeassistant/components/qbus/__init__.py b/homeassistant/components/qbus/__init__.py new file mode 100644 index 00000000000..da9dcfe69be --- /dev/null +++ b/homeassistant/components/qbus/__init__.py @@ -0,0 +1,87 @@ +"""The Qbus integration.""" + +import logging + +from homeassistant.components.mqtt import async_wait_for_mqtt_client +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, PLATFORMS +from .coordinator import ( + QBUS_KEY, + QbusConfigCoordinator, + QbusConfigEntry, + QbusControllerCoordinator, +) + +_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Qbus integration. + + We set up a single coordinator for managing Qbus config updates. The + config update contains the configuration for all controllers (and + config entries). This avoids having each device requesting and managing + the config on its own. + """ + _LOGGER.debug("Loading integration") + + if not await async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration not available") + return False + + config_coordinator = QbusConfigCoordinator.get_or_create(hass) + await config_coordinator.async_subscribe_to_config() + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> bool: + """Set up Qbus from a config entry.""" + _LOGGER.debug("%s - Loading entry", entry.unique_id) + + if not await async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration not available") + raise ConfigEntryNotReady("MQTT integration not available") + + coordinator = QbusControllerCoordinator(hass, entry) + entry.runtime_data = coordinator + + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Get current config + config = await QbusConfigCoordinator.get_or_create( + hass + ).async_get_or_request_config() + + # Update the controller config + if config: + await coordinator.async_update_controller_config(config) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("%s - Unloading entry", entry.unique_id) + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry.runtime_data.shutdown() + cleanup(hass, entry) + + return unload_ok + + +def cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None: + """Shutdown if no more entries are loaded.""" + entries = hass.config_entries.async_loaded_entries(DOMAIN) + count = len(entries) + + # During unloading of the entry, it is not marked as unloaded yet. So + # count can be 1 if it is the last one. + if count <= 1 and (config_coordinator := hass.data.get(QBUS_KEY)): + config_coordinator.shutdown() diff --git a/homeassistant/components/qbus/config_flow.py b/homeassistant/components/qbus/config_flow.py new file mode 100644 index 00000000000..2f08c5b47e2 --- /dev/null +++ b/homeassistant/components/qbus/config_flow.py @@ -0,0 +1,160 @@ +"""Config flow for Qbus.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from qbusmqttapi.discovery import QbusMqttDevice +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory + +from homeassistant.components.mqtt import client as mqtt +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ID +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from .const import CONF_SERIAL_NUMBER, DOMAIN +from .coordinator import QbusConfigCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class QbusFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle Qbus config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + + self._gateway_topic = self._topic_factory.get_gateway_state_topic() + self._config_topic = self._topic_factory.get_config_topic() + self._device_topic = self._topic_factory.get_device_state_topic("+") + + self._device: QbusMqttDevice | None = None + + async def async_step_mqtt( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by MQTT discovery.""" + _LOGGER.debug("Running mqtt discovery for topic %s", discovery_info.topic) + + # Abort if the payload is empty + if not discovery_info.payload: + _LOGGER.debug("Payload empty") + return self.async_abort(reason="invalid_discovery_info") + + match discovery_info.subscribed_topic: + case self._gateway_topic: + return await self._async_handle_gateway_topic(discovery_info) + + case self._config_topic: + return await self._async_handle_config_topic(discovery_info) + + case self._device_topic: + return await self._async_handle_device_topic(discovery_info) + + return self.async_abort(reason="invalid_discovery_info") + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup.""" + if TYPE_CHECKING: + assert self._device is not None + + if user_input is not None: + return self.async_create_entry( + title=f"Controller {self._device.serial_number}", + data={ + CONF_SERIAL_NUMBER: self._device.serial_number, + CONF_ID: self._device.id, + }, + ) + + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + CONF_SERIAL_NUMBER: self._device.serial_number, + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + return self.async_abort(reason="not_supported") + + async def _async_handle_gateway_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Handling gateway state") + gateway_state = self._message_factory.parse_gateway_state( + discovery_info.payload + ) + + if gateway_state is not None and gateway_state.online is True: + _LOGGER.debug("Requesting config") + await mqtt.async_publish( + self.hass, self._topic_factory.get_get_config_topic(), b"" + ) + + # Abort to wait for config topic + return self.async_abort(reason="discovery_in_progress") + + async def _async_handle_config_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Handling config topic") + qbus_config = self._message_factory.parse_discovery(discovery_info.payload) + + if qbus_config is not None: + QbusConfigCoordinator.get_or_create(self.hass).store_config(qbus_config) + + _LOGGER.debug("Requesting device states") + device_ids = [x.id for x in qbus_config.devices] + request = self._message_factory.create_state_request(device_ids) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + # Abort to wait for device topic + return self.async_abort(reason="discovery_in_progress") + + async def _async_handle_device_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Discovering device") + qbus_config = await QbusConfigCoordinator.get_or_create( + self.hass + ).async_get_or_request_config() + + if qbus_config is None: + _LOGGER.error("Qbus config not ready") + return self.async_abort(reason="invalid_discovery_info") + + device_id = discovery_info.topic.split("/")[2] + self._device = qbus_config.get_device_by_id(device_id) + + if self._device is None: + _LOGGER.warning("Device with id '%s' not found in config", device_id) + return self.async_abort(reason="invalid_discovery_info") + + await self.async_set_unique_id(self._device.serial_number) + + # Do not use error message "already_configured" (which is the + # default), as this will result in unsubscribing from the triggered + # mqtt topic. The topic subscribed to has a wildcard to allow + # discovery of multiple devices. Unsubscribing would result in + # not discovering new or unconfigured devices. + self._abort_if_unique_id_configured(error="device_already_configured") + + self.context.update( + { + "title_placeholders": { + CONF_SERIAL_NUMBER: self._device.serial_number, + } + } + ) + + return await self.async_step_discovery_confirm() diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py new file mode 100644 index 00000000000..ddfb8963cb7 --- /dev/null +++ b/homeassistant/components/qbus/const.py @@ -0,0 +1,12 @@ +"""Constants for the Qbus integration.""" + +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "qbus" +PLATFORMS: list[Platform] = [Platform.SWITCH] + +CONF_SERIAL_NUMBER: Final = "serial" + +MANUFACTURER: Final = "Qbus" diff --git a/homeassistant/components/qbus/coordinator.py b/homeassistant/components/qbus/coordinator.py new file mode 100644 index 00000000000..dd57a98787b --- /dev/null +++ b/homeassistant/components/qbus/coordinator.py @@ -0,0 +1,279 @@ +"""Qbus coordinator.""" + +from __future__ import annotations + +from datetime import datetime +import logging +from typing import cast + +from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice, QbusMqttOutput +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory + +from homeassistant.components.mqtt import ( + ReceiveMessage, + async_wait_for_mqtt_client, + client as mqtt, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.hass_dict import HassKey + +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +type QbusConfigEntry = ConfigEntry[QbusControllerCoordinator] +QBUS_KEY: HassKey[QbusConfigCoordinator] = HassKey(DOMAIN) + + +class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): + """Qbus data coordinator.""" + + _STATE_REQUEST_DELAY = 3 + + def __init__(self, hass: HomeAssistant, entry: QbusConfigEntry) -> None: + """Initialize Qbus coordinator.""" + + _LOGGER.debug("%s - Initializing coordinator", entry.unique_id) + self.config_entry: QbusConfigEntry + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=entry.unique_id or entry.entry_id, + always_update=False, + ) + + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + + self._controller_activated = False + self._subscribed_to_controller_state = False + self._controller: QbusMqttDevice | None = None + + # Clean up when HA stops + self.config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + ) + + async def _async_update_data(self) -> list[QbusMqttOutput]: + return self._controller.outputs if self._controller else [] + + def shutdown(self, event: Event | None = None) -> None: + """Shutdown Qbus coordinator.""" + _LOGGER.debug( + "%s - Shutting down entry coordinator", self.config_entry.unique_id + ) + + self._controller_activated = False + self._subscribed_to_controller_state = False + self._controller = None + + async def async_update_controller_config(self, config: QbusDiscovery) -> None: + """Update the controller based on the config.""" + _LOGGER.debug("%s - Updating config", self.config_entry.unique_id) + serial = self.config_entry.data.get(CONF_SERIAL_NUMBER, "") + controller = config.get_device_by_serial(serial) + + if controller is None: + _LOGGER.warning( + "%s - Controller with serial %s not found", + self.config_entry.unique_id, + serial, + ) + return + + self._controller = controller + + self._update_device_info() + await self._async_subscribe_to_controller_state() + await self.async_refresh() + self._request_controller_state() + self._request_entity_states() + + def _update_device_info(self) -> None: + if self._controller is None: + return + + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, format_mac(self._controller.mac))}, + manufacturer=MANUFACTURER, + model="CTD3.x", + name=f"CTD {self._controller.serial_number}", + serial_number=self._controller.serial_number, + sw_version=self._controller.version, + ) + + async def _async_subscribe_to_controller_state(self) -> None: + if self._controller is None or self._subscribed_to_controller_state is True: + return + + controller_state_topic = self._topic_factory.get_device_state_topic( + self._controller.id + ) + _LOGGER.debug( + "%s - Subscribing to %s", + self.config_entry.unique_id, + controller_state_topic, + ) + self._subscribed_to_controller_state = True + self.config_entry.async_on_unload( + await mqtt.async_subscribe( + self.hass, + controller_state_topic, + self._controller_state_received, + ) + ) + + async def _controller_state_received(self, msg: ReceiveMessage) -> None: + _LOGGER.debug( + "%s - Receiving controller state %s", self.config_entry.unique_id, msg.topic + ) + + if self._controller is None or self._controller_activated: + return + + state = self._message_factory.parse_device_state(msg.payload) + + if state and state.properties and state.properties.connectable is False: + _LOGGER.debug( + "%s - Activating controller %s", self.config_entry.unique_id, state.id + ) + self._controller_activated = True + request = self._message_factory.create_device_activate_request( + self._controller + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + def _request_entity_states(self) -> None: + async def request_state(_: datetime) -> None: + if self._controller is None: + return + + _LOGGER.debug( + "%s - Requesting %s entity states", + self.config_entry.unique_id, + len(self._controller.outputs), + ) + + request = self._message_factory.create_state_request( + [item.id for item in self._controller.outputs] + ) + + await mqtt.async_publish(self.hass, request.topic, request.payload) + + if self._controller and len(self._controller.outputs) > 0: + async_call_later(self.hass, self._STATE_REQUEST_DELAY, request_state) + + def _request_controller_state(self) -> None: + async def request_controller_state(_: datetime) -> None: + if self._controller is None: + return + + _LOGGER.debug( + "%s - Requesting controller state", self.config_entry.unique_id + ) + request = self._message_factory.create_device_state_request( + self._controller + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + if self._controller: + async_call_later( + self.hass, self._STATE_REQUEST_DELAY, request_controller_state + ) + + +class QbusConfigCoordinator: + """Class responsible for Qbus config updates.""" + + _qbus_config: QbusDiscovery | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize config coordinator.""" + + self._hass = hass + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + self._cleanup_callbacks: list[CALLBACK_TYPE] = [] + + self._cleanup_callbacks.append( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + ) + + @classmethod + def get_or_create(cls, hass: HomeAssistant) -> QbusConfigCoordinator: + """Get the coordinator and create if necessary.""" + if (coordinator := hass.data.get(QBUS_KEY)) is None: + coordinator = cls(hass) + hass.data[QBUS_KEY] = coordinator + + return coordinator + + def shutdown(self, event: Event | None = None) -> None: + """Shutdown Qbus config coordinator.""" + _LOGGER.debug("Shutting down Qbus config coordinator") + while self._cleanup_callbacks: + cleanup_callback = self._cleanup_callbacks.pop() + cleanup_callback() + + async def async_subscribe_to_config(self) -> None: + """Subscribe to config changes.""" + config_topic = self._topic_factory.get_config_topic() + _LOGGER.debug("Subscribing to %s", config_topic) + + self._cleanup_callbacks.append( + await mqtt.async_subscribe(self._hass, config_topic, self._config_received) + ) + + async def async_get_or_request_config(self) -> QbusDiscovery | None: + """Get or request Qbus config.""" + _LOGGER.debug("Requesting Qbus config") + + # Config already available + if self._qbus_config: + _LOGGER.debug("Qbus config already available") + return self._qbus_config + + if not await async_wait_for_mqtt_client(self._hass): + _LOGGER.debug("MQTT client not ready yet") + return None + + # Request config + _LOGGER.debug("Publishing config request") + await mqtt.async_publish( + self._hass, self._topic_factory.get_get_config_topic(), b"" + ) + + return self._qbus_config + + def store_config(self, config: QbusDiscovery) -> None: + "Store the Qbus config." + _LOGGER.debug("Storing config") + + self._qbus_config = config + + async def _config_received(self, msg: ReceiveMessage) -> None: + """Handle the received MQTT message containing the Qbus config.""" + _LOGGER.debug("Receiving Qbus config") + + config = self._message_factory.parse_discovery(msg.payload) + + if config is None: + _LOGGER.debug("Incomplete Qbus config") + return + + self.store_config(config) + + for entry in self._hass.config_entries.async_loaded_entries(DOMAIN): + entry = cast(QbusConfigEntry, entry) + await entry.runtime_data.async_update_controller_config(config) diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py new file mode 100644 index 00000000000..39bcddaaf4f --- /dev/null +++ b/homeassistant/components/qbus/entity.py @@ -0,0 +1,76 @@ +"""Base class for Qbus entities.""" + +from abc import ABC, abstractmethod +import re + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory +from qbusmqttapi.state import QbusMqttState + +from homeassistant.components.mqtt import ReceiveMessage, client as mqtt +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, MANUFACTURER + +_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$") + + +def format_ref_id(ref_id: str) -> str | None: + """Format the Qbus ref_id.""" + matches: list[str] = re.findall(_REFID_REGEX, ref_id) + + if len(matches) > 0: + if ref_id := matches[0]: + return ref_id.replace("/", "-") + + return None + + +class QbusEntity(Entity, ABC): + """Representation of a Qbus entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_should_poll = False + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize the Qbus entity.""" + + self._topic_factory = QbusMqttTopicFactory() + self._message_factory = QbusMqttMessageFactory() + + ref_id = format_ref_id(mqtt_output.ref_id) + + self._attr_unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}" + + self._attr_device_info = DeviceInfo( + name=mqtt_output.name.title(), + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, + suggested_area=mqtt_output.location.title(), + via_device=(DOMAIN, format_mac(mqtt_output.device.mac)), + ) + + self._mqtt_output = mqtt_output + self._state_topic = self._topic_factory.get_output_state_topic( + mqtt_output.device.id, mqtt_output.id + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self.async_on_remove( + await mqtt.async_subscribe( + self.hass, self._state_topic, self._state_received + ) + ) + + @abstractmethod + async def _state_received(self, msg: ReceiveMessage) -> None: + pass + + async def _async_publish_output_state(self, state: QbusMqttState) -> None: + request = self._message_factory.create_set_output_state_request( + self._mqtt_output.device, state + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json new file mode 100644 index 00000000000..ac76110363f --- /dev/null +++ b/homeassistant/components/qbus/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "qbus", + "name": "Qbus", + "codeowners": ["@Qbus-iot", "@thomasddn"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://www.home-assistant.io/integrations/qbus", + "integration_type": "hub", + "iot_class": "local_push", + "mqtt": [ + "cloudapp/QBUSMQTTGW/state", + "cloudapp/QBUSMQTTGW/config", + "cloudapp/QBUSMQTTGW/+/state" + ], + "quality_scale": "bronze", + "requirements": ["qbusmqttapi==1.2.3"] +} diff --git a/homeassistant/components/qbus/quality_scale.yaml b/homeassistant/components/qbus/quality_scale.yaml new file mode 100644 index 00000000000..7e106ef6b93 --- /dev/null +++ b/homeassistant/components/qbus/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: + status: exempt + comment: | + The integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: exempt + comment: | + The integration relies solely on auto-discovery. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow. + docs-installation-parameters: + status: exempt + comment: There are no parameters. + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: + status: exempt + comment: The integration does not require authentication. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: exempt + comment: The integration uses the name of what the user configured in the closed system. + exception-translations: todo + icon-translations: + status: exempt + comment: The integration creates unknown number of entities based on what is in the closed system and does not know what each entity stands for. + reconfiguration-flow: + status: exempt + comment: The integration has no settings. + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: The integration does not make HTTP requests. + strict-typing: done diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json new file mode 100644 index 00000000000..b8918497c41 --- /dev/null +++ b/homeassistant/components/qbus/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "flow_title": "Controller {serial}", + "step": { + "discovery_confirm": { + "title": "Add controller", + "description": "Add controller {serial}?" + } + }, + "abort": { + "already_configured": "Controller already configured", + "discovery_in_progress": "Discovery in progress", + "not_supported": "Configuration for QBUS happens through MQTT discovery. If your controller is not automatically discovered, check the prerequisites and troubleshooting section of the documention." + }, + "error": { + "no_controller": "No controllers were found" + } + } +} diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py new file mode 100644 index 00000000000..2413b8f152f --- /dev/null +++ b/homeassistant/components/qbus/switch.py @@ -0,0 +1,83 @@ +"""Support for Qbus switch.""" + +from typing import Any + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttOnOffState, StateType + +from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import QbusConfigEntry +from .entity import QbusEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, entry: QbusConfigEntry, add_entities: AddEntitiesCallback +) -> None: + """Set up switch entities.""" + coordinator = entry.runtime_data + + added_outputs: list[QbusMqttOutput] = [] + + # Local function that calls add_entities for new entities + def _check_outputs() -> None: + added_output_ids = {k.id for k in added_outputs} + + new_outputs = [ + item + for item in coordinator.data + if item.type == "onoff" and item.id not in added_output_ids + ] + + if new_outputs: + added_outputs.extend(new_outputs) + add_entities([QbusSwitch(output) for output in new_outputs]) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusSwitch(QbusEntity, SwitchEntity): + """Representation of a Qbus switch entity.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__( + self, + mqtt_output: QbusMqttOutput, + ) -> None: + """Initialize switch entity.""" + + super().__init__(mqtt_output) + + self._attr_is_on = False + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + state = QbusMqttOnOffState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_value(True) + + await self._async_publish_output_state(state) + self._attr_is_on = True + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + state = QbusMqttOnOffState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_value(False) + + await self._async_publish_output_state(state) + self._attr_is_on = False + + async def _state_received(self, msg: ReceiveMessage) -> None: + output = self._message_factory.parse_output_state( + QbusMqttOnOffState, msg.payload + ) + + if output is not None: + self._attr_is_on = output.read_value() + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/qnap_qsw/config_flow.py b/homeassistant/components/qnap_qsw/config_flow.py index 3a10e54ac82..3ccb13e0f64 100644 --- a/homeassistant/components/qnap_qsw/config_flow.py +++ b/homeassistant/components/qnap_qsw/config_flow.py @@ -9,12 +9,12 @@ from aioqsw.exceptions import LoginError, QswError from aioqsw.localapi import ConnectionOptions, QnapQswApi import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -73,7 +73,7 @@ class QNapQSWConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" self._discovered_url = f"http://{discovery_info.ip}" diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py index 45ec1828b9d..e7f2c18638f 100644 --- a/homeassistant/components/qnap_qsw/sensor.py +++ b/homeassistant/components/qnap_qsw/sensor.py @@ -27,12 +27,9 @@ from aioqsw.const import ( QSD_TEMP_MAX, QSD_TX_OCTETS, QSD_TX_SPEED, - QSD_UPTIME_SECONDS, QSD_UPTIME_TIMESTAMP, ) -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -45,10 +42,8 @@ from homeassistant.const import ( UnitOfDataRate, UnitOfInformation, UnitOfTemperature, - UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.util import dt as dt_util @@ -68,16 +63,6 @@ class QswSensorEntityDescription(SensorEntityDescription, QswEntityDescription): value_fn: Callable[[str], datetime | StateType] = lambda value: value -DEPRECATED_UPTIME_SECONDS = QswSensorEntityDescription( - translation_key="uptime", - key=QSD_SYSTEM_TIME, - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=UnitOfTime.SECONDS, - state_class=SensorStateClass.TOTAL_INCREASING, - subkey=QSD_UPTIME_SECONDS, -) - - SENSOR_TYPES: Final[tuple[QswSensorEntityDescription, ...]] = ( QswSensorEntityDescription( translation_key="fan_1_speed", @@ -355,46 +340,6 @@ async def async_setup_entry( ) entities.append(QswSensor(coordinator, _desc, entry, port_id)) - # Can be removed in HA 2025.5.0 - entity_reg = er.async_get(hass) - reg_entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id) - for entity in reg_entities: - if entity.domain == "sensor" and entity.unique_id.endswith( - ("_uptime", "_uptime_seconds") - ): - entity_id = entity.entity_id - - if entity.disabled: - entity_reg.async_remove(entity_id) - continue - - if ( - DEPRECATED_UPTIME_SECONDS.key in coordinator.data - and DEPRECATED_UPTIME_SECONDS.subkey - in coordinator.data[DEPRECATED_UPTIME_SECONDS.key] - ): - entities.append( - QswSensor(coordinator, DEPRECATED_UPTIME_SECONDS, entry) - ) - - entity_automations = automations_with_entity(hass, entity_id) - entity_scripts = scripts_with_entity(hass, entity_id) - - for item in entity_automations + entity_scripts: - ir.async_create_issue( - hass, - DOMAIN, - f"uptime_seconds_deprecated_{entity_id}_{item}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="uptime_seconds_deprecated", - translation_placeholders={ - "entity": entity_id, - "info": item, - }, - ) - async_add_entities(entities) diff --git a/homeassistant/components/qnap_qsw/strings.json b/homeassistant/components/qnap_qsw/strings.json index 462e66a25c3..e946bc4257d 100644 --- a/homeassistant/components/qnap_qsw/strings.json +++ b/homeassistant/components/qnap_qsw/strings.json @@ -57,11 +57,5 @@ "name": "Uptime timestamp" } } - }, - "issues": { - "uptime_seconds_deprecated": { - "title": "QNAP QSW uptime seconds sensor deprecated", - "description": "The QNAP QSW uptime seconds sensor entity is deprecated and will be removed in HA 2025.2.0.\nHome Assistant detected that entity `{entity}` is being used in `{info}`\n\nYou should remove the uptime seconds entity from `{info}` then click submit to fix this issue." - } } } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 9634d45b069..cd3ee8eca42 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -6,5 +6,5 @@ "iot_class": "calculated", "loggers": ["pyzbar"], "quality_scale": "legacy", - "requirements": ["Pillow==11.0.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==11.1.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py index 64e560b4f08..64b95fb17f6 100644 --- a/homeassistant/components/qwikswitch/sensor.py +++ b/homeassistant/components/qwikswitch/sensor.py @@ -48,9 +48,9 @@ class QSSensor(QSEntity, SensorEntity): self._decode, self.unit = SENSORS[sensor_type] # this cannot happen because it only happens in bool and this should be redirected to binary_sensor - assert not isinstance( - self.unit, type - ), f"boolean sensor id={sensor['id']} name={sensor['name']}" + assert not isinstance(self.unit, type), ( + f"boolean sensor id={sensor['id']} name={sensor['name']}" + ) @callback def update_packet(self, packet): diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py index 1bee69219b0..f4487a73b58 100644 --- a/homeassistant/components/rabbitair/config_flow.py +++ b/homeassistant/components/rabbitair/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -99,7 +100,7 @@ class RabbitAirConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" mac = dr.format_mac(discovery_info.properties["id"]) diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py index ba1896cba2f..cfbee0be67c 100644 --- a/homeassistant/components/rabbitair/fan.py +++ b/homeassistant/components/rabbitair/fan.py @@ -55,7 +55,6 @@ class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index fac93952b35..cc32bd2e56f 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -10,7 +10,6 @@ from rachiopy import Rachio from requests.exceptions import ConnectTimeout import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import ( CONF_MANUAL_RUN_MINS, @@ -92,13 +95,11 @@ class RachioConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self._async_abort_entries_match() - await self.async_set_unique_id( - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] - ) + await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured() return await self.async_step_user() diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index 73ab3644a0b..af52c5fcea3 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -107,7 +107,6 @@ class RadioThermostat(RadioThermostatEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_precision = PRECISION_HALVES _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: """Initialize the thermostat.""" diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index e29c4703e08..298421d3964 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -9,11 +9,11 @@ from urllib.error import URLError from radiotherm.validate import RadiothermTstatError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN from .data import RadioThermInitData, async_get_init_data @@ -44,7 +44,7 @@ class RadioThermConfigFlow(ConfigFlow, domain=DOMAIN): self.discovered_init_data: RadioThermInitData | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Discover via DHCP.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 97dec9a681e..d8b71e2df0b 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -9,7 +9,6 @@ import aiohttp from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -46,7 +45,7 @@ DOMAIN = "rainbird" def _async_register_clientsession_shutdown( hass: HomeAssistant, - entry: ConfigEntry, + entry: RainbirdConfigEntry, clientsession: aiohttp.ClientSession, ) -> None: """Register cleanup hooks for the clientsession.""" @@ -65,8 +64,6 @@ def _async_register_clientsession_shutdown( async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> bool: """Set up the config entry for Rain Bird.""" - hass.data.setdefault(DOMAIN, {}) - clientsession = async_create_clientsession() _async_register_clientsession_shutdown(hass, entry, clientsession) @@ -82,14 +79,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> return False if mac_address := entry.data.get(CONF_MAC): _async_fix_entity_unique_id( - hass, er.async_get(hass), entry.entry_id, format_mac(mac_address), str(entry.data[CONF_SERIAL_NUMBER]), ) _async_fix_device_id( - hass, dr.async_get(hass), entry.entry_id, format_mac(mac_address), @@ -128,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> async def _async_fix_unique_id( - hass: HomeAssistant, controller: AsyncRainbirdController, entry: ConfigEntry + hass: HomeAssistant, controller: AsyncRainbirdController, entry: RainbirdConfigEntry ) -> bool: """Update the config entry with a unique id based on the mac address.""" _LOGGER.debug("Checking for migration of config entry (%s)", entry.unique_id) @@ -173,7 +168,6 @@ async def _async_fix_unique_id( def _async_fix_entity_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, config_entry_id: str, mac_address: str, @@ -217,7 +211,6 @@ def _async_device_entry_to_keep( def _async_fix_device_id( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry_id: str, mac_address: str, @@ -257,6 +250,6 @@ def _async_fix_device_id( ) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 86a3c5d5d1c..1390650ea02 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -12,17 +12,13 @@ from pyrainbird.data import WifiParams from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac +from . import RainbirdConfigEntry from .const import ( ATTR_DURATION, CONF_SERIAL_NUMBER, @@ -69,7 +65,7 @@ class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RainbirdConfigEntry, ) -> RainBirdOptionsFlowHandler: """Define the config flow to handle options.""" return RainBirdOptionsFlowHandler() diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 437aa7ddbd4..2ccfa0af62a 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -15,13 +15,13 @@ from pyrainbird.async_client import ( ) from pyrainbird.data import ModelAndVersion, Schedule -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS +from .types import RainbirdConfigEntry UPDATE_INTERVAL = datetime.timedelta(minutes=1) # The calendar data requires RPCs for each program/zone, and the data rarely @@ -140,7 +140,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]): """Coordinator for rainbird irrigation schedule calls.""" - config_entry: ConfigEntry + config_entry: RainbirdConfigEntry def __init__( self, diff --git a/homeassistant/components/rainbird/quality_scale.yaml b/homeassistant/components/rainbird/quality_scale.yaml index cd000c63fad..8b4805a9b0e 100644 --- a/homeassistant/components/rainbird/quality_scale.yaml +++ b/homeassistant/components/rainbird/quality_scale.yaml @@ -34,21 +34,31 @@ rules: docs-removal-instructions: todo test-before-setup: done docs-high-level-description: done - config-flow-test-coverage: done + config-flow-test-coverage: + status: todo + comment: | + All config flow tests should finish with CREATE_ENTRY and ABORT to + test they are able to recover from errors docs-actions: done runtime-data: done # Silver - log-when-unavailable: todo - config-entry-unloading: todo + log-when-unavailable: done + config-entry-unloading: done reauthentication-flow: done - action-exceptions: todo - docs-installation-parameters: todo - integration-owner: todo - parallel-updates: todo - test-coverage: todo - docs-configuration-parameters: todo - entity-unavailable: todo + action-exceptions: done + docs-installation-parameters: + status: todo + comment: The documentation does not mention installation parameters + integration-owner: done + parallel-updates: + status: todo + comment: The integration does not explicitly set a number of parallel updates. + test-coverage: done + docs-configuration-parameters: + status: todo + comment: The documentation for configuration parameters could be improved. + entity-unavailable: done # Gold docs-examples: todo diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 25d3a962b36..6f92b1bdb97 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -40,6 +40,9 @@ "title": "[%key:component::rainbird::config::step::user::title%]", "data": { "duration": "Default irrigation time in minutes" + }, + "data_description": { + "duration": "The default duration the sprinkler will run when turned on." } } } diff --git a/homeassistant/components/rainbird/types.py b/homeassistant/components/rainbird/types.py index b452712d971..cc43353ac17 100644 --- a/homeassistant/components/rainbird/types.py +++ b/homeassistant/components/rainbird/types.py @@ -1,13 +1,20 @@ """Types for Rain Bird integration.""" +from __future__ import annotations + from dataclasses import dataclass +from typing import TYPE_CHECKING from pyrainbird.async_client import AsyncRainbirdController from pyrainbird.data import ModelAndVersion from homeassistant.config_entries import ConfigEntry -from .coordinator import RainbirdScheduleUpdateCoordinator, RainbirdUpdateCoordinator +if TYPE_CHECKING: + from .coordinator import ( + RainbirdScheduleUpdateCoordinator, + RainbirdUpdateCoordinator, + ) @dataclass diff --git a/homeassistant/components/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py index 72d258dc1d3..f8e3dde446a 100644 --- a/homeassistant/components/rainforest_raven/config_flow.py +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -20,6 +20,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, ) +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -30,7 +31,7 @@ def _format_id(value: str | int) -> str: return f"{value or 0:04X}" -def _generate_unique_id(info: ListPortInfo | usb.UsbServiceInfo) -> str: +def _generate_unique_id(info: ListPortInfo | UsbServiceInfo) -> str: """Generate unique id from usb attributes.""" return ( f"{_format_id(info.vid)}:{_format_id(info.pid)}_{info.serial_number}" @@ -98,9 +99,7 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_show_form(step_id="meters", data_schema=schema, errors=errors) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" device = discovery_info.device dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) diff --git a/homeassistant/components/rainforest_raven/manifest.json b/homeassistant/components/rainforest_raven/manifest.json index 49bd11e8880..3a902377c2e 100644 --- a/homeassistant/components/rainforest_raven/manifest.json +++ b/homeassistant/components/rainforest_raven/manifest.json @@ -6,7 +6,7 @@ "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/rainforest_raven", "iot_class": "local_polling", - "requirements": ["aioraven==0.7.0"], + "requirements": ["aioraven==0.7.1"], "usb": [ { "vid": "0403", diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 0b40d506566..6ce95d7e547 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -9,7 +9,6 @@ from regenmaschine.controller import Controller from regenmaschine.errors import RainMachineError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -19,6 +18,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_ALLOW_INACTIVE_ZONES_TO_RUN, @@ -66,19 +66,19 @@ class RainMachineFlowHandler(ConfigFlow, domain=DOMAIN): return RainMachineOptionsFlowHandler() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by homekit discovery.""" return await self.async_step_homekit_zeroconf(discovery_info) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery via zeroconf.""" return await self.async_step_homekit_zeroconf(discovery_info) async def async_step_homekit_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery via zeroconf.""" ip_address = discovery_info.host diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py index 00314169260..35b7757580e 100644 --- a/homeassistant/components/random/config_flow.py +++ b/homeassistant/components/random/config_flow.py @@ -106,8 +106,12 @@ def _validate_unit(options: dict[str, Any]) -> None: and (units := DEVICE_CLASS_UNITS.get(device_class)) and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units ): + # Sort twice to make sure strings with same case-insensitive order of + # letters are sorted consistently still (sorted() is guaranteed stable). sorted_units = sorted( - [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units], + sorted( + [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units], + ), key=str.casefold, ) if len(sorted_units) == 1: diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 8564827d839..a40760c67f4 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,7 +28,14 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.event_type import EventType -from . import entity_registry, websocket_api +# Pre-import backup to avoid it being imported +# later when the import executor is busy and delaying +# startup +from . import ( + backup, # noqa: F401 + entity_registry, + websocket_api, +) from .const import ( # noqa: F401 CONF_DB_INTEGRITY_CHECK, DOMAIN, diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py index b73744ef0d1..f203d6ab69a 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py @@ -17,7 +17,7 @@ from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.util import dt as dt_util -from ...const import SQLITE_MAX_BIND_VARS +from ...const import DEFAULT_MAX_BIND_VARS from ...db_schema import Statistics, StatisticsBase, StatisticsMeta, StatisticsShortTerm from ...util import database_job_retry_wrapper, execute @@ -61,7 +61,7 @@ def _find_duplicates( ) .filter(subquery.c.is_duplicate == 1) .order_by(table.metadata_id, table.start, table.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) + .limit(1000 * DEFAULT_MAX_BIND_VARS) ) duplicates = execute(query) original_as_dict = {} @@ -125,10 +125,10 @@ def _delete_duplicates_from_table( if not duplicate_ids: break all_non_identical_duplicates.extend(non_identical_duplicates) - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + for i in range(0, len(duplicate_ids), DEFAULT_MAX_BIND_VARS): deleted_rows = ( session.query(table) - .filter(table.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS])) + .filter(table.id.in_(duplicate_ids[i : i + DEFAULT_MAX_BIND_VARS])) .delete(synchronize_session=False) ) total_deleted_rows += deleted_rows @@ -205,7 +205,7 @@ def _find_statistics_meta_duplicates(session: Session) -> list[int]: ) .filter(subquery.c.is_duplicate == 1) .order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) + .limit(1000 * DEFAULT_MAX_BIND_VARS) ) duplicates = execute(query) statistic_id = None @@ -230,11 +230,11 @@ def _delete_statistics_meta_duplicates(session: Session) -> int: duplicate_ids = _find_statistics_meta_duplicates(session) if not duplicate_ids: break - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + for i in range(0, len(duplicate_ids), DEFAULT_MAX_BIND_VARS): deleted_rows = ( session.query(StatisticsMeta) .filter( - StatisticsMeta.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS]) + StatisticsMeta.id.in_(duplicate_ids[i : i + DEFAULT_MAX_BIND_VARS]) ) .delete(synchronize_session=False) ) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 409641e54c9..c91845e8436 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -32,16 +32,6 @@ MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG = 256 * 1024**2 # The maximum number of rows (events) we purge in one delete statement -# sqlite3 has a limit of 999 until version 3.32.0 -# in https://github.com/sqlite/sqlite/commit/efdba1a8b3c6c967e7fae9c1989c40d420ce64cc -# We can increase this back to 1000 once most -# have upgraded their sqlite version -SQLITE_MAX_BIND_VARS = 998 - -# The maximum bind vars for sqlite 3.32.0 and above, but -# capped at 4000 to avoid performance issues -SQLITE_MODERN_MAX_BIND_VARS = 4000 - DEFAULT_MAX_BIND_VARS = 4000 DB_WORKER_PREFIX = "DbWorker" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 6ba64d4a571..fc8b136f38a 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -14,7 +14,7 @@ import threading import time from typing import TYPE_CHECKING, Any, cast -from propcache import cached_property +from propcache.api import cached_property import psutil_home_assistant as ha_psutil from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select, update from sqlalchemy.engine import Engine @@ -52,6 +52,7 @@ from homeassistant.util.event_type import EventType from . import migration, statistics from .const import ( DB_WORKER_PREFIX, + DEFAULT_MAX_BIND_VARS, DOMAIN, KEEPALIVE_TIME, LAST_REPORTED_SCHEMA_VERSION, @@ -61,7 +62,6 @@ from .const import ( MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG, MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, - SQLITE_MAX_BIND_VARS, SQLITE_URL_PREFIX, SupportedDialect, ) @@ -230,12 +230,9 @@ class Recorder(threading.Thread): self._dialect_name: SupportedDialect | None = None self.enabled = True - # For safety we default to the lowest value for max_bind_vars - # of all the DB types (SQLITE_MAX_BIND_VARS). - # # We update the value once we connect to the DB # and determine what is actually supported. - self.max_bind_vars = SQLITE_MAX_BIND_VARS + self.max_bind_vars = DEFAULT_MAX_BIND_VARS @property def backlog(self) -> int: @@ -712,12 +709,24 @@ class Recorder(threading.Thread): setup_result = self._setup_recorder() if not setup_result: + _LOGGER.error("Recorder setup failed, recorder shutting down") # Give up if we could not connect return schema_status = migration.validate_db_schema(self.hass, self, self.get_session) if schema_status is None: # Give up if we could not validate the schema + _LOGGER.error("Failed to validate schema, recorder shutting down") + return + if schema_status.current_version > SCHEMA_VERSION: + _LOGGER.error( + "The database schema version %s is newer than %s which is the maximum " + "database schema version supported by the installed version of " + "Home Assistant Core, either upgrade Home Assistant Core or restore " + "the database from a backup compatible with this version", + schema_status.current_version, + SCHEMA_VERSION, + ) return self.schema_version = schema_status.current_version @@ -740,7 +749,7 @@ class Recorder(threading.Thread): self.schema_version = schema_status.current_version # Do non-live data migration - migration.migrate_data_non_live(self, self.get_session, schema_status) + self._migrate_data_offline(schema_status) # Non-live migration is now completed, remaining steps are live self.migration_is_live = True @@ -916,6 +925,13 @@ class Recorder(threading.Thread): return False + def _migrate_data_offline( + self, schema_status: migration.SchemaValidationStatus + ) -> None: + """Migrate data.""" + with self.hass.timeout.freeze(DOMAIN): + migration.migrate_data_non_live(self, self.get_session, schema_status) + def _migrate_schema_offline( self, schema_status: migration.SchemaValidationStatus ) -> tuple[bool, migration.SchemaValidationStatus]: @@ -963,6 +979,7 @@ class Recorder(threading.Thread): # which does not need migration or repair. new_schema_status = migration.SchemaValidationStatus( current_version=SCHEMA_VERSION, + initial_version=SCHEMA_VERSION, migration_needed=False, non_live_data_migration_needed=False, schema_errors=set(), @@ -1121,7 +1138,6 @@ class Recorder(threading.Thread): # Map the event data to the StateAttributes table shared_attrs = shared_attrs_bytes.decode("utf-8") - dbstate.attributes = None # Matching attributes found in the pending commit if pending_event_data := state_attributes_manager.get_pending(shared_attrs): dbstate.state_attributes = pending_event_data @@ -1424,6 +1440,7 @@ class Recorder(threading.Thread): with session_scope(session=self.get_session()) as session: end_incomplete_runs(session, self.recorder_runs_manager.recording_start) self.recorder_runs_manager.start(session) + self.states_manager.load_from_db(session) self._open_event_session() diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 7e8343321c3..cefce9c4e72 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -6,7 +6,7 @@ from collections.abc import Callable from datetime import datetime, timedelta import logging import time -from typing import Any, Self, cast +from typing import Any, Final, Protocol, Self, cast import ciso8601 from fnv_hash_fast import fnv1a_32 @@ -77,7 +77,7 @@ class LegacyBase(DeclarativeBase): """Base class for tables, used for schema migration.""" -SCHEMA_VERSION = 47 +SCHEMA_VERSION = 48 _LOGGER = logging.getLogger(__name__) @@ -130,7 +130,8 @@ METADATA_ID_LAST_UPDATED_INDEX_TS = "ix_states_metadata_id_last_updated_ts" EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" LEGACY_STATES_EVENT_ID_INDEX = "ix_states_event_id" -LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated_ts" +LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX = "ix_states_entity_id_last_updated_ts" +LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36 CONTEXT_ID_BIN_MAX_LENGTH = 16 MYSQL_COLLATE = "utf8mb4_unicode_ci" @@ -162,14 +163,14 @@ class Unused(CHAR): """An unused column type that behaves like a string.""" -@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call] -@compiles(Unused, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call] +@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite") +@compiles(Unused, "mysql", "mariadb", "sqlite") def compile_char_zero(type_: TypeDecorator, compiler: Any, **kw: Any) -> str: """Compile UnusedDateTime and Unused as CHAR(0) on mysql, mariadb, and sqlite.""" return "CHAR(0)" # Uses 1 byte on MySQL (no change on sqlite) -@compiles(Unused, "postgresql") # type: ignore[misc,no-untyped-call] +@compiles(Unused, "postgresql") def compile_char_one(type_: TypeDecorator, compiler: Any, **kw: Any) -> str: """Compile Unused as CHAR(1) on postgresql.""" return "CHAR(1)" # Uses 1 byte @@ -232,10 +233,14 @@ CONTEXT_BINARY_TYPE = LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH).with_variant( TIMESTAMP_TYPE = DOUBLE_TYPE +class _LiteralProcessorType(Protocol): + def __call__(self, value: Any) -> str: ... + + class JSONLiteral(JSON): """Teach SA how to literalize json.""" - def literal_processor(self, dialect: Dialect) -> Callable[[Any], str]: + def literal_processor(self, dialect: Dialect) -> _LiteralProcessorType: """Processor to convert a value to JSON.""" def process(value: Any) -> str: @@ -350,6 +355,17 @@ class Events(Base): return None +class LegacyEvents(LegacyBase): + """Event history data with event_id, used for schema migration.""" + + __table_args__ = (_DEFAULT_TABLE_ARGS,) + __tablename__ = TABLE_EVENTS + event_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) + context_id: Mapped[str | None] = mapped_column( + String(LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID), index=True + ) + + class EventData(Base): """Event data history.""" @@ -575,6 +591,28 @@ class States(Base): ) +class LegacyStates(LegacyBase): + """State change history with entity_id, used for schema migration.""" + + __table_args__ = ( + Index( + LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX, + "entity_id", + "last_updated_ts", + ), + _DEFAULT_TABLE_ARGS, + ) + __tablename__ = TABLE_STATES + state_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True) + entity_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN) + last_updated_ts: Mapped[float | None] = mapped_column( + TIMESTAMP_TYPE, default=time.time, index=True + ) + context_id: Mapped[str | None] = mapped_column( + String(LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID), index=True + ) + + class StateAttributes(Base): """State attribute change history.""" @@ -691,12 +729,14 @@ class StatisticsBase: duration: timedelta @classmethod - def from_stats(cls, metadata_id: int, stats: StatisticData) -> Self: + def from_stats( + cls, metadata_id: int, stats: StatisticData, now_timestamp: float | None = None + ) -> Self: """Create object from a statistics with datetime objects.""" return cls( # type: ignore[call-arg] metadata_id=metadata_id, created=None, - created_ts=time.time(), + created_ts=now_timestamp or time.time(), start=None, start_ts=stats["start"].timestamp(), mean=stats.get("mean"), @@ -709,12 +749,17 @@ class StatisticsBase: ) @classmethod - def from_stats_ts(cls, metadata_id: int, stats: StatisticDataTimestamp) -> Self: + def from_stats_ts( + cls, + metadata_id: int, + stats: StatisticDataTimestamp, + now_timestamp: float | None = None, + ) -> Self: """Create object from a statistics with timestamps.""" return cls( # type: ignore[call-arg] metadata_id=metadata_id, created=None, - created_ts=time.time(), + created_ts=now_timestamp or time.time(), start=None, start_ts=stats["start_ts"], mean=stats.get("mean"), diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index b59fc43c3d0..dc49ebb9768 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -22,9 +22,9 @@ from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance import homeassistant.util.dt as dt_util -from ..db_schema import RecorderRuns, StateAttributes, States +from ..db_schema import StateAttributes, States from ..filters import Filters -from ..models import process_timestamp, process_timestamp_to_utc_isoformat +from ..models import process_timestamp_to_utc_isoformat from ..models.legacy import LegacyLazyState, legacy_row_to_compressed_state from ..util import execute_stmt_lambda_element, session_scope from .const import ( @@ -436,7 +436,7 @@ def get_last_state_changes( def _get_states_for_entities_stmt( - run_start: datetime, + run_start_ts: float, utc_point_in_time: datetime, entity_ids: list[str], no_attributes: bool, @@ -447,8 +447,7 @@ def _get_states_for_entities_stmt( ) # We got an include-list of entities, accelerate the query by filtering already # in the inner query. - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + utc_point_in_time_ts = utc_point_in_time.timestamp() stmt += lambda q: q.join( ( most_recent_states_for_entities_by_date := ( @@ -483,7 +482,7 @@ def _get_rows_with_session( session: Session, utc_point_in_time: datetime, entity_ids: list[str], - run: RecorderRuns | None = None, + *, no_attributes: bool = False, ) -> Iterable[Row]: """Return the states at a specific point in time.""" @@ -495,17 +494,16 @@ def _get_rows_with_session( ), ) - if run is None: - run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time) + oldest_ts = get_instance(hass).states_manager.oldest_ts - if run is None or process_timestamp(run.start) > utc_point_in_time: - # History did not run before utc_point_in_time + if oldest_ts is None or oldest_ts > utc_point_in_time.timestamp(): + # We don't have any states for the requested time return [] # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. stmt = _get_states_for_entities_stmt( - run.start, utc_point_in_time, entity_ids, no_attributes + oldest_ts, utc_point_in_time, entity_ids, no_attributes ) return execute_stmt_lambda_element(session, stmt) @@ -520,7 +518,7 @@ def _get_single_entity_states_stmt( stmt, join_attributes = _lambda_stmt_and_join_attributes( no_attributes, include_last_changed=True ) - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + utc_point_in_time_ts = utc_point_in_time.timestamp() stmt += ( lambda q: q.filter( States.last_updated_ts < utc_point_in_time_ts, diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index b44bec0d0ee..2d8f4da5f38 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -28,13 +28,17 @@ from homeassistant.helpers.recorder import get_instance import homeassistant.util.dt as dt_util from ..const import LAST_REPORTED_SCHEMA_VERSION -from ..db_schema import SHARED_ATTR_OR_LEGACY_ATTRIBUTES, StateAttributes, States +from ..db_schema import ( + SHARED_ATTR_OR_LEGACY_ATTRIBUTES, + StateAttributes, + States, + StatesMeta, +) from ..filters import Filters from ..models import ( LazyState, datetime_to_timestamp_or_none, extract_metadata_ids, - process_timestamp, row_to_compressed_state, ) from ..util import execute_stmt_lambda_element, session_scope @@ -178,7 +182,6 @@ def _significant_states_stmt( unioned_subquery = union_all( _select_from_subquery( _get_start_time_state_stmt( - run_start_ts, start_time_ts, single_metadata_id, metadata_ids, @@ -246,12 +249,12 @@ def get_significant_states_with_session( if metadata_id is not None and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS ] - run_start_ts: float | None = None + oldest_ts: float | None = None if include_start_time_state and not ( - run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time) + oldest_ts := _get_oldest_possible_ts(hass, start_time) ): include_start_time_state = False - start_time_ts = dt_util.utc_to_timestamp(start_time) + start_time_ts = start_time.timestamp() end_time_ts = datetime_to_timestamp_or_none(end_time) single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None stmt = lambda_stmt( @@ -264,7 +267,7 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, include_start_time_state, - run_start_ts, + oldest_ts, ), track_on=[ bool(single_metadata_id), @@ -348,11 +351,12 @@ def _state_changed_during_period_stmt( ) if limit: stmt = stmt.limit(limit) - stmt = stmt.order_by( - States.metadata_id, - States.last_updated_ts, - ) + stmt = stmt.order_by(States.metadata_id, States.last_updated_ts) if not include_start_time_state or not run_start_ts: + # If we do not need the start time state or the + # oldest possible timestamp is newer than the start time + # we can return the statement as is as there will + # never be a start time state. return stmt return _select_from_subquery( union_all( @@ -411,12 +415,12 @@ def state_changes_during_period( entity_id_to_metadata_id: dict[str, int | None] = { entity_id: single_metadata_id } - run_start_ts: float | None = None + oldest_ts: float | None = None if include_start_time_state and not ( - run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time) + oldest_ts := _get_oldest_possible_ts(hass, start_time) ): include_start_time_state = False - start_time_ts = dt_util.utc_to_timestamp(start_time) + start_time_ts = start_time.timestamp() end_time_ts = datetime_to_timestamp_or_none(end_time) stmt = lambda_stmt( lambda: _state_changed_during_period_stmt( @@ -426,7 +430,7 @@ def state_changes_during_period( no_attributes, limit, include_start_time_state, - run_start_ts, + oldest_ts, has_last_reported, ), track_on=[ @@ -551,47 +555,43 @@ def get_last_state_changes( def _get_start_time_state_for_entities_stmt( - run_start_ts: float, epoch_time: float, metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, ) -> Select: """Baked query to get states for specific entities.""" - # We got an include-list of entities, accelerate the query by filtering already - # in the inner and the outer query. + # This query is the result of significant research in + # https://github.com/home-assistant/core/issues/132865 + # A reverse index scan with a limit 1 is the fastest way to get the + # last state change before a specific point in time for all supported + # databases. Since all databases support this query as a join + # condition we can use it as a subquery to get the last state change + # before a specific point in time for all entities. stmt = ( _stmt_and_join_attributes_for_start_state( no_attributes, include_last_changed, False ) + .select_from(StatesMeta) .join( - ( - most_recent_states_for_entities_by_date := ( - select( - States.metadata_id.label("max_metadata_id"), - func.max(States.last_updated_ts).label("max_last_updated"), - ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < epoch_time) - & States.metadata_id.in_(metadata_ids) - ) - .group_by(States.metadata_id) - .subquery() - ) - ), + States, and_( - States.metadata_id - == most_recent_states_for_entities_by_date.c.max_metadata_id, States.last_updated_ts - == most_recent_states_for_entities_by_date.c.max_last_updated, + == ( + select(States.last_updated_ts) + .where( + (StatesMeta.metadata_id == States.metadata_id) + & (States.last_updated_ts < epoch_time) + ) + .order_by(States.last_updated_ts.desc()) + .limit(1) + ) + .scalar_subquery() + .correlate(StatesMeta), + States.metadata_id == StatesMeta.metadata_id, ), ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < epoch_time) - & States.metadata_id.in_(metadata_ids) - ) + .where(StatesMeta.metadata_id.in_(metadata_ids)) ) if no_attributes: return stmt @@ -600,22 +600,21 @@ def _get_start_time_state_for_entities_stmt( ) -def _get_run_start_ts_for_utc_point_in_time( +def _get_oldest_possible_ts( hass: HomeAssistant, utc_point_in_time: datetime ) -> float | None: - """Return the start time of a run.""" - run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time) - if ( - run is not None - and (run_start := process_timestamp(run.start)) < utc_point_in_time - ): - return run_start.timestamp() - # History did not run before utc_point_in_time but we still + """Return the oldest possible timestamp. + + Returns None if there are no states as old as utc_point_in_time. + """ + + oldest_ts = get_instance(hass).states_manager.oldest_ts + if oldest_ts is not None and oldest_ts < utc_point_in_time.timestamp(): + return oldest_ts return None def _get_start_time_state_stmt( - run_start_ts: float, epoch_time: float, single_metadata_id: int | None, metadata_ids: list[int], @@ -636,7 +635,6 @@ def _get_start_time_state_stmt( # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. return _get_start_time_state_for_entities_stmt( - run_start_ts, epoch_time, metadata_ids, no_attributes, diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 2be4b6862ba..d3b6e52ad11 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,8 +7,8 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.31", - "fnv-hash-fast==1.0.2", + "SQLAlchemy==2.0.36", + "fnv-hash-fast==1.2.2", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 02ab05288c5..c6cdd6d317f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -23,6 +23,7 @@ from sqlalchemy.exc import ( ProgrammingError, SQLAlchemyError, ) +from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm.session import Session from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint from sqlalchemy.sql.expression import true @@ -59,7 +60,7 @@ from .db_schema import ( BIG_INTEGER_SQL, CONTEXT_ID_BIN_MAX_LENGTH, DOUBLE_PRECISION_TYPE_SQL, - LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX, + LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX, LEGACY_STATES_EVENT_ID_INDEX, MYSQL_COLLATE, MYSQL_DEFAULT_CHARSET, @@ -169,6 +170,24 @@ _COLUMN_TYPES_FOR_DIALECT: dict[SupportedDialect | None, _ColumnTypesForDialect] } +def _unindexable_legacy_column( + instance: Recorder, base: type[DeclarativeBase], err: Exception +) -> bool: + """Ignore index errors on char(0) columns.""" + # The error code is hard coded because the PyMySQL library may not be + # installed when using database engines other than MySQL or MariaDB. + # 1167: The used storage engine can't index column '%s' + return bool( + base == LegacyBase + and isinstance(err, OperationalError) + and instance.engine + and instance.engine.dialect.name == SupportedDialect.MYSQL + and isinstance(err.orig, BaseException) + and err.orig.args + and err.orig.args[0] == 1167 + ) + + def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str]) -> None: """Raise if the exception and cause do not contain the match substrs.""" lower_ex_strs = [str(ex).lower(), str(ex.__cause__).lower()] @@ -180,7 +199,27 @@ def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str]) raise ex -def _get_schema_version(session: Session) -> int | None: +def _get_initial_schema_version(session: Session) -> int | None: + """Get the schema version the database was created with.""" + res = ( + session.query(SchemaChanges.schema_version) + .order_by(SchemaChanges.change_id.asc()) + .first() + ) + return getattr(res, "schema_version", None) + + +def get_initial_schema_version(session_maker: Callable[[], Session]) -> int | None: + """Get the schema version the database was created with.""" + try: + with session_scope(session=session_maker(), read_only=True) as session: + return _get_initial_schema_version(session) + except Exception: + _LOGGER.exception("Error when determining DB schema version") + return None + + +def _get_current_schema_version(session: Session) -> int | None: """Get the schema version.""" res = ( session.query(SchemaChanges.schema_version) @@ -190,11 +229,11 @@ def _get_schema_version(session: Session) -> int | None: return getattr(res, "schema_version", None) -def get_schema_version(session_maker: Callable[[], Session]) -> int | None: +def get_current_schema_version(session_maker: Callable[[], Session]) -> int | None: """Get the schema version.""" try: with session_scope(session=session_maker(), read_only=True) as session: - return _get_schema_version(session) + return _get_current_schema_version(session) except Exception: _LOGGER.exception("Error when determining DB schema version") return None @@ -205,6 +244,7 @@ class SchemaValidationStatus: """Store schema validation status.""" current_version: int + initial_version: int migration_needed: bool non_live_data_migration_needed: bool schema_errors: set[str] @@ -227,8 +267,9 @@ def validate_db_schema( """ schema_errors: set[str] = set() - current_version = get_schema_version(session_maker) - if current_version is None: + current_version = get_current_schema_version(session_maker) + initial_version = get_initial_schema_version(session_maker) + if current_version is None or initial_version is None: return None if is_current := _schema_is_current(current_version): @@ -238,11 +279,15 @@ def validate_db_schema( schema_migration_needed = not is_current _non_live_data_migration_needed = non_live_data_migration_needed( - instance, session_maker, current_version + instance, + session_maker, + initial_schema_version=initial_version, + start_schema_version=current_version, ) return SchemaValidationStatus( current_version=current_version, + initial_version=initial_version, non_live_data_migration_needed=_non_live_data_migration_needed, migration_needed=schema_migration_needed or _non_live_data_migration_needed, schema_errors=schema_errors, @@ -313,7 +358,7 @@ def _migrate_schema( for version in range(current_version, end_version): new_version = version + 1 - _LOGGER.info("Upgrading recorder db schema to version %s", new_version) + _LOGGER.warning("Upgrading recorder db schema to version %s", new_version) _apply_update(instance, hass, engine, session_maker, new_version, start_version) with session_scope(session=session_maker()) as session: session.add(SchemaChanges(schema_version=new_version)) @@ -377,17 +422,26 @@ def _get_migration_changes(session: Session) -> dict[str, int]: def non_live_data_migration_needed( instance: Recorder, session_maker: Callable[[], Session], - schema_version: int, + *, + initial_schema_version: int, + start_schema_version: int, ) -> bool: """Return True if non-live data migration is needed. + :param initial_schema_version: The schema version the database was created with. + :param start_schema_version: The schema version when starting the migration. + This must only be called if database schema is current. """ migration_needed = False with session_scope(session=session_maker()) as session: migration_changes = _get_migration_changes(session) for migrator_cls in NON_LIVE_DATA_MIGRATORS: - migrator = migrator_cls(schema_version, migration_changes) + migrator = migrator_cls( + initial_schema_version=initial_schema_version, + start_schema_version=start_schema_version, + migration_changes=migration_changes, + ) migration_needed |= migrator.needs_migrate(instance, session) return migration_needed @@ -406,7 +460,11 @@ def migrate_data_non_live( migration_changes = _get_migration_changes(session) for migrator_cls in NON_LIVE_DATA_MIGRATORS: - migrator = migrator_cls(schema_status.start_version, migration_changes) + migrator = migrator_cls( + initial_schema_version=schema_status.initial_version, + start_schema_version=schema_status.start_version, + migration_changes=migration_changes, + ) migrator.migrate_all(instance, session_maker) @@ -423,19 +481,28 @@ def migrate_data_live( migration_changes = _get_migration_changes(session) for migrator_cls in LIVE_DATA_MIGRATORS: - migrator = migrator_cls(schema_status.start_version, migration_changes) + migrator = migrator_cls( + initial_schema_version=schema_status.initial_version, + start_schema_version=schema_status.start_version, + migration_changes=migration_changes, + ) migrator.queue_migration(instance, session) def _create_index( - session_maker: Callable[[], Session], table_name: str, index_name: str + instance: Recorder, + session_maker: Callable[[], Session], + table_name: str, + index_name: str, + *, + base: type[DeclarativeBase] = Base, ) -> None: """Create an index for the specified table. The index name should match the name given for the index within the table definition described in the models """ - table = Table(table_name, Base.metadata) + table = Table(table_name, base.metadata) _LOGGER.debug("Looking up index %s for table %s", index_name, table_name) # Look up the index object by name from the table is the models index_list = [idx for idx in table.indexes if idx.name == index_name] @@ -455,10 +522,18 @@ def _create_index( connection = session.connection() index.create(connection) except (InternalError, OperationalError, ProgrammingError) as err: + if _unindexable_legacy_column(instance, base, err): + _LOGGER.debug( + "Can't add legacy index %s to column %s, continuing", + index_name, + table_name, + ) + return raise_if_exception_missing_str(err, ["already exists", "duplicate"]) _LOGGER.warning( "Index %s already exists on %s, continuing", index_name, table_name ) + return _LOGGER.warning("Finished adding index `%s` to table `%s`", index_name, table_name) @@ -997,7 +1072,12 @@ class _SchemaVersion2Migrator(_SchemaVersionMigrator, target_version=2): def _apply_update(self) -> None: """Version specific update method.""" # Create compound start/end index for recorder_runs - _create_index(self.session_maker, "recorder_runs", "ix_recorder_runs_start_end") + _create_index( + self.instance, + self.session_maker, + "recorder_runs", + "ix_recorder_runs_start_end", + ) # This used to create ix_states_last_updated bit it was removed in version 32 @@ -1032,7 +1112,9 @@ class _SchemaVersion5Migrator(_SchemaVersionMigrator, target_version=5): def _apply_update(self) -> None: """Version specific update method.""" # Create supporting index for States.event_id foreign key - _create_index(self.session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) + _create_index( + self.instance, self.session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX + ) class _SchemaVersion6Migrator(_SchemaVersionMigrator, target_version=6): @@ -1043,7 +1125,9 @@ class _SchemaVersion6Migrator(_SchemaVersionMigrator, target_version=6): "events", ["context_id CHARACTER(36)", "context_user_id CHARACTER(36)"], ) - _create_index(self.session_maker, "events", "ix_events_context_id") + _create_index( + self.instance, self.session_maker, "events", "ix_events_context_id" + ) # This used to create ix_events_context_user_id, # but it was removed in version 28 _add_columns( @@ -1051,7 +1135,9 @@ class _SchemaVersion6Migrator(_SchemaVersionMigrator, target_version=6): "states", ["context_id CHARACTER(36)", "context_user_id CHARACTER(36)"], ) - _create_index(self.session_maker, "states", "ix_states_context_id") + _create_index( + self.instance, self.session_maker, "states", "ix_states_context_id" + ) # This used to create ix_states_context_user_id, # but it was removed in version 28 @@ -1105,7 +1191,9 @@ class _SchemaVersion10Migrator(_SchemaVersionMigrator, target_version=10): class _SchemaVersion11Migrator(_SchemaVersionMigrator, target_version=11): def _apply_update(self) -> None: """Version specific update method.""" - _create_index(self.session_maker, "states", "ix_states_old_state_id") + _create_index( + self.instance, self.session_maker, "states", "ix_states_old_state_id" + ) # _update_states_table_with_foreign_key_options first drops foreign # key constraints, and then re-adds them with the correct settings. @@ -1347,13 +1435,20 @@ class _SchemaVersion25Migrator(_SchemaVersionMigrator, target_version=25): "states", [f"attributes_id {self.column_types.big_int_type}"], ) - _create_index(self.session_maker, "states", "ix_states_attributes_id") + _create_index( + self.instance, self.session_maker, "states", "ix_states_attributes_id" + ) class _SchemaVersion26Migrator(_SchemaVersionMigrator, target_version=26): def _apply_update(self) -> None: """Version specific update method.""" - _create_index(self.session_maker, "statistics_runs", "ix_statistics_runs_start") + _create_index( + self.instance, + self.session_maker, + "statistics_runs", + "ix_statistics_runs_start", + ) class _SchemaVersion27Migrator(_SchemaVersionMigrator, target_version=27): @@ -1362,7 +1457,7 @@ class _SchemaVersion27Migrator(_SchemaVersionMigrator, target_version=27): _add_columns( self.session_maker, "events", [f"data_id {self.column_types.big_int_type}"] ) - _create_index(self.session_maker, "events", "ix_events_data_id") + _create_index(self.instance, self.session_maker, "events", "ix_events_data_id") class _SchemaVersion28Migrator(_SchemaVersionMigrator, target_version=28): @@ -1382,7 +1477,9 @@ class _SchemaVersion28Migrator(_SchemaVersionMigrator, target_version=28): "context_parent_id VARCHAR(36)", ], ) - _create_index(self.session_maker, "states", "ix_states_context_id") + _create_index( + self.instance, self.session_maker, "states", "ix_states_context_id" + ) # Once there are no longer any state_changed events # in the events table we can drop the index on states.event_id @@ -1409,7 +1506,10 @@ class _SchemaVersion29Migrator(_SchemaVersionMigrator, target_version=29): ) try: _create_index( - self.session_maker, "statistics_meta", "ix_statistics_meta_statistic_id" + self.instance, + self.session_maker, + "statistics_meta", + "ix_statistics_meta_statistic_id", ) except DatabaseError: # There may be duplicated statistics_meta entries, delete duplicates @@ -1417,7 +1517,10 @@ class _SchemaVersion29Migrator(_SchemaVersionMigrator, target_version=29): with session_scope(session=self.session_maker()) as session: delete_statistics_meta_duplicates(self.instance, session) _create_index( - self.session_maker, "statistics_meta", "ix_statistics_meta_statistic_id" + self.instance, + self.session_maker, + "statistics_meta", + "ix_statistics_meta_statistic_id", ) @@ -1451,14 +1554,24 @@ class _SchemaVersion31Migrator(_SchemaVersionMigrator, target_version=31): f"last_changed_ts {self.column_types.timestamp_type}", ], ) - _create_index(self.session_maker, "events", "ix_events_time_fired_ts") _create_index( - self.session_maker, "events", "ix_events_event_type_time_fired_ts" + self.instance, self.session_maker, "events", "ix_events_time_fired_ts" ) _create_index( - self.session_maker, "states", "ix_states_entity_id_last_updated_ts" + self.instance, + self.session_maker, + "events", + "ix_events_event_type_time_fired_ts", + ) + _create_index( + self.instance, + self.session_maker, + "states", + "ix_states_entity_id_last_updated_ts", + ) + _create_index( + self.instance, self.session_maker, "states", "ix_states_last_updated_ts" ) - _create_index(self.session_maker, "states", "ix_states_last_updated_ts") _migrate_columns_to_timestamp(self.instance, self.session_maker, self.engine) @@ -1516,16 +1629,23 @@ class _SchemaVersion34Migrator(_SchemaVersionMigrator, target_version=34): f"last_reset_ts {self.column_types.timestamp_type}", ], ) - _create_index(self.session_maker, "statistics", "ix_statistics_start_ts") _create_index( - self.session_maker, "statistics", "ix_statistics_statistic_id_start_ts" + self.instance, self.session_maker, "statistics", "ix_statistics_start_ts" ) _create_index( + self.instance, + self.session_maker, + "statistics", + "ix_statistics_statistic_id_start_ts", + ) + _create_index( + self.instance, self.session_maker, "statistics_short_term", "ix_statistics_short_term_start_ts", ) _create_index( + self.instance, self.session_maker, "statistics_short_term", "ix_statistics_short_term_statistic_id_start_ts", @@ -1575,8 +1695,12 @@ class _SchemaVersion36Migrator(_SchemaVersionMigrator, target_version=36): f"context_parent_id_bin {self.column_types.context_bin_type}", ], ) - _create_index(self.session_maker, "events", "ix_events_context_id_bin") - _create_index(self.session_maker, "states", "ix_states_context_id_bin") + _create_index( + self.instance, self.session_maker, "events", "ix_events_context_id_bin" + ) + _create_index( + self.instance, self.session_maker, "states", "ix_states_context_id_bin" + ) class _SchemaVersion37Migrator(_SchemaVersionMigrator, target_version=37): @@ -1587,10 +1711,15 @@ class _SchemaVersion37Migrator(_SchemaVersionMigrator, target_version=37): "events", [f"event_type_id {self.column_types.big_int_type}"], ) - _create_index(self.session_maker, "events", "ix_events_event_type_id") + _create_index( + self.instance, self.session_maker, "events", "ix_events_event_type_id" + ) _drop_index(self.session_maker, "events", "ix_events_event_type_time_fired_ts") _create_index( - self.session_maker, "events", "ix_events_event_type_id_time_fired_ts" + self.instance, + self.session_maker, + "events", + "ix_events_event_type_id_time_fired_ts", ) @@ -1602,9 +1731,14 @@ class _SchemaVersion38Migrator(_SchemaVersionMigrator, target_version=38): "states", [f"metadata_id {self.column_types.big_int_type}"], ) - _create_index(self.session_maker, "states", "ix_states_metadata_id") _create_index( - self.session_maker, "states", "ix_states_metadata_id_last_updated_ts" + self.instance, self.session_maker, "states", "ix_states_metadata_id" + ) + _create_index( + self.instance, + self.session_maker, + "states", + "ix_states_metadata_id_last_updated_ts", ) @@ -1688,8 +1822,15 @@ class _SchemaVersion40Migrator(_SchemaVersionMigrator, target_version=40): class _SchemaVersion41Migrator(_SchemaVersionMigrator, target_version=41): def _apply_update(self) -> None: """Version specific update method.""" - _create_index(self.session_maker, "event_types", "ix_event_types_event_type") - _create_index(self.session_maker, "states_meta", "ix_states_meta_entity_id") + _create_index( + self.instance, + self.session_maker, + "event_types", + "ix_event_types_event_type", + ) + _create_index( + self.instance, self.session_maker, "states_meta", "ix_states_meta_entity_id" + ) class _SchemaVersion42Migrator(_SchemaVersionMigrator, target_version=42): @@ -1835,6 +1976,17 @@ class _SchemaVersion47Migrator(_SchemaVersionMigrator, target_version=47): ) +class _SchemaVersion48Migrator(_SchemaVersionMigrator, target_version=48): + def _apply_update(self) -> None: + """Version specific update method.""" + # https://github.com/home-assistant/core/issues/134002 + # If the system has unmigrated states rows, we need to + # ensure they are migrated now so the new optimized + # queries can be used. For most systems, this should + # be very fast and nothing will be migrated. + _migrate_columns_to_timestamp(self.instance, self.session_maker, self.engine) + + def _migrate_statistics_columns_to_timestamp_removing_duplicates( hass: HomeAssistant, instance: Recorder, @@ -1921,10 +2073,7 @@ def _wipe_old_string_time_columns( session.execute(text("UPDATE events set time_fired=NULL LIMIT 100000;")) session.commit() session.execute( - text( - "UPDATE states set last_updated=NULL, last_changed=NULL " - " LIMIT 100000;" - ) + text("UPDATE states set last_updated=NULL, last_changed=NULL LIMIT 100000;") ) session.commit() elif engine.dialect.name == SupportedDialect.POSTGRESQL: @@ -1968,7 +2117,8 @@ def _migrate_columns_to_timestamp( connection.execute( text( 'UPDATE events set time_fired_ts=strftime("%s",time_fired) + ' - "cast(substr(time_fired,-7) AS FLOAT);" + "cast(substr(time_fired,-7) AS FLOAT) " + "WHERE time_fired_ts is NULL;" ) ) connection.execute( @@ -1976,7 +2126,8 @@ def _migrate_columns_to_timestamp( 'UPDATE states set last_updated_ts=strftime("%s",last_updated) + ' "cast(substr(last_updated,-7) AS FLOAT), " 'last_changed_ts=strftime("%s",last_changed) + ' - "cast(substr(last_changed,-7) AS FLOAT);" + "cast(substr(last_changed,-7) AS FLOAT) " + " WHERE last_updated_ts is NULL;" ) ) elif engine.dialect.name == SupportedDialect.MYSQL: @@ -1996,7 +2147,7 @@ def _migrate_columns_to_timestamp( ) ) result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2027,7 +2178,7 @@ def _migrate_columns_to_timestamp( ) ) result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2126,7 +2277,7 @@ def _migrate_statistics_columns_to_timestamp( # updated all rows in the table until the rowcount is 0 for table in STATISTICS_TABLES: result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2148,7 +2299,7 @@ def _migrate_statistics_columns_to_timestamp( # updated all rows in the table until the rowcount is 0 for table in STATISTICS_TABLES: result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2233,7 +2384,7 @@ def initialize_database(session_maker: Callable[[], Session]) -> bool: """Initialize a new database.""" try: with session_scope(session=session_maker(), read_only=True) as session: - if _get_schema_version(session) is not None: + if _get_current_schema_version(session) is not None: return True with session_scope(session=session_maker()) as session: @@ -2276,30 +2427,42 @@ class DataMigrationStatus: class BaseMigration(ABC): """Base class for migrations.""" - index_to_drop: tuple[str, str] | None = None - required_schema_version = 0 + index_to_drop: tuple[str, str, type[DeclarativeBase]] | None = None + required_schema_version = 0 # Schema version required to run migration queries + max_initial_schema_version: int # Skip migration if db created after this version migration_version = 1 migration_id: str - def __init__(self, schema_version: int, migration_changes: dict[str, int]) -> None: - """Initialize a new BaseRunTimeMigration.""" - self.schema_version = schema_version + def __init__( + self, + *, + initial_schema_version: int, + start_schema_version: int, + migration_changes: dict[str, int], + ) -> None: + """Initialize a new BaseRunTimeMigration. + + :param initial_schema_version: The schema version the database was created with. + :param start_schema_version: The schema version when starting the migration. + """ + self.initial_schema_version = initial_schema_version + self.start_schema_version = start_schema_version self.migration_changes = migration_changes @abstractmethod - def migrate_data(self, instance: Recorder) -> bool: + def migrate_data(self, instance: Recorder, /) -> bool: """Migrate some data, return True if migration is completed.""" def _migrate_data(self, instance: Recorder) -> bool: """Migrate some data, returns True if migration is completed.""" status = self.migrate_data_impl(instance) if status.migration_done: - if self.index_to_drop is not None: - table, index = self.index_to_drop - _drop_index(instance.get_session, table, index) with session_scope(session=instance.get_session()) as session: self.migration_done(instance, session) _mark_migration_done(session, self.__class__) + if self.index_to_drop is not None: + table, index, _ = self.index_to_drop + _drop_index(instance.get_session, table, index) return not status.needs_migrate @abstractmethod @@ -2324,26 +2487,62 @@ class BaseMigration(ABC): mark the migration as done in the database if its not already marked as done. """ - if self.schema_version < self.required_schema_version: + if self.initial_schema_version > self.max_initial_schema_version: + _LOGGER.debug( + "Data migration '%s' not needed, database created with version %s " + "after migrator was added", + self.migration_id, + self.initial_schema_version, + ) + return False + if self.start_schema_version < self.required_schema_version: # Schema is too old, we must have to migrate + _LOGGER.info( + "Data migration '%s' needed, schema too old", self.migration_id + ) + return True + has_needed_index = self._has_needed_index(session) + if has_needed_index is True: + # The index to be removed by the migration still exists + _LOGGER.info( + "Data migration '%s' needed, index to drop still exists", + self.migration_id, + ) return True if self.migration_changes.get(self.migration_id, -1) >= self.migration_version: # The migration changes table indicates that the migration has been done + _LOGGER.debug( + "Data migration '%s' not needed, already completed", self.migration_id + ) return False - # We do not know if the migration is done from the - # migration changes table so we must check the index and data - # This is the slow path - if ( - self.index_to_drop is not None - and get_index_by_name(session, self.index_to_drop[0], self.index_to_drop[1]) - is not None - ): + if has_needed_index is False: + # The index to be removed by the migration does not exist, but the migration + # changes table indicates that the migration has not been done + _LOGGER.info( + "Data migration '%s' needed, index to drop does not exist", + self.migration_id, + ) return True + # We do not know if the migration is done from the + # migration changes table or the index so we must check the data + # This is the slow path needs_migrate = self.needs_migrate_impl(instance, session) if needs_migrate.migration_done: _mark_migration_done(session, self.__class__) + _LOGGER.info( + "Data migration '%s' needed: %s", + self.migration_id, + needs_migrate.needs_migrate, + ) return needs_migrate.needs_migrate + def _has_needed_index(self, session: Session) -> bool | None: + """Check if the index needed by the migration exists.""" + if self.index_to_drop is None: + return None + table_name, index_name, _ = self.index_to_drop + return get_index_by_name(session, table_name, index_name) is not None + class BaseOffLineMigration(BaseMigration): """Base class for off line migrations.""" @@ -2354,16 +2553,43 @@ class BaseOffLineMigration(BaseMigration): """Migrate all data.""" with session_scope(session=session_maker()) as session: if not self.needs_migrate(instance, session): + _LOGGER.debug("Migration not needed for '%s'", self.migration_id) self.migration_done(instance, session) return + self._ensure_index_exists(instance) + _LOGGER.warning( + "The database is about to do data migration step '%s', %s", + self.migration_id, + MIGRATION_NOTE_OFFLINE, + ) while not self.migrate_data(instance): pass + _LOGGER.warning("Data migration step '%s' completed", self.migration_id) @database_job_retry_wrapper_method("migrate data", 10) def migrate_data(self, instance: Recorder) -> bool: """Migrate some data, returns True if migration is completed.""" return self._migrate_data(instance) + def _ensure_index_exists(self, instance: Recorder) -> None: + """Ensure the index needed by the migration exists.""" + if not self.index_to_drop: + return + table_name, index_name, base = self.index_to_drop + with session_scope(session=instance.get_session()) as session: + if get_index_by_name(session, table_name, index_name) is not None: + return + _LOGGER.warning( + ( + "Data migration step '%s' needs index `%s` on table `%s`, but " + "it does not exist and will be added now" + ), + self.migration_id, + index_name, + table_name, + ) + _create_index(instance, instance.get_session, table_name, index_name, base=base) + class BaseRunTimeMigration(BaseMigration): """Base class for run time migrations.""" @@ -2404,9 +2630,10 @@ class StatesContextIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): """Migration to migrate states context_ids to binary format.""" required_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION + max_initial_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION - 1 migration_id = "state_context_id_as_binary" migration_version = 2 - index_to_drop = ("states", "ix_states_context_id") + index_to_drop = ("states", "ix_states_context_id", LegacyBase) def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate states context_ids to use binary format, return True if completed.""" @@ -2447,9 +2674,10 @@ class EventsContextIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): """Migration to migrate events context_ids to binary format.""" required_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION + max_initial_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION - 1 migration_id = "event_context_id_as_binary" migration_version = 2 - index_to_drop = ("events", "ix_events_context_id") + index_to_drop = ("events", "ix_events_context_id", LegacyBase) def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate events context_ids to use binary format, return True if completed.""" @@ -2486,15 +2714,12 @@ class EventsContextIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): return has_events_context_ids_to_migrate() -class EventTypeIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration): +class EventTypeIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): """Migration to migrate event_type to event_type_ids.""" required_schema_version = EVENT_TYPE_IDS_SCHEMA_VERSION + max_initial_schema_version = EVENT_TYPE_IDS_SCHEMA_VERSION - 1 migration_id = "event_type_id_migration" - task = CommitBeforeMigrationTask - # We have to commit before to make sure there are - # no new pending event_types about to be added to - # the db since this happens live def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate event_type to event_type_ids, return True if completed.""" @@ -2527,9 +2752,9 @@ class EventTypeIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration): for db_event_type in missing_db_event_types: # We cannot add the assigned ids to the event_type_manager # because the commit could get rolled back - assert ( - db_event_type.event_type is not None - ), "event_type should never be None" + assert db_event_type.event_type is not None, ( + "event_type should never be None" + ) event_type_to_id[db_event_type.event_type] = ( db_event_type.event_type_id ) @@ -2554,25 +2779,17 @@ class EventTypeIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration): _LOGGER.debug("Migrating event_types done=%s", is_done) return DataMigrationStatus(needs_migrate=not is_done, migration_done=is_done) - def migration_done(self, instance: Recorder, session: Session) -> None: - """Will be called after migrate returns True.""" - _LOGGER.debug("Activating event_types manager as all data is migrated") - instance.event_type_manager.active = True - def needs_migrate_query(self) -> StatementLambdaElement: """Check if the data is migrated.""" return has_event_type_to_migrate() -class EntityIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration): +class EntityIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): """Migration to migrate entity_ids to states_meta.""" required_schema_version = STATES_META_SCHEMA_VERSION + max_initial_schema_version = STATES_META_SCHEMA_VERSION - 1 migration_id = "entity_id_migration" - task = CommitBeforeMigrationTask - # We have to commit before to make sure there are - # no new pending states_meta about to be added to - # the db since this happens live def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate entity_ids to states_meta, return True if completed. @@ -2613,9 +2830,9 @@ class EntityIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration): for db_states_metadata in missing_states_metadata: # We cannot add the assigned ids to the event_type_manager # because the commit could get rolled back - assert ( - db_states_metadata.entity_id is not None - ), "entity_id should never be None" + assert db_states_metadata.entity_id is not None, ( + "entity_id should never be None" + ) entity_id_to_metadata_id[db_states_metadata.entity_id] = ( db_states_metadata.metadata_id ) @@ -2642,18 +2859,6 @@ class EntityIDMigration(BaseMigrationWithQuery, BaseRunTimeMigration): _LOGGER.debug("Migrating entity_ids done=%s", is_done) return DataMigrationStatus(needs_migrate=not is_done, migration_done=is_done) - def migration_done(self, instance: Recorder, session: Session) -> None: - """Will be called after migrate returns True.""" - # The migration has finished, now we start the post migration - # to remove the old entity_id data from the states table - # at this point we can also start using the StatesMeta table - # so we set active to True - _LOGGER.debug("Activating states_meta manager as all data is migrated") - instance.states_meta_manager.active = True - with contextlib.suppress(SQLAlchemyError): - migrate = EntityIDPostMigration(self.schema_version, self.migration_changes) - migrate.queue_migration(instance, session) - def needs_migrate_query(self) -> StatementLambdaElement: """Check if the data is migrated.""" return has_entity_ids_to_migrate() @@ -2663,6 +2868,7 @@ class EventIDPostMigration(BaseRunTimeMigration): """Migration to remove old event_id index from states.""" migration_id = "event_id_post_migration" + max_initial_schema_version = LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION - 1 task = MigrationTask migration_version = 2 @@ -2731,7 +2937,7 @@ class EventIDPostMigration(BaseRunTimeMigration): self, instance: Recorder, session: Session ) -> DataMigrationStatus: """Return if the migration needs to run.""" - if self.schema_version <= LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION: + if self.start_schema_version <= LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION: return DataMigrationStatus(needs_migrate=False, migration_done=False) if get_index_by_name( session, TABLE_STATES, LEGACY_STATES_EVENT_ID_INDEX @@ -2741,12 +2947,19 @@ class EventIDPostMigration(BaseRunTimeMigration): return DataMigrationStatus(needs_migrate=False, migration_done=True) -class EntityIDPostMigration(BaseMigrationWithQuery, BaseRunTimeMigration): - """Migration to remove old entity_id strings from states.""" +class EntityIDPostMigration(BaseMigrationWithQuery, BaseOffLineMigration): + """Migration to remove old entity_id strings from states. + + Introduced in HA Core 2023.4 by PR #89557. + """ migration_id = "entity_id_post_migration" - task = MigrationTask - index_to_drop = (TABLE_STATES, LEGACY_STATES_ENTITY_ID_LAST_UPDATED_INDEX) + max_initial_schema_version = STATES_META_SCHEMA_VERSION - 1 + index_to_drop = ( + TABLE_STATES, + LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX, + LegacyBase, + ) def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate some data, returns True if migration is completed.""" @@ -2758,15 +2971,16 @@ class EntityIDPostMigration(BaseMigrationWithQuery, BaseRunTimeMigration): return has_used_states_entity_ids() -NON_LIVE_DATA_MIGRATORS = ( - StatesContextIDMigration, # Introduced in HA Core 2023.4 - EventsContextIDMigration, # Introduced in HA Core 2023.4 +NON_LIVE_DATA_MIGRATORS: tuple[type[BaseOffLineMigration], ...] = ( + StatesContextIDMigration, # Introduced in HA Core 2023.4 by PR #88942 + EventsContextIDMigration, # Introduced in HA Core 2023.4 by PR #88942 + EventTypeIDMigration, # Introduced in HA Core 2023.4 by PR #89465 + EntityIDMigration, # Introduced in HA Core 2023.4 by PR #89557 + EntityIDPostMigration, # Introduced in HA Core 2023.4 by PR #89557 ) -LIVE_DATA_MIGRATORS = ( - EventTypeIDMigration, - EntityIDMigration, - EventIDPostMigration, +LIVE_DATA_MIGRATORS: tuple[type[BaseRunTimeMigration], ...] = ( + EventIDPostMigration, # Introduced in HA Core 2023.4 by PR #89901 ) diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py index 94c5a7cc027..b86fd299793 100644 --- a/homeassistant/components/recorder/models/database.py +++ b/homeassistant/components/recorder/models/database.py @@ -32,4 +32,8 @@ class DatabaseOptimizer: # # https://jira.mariadb.org/browse/MDEV-25020 # + # PostgreSQL does not support a skip/loose index scan so its + # also slow for large distinct queries: + # https://wiki.postgresql.org/wiki/Loose_indexscan + # https://github.com/home-assistant/core/issues/126084 slow_range_in_select: bool diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py index 21a8a39ba0f..b5e67ff050b 100644 --- a/homeassistant/components/recorder/models/legacy.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -24,12 +24,12 @@ class LegacyLazyState(State): """A lazy version of core State after schema 31.""" __slots__ = [ - "_row", "_attributes", - "_last_changed_ts", - "_last_updated_ts", - "_last_reported_ts", "_context", + "_last_changed_ts", + "_last_reported_ts", + "_last_updated_ts", + "_row", "attr_cache", ] @@ -46,7 +46,7 @@ class LegacyLazyState(State): self.state = self._row.state or "" self._attributes: dict[str, Any] | None = None self._last_updated_ts: float | None = self._row.last_updated_ts or ( - dt_util.utc_to_timestamp(start_time) if start_time else None + start_time.timestamp() if start_time else None ) self._last_changed_ts: float | None = ( self._row.last_changed_ts or self._last_updated_ts @@ -146,7 +146,7 @@ def legacy_row_to_compressed_state( COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row_legacy(row, attr_cache), } if start_time: - comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time) + comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp() else: row_last_updated_ts: float = row.last_updated_ts comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 89281a85c15..1ceaee633ae 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -6,7 +6,7 @@ from datetime import datetime import logging from typing import TYPE_CHECKING, Any -from propcache import cached_property +from propcache.api import cached_property from sqlalchemy.engine.row import Row from homeassistant.const import ( @@ -58,8 +58,8 @@ class LazyState(State): self.attr_cache = attr_cache self.context = EMPTY_CONTEXT - @cached_property # type: ignore[override] - def attributes(self) -> dict[str, Any]: + @cached_property + def attributes(self) -> dict[str, Any]: # type: ignore[override] """State attributes.""" return decode_attributes_from_source( getattr(self._row, "attributes", None), self.attr_cache @@ -96,6 +96,29 @@ class LazyState(State): assert self._last_updated_ts is not None return dt_util.utc_from_timestamp(self._last_updated_ts) + @cached_property + def last_updated_timestamp(self) -> float: # type: ignore[override] + """Last updated timestamp.""" + if TYPE_CHECKING: + assert self._last_updated_ts is not None + return self._last_updated_ts + + @cached_property + def last_changed_timestamp(self) -> float: # type: ignore[override] + """Last changed timestamp.""" + ts = self._last_changed_ts or self._last_updated_ts + if TYPE_CHECKING: + assert ts is not None + return ts + + @cached_property + def last_reported_timestamp(self) -> float: # type: ignore[override] + """Last reported timestamp.""" + ts = self._last_reported_ts or self._last_updated_ts + if TYPE_CHECKING: + assert ts is not None + return ts + def as_dict(self) -> dict[str, Any]: # type: ignore[override] """Return a dict representation of the LazyState. diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index fc2a8ccb1cc..30e277d7c0a 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -47,9 +47,9 @@ class RecorderPool(SingletonThreadPool, NullPool): ) -> None: """Create the pool.""" kw["pool_size"] = POOL_SIZE - assert ( - recorder_and_worker_thread_ids is not None - ), "recorder_and_worker_thread_ids is required" + assert recorder_and_worker_thread_ids is not None, ( + "recorder_and_worker_thread_ids is required" + ) self.recorder_and_worker_thread_ids = recorder_and_worker_thread_ids SingletonThreadPool.__init__(self, creator, **kw) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 329f48e5455..ea2b93efba7 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime -from itertools import zip_longest import logging import time from typing import TYPE_CHECKING @@ -116,13 +115,15 @@ def purge_old_data( # This purge cycle is finished, clean up old event types and # recorder runs - if instance.event_type_manager.active: - _purge_old_event_types(instance, session) + _purge_old_event_types(instance, session) if instance.states_meta_manager.active: _purge_old_entity_ids(instance, session) _purge_old_recorder_runs(instance, session, purge_before) + with session_scope(session=instance.get_session(), read_only=True) as session: + instance.recorder_runs_manager.load_from_db(session) + instance.states_manager.load_from_db(session) if repack: repack_database(instance) return True @@ -295,64 +296,18 @@ def _select_unused_attributes_ids( seen_ids: set[int] = set() if not database_engine.optimizer.slow_range_in_select: - # + query = attributes_ids_exist_in_states_with_fast_in_distinct # SQLite has a superior query optimizer for the distinct query below as it uses # the covering index without having to examine the rows directly for both of the # queries below. - # - # We use the distinct query for SQLite since the query in the other branch can - # generate more than 500 unions which SQLite does not support. - # - # How MariaDB's query optimizer handles this query: - # > explain select distinct attributes_id from states where attributes_id in - # (136723); - # ...Using index - # - for attributes_ids_chunk in chunked_or_all( - attributes_ids, instance.max_bind_vars - ): - seen_ids.update( - state[0] - for state in session.execute( - attributes_ids_exist_in_states_with_fast_in_distinct( - attributes_ids_chunk - ) - ).all() - ) else: - # + query = attributes_ids_exist_in_states # This branch is for DBMS that cannot optimize the distinct query well and has # to examine all the rows that match. - # - # This branch uses a union of simple queries, as each query is optimized away - # as the answer to the query can be found in the index. - # - # The below query works for SQLite as long as there are no more than 500 - # attributes_id to be selected. We currently do not have MySQL or PostgreSQL - # servers running in the test suite; we test this path using SQLite when there - # are less than 500 attributes_id. - # - # How MariaDB's query optimizer handles this query: - # > explain select min(attributes_id) from states where attributes_id = 136723; - # ...Select tables optimized away - # - # We used to generate a query based on how many attribute_ids to find but - # that meant sqlalchemy Transparent SQL Compilation Caching was working against - # us by cached up to max_bind_vars different statements which could be - # up to 500MB for large database due to the complexity of the ORM objects. - # - # We now break the query into groups of 100 and use a lambda_stmt to ensure - # that the query is only cached once. - # - groups = [iter(attributes_ids)] * 100 - for attr_ids in zip_longest(*groups, fillvalue=None): - seen_ids |= { - attrs_id[0] - for attrs_id in session.execute( - attributes_ids_exist_in_states(*attr_ids) # type: ignore[arg-type] - ).all() - if attrs_id[0] is not None - } + for attributes_ids_chunk in chunked_or_all(attributes_ids, instance.max_bind_vars): + seen_ids.update( + state[0] for state in session.execute(query(attributes_ids_chunk)).all() + ) to_remove = attributes_ids - seen_ids _LOGGER.debug( "Selected %s shared attributes to remove", @@ -389,23 +344,13 @@ def _select_unused_event_data_ids( # See _select_unused_attributes_ids for why this function # branches for non-sqlite databases. if not database_engine.optimizer.slow_range_in_select: - for data_ids_chunk in chunked_or_all(data_ids, instance.max_bind_vars): - seen_ids.update( - state[0] - for state in session.execute( - data_ids_exist_in_events_with_fast_in_distinct(data_ids_chunk) - ).all() - ) + query = data_ids_exist_in_events_with_fast_in_distinct else: - groups = [iter(data_ids)] * 100 - for data_ids_group in zip_longest(*groups, fillvalue=None): - seen_ids |= { - data_id[0] - for data_id in session.execute( - data_ids_exist_in_events(*data_ids_group) # type: ignore[arg-type] - ).all() - if data_id[0] is not None - } + query = data_ids_exist_in_events + for data_ids_chunk in chunked_or_all(data_ids, instance.max_bind_vars): + seen_ids.update( + state[0] for state in session.execute(query(data_ids_chunk)).all() + ) to_remove = data_ids - seen_ids _LOGGER.debug("Selected %s shared event data to remove", len(to_remove)) return to_remove diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 2e4b588a0b0..eb681f86702 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime -from sqlalchemy import delete, distinct, func, lambda_stmt, select, union_all, update +from sqlalchemy import and_, delete, distinct, func, lambda_stmt, select, update from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select @@ -76,11 +76,6 @@ def find_states_metadata_ids(entity_ids: Iterable[str]) -> StatementLambdaElemen ) -def _state_attrs_exist(attr: int | None) -> Select: - """Check if a state attributes id exists in the states table.""" - return select(func.min(States.attributes_id)).where(States.attributes_id == attr) - - def attributes_ids_exist_in_states_with_fast_in_distinct( attributes_ids: Iterable[int], ) -> StatementLambdaElement: @@ -93,214 +88,35 @@ def attributes_ids_exist_in_states_with_fast_in_distinct( def attributes_ids_exist_in_states( - attr1: int, - attr2: int | None, - attr3: int | None, - attr4: int | None, - attr5: int | None, - attr6: int | None, - attr7: int | None, - attr8: int | None, - attr9: int | None, - attr10: int | None, - attr11: int | None, - attr12: int | None, - attr13: int | None, - attr14: int | None, - attr15: int | None, - attr16: int | None, - attr17: int | None, - attr18: int | None, - attr19: int | None, - attr20: int | None, - attr21: int | None, - attr22: int | None, - attr23: int | None, - attr24: int | None, - attr25: int | None, - attr26: int | None, - attr27: int | None, - attr28: int | None, - attr29: int | None, - attr30: int | None, - attr31: int | None, - attr32: int | None, - attr33: int | None, - attr34: int | None, - attr35: int | None, - attr36: int | None, - attr37: int | None, - attr38: int | None, - attr39: int | None, - attr40: int | None, - attr41: int | None, - attr42: int | None, - attr43: int | None, - attr44: int | None, - attr45: int | None, - attr46: int | None, - attr47: int | None, - attr48: int | None, - attr49: int | None, - attr50: int | None, - attr51: int | None, - attr52: int | None, - attr53: int | None, - attr54: int | None, - attr55: int | None, - attr56: int | None, - attr57: int | None, - attr58: int | None, - attr59: int | None, - attr60: int | None, - attr61: int | None, - attr62: int | None, - attr63: int | None, - attr64: int | None, - attr65: int | None, - attr66: int | None, - attr67: int | None, - attr68: int | None, - attr69: int | None, - attr70: int | None, - attr71: int | None, - attr72: int | None, - attr73: int | None, - attr74: int | None, - attr75: int | None, - attr76: int | None, - attr77: int | None, - attr78: int | None, - attr79: int | None, - attr80: int | None, - attr81: int | None, - attr82: int | None, - attr83: int | None, - attr84: int | None, - attr85: int | None, - attr86: int | None, - attr87: int | None, - attr88: int | None, - attr89: int | None, - attr90: int | None, - attr91: int | None, - attr92: int | None, - attr93: int | None, - attr94: int | None, - attr95: int | None, - attr96: int | None, - attr97: int | None, - attr98: int | None, - attr99: int | None, - attr100: int | None, + attributes_ids: Iterable[int], ) -> StatementLambdaElement: - """Generate the find attributes select only once. + """Find attributes ids that exist in the states table. - https://docs.sqlalchemy.org/en/14/core/connections.html#quick-guidelines-for-lambdas + PostgreSQL does not support skip/loose index scan + https://wiki.postgresql.org/wiki/Loose_indexscan + + To avoid using distinct, we use a subquery to get the latest last_updated_ts + for each attributes_id. This is then used to filter out the attributes_id + that no longer exist in the States table. + + This query is fast for older MariaDB, older MySQL, and PostgreSQL. """ return lambda_stmt( - lambda: union_all( - _state_attrs_exist(attr1), - _state_attrs_exist(attr2), - _state_attrs_exist(attr3), - _state_attrs_exist(attr4), - _state_attrs_exist(attr5), - _state_attrs_exist(attr6), - _state_attrs_exist(attr7), - _state_attrs_exist(attr8), - _state_attrs_exist(attr9), - _state_attrs_exist(attr10), - _state_attrs_exist(attr11), - _state_attrs_exist(attr12), - _state_attrs_exist(attr13), - _state_attrs_exist(attr14), - _state_attrs_exist(attr15), - _state_attrs_exist(attr16), - _state_attrs_exist(attr17), - _state_attrs_exist(attr18), - _state_attrs_exist(attr19), - _state_attrs_exist(attr20), - _state_attrs_exist(attr21), - _state_attrs_exist(attr22), - _state_attrs_exist(attr23), - _state_attrs_exist(attr24), - _state_attrs_exist(attr25), - _state_attrs_exist(attr26), - _state_attrs_exist(attr27), - _state_attrs_exist(attr28), - _state_attrs_exist(attr29), - _state_attrs_exist(attr30), - _state_attrs_exist(attr31), - _state_attrs_exist(attr32), - _state_attrs_exist(attr33), - _state_attrs_exist(attr34), - _state_attrs_exist(attr35), - _state_attrs_exist(attr36), - _state_attrs_exist(attr37), - _state_attrs_exist(attr38), - _state_attrs_exist(attr39), - _state_attrs_exist(attr40), - _state_attrs_exist(attr41), - _state_attrs_exist(attr42), - _state_attrs_exist(attr43), - _state_attrs_exist(attr44), - _state_attrs_exist(attr45), - _state_attrs_exist(attr46), - _state_attrs_exist(attr47), - _state_attrs_exist(attr48), - _state_attrs_exist(attr49), - _state_attrs_exist(attr50), - _state_attrs_exist(attr51), - _state_attrs_exist(attr52), - _state_attrs_exist(attr53), - _state_attrs_exist(attr54), - _state_attrs_exist(attr55), - _state_attrs_exist(attr56), - _state_attrs_exist(attr57), - _state_attrs_exist(attr58), - _state_attrs_exist(attr59), - _state_attrs_exist(attr60), - _state_attrs_exist(attr61), - _state_attrs_exist(attr62), - _state_attrs_exist(attr63), - _state_attrs_exist(attr64), - _state_attrs_exist(attr65), - _state_attrs_exist(attr66), - _state_attrs_exist(attr67), - _state_attrs_exist(attr68), - _state_attrs_exist(attr69), - _state_attrs_exist(attr70), - _state_attrs_exist(attr71), - _state_attrs_exist(attr72), - _state_attrs_exist(attr73), - _state_attrs_exist(attr74), - _state_attrs_exist(attr75), - _state_attrs_exist(attr76), - _state_attrs_exist(attr77), - _state_attrs_exist(attr78), - _state_attrs_exist(attr79), - _state_attrs_exist(attr80), - _state_attrs_exist(attr81), - _state_attrs_exist(attr82), - _state_attrs_exist(attr83), - _state_attrs_exist(attr84), - _state_attrs_exist(attr85), - _state_attrs_exist(attr86), - _state_attrs_exist(attr87), - _state_attrs_exist(attr88), - _state_attrs_exist(attr89), - _state_attrs_exist(attr90), - _state_attrs_exist(attr91), - _state_attrs_exist(attr92), - _state_attrs_exist(attr93), - _state_attrs_exist(attr94), - _state_attrs_exist(attr95), - _state_attrs_exist(attr96), - _state_attrs_exist(attr97), - _state_attrs_exist(attr98), - _state_attrs_exist(attr99), - _state_attrs_exist(attr100), + lambda: select(StateAttributes.attributes_id) + .select_from(StateAttributes) + .join( + States, + and_( + States.attributes_id == StateAttributes.attributes_id, + States.last_updated_ts + == select(States.last_updated_ts) + .where(States.attributes_id == StateAttributes.attributes_id) + .limit(1) + .scalar_subquery() + .correlate(StateAttributes), + ), ) + .where(StateAttributes.attributes_id.in_(attributes_ids)) ) @@ -313,220 +129,36 @@ def data_ids_exist_in_events_with_fast_in_distinct( ) -def _event_data_id_exist(data_id: int | None) -> Select: - """Check if a event data id exists in the events table.""" - return select(func.min(Events.data_id)).where(Events.data_id == data_id) - - def data_ids_exist_in_events( - id1: int, - id2: int | None, - id3: int | None, - id4: int | None, - id5: int | None, - id6: int | None, - id7: int | None, - id8: int | None, - id9: int | None, - id10: int | None, - id11: int | None, - id12: int | None, - id13: int | None, - id14: int | None, - id15: int | None, - id16: int | None, - id17: int | None, - id18: int | None, - id19: int | None, - id20: int | None, - id21: int | None, - id22: int | None, - id23: int | None, - id24: int | None, - id25: int | None, - id26: int | None, - id27: int | None, - id28: int | None, - id29: int | None, - id30: int | None, - id31: int | None, - id32: int | None, - id33: int | None, - id34: int | None, - id35: int | None, - id36: int | None, - id37: int | None, - id38: int | None, - id39: int | None, - id40: int | None, - id41: int | None, - id42: int | None, - id43: int | None, - id44: int | None, - id45: int | None, - id46: int | None, - id47: int | None, - id48: int | None, - id49: int | None, - id50: int | None, - id51: int | None, - id52: int | None, - id53: int | None, - id54: int | None, - id55: int | None, - id56: int | None, - id57: int | None, - id58: int | None, - id59: int | None, - id60: int | None, - id61: int | None, - id62: int | None, - id63: int | None, - id64: int | None, - id65: int | None, - id66: int | None, - id67: int | None, - id68: int | None, - id69: int | None, - id70: int | None, - id71: int | None, - id72: int | None, - id73: int | None, - id74: int | None, - id75: int | None, - id76: int | None, - id77: int | None, - id78: int | None, - id79: int | None, - id80: int | None, - id81: int | None, - id82: int | None, - id83: int | None, - id84: int | None, - id85: int | None, - id86: int | None, - id87: int | None, - id88: int | None, - id89: int | None, - id90: int | None, - id91: int | None, - id92: int | None, - id93: int | None, - id94: int | None, - id95: int | None, - id96: int | None, - id97: int | None, - id98: int | None, - id99: int | None, - id100: int | None, + data_ids: Iterable[int], ) -> StatementLambdaElement: - """Generate the find event data select only once. + """Find data ids that exist in the events table. - https://docs.sqlalchemy.org/en/14/core/connections.html#quick-guidelines-for-lambdas + PostgreSQL does not support skip/loose index scan + https://wiki.postgresql.org/wiki/Loose_indexscan + + To avoid using distinct, we use a subquery to get the latest time_fired_ts + for each data_id. This is then used to filter out the data_id + that no longer exist in the Events table. + + This query is fast for older MariaDB, older MySQL, and PostgreSQL. """ return lambda_stmt( - lambda: union_all( - _event_data_id_exist(id1), - _event_data_id_exist(id2), - _event_data_id_exist(id3), - _event_data_id_exist(id4), - _event_data_id_exist(id5), - _event_data_id_exist(id6), - _event_data_id_exist(id7), - _event_data_id_exist(id8), - _event_data_id_exist(id9), - _event_data_id_exist(id10), - _event_data_id_exist(id11), - _event_data_id_exist(id12), - _event_data_id_exist(id13), - _event_data_id_exist(id14), - _event_data_id_exist(id15), - _event_data_id_exist(id16), - _event_data_id_exist(id17), - _event_data_id_exist(id18), - _event_data_id_exist(id19), - _event_data_id_exist(id20), - _event_data_id_exist(id21), - _event_data_id_exist(id22), - _event_data_id_exist(id23), - _event_data_id_exist(id24), - _event_data_id_exist(id25), - _event_data_id_exist(id26), - _event_data_id_exist(id27), - _event_data_id_exist(id28), - _event_data_id_exist(id29), - _event_data_id_exist(id30), - _event_data_id_exist(id31), - _event_data_id_exist(id32), - _event_data_id_exist(id33), - _event_data_id_exist(id34), - _event_data_id_exist(id35), - _event_data_id_exist(id36), - _event_data_id_exist(id37), - _event_data_id_exist(id38), - _event_data_id_exist(id39), - _event_data_id_exist(id40), - _event_data_id_exist(id41), - _event_data_id_exist(id42), - _event_data_id_exist(id43), - _event_data_id_exist(id44), - _event_data_id_exist(id45), - _event_data_id_exist(id46), - _event_data_id_exist(id47), - _event_data_id_exist(id48), - _event_data_id_exist(id49), - _event_data_id_exist(id50), - _event_data_id_exist(id51), - _event_data_id_exist(id52), - _event_data_id_exist(id53), - _event_data_id_exist(id54), - _event_data_id_exist(id55), - _event_data_id_exist(id56), - _event_data_id_exist(id57), - _event_data_id_exist(id58), - _event_data_id_exist(id59), - _event_data_id_exist(id60), - _event_data_id_exist(id61), - _event_data_id_exist(id62), - _event_data_id_exist(id63), - _event_data_id_exist(id64), - _event_data_id_exist(id65), - _event_data_id_exist(id66), - _event_data_id_exist(id67), - _event_data_id_exist(id68), - _event_data_id_exist(id69), - _event_data_id_exist(id70), - _event_data_id_exist(id71), - _event_data_id_exist(id72), - _event_data_id_exist(id73), - _event_data_id_exist(id74), - _event_data_id_exist(id75), - _event_data_id_exist(id76), - _event_data_id_exist(id77), - _event_data_id_exist(id78), - _event_data_id_exist(id79), - _event_data_id_exist(id80), - _event_data_id_exist(id81), - _event_data_id_exist(id82), - _event_data_id_exist(id83), - _event_data_id_exist(id84), - _event_data_id_exist(id85), - _event_data_id_exist(id86), - _event_data_id_exist(id87), - _event_data_id_exist(id88), - _event_data_id_exist(id89), - _event_data_id_exist(id90), - _event_data_id_exist(id91), - _event_data_id_exist(id92), - _event_data_id_exist(id93), - _event_data_id_exist(id94), - _event_data_id_exist(id95), - _event_data_id_exist(id96), - _event_data_id_exist(id97), - _event_data_id_exist(id98), - _event_data_id_exist(id99), - _event_data_id_exist(id100), + lambda: select(EventData.data_id) + .select_from(EventData) + .join( + Events, + and_( + Events.data_id == EventData.data_id, + Events.time_fired_ts + == select(Events.time_fired_ts) + .where(Events.data_id == EventData.data_id) + .limit(1) + .scalar_subquery() + .correlate(EventData), + ), ) + .where(EventData.data_id.in_(data_ids)) ) @@ -637,6 +269,15 @@ def find_states_to_purge( ) +def find_oldest_state() -> StatementLambdaElement: + """Find the last_updated_ts of the oldest state.""" + return lambda_stmt( + lambda: select(States.last_updated_ts) + .order_by(States.last_updated_ts.asc()) + .limit(1) + ) + + def find_short_term_statistics_to_purge( purge_before: datetime, max_bind_vars: int ) -> StatementLambdaElement: @@ -829,16 +470,33 @@ def get_migration_changes() -> StatementLambdaElement: def find_event_types_to_purge() -> StatementLambdaElement: - """Find event_type_ids to purge.""" + """Find event_type_ids to purge. + + PostgreSQL does not support skip/loose index scan + https://wiki.postgresql.org/wiki/Loose_indexscan + + To avoid using distinct, we use a subquery to get the latest time_fired_ts + for each event_type. This is then used to filter out the event_type_ids + that no longer exist in the Events table. + + This query is fast for SQLite, MariaDB, MySQL, and PostgreSQL. + """ return lambda_stmt( lambda: select(EventTypes.event_type_id, EventTypes.event_type).where( EventTypes.event_type_id.not_in( - select(EventTypes.event_type_id).join( - used_event_type_ids := select( - distinct(Events.event_type_id).label("used_event_type_id") - ).subquery(), - EventTypes.event_type_id - == used_event_type_ids.c.used_event_type_id, + select(EventTypes.event_type_id) + .select_from(EventTypes) + .join( + Events, + and_( + EventTypes.event_type_id == Events.event_type_id, + Events.time_fired_ts + == select(Events.time_fired_ts) + .where(Events.event_type_id == EventTypes.event_type_id) + .limit(1) + .scalar_subquery() + .correlate(EventTypes), + ), ) ) ) @@ -846,16 +504,33 @@ def find_event_types_to_purge() -> StatementLambdaElement: def find_entity_ids_to_purge() -> StatementLambdaElement: - """Find entity_ids to purge.""" + """Find metadata_ids for each entity_id to purge. + + PostgreSQL does not support skip/loose index scan + https://wiki.postgresql.org/wiki/Loose_indexscan + + To avoid using distinct, we use a subquery to get the latest last_updated_ts + for each entity_id. This is then used to filter out the metadata_ids + that no longer exist in the States table. + + This query is fast for SQLite, MariaDB, MySQL, and PostgreSQL. + """ return lambda_stmt( lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id).where( StatesMeta.metadata_id.not_in( - select(StatesMeta.metadata_id).join( - used_states_metadata_id := select( - distinct(States.metadata_id).label("used_states_metadata_id") - ).subquery(), - StatesMeta.metadata_id - == used_states_metadata_id.c.used_states_metadata_id, + select(StatesMeta.metadata_id) + .select_from(StatesMeta) + .join( + States, + and_( + StatesMeta.metadata_id == States.metadata_id, + States.last_updated_ts + == select(States.last_updated_ts) + .where(States.metadata_id == StatesMeta.metadata_id) + .limit(1) + .scalar_subquery() + .correlate(StatesMeta), + ), ) ) ) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 9f01fd0399c..8995f57ef30 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -11,6 +11,7 @@ from itertools import chain, groupby import logging from operator import itemgetter import re +from time import time as time_time from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text @@ -62,6 +63,7 @@ from .db_schema import ( STATISTICS_TABLES, Statistics, StatisticsBase, + StatisticsMeta, StatisticsRuns, StatisticsShortTerm, ) @@ -446,8 +448,9 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None: } # Insert compiled hourly statistics in the database + now_timestamp = time_time() session.add_all( - Statistics.from_stats_ts(metadata_id, summary_item) + Statistics.from_stats_ts(metadata_id, summary_item, now_timestamp) for metadata_id, summary_item in summary.items() ) @@ -578,6 +581,7 @@ def _compile_statistics( new_short_term_stats: list[StatisticsBase] = [] updated_metadata_ids: set[int] = set() + now_timestamp = time_time() # Insert collected statistics in the database for stats in platform_stats: modified_statistic_id, metadata_id = statistics_meta_manager.update_or_add( @@ -587,10 +591,7 @@ def _compile_statistics( modified_statistic_ids.add(modified_statistic_id) updated_metadata_ids.add(metadata_id) if new_stat := _insert_statistics( - session, - StatisticsShortTerm, - metadata_id, - stats["stat"], + session, StatisticsShortTerm, metadata_id, stats["stat"], now_timestamp ): new_short_term_stats.append(new_stat) @@ -666,10 +667,11 @@ def _insert_statistics( table: type[StatisticsBase], metadata_id: int, statistic: StatisticData, + now_timestamp: float, ) -> StatisticsBase | None: """Insert statistics in the database.""" try: - stat = table.from_stats(metadata_id, statistic) + stat = table.from_stats(metadata_id, statistic, now_timestamp) session.add(stat) except SQLAlchemyError: _LOGGER.exception( @@ -966,12 +968,10 @@ def _reduce_statistics( return result -def reduce_day_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_day_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same day and day start end.""" _lower_bound: float = 0 _upper_bound: float = 0 @@ -1015,12 +1015,10 @@ def _reduce_statistics_per_day( ) -def reduce_week_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_week_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same week and week start end.""" _lower_bound: float = 0 _upper_bound: float = 0 @@ -1073,12 +1071,10 @@ def _find_month_end_time(timestamp: datetime) -> datetime: ) -def reduce_month_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_month_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same month and month start end.""" _lower_bound: float = 0 _upper_bound: float = 0 @@ -2033,24 +2029,35 @@ def _generate_statistics_at_time_stmt( types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Create the statement for finding the statistics for a given time.""" + # This query is the result of significant research in + # https://github.com/home-assistant/core/issues/132865 + # A reverse index scan with a limit 1 is the fastest way to get the + # last start_time_ts before a specific point in time for all supported + # databases. Since all databases support this query as a join + # condition we can use it as a subquery to get the last start_time_ts + # before a specific point in time for all entities. stmt = _generate_select_columns_for_types_stmt(table, types) - stmt += lambda q: q.join( - ( - most_recent_statistic_ids := ( - select( - func.max(table.start_ts).label("max_start_ts"), - table.metadata_id.label("max_metadata_id"), + stmt += ( + lambda q: q.select_from(StatisticsMeta) + .join( + table, + and_( + table.start_ts + == ( + select(table.start_ts) + .where( + (StatisticsMeta.id == table.metadata_id) + & (table.start_ts < start_time_ts) + ) + .order_by(table.start_ts.desc()) + .limit(1) ) - .filter(table.start_ts < start_time_ts) - .filter(table.metadata_id.in_(metadata_ids)) - .group_by(table.metadata_id) - .subquery() - ) - ), - and_( - table.start_ts == most_recent_statistic_ids.c.max_start_ts, - table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, - ), + .scalar_subquery() + .correlate(StatisticsMeta), + table.metadata_id == StatisticsMeta.id, + ), + ) + .where(table.metadata_id.in_(metadata_ids)) ) return stmt @@ -2347,11 +2354,12 @@ def _import_statistics_with_session( _, metadata_id = statistics_meta_manager.update_or_add( session, metadata, old_metadata_dict ) + now_timestamp = time_time() for stat in statistics: if stat_id := _statistics_exists(session, table, metadata_id, stat["start"]): _update_statistics(session, table, stat_id, stat) else: - _insert_statistics(session, table, metadata_id, stat) + _insert_statistics(session, table, metadata_id, stat, now_timestamp) if table != StatisticsShortTerm: return True diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 2ded6be58d6..43c2ecdc14f 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -16,10 +16,6 @@ "backup_failed_out_of_resources": { "title": "Database backup failed due to lack of resources", "description": "The database backup stated at {start_time} failed due to lack of resources. The backup cannot be trusted and must be restarted. This can happen if the database is too large or if the system is under heavy load. Consider upgrading the system hardware or reducing the size of the database by decreasing the number of history days to keep or creating a filter." - }, - "sqlite_too_old": { - "title": "Update SQLite to {min_version} or later to continue using the recorder", - "description": "Support for version {server_version} of SQLite is ending; the minimum supported version is {min_version}. Please upgrade your database software." } }, "services": { diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index 16feaa19886..6923b792b8b 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -40,7 +40,7 @@ def _get_db_stats(instance: Recorder, database_name: str) -> dict[str, Any]: and (get_size := DIALECT_TO_GET_SIZE.get(dialect_name)) and (db_bytes := get_size(session, database_name)) ): - db_stats["estimated_db_size"] = f"{db_bytes/1024/1024:.2f} MiB" + db_stats["estimated_db_size"] = f"{db_bytes / 1024 / 1024:.2f} MiB" return db_stats diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 81bddce948d..266c970fe1f 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -28,8 +28,6 @@ CACHE_SIZE = 2048 class EventTypeManager(BaseLRUTableManager[EventTypes]): """Manage the EventTypes table.""" - active = False - def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" super().__init__(recorder, CACHE_SIZE) diff --git a/homeassistant/components/recorder/table_managers/recorder_runs.py b/homeassistant/components/recorder/table_managers/recorder_runs.py index b0b9818118b..4ca0aa18b88 100644 --- a/homeassistant/components/recorder/table_managers/recorder_runs.py +++ b/homeassistant/components/recorder/table_managers/recorder_runs.py @@ -2,8 +2,6 @@ from __future__ import annotations -import bisect -from dataclasses import dataclass from datetime import datetime from sqlalchemy.orm.session import Session @@ -11,34 +9,6 @@ from sqlalchemy.orm.session import Session import homeassistant.util.dt as dt_util from ..db_schema import RecorderRuns -from ..models import process_timestamp - - -def _find_recorder_run_for_start_time( - run_history: _RecorderRunsHistory, start: datetime -) -> RecorderRuns | None: - """Find the recorder run for a start time in _RecorderRunsHistory.""" - run_timestamps = run_history.run_timestamps - runs_by_timestamp = run_history.runs_by_timestamp - - # bisect_left tells us were we would insert - # a value in the list of runs after the start timestamp. - # - # The run before that (idx-1) is when the run started - # - # If idx is 0, history never ran before the start timestamp - # - if idx := bisect.bisect_left(run_timestamps, start.timestamp()): - return runs_by_timestamp[run_timestamps[idx - 1]] - return None - - -@dataclass(frozen=True) -class _RecorderRunsHistory: - """Bisectable history of RecorderRuns.""" - - run_timestamps: list[int] - runs_by_timestamp: dict[int, RecorderRuns] class RecorderRunsManager: @@ -48,7 +18,7 @@ class RecorderRunsManager: """Track recorder run history.""" self._recording_start = dt_util.utcnow() self._current_run_info: RecorderRuns | None = None - self._run_history = _RecorderRunsHistory([], {}) + self._first_run: RecorderRuns | None = None @property def recording_start(self) -> datetime: @@ -58,9 +28,7 @@ class RecorderRunsManager: @property def first(self) -> RecorderRuns: """Get the first run.""" - if runs_by_timestamp := self._run_history.runs_by_timestamp: - return next(iter(runs_by_timestamp.values())) - return self.current + return self._first_run or self.current @property def current(self) -> RecorderRuns: @@ -78,15 +46,6 @@ class RecorderRunsManager: """Return if a run is active.""" return self._current_run_info is not None - def get(self, start: datetime) -> RecorderRuns | None: - """Return the recorder run that started before or at start. - - If the first run started after the start, return None - """ - if start >= self.recording_start: - return self.current - return _find_recorder_run_for_start_time(self._run_history, start) - def start(self, session: Session) -> None: """Start a new run. @@ -122,31 +81,17 @@ class RecorderRunsManager: Must run in the recorder thread. """ - run_timestamps: list[int] = [] - runs_by_timestamp: dict[int, RecorderRuns] = {} - - for run in session.query(RecorderRuns).order_by(RecorderRuns.start.asc()).all(): + if ( + run := session.query(RecorderRuns) + .order_by(RecorderRuns.start.asc()) + .first() + ): session.expunge(run) - if run_dt := process_timestamp(run.start): - # Not sure if this is correct or runs_by_timestamp annotation should be changed - timestamp = int(run_dt.timestamp()) - run_timestamps.append(timestamp) - runs_by_timestamp[timestamp] = run - - # - # self._run_history is accessed in get() - # which is allowed to be called from any thread - # - # We use a dataclass to ensure that when we update - # run_timestamps and runs_by_timestamp - # are never out of sync with each other. - # - self._run_history = _RecorderRunsHistory(run_timestamps, runs_by_timestamp) + self._first_run = run def clear(self) -> None: """Clear the current run after ending it. Must run in the recorder thread. """ - if self._current_run_info: - self._current_run_info = None + self._current_run_info = None diff --git a/homeassistant/components/recorder/table_managers/states.py b/homeassistant/components/recorder/table_managers/states.py index d5cef759c54..fafcfa0ea61 100644 --- a/homeassistant/components/recorder/table_managers/states.py +++ b/homeassistant/components/recorder/table_managers/states.py @@ -2,7 +2,15 @@ from __future__ import annotations +from collections.abc import Sequence +from typing import Any, cast + +from sqlalchemy.engine.row import Row +from sqlalchemy.orm.session import Session + from ..db_schema import States +from ..queries import find_oldest_state +from ..util import execute_stmt_lambda_element class StatesManager: @@ -13,6 +21,12 @@ class StatesManager: self._pending: dict[str, States] = {} self._last_committed_id: dict[str, int] = {} self._last_reported: dict[int, float] = {} + self._oldest_ts: float | None = None + + @property + def oldest_ts(self) -> float | None: + """Return the oldest timestamp.""" + return self._oldest_ts def pop_pending(self, entity_id: str) -> States | None: """Pop a pending state. @@ -44,6 +58,8 @@ class StatesManager: recorder thread. """ self._pending[entity_id] = state + if self._oldest_ts is None: + self._oldest_ts = state.last_updated_ts def update_pending_last_reported( self, state_id: int, last_reported_timestamp: float @@ -74,6 +90,22 @@ class StatesManager: """ self._last_committed_id.clear() self._pending.clear() + self._oldest_ts = None + + def load_from_db(self, session: Session) -> None: + """Update the cache. + + Must run in the recorder thread. + """ + result = cast( + Sequence[Row[Any]], + execute_stmt_lambda_element(session, find_oldest_state()), + ) + if not result: + ts = None + else: + ts = result[0].last_updated_ts + self._oldest_ts = ts def evict_purged_state_ids(self, purged_state_ids: set[int]) -> None: """Evict purged states from the committed states. diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 80d20dbec94..75afb6589a1 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -24,7 +24,7 @@ CACHE_SIZE = 8192 class StatesMetaManager(BaseLRUTableManager[StatesMeta]): """Manage the StatesMeta table.""" - active = False + active = True def __init__(self, recorder: Recorder) -> None: """Initialize the states meta manager.""" diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 783f0a80b8e..fa10c12aa68 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -120,8 +120,6 @@ class PurgeTask(RecorderTask): if purge.purge_old_data( instance, self.purge_before, self.repack, self.apply_filter ): - with instance.get_session() as session: - instance.recorder_runs_manager.load_from_db(session) # We always need to do the db cleanups after a purge # is finished to ensure the WAL checkpoint and other # tasks happen after a vacuum. diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a59519ef38d..a1f8d90953c 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -36,14 +36,7 @@ from homeassistant.helpers.recorder import ( # noqa: F401 ) import homeassistant.util.dt as dt_util -from .const import ( - DEFAULT_MAX_BIND_VARS, - DOMAIN, - SQLITE_MAX_BIND_VARS, - SQLITE_MODERN_MAX_BIND_VARS, - SQLITE_URL_PREFIX, - SupportedDialect, -) +from .const import DEFAULT_MAX_BIND_VARS, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect from .db_schema import ( TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, @@ -95,9 +88,7 @@ RECOMMENDED_MIN_VERSION_MARIA_DB_108 = _simple_version("10.8.4") MARIADB_WITH_FIXED_IN_QUERIES_108 = _simple_version("10.8.4") MIN_VERSION_MYSQL = _simple_version("8.0.0") MIN_VERSION_PGSQL = _simple_version("12.0") -MIN_VERSION_SQLITE = _simple_version("3.31.0") -UPCOMING_MIN_VERSION_SQLITE = _simple_version("3.40.1") -MIN_VERSION_SQLITE_MODERN_BIND_VARS = _simple_version("3.32.0") +MIN_VERSION_SQLITE = _simple_version("3.40.1") # This is the maximum time after the recorder ends the session @@ -107,6 +98,8 @@ MAX_RESTART_TIME = timedelta(minutes=10) # Retry when one of the following MySQL errors occurred: RETRYABLE_MYSQL_ERRORS = (1205, 1206, 1213) +# The error codes are hard coded because the PyMySQL library may not be +# installed when using database engines other than MySQL or MariaDB. # 1205: Lock wait timeout exceeded; try restarting transaction # 1206: The total number of locks exceeds the lock table size # 1213: Deadlock found when trying to get lock; try restarting transaction @@ -374,37 +367,6 @@ def _raise_if_version_unsupported( raise UnsupportedDialect -@callback -def _async_delete_issue_deprecated_version( - hass: HomeAssistant, dialect_name: str -) -> None: - """Delete the issue about upcoming unsupported database version.""" - ir.async_delete_issue(hass, DOMAIN, f"{dialect_name}_too_old") - - -@callback -def _async_create_issue_deprecated_version( - hass: HomeAssistant, - server_version: AwesomeVersion, - dialect_name: str, - min_version: AwesomeVersion, -) -> None: - """Warn about upcoming unsupported database version.""" - ir.async_create_issue( - hass, - DOMAIN, - f"{dialect_name}_too_old", - is_fixable=False, - severity=ir.IssueSeverity.CRITICAL, - translation_key=f"{dialect_name}_too_old", - translation_placeholders={ - "server_version": str(server_version), - "min_version": str(min_version), - }, - breaks_in_ha_version="2025.2.0", - ) - - def _extract_version_from_server_response_or_raise( server_response: str, ) -> AwesomeVersion: @@ -503,7 +465,6 @@ def setup_connection_for_dialect( version: AwesomeVersion | None = None slow_range_in_select = False if dialect_name == SupportedDialect.SQLITE: - max_bind_vars = SQLITE_MAX_BIND_VARS if first_connection: old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] dbapi_connection.isolation_level = None # type: ignore[attr-defined] @@ -521,23 +482,6 @@ def setup_connection_for_dialect( version or version_string, "SQLite", MIN_VERSION_SQLITE ) - # No elif here since _raise_if_version_unsupported raises - if version < UPCOMING_MIN_VERSION_SQLITE: - instance.hass.add_job( - _async_create_issue_deprecated_version, - instance.hass, - version or version_string, - dialect_name, - UPCOMING_MIN_VERSION_SQLITE, - ) - else: - instance.hass.add_job( - _async_delete_issue_deprecated_version, instance.hass, dialect_name - ) - - if version and version > MIN_VERSION_SQLITE_MODERN_BIND_VARS: - max_bind_vars = SQLITE_MODERN_MAX_BIND_VARS - # The upper bound on the cache size is approximately 16MiB of memory execute_on_connection(dbapi_connection, "PRAGMA cache_size = -16384") @@ -556,7 +500,6 @@ def setup_connection_for_dialect( execute_on_connection(dbapi_connection, "PRAGMA foreign_keys=ON") elif dialect_name == SupportedDialect.MYSQL: - max_bind_vars = DEFAULT_MAX_BIND_VARS execute_on_connection(dbapi_connection, "SET session wait_timeout=28800") if first_connection: result = query_on_connection(dbapi_connection, "SELECT VERSION()") @@ -597,7 +540,12 @@ def setup_connection_for_dialect( # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") elif dialect_name == SupportedDialect.POSTGRESQL: - max_bind_vars = DEFAULT_MAX_BIND_VARS + # PostgreSQL does not support a skip/loose index scan so its + # also slow for large distinct queries: + # https://wiki.postgresql.org/wiki/Loose_indexscan + # https://github.com/home-assistant/core/issues/126084 + # so we set slow_range_in_select to True + slow_range_in_select = True if first_connection: # server_version_num was added in 2006 result = query_on_connection(dbapi_connection, "SHOW server_version") @@ -618,7 +566,7 @@ def setup_connection_for_dialect( dialect=SupportedDialect(dialect_name), version=version, optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), - max_bind_vars=max_bind_vars, + max_bind_vars=DEFAULT_MAX_BIND_VARS, ) @@ -892,17 +840,16 @@ def resolve_period( start_time += timedelta(days=cal_offset * 7) end_time = start_time + timedelta(weeks=1) elif calendar_period == "month": - start_time = start_of_day.replace(day=28) - # This works for up to 48 months of offset - start_time = (start_time + timedelta(days=cal_offset * 31)).replace(day=1) + month_now = start_of_day.month + new_month = (month_now - 1 + cal_offset) % 12 + 1 + new_year = start_of_day.year + (month_now - 1 + cal_offset) // 12 + start_time = start_of_day.replace(year=new_year, month=new_month, day=1) end_time = (start_time + timedelta(days=31)).replace(day=1) else: # calendar_period = "year" - start_time = start_of_day.replace(month=12, day=31) - # This works for 100+ years of offset - start_time = (start_time + timedelta(days=cal_offset * 366)).replace( - month=1, day=1 + start_time = start_of_day.replace( + year=start_of_day.year + cal_offset, month=1, day=1 ) - end_time = (start_time + timedelta(days=365)).replace(day=1) + end_time = (start_time + timedelta(days=366)).replace(day=1) start_time = dt_util.as_utc(start_time) end_time = dt_util.as_utc(end_time) @@ -980,10 +927,7 @@ def filter_unique_constraint_integrity_error( if ignore: _LOGGER.warning( - ( - "Blocked attempt to insert duplicated %s rows, please report" - " at %s" - ), + "Blocked attempt to insert duplicated %s rows, please report at %s", row_type, "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", exc_info=err, diff --git a/homeassistant/components/refoss/bridge.py b/homeassistant/components/refoss/bridge.py index 82cc2540f6c..11e92620fbb 100644 --- a/homeassistant/components/refoss/bridge.py +++ b/homeassistant/components/refoss/bridge.py @@ -9,7 +9,7 @@ from refoss_ha.discovery import Discovery, Listener from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN from .coordinator import RefossDataUpdateCoordinator @@ -36,11 +36,21 @@ class DiscoveryService(Listener): self.hass.data[DOMAIN][COORDINATORS].append(coordo) await coordo.async_refresh() + _LOGGER.debug( + "Discover new device: %s, ip: %s", + device_info.dev_name, + device_info.inner_ip, + ) async_dispatcher_send(self.hass, DISPATCH_DEVICE_DISCOVERED, coordo) async def device_update(self, device_info: DeviceInfo) -> None: """Handle updates in device information, update if ip has changed.""" for coordinator in self.hass.data[DOMAIN][COORDINATORS]: if coordinator.device.device_info.mac == device_info.mac: + _LOGGER.debug( + "Update device %s ip to %s", + device_info.dev_name, + device_info.inner_ip, + ) coordinator.device.device_info.inner_ip = device_info.inner_ip await coordinator.async_refresh() diff --git a/homeassistant/components/refoss/config_flow.py b/homeassistant/components/refoss/config_flow.py index fe33cefc1bd..5b667940731 100644 --- a/homeassistant/components/refoss/config_flow.py +++ b/homeassistant/components/refoss/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow -from .const import DISCOVERY_TIMEOUT, DOMAIN +from .const import _LOGGER, DISCOVERY_TIMEOUT, DOMAIN from .util import refoss_discovery_server @@ -14,6 +14,9 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: refoss_discovery = await refoss_discovery_server(hass) devices = await refoss_discovery.broadcast_msg(wait_for=DISCOVERY_TIMEOUT) + _LOGGER.debug( + "Discovered devices: [%s]", ", ".join([info.dev_name for info in devices]) + ) return len(devices) > 0 diff --git a/homeassistant/components/refoss/const.py b/homeassistant/components/refoss/const.py index 851f8ba8f77..62db733ece5 100644 --- a/homeassistant/components/refoss/const.py +++ b/homeassistant/components/refoss/const.py @@ -11,7 +11,7 @@ COORDINATORS = "coordinators" DATA_DISCOVERY_SERVICE = "refoss_discovery" DISCOVERY_SCAN_INTERVAL = 30 -DISCOVERY_TIMEOUT = 8 +DISCOVERY_TIMEOUT = 20 DISPATCH_DEVICE_DISCOVERED = "refoss_device_discovered" DISPATCHERS = "dispatchers" diff --git a/homeassistant/components/refoss/coordinator.py b/homeassistant/components/refoss/coordinator.py index 8b03313d6d6..929d1b3962b 100644 --- a/homeassistant/components/refoss/coordinator.py +++ b/homeassistant/components/refoss/coordinator.py @@ -34,6 +34,11 @@ class RefossDataUpdateCoordinator(DataUpdateCoordinator[None]): self.last_update_success = True self._error_count = 0 except DeviceTimeoutError: + _LOGGER.debug( + "Update device %s status timeout,ip: %s", + self.device.dev_name, + self.device.inner_ip, + ) self._error_count += 1 if self._error_count >= MAX_ERRORS: diff --git a/homeassistant/components/refoss/entity.py b/homeassistant/components/refoss/entity.py index 502101608ec..662b7c89376 100644 --- a/homeassistant/components/refoss/entity.py +++ b/homeassistant/components/refoss/entity.py @@ -23,5 +23,7 @@ class RefossEntity(CoordinatorEntity[RefossDataUpdateCoordinator]): connections={(CONNECTION_NETWORK_MAC, mac)}, identifiers={(DOMAIN, mac)}, manufacturer="Refoss", + sw_version=coordinator.device.fmware_version, + hw_version=coordinator.device.hdware_version, name=coordinator.device.dev_name, ) diff --git a/homeassistant/components/refoss/manifest.json b/homeassistant/components/refoss/manifest.json index bf046e954d1..93ffe5b3f26 100644 --- a/homeassistant/components/refoss/manifest.json +++ b/homeassistant/components/refoss/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/refoss", "iot_class": "local_polling", - "requirements": ["refoss-ha==1.2.4"] + "requirements": ["refoss-ha==1.2.5"], + "single_config_entry": true } diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py index 26454cae48d..7065470657f 100644 --- a/homeassistant/components/refoss/sensor.py +++ b/homeassistant/components/refoss/sensor.py @@ -27,6 +27,7 @@ from homeassistant.helpers.typing import StateType from .bridge import RefossDataUpdateCoordinator from .const import ( + _LOGGER, CHANNEL_DISPLAY_NAME, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, @@ -143,6 +144,7 @@ async def async_setup_entry( for channel in device.channels for description in descriptions ) + _LOGGER.debug("Device %s add sensor entity success", device.dev_name) for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py index 0f5aba0cfc4..aed132ecc3a 100644 --- a/homeassistant/components/refoss/switch.py +++ b/homeassistant/components/refoss/switch.py @@ -13,7 +13,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .bridge import RefossDataUpdateCoordinator -from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN from .entity import RefossEntity @@ -37,6 +37,7 @@ async def async_setup_entry( new_entities.append(entity) async_add_entities(new_entities) + _LOGGER.debug("Device %s add switch entity success", device.dev_name) for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) diff --git a/homeassistant/components/remember_the_milk/strings.json b/homeassistant/components/remember_the_milk/strings.json index da499b0584c..de6ae8a7f04 100644 --- a/homeassistant/components/remember_the_milk/strings.json +++ b/homeassistant/components/remember_the_milk/strings.json @@ -2,7 +2,7 @@ "services": { "create_task": { "name": "Create task", - "description": "Creates (or update) a new task in your Remember The Milk account. If you want to update a task later on, you have to set an \"id\" when creating the task. Note: Updating a tasks does not support the smart syntax.", + "description": "Creates a new task in your Remember The Milk account or updates an existing one. If you want to update a task later on, you have to set an \"ID\" when creating the task. Note: Updating a task does not support the smart syntax.", "fields": { "name": { "name": "[%key:common::config_flow::data::name%]", diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 6a007bde0b4..f7d87fbf021 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -9,7 +9,7 @@ import functools as ft import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -22,12 +22,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -74,19 +68,6 @@ class RemoteEntityFeature(IntFlag): ACTIVITY = 4 -# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. -# Please use the RemoteEntityFeature enum instead. -_DEPRECATED_SUPPORT_LEARN_COMMAND = DeprecatedConstantEnum( - RemoteEntityFeature.LEARN_COMMAND, "2025.1" -) -_DEPRECATED_SUPPORT_DELETE_COMMAND = DeprecatedConstantEnum( - RemoteEntityFeature.DELETE_COMMAND, "2025.1" -) -_DEPRECATED_SUPPORT_ACTIVITY = DeprecatedConstantEnum( - RemoteEntityFeature.ACTIVITY, "2025.1" -) - - REMOTE_SERVICE_ACTIVITY_SCHEMA = cv.make_entity_service_schema( {vol.Optional(ATTR_ACTIVITY): cv.string} ) @@ -189,19 +170,6 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) """Flag supported features.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> RemoteEntityFeature: - """Return the supported features as RemoteEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = RemoteEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - @cached_property def current_activity(self) -> str | None: """Active activity.""" @@ -216,7 +184,7 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) @property def state_attributes(self) -> dict[str, Any] | None: """Return optional state attributes.""" - if RemoteEntityFeature.ACTIVITY not in self.supported_features_compat: + if RemoteEntityFeature.ACTIVITY not in self.supported_features: return None return { @@ -251,11 +219,3 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) await self.hass.async_add_executor_job( ft.partial(self.delete_command, **kwargs) ) - - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = ft.partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 98c298761ce..a8fdf324f1c 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -19,6 +19,9 @@ from homeassistant.helpers.typing import StateType from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RenaultBinarySensorEntityDescription( diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index d3666388fbb..6a9f5e05a38 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -13,6 +13,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RenaultConfigEntry from .entity import RenaultEntity +# Coordinator is used to centralize the data updates +# but renault servers are unreliable and it's safer to queue action calls +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class RenaultButtonEntityDescription(ButtonEntityDescription): diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index d7aed6e3560..89e62867130 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -18,7 +18,7 @@ from renault_api.kamereon.models import KamereonVehicleDataAttributes from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -T = TypeVar("T", bound=KamereonVehicleDataAttributes | None) +T = TypeVar("T", bound=KamereonVehicleDataAttributes) # We have potentially 7 coordinators per vehicle _PARALLEL_SEMAPHORE = asyncio.Semaphore(1) @@ -27,6 +27,8 @@ _PARALLEL_SEMAPHORE = asyncio.Semaphore(1) class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): """Handle vehicle communication with Renault servers.""" + update_method: Callable[[], Awaitable[T]] + def __init__( self, hass: HomeAssistant, @@ -50,8 +52,6 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): async def _async_update_data(self) -> T: """Fetch the latest data from the source.""" - if self.update_method is None: - raise NotImplementedError("Update method not implemented") try: async with _PARALLEL_SEMAPHORE: data = await self.update_method() diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index 2f7aeda5c39..08a2a698802 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -16,6 +16,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RenaultTrackerEntityDescription( diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index 10de028b2d0..7beb91e9603 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -59,6 +59,4 @@ class RenaultDataEntity( def _get_data_attr(self, key: str) -> StateType: """Return the attribute value from the coordinator data.""" - if self.coordinator.data is None: - return None # type: ignore[unreachable] return cast(StateType, getattr(self.coordinator.data, key)) diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 396410dfc20..1a599afe4e4 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["renault_api"], - "requirements": ["renault-api==0.2.7"] + "quality_scale": "silver", + "requirements": ["renault-api==0.2.9"] } diff --git a/homeassistant/components/renault/quality_scale.yaml b/homeassistant/components/renault/quality_scale.yaml new file mode 100644 index 00000000000..f2d70622192 --- /dev/null +++ b/homeassistant/components/renault/quality_scale.yaml @@ -0,0 +1,64 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Discovery not possible + discovery: + status: exempt + comment: Discovery not possible + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: done + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index b430da9396e..cab1d1f4d8a 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -15,6 +15,10 @@ from homeassistant.helpers.typing import StateType from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription +# Coordinator is used to centralize the data updates +# but renault servers are unreliable and it's safer to queue action calls +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class RenaultSelectEntityDescription( diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 78e64ae9acc..7854d70b1c4 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -40,6 +40,9 @@ from .coordinator import T from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_vehicle import RenaultVehicleProxy +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RenaultSensorEntityDescription( diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index a6487772bb6..7d9cae1bcf1 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -13,14 +13,21 @@ "step": { "kamereon": { "data": { - "kamereon_account_id": "Kamereon account id" + "kamereon_account_id": "Account ID" }, - "title": "Select Kamereon account id" + "data_description": { + "kamereon_account_id": "The Kamereon account ID associated with your vehicle" + }, + "title": "Kamereon Account ID", + "description": "You have multiple Kamereon accounts associated to this email, please select one" }, "reauth_confirm": { "data": { "password": "[%key:common::config_flow::data::password%]" }, + "data_description": { + "password": "Your MyRenault phone application password" + }, "description": "Please update your password for {username}", "title": "[%key:common::config_flow::title::reauth%]" }, @@ -30,6 +37,11 @@ "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" }, + "data_description": { + "locale": "Your country code", + "username": "Your MyRenault phone application email address", + "password": "Your MyRenault phone application password" + }, "title": "Set Renault credentials" } } diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 44bea28ce3c..56b3655ef94 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -127,7 +127,6 @@ class RensonFan(RensonEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, api: RensonVentilation, coordinator: RensonCoordinator) -> None: """Initialize the Renson fan.""" diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json index fa94207748e..fcc482959f2 100644 --- a/homeassistant/components/renson/manifest.json +++ b/homeassistant/components/renson/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/renson", "iot_class": "local_polling", - "requirements": ["renson-endura-delta==1.7.1"] + "requirements": ["renson-endura-delta==1.7.2"] } diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index b756d16ea79..c81086502ad 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -186,46 +186,46 @@ "services": { "set_timer_level": { "name": "Set timer", - "description": "Set the ventilation timer", + "description": "Sets the ventilation timer", "fields": { "timer_level": { "name": "Level", - "description": "Level setting" + "description": "Ventilation level" }, "minutes": { "name": "Time", - "description": "Time of the timer (0 will disable the timer)" + "description": "Duration of the timer (0 will disable the timer)" } } }, "set_breeze": { - "name": "Set breeze", - "description": "Set the breeze function of the ventilation system", + "name": "Set Breeze", + "description": "Sets the Breeze function of the ventilation system", "fields": { "breeze_level": { "name": "[%key:component::renson::services::set_timer_level::fields::timer_level::name%]", - "description": "Ventilation level when breeze function is activated" + "description": "Ventilation level when Breeze function is activated" }, "temperature": { "name": "Temperature", - "description": "Temperature when the breeze function should be activated" + "description": "Temperature when the Breeze function should be activated" }, "activate": { "name": "Activate", - "description": "Activate or disable the breeze feature" + "description": "Activate or disable the Breeze feature" } } }, "set_pollution_settings": { "name": "Set pollution settings", - "description": "Set all the pollution settings of the ventilation system", + "description": "Sets all the pollution settings of the ventilation system", "fields": { "day_pollution_level": { - "name": "Day pollution Level", + "name": "Day pollution level", "description": "Ventilation level when pollution is detected in the day" }, "night_pollution_level": { - "name": "Night pollution Level", + "name": "Night pollution level", "description": "Ventilation level when pollution is detected in the night" }, "humidity_control": { @@ -242,11 +242,11 @@ }, "co2_threshold": { "name": "CO2 threshold", - "description": "Sets the CO2 pollution threshold level in ppm" + "description": "The CO2 pollution threshold level in ppm" }, "co2_hysteresis": { "name": "CO2 hysteresis", - "description": "Sets the CO2 pollution threshold hysteresis level in ppm" + "description": "The CO2 pollution threshold hysteresis level in ppm" } } } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index ae0badb3d84..747e68e8a00 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -27,6 +27,7 @@ from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch +from .views import PlaybackProxyView _LOGGER = logging.getLogger(__name__) @@ -73,7 +74,9 @@ async def async_setup_entry( ) as err: await host.stop() raise ConfigEntryNotReady( - f"Error while trying to setup {host.api.host}:{host.api.port}: {err!s}" + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + translation_placeholders={"host": host.api.host, "err": str(err)}, ) from err except BaseException: await host.stop() @@ -187,6 +190,8 @@ async def async_setup_entry( migrate_entity_ids(hass, config_entry.entry_id, host) + hass.http.register_view(PlaybackProxyView(hass)) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( @@ -356,7 +361,7 @@ def migrate_entity_ids( if host.api.supported(None, "UID") and not entity.unique_id.startswith( host.unique_id ): - new_id = f"{host.unique_id}_{entity.unique_id.split("_", 1)[1]}" + new_id = f"{host.unique_id}_{entity.unique_id.split('_', 1)[1]}" entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) if entity.device_id in ch_device_ids: diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index c59c1e7785f..2191dedc9cf 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -100,6 +100,13 @@ BINARY_PUSH_SENSORS = ( value=lambda api, ch: api.visitor_detected(ch), supported=lambda api, ch: api.is_doorbell(ch), ), + ReolinkBinarySensorEntityDescription( + key="cry", + cmd_id=33, + translation_key="cry", + value=lambda api, ch: api.ai_detected(ch, "cry"), + supported=lambda api, ch: api.ai_supported(ch, "cry"), + ), ) BINARY_SENSORS = ( @@ -176,14 +183,14 @@ class ReolinkPushBinarySensorEntity(ReolinkBinarySensorEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"{self._host.webhook_id}_{self._channel}", + f"{self._host.unique_id}_{self._channel}", self._async_handle_event, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - f"{self._host.webhook_id}_all", + f"{self._host.unique_id}_all", self._async_handle_event, ) ) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index cd1e1b05fae..6b1fcc65a2f 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import GuardEnum, Host, PtzEnum -from reolink_aio.exceptions import ReolinkError import voluptuous as vol from homeassistant.components.button import ( @@ -18,7 +17,6 @@ from homeassistant.components.button import ( from homeassistant.components.camera import CameraEntityFeature from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, @@ -31,7 +29,7 @@ from .entity import ( ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) -from .util import ReolinkConfigEntry, ReolinkData +from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error PARALLEL_UPDATES = 0 ATTR_SPEED = "speed" @@ -205,22 +203,18 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): ): self._attr_supported_features = SUPPORT_PTZ_SPEED + @raise_translated_error async def async_press(self) -> None: """Execute the button action.""" - try: - await self.entity_description.method(self._host.api, self._channel) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._host.api, self._channel) + @raise_translated_error async def async_ptz_move(self, **kwargs: Any) -> None: """PTZ move with speed.""" speed = kwargs[ATTR_SPEED] - try: - await self._host.api.set_ptz_command( - self._channel, command=self.entity_description.ptz_cmd, speed=speed - ) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self._host.api.set_ptz_command( + self._channel, command=self.entity_description.ptz_cmd, speed=speed + ) class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): @@ -237,9 +231,7 @@ class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): self.entity_description = entity_description super().__init__(reolink_data) + @raise_translated_error async def async_press(self) -> None: """Execute the button action.""" - try: - await self.entity_description.method(self._host.api) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._host.api) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 26ef0b0f4fc..a597be3ec7a 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -6,7 +6,6 @@ from dataclasses import dataclass import logging from reolink_aio.api import DUAL_LENS_MODELS -from reolink_aio.exceptions import ReolinkError from homeassistant.components.camera import ( Camera, @@ -14,11 +13,10 @@ from homeassistant.components.camera import ( CameraEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -from .util import ReolinkConfigEntry, ReolinkData +from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -102,7 +100,7 @@ async def async_setup_entry( if not entity_description.supported(reolink_data.host.api, channel): continue stream_url = await reolink_data.host.api.get_stream_source( - channel, entity_description.stream + channel, entity_description.stream, False ) if stream_url is None and "snapshots" not in entity_description.stream: continue @@ -142,13 +140,11 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): self._channel, self.entity_description.stream ) + @raise_translated_error async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - try: - return await self._host.api.get_snapshot( - self._channel, self.entity_description.stream - ) - except ReolinkError as err: - raise HomeAssistantError(err) from err + return await self._host.api.get_snapshot( + self._channel, self.entity_description.stream + ) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 0b1ed7b4b15..48be2fc8ca7 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -15,7 +15,6 @@ from reolink_aio.exceptions import ( ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -34,6 +33,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_USE_HTTPS, DOMAIN from .exceptions import ( @@ -128,13 +128,8 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Dialog that informs the user that reauth is required.""" - if user_input is not None: - return await self.async_step_user() - placeholders = {"name": self.context["title_placeholders"]["name"]} - return self.async_show_form( - step_id="reauth_confirm", description_placeholders=placeholders - ) + """Perform a reauthentication.""" + return await self.async_step_user() async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None @@ -147,7 +142,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress) @@ -278,7 +273,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_update_reload_and_abort( entry=self._get_reconfigure_entry(), data=user_input ) - self._abort_if_unique_id_configured(updates=user_input) + self._abort_if_unique_id_configured() return self.async_create_entry( title=str(host.api.nvr_name), diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index d2b2bba6276..97d888c0323 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -536,6 +536,8 @@ class ReolinkHost: async def renew(self) -> None: """Renew the subscription of motion events (lease time is 15 minutes).""" + await self._api.baichuan.check_subscribe_events() + if self._api.baichuan.events_active and self._api.subscribed(SubType.push): # TCP push active, unsubscribe from ONVIF push because not needed self.unregister_webhook() @@ -721,7 +723,7 @@ class ReolinkHost: self._hass, POLL_INTERVAL_NO_PUSH, self._poll_job ) - self._signal_write_ha_state(None) + self._signal_write_ha_state() async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request @@ -780,7 +782,7 @@ class ReolinkHost: "Could not poll motion state after losing connection during receiving ONVIF event" ) return - async_dispatcher_send(hass, f"{webhook_id}_all", {}) + self._signal_write_ha_state() return message = data.decode("utf-8") @@ -793,14 +795,14 @@ class ReolinkHost: self._signal_write_ha_state(channels) - def _signal_write_ha_state(self, channels: list[int] | None) -> None: + def _signal_write_ha_state(self, channels: list[int] | None = None) -> None: """Update the binary sensors with async_write_ha_state.""" if channels is None: - async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {}) + async_dispatcher_send(self._hass, f"{self.unique_id}_all", {}) return for channel in channels: - async_dispatcher_send(self._hass, f"{self.webhook_id}_{channel}", {}) + async_dispatcher_send(self._hass, f"{self.unique_id}_{channel}", {}) @property def event_connection(self) -> str: diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index cee044189ea..a9c231bf68f 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -38,9 +38,15 @@ } }, "person": { - "default": "mdi:motion-sensor-off", + "default": "mdi:account-off", "state": { - "on": "mdi:motion-sensor" + "on": "mdi:account" + } + }, + "cry": { + "default": "mdi:emoticon-happy-outline", + "state": { + "on": "mdi:emoticon-cry-outline" } }, "sleep": { diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 3bd9a120798..bbb9592dd76 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import Host -from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -17,7 +16,6 @@ from homeassistant.components.light import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import ( @@ -26,7 +24,7 @@ from .entity import ( ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) -from .util import ReolinkConfigEntry, ReolinkData +from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error PARALLEL_UPDATES = 0 @@ -154,37 +152,28 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): return round(255 * bright_pct / 100.0) + @raise_translated_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn light off.""" - try: - await self.entity_description.turn_on_off_fn( - self._host.api, self._channel, False - ) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, False + ) self.async_write_ha_state() + @raise_translated_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn light on.""" if ( brightness := kwargs.get(ATTR_BRIGHTNESS) ) is not None and self.entity_description.set_brightness_fn is not None: brightness_pct = int(brightness / 255.0 * 100) - try: - await self.entity_description.set_brightness_fn( - self._host.api, self._channel, brightness_pct - ) - except InvalidParameterError as err: - raise ServiceValidationError(err) from err - except ReolinkError as err: - raise HomeAssistantError(err) from err - - try: - await self.entity_description.turn_on_off_fn( - self._host.api, self._channel, True + await self.entity_description.set_brightness_fn( + self._host.api, self._channel, brightness_pct ) - except ReolinkError as err: - raise HomeAssistantError(err) from err + + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, True + ) self.async_write_ha_state() @@ -209,18 +198,14 @@ class ReolinkHostLightEntity(ReolinkHostCoordinatorEntity, LightEntity): """Return true if light is on.""" return self.entity_description.is_on_fn(self._host.api) + @raise_translated_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn light off.""" - try: - await self.entity_description.turn_on_off_fn(self._host.api, False) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.turn_on_off_fn(self._host.api, False) self.async_write_ha_state() + @raise_translated_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn light on.""" - try: - await self.entity_description.turn_on_off_fn(self._host.api, True) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.turn_on_off_fn(self._host.api, True) self.async_write_ha_state() diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 4846ec8cb94..83729fef3cd 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -1,9 +1,9 @@ { "domain": "reolink", - "name": "Reolink IP NVR/camera", + "name": "Reolink", "codeowners": ["@starkillerOG"], "config_flow": true, - "dependencies": ["webhook"], + "dependencies": ["http", "webhook"], "dhcp": [ { "hostname": "reolink*" @@ -18,5 +18,6 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.11.3"] + "quality_scale": "platinum", + "requirements": ["reolink-aio==0.11.8"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 0c23bed7e2f..e912bfb5100 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -23,8 +23,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN -from .host import ReolinkHost -from .util import ReolinkConfigEntry +from .util import get_host +from .views import async_generate_playback_proxy_url _LOGGER = logging.getLogger(__name__) @@ -47,15 +47,6 @@ def res_name(stream: str) -> str: return "Low res." -def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: - """Return the Reolink host from the config entry id.""" - config_entry: ReolinkConfigEntry | None = hass.config_entries.async_get_entry( - config_entry_id - ) - assert config_entry is not None - return config_entry.runtime_data.host - - class ReolinkVODMediaSource(MediaSource): """Provide Reolink camera VODs as media sources.""" @@ -81,6 +72,8 @@ class ReolinkVODMediaSource(MediaSource): def get_vod_type() -> VodRequestType: if filename.endswith(".mp4"): + if host.api.is_nvr: + return VodRequestType.DOWNLOAD return VodRequestType.PLAYBACK if host.api.is_nvr: return VodRequestType.FLV @@ -88,22 +81,22 @@ class ReolinkVODMediaSource(MediaSource): vod_type = get_vod_type() + if vod_type in [VodRequestType.DOWNLOAD, VodRequestType.PLAYBACK]: + proxy_url = async_generate_playback_proxy_url( + config_entry_id, channel, filename, stream_res, vod_type.value + ) + return PlayMedia(proxy_url, "video/mp4") + mime_type, url = await host.api.get_vod_source( channel, filename, stream_res, vod_type ) if _LOGGER.isEnabledFor(logging.DEBUG): - url_log = url - if "&user=" in url_log: - url_log = f"{url_log.split('&user=')[0]}&user=xxxxx&password=xxxxx" - elif "&token=" in url_log: - url_log = f"{url_log.split('&token=')[0]}&token=xxxxx" _LOGGER.debug( - "Opening VOD stream from %s: %s", host.api.camera_name(channel), url_log + "Opening VOD stream from %s: %s", + host.api.camera_name(channel), + host.api.hide_password(url), ) - if mime_type == "video/mp4": - return PlayMedia(url, mime_type) - stream = create_stream(self.hass, url, {}, DynamicStreamSettings()) stream.add_provider("hls", timeout=3600) stream_url: str = stream.endpoint_url("hls") diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 692b43bca9e..e4b52c85d45 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import Chime, Host -from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.number import ( NumberEntity, @@ -16,7 +15,6 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import ( @@ -27,7 +25,7 @@ from .entity import ( ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) -from .util import ReolinkConfigEntry, ReolinkData +from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error PARALLEL_UPDATES = 0 @@ -589,14 +587,10 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): """State of the number entity.""" return self.entity_description.value(self._host.api, self._channel) + @raise_translated_error async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - try: - await self.entity_description.method(self._host.api, self._channel, value) - except InvalidParameterError as err: - raise ServiceValidationError(err) from err - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._host.api, self._channel, value) self.async_write_ha_state() @@ -621,14 +615,10 @@ class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity): """State of the number entity.""" return self.entity_description.value(self._host.api) + @raise_translated_error async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - try: - await self.entity_description.method(self._host.api, value) - except InvalidParameterError as err: - raise ServiceValidationError(err) from err - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._host.api, value) self.async_write_ha_state() @@ -654,12 +644,8 @@ class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity): """State of the number entity.""" return self.entity_description.value(self._chime) + @raise_translated_error async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - try: - await self.entity_description.method(self._chime, value) - except InvalidParameterError as err: - raise ServiceValidationError(err) from err - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._chime, value) self.async_write_ha_state() diff --git a/homeassistant/components/reolink/quality_scale.yaml b/homeassistant/components/reolink/quality_scale.yaml new file mode 100644 index 00000000000..5cc054b7a4c --- /dev/null +++ b/homeassistant/components/reolink/quality_scale.yaml @@ -0,0 +1,71 @@ +rules: + # Bronze + action-setup: + status: done + comment: | + play_chime service is setup in async_setup + ptz_move service is setup in async_setup_entry since it is a entity_service + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: done + comment: | + Coordinators are used and asyncio mutex locks ensure safe operation in the upstream lib + Parallel_update=0 set on all platforms + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: done + comment: | + For standalone cameras this does not apply: the integration should be removed. + For cameras connected to a NVR/Hub: the entities of a device are marked unavailable when power is unplugged. They can be removed using async_remove_config_entry_device. + Chimes can be uncoupled from the doorbell and removed from HA using async_remove_config_entry_device + Automatic removal lead to many user issues when a device was temporarily out of wifi range or disconnected from power, so not implemented anymore. + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 8625f7fb600..7a74be2e28c 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -19,12 +19,10 @@ from reolink_aio.api import ( StatusLedEnum, TrackMethodEnum, ) -from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfFrequency from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import ( @@ -33,7 +31,7 @@ from .entity import ( ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, ) -from .util import ReolinkConfigEntry, ReolinkData +from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -354,14 +352,10 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): self._log_error = True return option + @raise_translated_error async def async_select_option(self, option: str) -> None: """Change the selected option.""" - try: - await self.entity_description.method(self._host.api, self._channel, option) - except InvalidParameterError as err: - raise ServiceValidationError(err) from err - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._host.api, self._channel, option) self.async_write_ha_state() @@ -396,12 +390,8 @@ class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity): self._log_error = True return option + @raise_translated_error async def async_select_option(self, option: str) -> None: """Change the selected option.""" - try: - await self.entity_description.method(self._chime, option) - except InvalidParameterError as err: - raise ServiceValidationError(err) from err - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._chime, option) self.async_write_ha_state() diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py index 326093e7a93..acd31fe0d7d 100644 --- a/homeassistant/components/reolink/services.py +++ b/homeassistant/components/reolink/services.py @@ -4,18 +4,17 @@ from __future__ import annotations from reolink_aio.api import Chime from reolink_aio.enums import ChimeToneEnum -from reolink_aio.exceptions import InvalidParameterError, ReolinkError import voluptuous as vol from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from .const import DOMAIN from .host import ReolinkHost -from .util import get_device_uid_and_ch +from .util import get_device_uid_and_ch, raise_translated_error ATTR_RINGTONE = "ringtone" @@ -24,6 +23,7 @@ ATTR_RINGTONE = "ringtone" def async_setup_services(hass: HomeAssistant) -> None: """Set up Reolink services.""" + @raise_translated_error async def async_play_chime(service_call: ServiceCall) -> None: """Play a ringtone.""" service_data = service_call.data @@ -58,12 +58,7 @@ def async_setup_services(hass: HomeAssistant) -> None: ) ringtone = service_data[ATTR_RINGTONE] - try: - await chime.play(ChimeToneEnum[ringtone].value) - except InvalidParameterError as err: - raise ServiceValidationError(err) from err - except ReolinkError as err: - raise HomeAssistantError(err) from err + await chime.play(ChimeToneEnum[ringtone].value) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index cb12eb5d38c..74bb227d078 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -5,8 +5,6 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from reolink_aio.exceptions import InvalidParameterError, ReolinkError - from homeassistant.components.siren import ( ATTR_DURATION, ATTR_VOLUME_LEVEL, @@ -15,11 +13,10 @@ from homeassistant.components.siren import ( SirenEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -from .util import ReolinkConfigEntry, ReolinkData +from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error PARALLEL_UPDATES = 0 @@ -77,26 +74,15 @@ class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity): self.entity_description = entity_description super().__init__(reolink_data, channel) + @raise_translated_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the siren.""" if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: - try: - await self._host.api.set_volume(self._channel, int(volume * 100)) - except InvalidParameterError as err: - raise ServiceValidationError(err) from err - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self._host.api.set_volume(self._channel, int(volume * 100)) duration = kwargs.get(ATTR_DURATION) - try: - await self._host.api.set_siren(self._channel, True, duration) - except InvalidParameterError as err: - raise ServiceValidationError(err) from err - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self._host.api.set_siren(self._channel, True, duration) + @raise_translated_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the siren.""" - try: - await self._host.api.set_siren(self._channel, False, None) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self._host.api.set_siren(self._channel, False, None) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 3fe7fe14ec5..412362fc447 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -18,10 +18,6 @@ "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } - }, - "reauth_confirm": { - "title": "[%key:common::config_flow::title::reauth%]", - "description": "The Reolink integration needs to re-authenticate your connection details" } }, "error": { @@ -37,7 +33,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The MAC address of the device does not match the previous MAC address" } }, "options": { @@ -57,7 +54,49 @@ "message": "Reolink {service_name} error: config entry not found or not loaded" }, "service_not_chime": { - "message": "Reolink play_chime error: {device_name} is not a chime" + "message": "Reolink play_chime error: {device_name} is not a Chime" + }, + "invalid_parameter": { + "message": "Invalid input parameter: {err}" + }, + "api_error": { + "message": "The device responded with a error: {err}" + }, + "invalid_content_type": { + "message": "Received a different content type than expected: {err}" + }, + "invalid_credentials": { + "message": "Invalid credentials: {err}" + }, + "login_error": { + "message": "Error during login attempt: {err}" + }, + "no_data": { + "message": "Device returned no data: {err}" + }, + "unexpected_data": { + "message": "Device returned unexpected data: {err}" + }, + "not_supported": { + "message": "Function not supported by this device: {err}" + }, + "subscription_error": { + "message": "Error during ONVIF subscription: {err}" + }, + "connection_error": { + "message": "Could not connect to the device: {err}" + }, + "timeout": { + "message": "Timeout waiting on a response: {err}" + }, + "unexpected": { + "message": "Unexpected Reolink error: {err}" + }, + "firmware_install_error": { + "message": "Error trying to update Reolink firmware: {err}" + }, + "config_entry_not_ready": { + "message": "Error while trying to setup {host}: {err}" } }, "issues": { @@ -79,11 +118,7 @@ }, "firmware_update": { "title": "Reolink firmware update required", - "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The latest firmware can be downloaded from the [Reolink download center]({download_link})." - }, - "hdr_switch_deprecated": { - "title": "Reolink HDR switch deprecated", - "description": "The Reolink HDR switch entity is deprecated and will be removed in HA 2025.2.0. It has been replaced by a HDR select entity offering options `on`, `off` and `auto`. To remove this issue, please adjust automations accordingly and disable the HDR switch entity." + "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})." }, "hub_switch_deprecated": { "title": "Reolink Home Hub switches deprecated", @@ -93,7 +128,7 @@ "services": { "ptz_move": { "name": "PTZ move", - "description": "Move the camera with a specific speed.", + "description": "Moves the camera with a specific speed.", "fields": { "speed": { "name": "Speed", @@ -103,11 +138,11 @@ }, "play_chime": { "name": "Play chime", - "description": "Play a ringtone on a chime.", + "description": "Plays a ringtone on a Reolink Chime.", "fields": { "device_id": { "name": "Target chime", - "description": "The chime to play the ringtone on." + "description": "The Reolink Chime to play the ringtone on." }, "ringtone": { "name": "Ringtone", @@ -179,6 +214,13 @@ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, + "cry": { + "name": "Baby crying", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, "motion_lens_0": { "name": "Motion lens 0", "state": { @@ -531,6 +573,7 @@ "stayoff": "Stay off", "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]", "alwaysonatnight": "Auto & always on at night", + "always": "Always on", "alwayson": "Always on" } }, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index c274609599d..85c35b5c987 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -7,12 +7,10 @@ from dataclasses import dataclass from typing import Any from reolink_aio.api import Chime, Host -from reolink_aio.exceptions import ReolinkError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,7 +23,7 @@ from .entity import ( ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) -from .util import ReolinkConfigEntry, ReolinkData +from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error PARALLEL_UPDATES = 0 @@ -269,18 +267,6 @@ CHIME_SWITCH_ENTITIES = ( ), ) -# Can be removed in HA 2025.2.0 -DEPRECATED_HDR = ReolinkSwitchEntityDescription( - key="hdr", - cmd_key="GetIsp", - translation_key="hdr", - entity_category=EntityCategory.CONFIG, - entity_registry_enabled_default=False, - supported=lambda api, ch: api.supported(ch, "HDR"), - value=lambda api, ch: api.HDR_on(ch) is True, - method=lambda api, ch, value: api.set_HDR(ch, value), -) - # Can be removed in HA 2025.4.0 DEPRECATED_NVR_SWITCHES = [ ReolinkNVRSwitchEntityDescription( @@ -369,26 +355,6 @@ async def async_setup_entry( entity_reg = er.async_get(hass) reg_entities = er.async_entries_for_config_entry(entity_reg, config_entry.entry_id) for entity in reg_entities: - # Can be removed in HA 2025.2.0 - if entity.domain == "switch" and entity.unique_id.endswith("_hdr"): - if entity.disabled: - entity_reg.async_remove(entity.entity_id) - continue - - ir.async_create_issue( - hass, - DOMAIN, - "hdr_switch_deprecated", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="hdr_switch_deprecated", - ) - entities.extend( - ReolinkSwitchEntity(reolink_data, channel, DEPRECATED_HDR) - for channel in reolink_data.host.api.channels - if DEPRECATED_HDR.supported(reolink_data.host.api, channel) - ) - # Can be removed in HA 2025.4.0 if entity.domain == "switch" and entity.unique_id in depricated_dict: if entity.disabled: @@ -430,20 +396,16 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): """Return true if switch is on.""" return self.entity_description.value(self._host.api, self._channel) + @raise_translated_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - try: - await self.entity_description.method(self._host.api, self._channel, True) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._host.api, self._channel, True) self.async_write_ha_state() + @raise_translated_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - try: - await self.entity_description.method(self._host.api, self._channel, False) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._host.api, self._channel, False) self.async_write_ha_state() @@ -466,20 +428,16 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): """Return true if switch is on.""" return self.entity_description.value(self._host.api) + @raise_translated_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - try: - await self.entity_description.method(self._host.api, True) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._host.api, True) self.async_write_ha_state() + @raise_translated_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - try: - await self.entity_description.method(self._host.api, False) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._host.api, False) self.async_write_ha_state() @@ -503,18 +461,14 @@ class ReolinkChimeSwitchEntity(ReolinkChimeCoordinatorEntity, SwitchEntity): """Return true if switch is on.""" return self.entity_description.value(self._chime) + @raise_translated_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - try: - await self.entity_description.method(self._chime, True) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._chime, True) self.async_write_ha_state() + @raise_translated_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - try: - await self.entity_description.method(self._chime, False) - except ReolinkError as err: - raise HomeAssistantError(err) from err + await self.entity_description.method(self._chime, False) self.async_write_ha_state() diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index aa607e2b29e..5a8c7d7dc08 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -24,6 +24,7 @@ from homeassistant.helpers.update_coordinator import ( ) from . import DEVICE_UPDATE_INTERVAL +from .const import DOMAIN from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, @@ -196,7 +197,9 @@ class ReolinkUpdateBaseEntity( await self._host.api.update_firmware(self._channel) except ReolinkError as err: raise HomeAssistantError( - f"Error trying to update Reolink firmware: {err}" + translation_domain=DOMAIN, + translation_key="firmware_install_error", + translation_placeholders={"err": str(err)}, ) from err finally: self.async_write_ha_state() diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index 98c0e7b925b..e43391f19fb 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -2,10 +2,29 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass +from typing import Any + +from reolink_aio.exceptions import ( + ApiError, + CredentialsInvalidError, + InvalidContentTypeError, + InvalidParameterError, + LoginError, + NoDataError, + NotSupportedError, + ReolinkConnectionError, + ReolinkError, + ReolinkTimeoutError, + SubscriptionError, + UnexpectedDataError, +) from homeassistant import config_entries +from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -33,6 +52,18 @@ def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry) ) +def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: + """Return the Reolink host from the config entry id.""" + config_entry: ReolinkConfigEntry | None = hass.config_entries.async_get_entry( + config_entry_id + ) + if config_entry is None: + raise Unresolvable( + f"Could not find Reolink config entry id '{config_entry_id}'." + ) + return config_entry.runtime_data.host + + def get_device_uid_and_ch( device: dr.DeviceEntry, host: ReolinkHost ) -> tuple[list[str], int | None, bool]: @@ -51,5 +82,92 @@ def get_device_uid_and_ch( ch = int(device_uid[1][5:]) is_chime = True else: - ch = host.api.channel_for_uid(device_uid[1]) + device_uid_part = "_".join(device_uid[1:]) + ch = host.api.channel_for_uid(device_uid_part) return (device_uid, ch, is_chime) + + +# Decorators +def raise_translated_error[**P, R]( + func: Callable[P, Awaitable[R]], +) -> Callable[P, Coroutine[Any, Any, R]]: + """Wrap a reolink-aio function to translate any potential errors.""" + + async def decorator_raise_translated_error(*args: P.args, **kwargs: P.kwargs) -> R: + """Try a reolink-aio function and translate any potential errors.""" + try: + return await func(*args, **kwargs) + except InvalidParameterError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_parameter", + translation_placeholders={"err": str(err)}, + ) from err + except ApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"err": str(err)}, + ) from err + except InvalidContentTypeError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_content_type", + translation_placeholders={"err": str(err)}, + ) from err + except CredentialsInvalidError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_credentials", + translation_placeholders={"err": str(err)}, + ) from err + except LoginError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="login_error", + translation_placeholders={"err": str(err)}, + ) from err + except NoDataError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_data", + translation_placeholders={"err": str(err)}, + ) from err + except UnexpectedDataError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unexpected_data", + translation_placeholders={"err": str(err)}, + ) from err + except NotSupportedError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_supported", + translation_placeholders={"err": str(err)}, + ) from err + except SubscriptionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="subscription_error", + translation_placeholders={"err": str(err)}, + ) from err + except ReolinkConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"err": str(err)}, + ) from err + except ReolinkTimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout", + translation_placeholders={"err": str(err)}, + ) from err + except ReolinkError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unexpected", + translation_placeholders={"err": str(err)}, + ) from err + + return decorator_raise_translated_error diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py new file mode 100644 index 00000000000..1a4585bc997 --- /dev/null +++ b/homeassistant/components/reolink/views.py @@ -0,0 +1,147 @@ +"""Reolink Integration views.""" + +from __future__ import annotations + +from base64 import urlsafe_b64decode, urlsafe_b64encode +from http import HTTPStatus +import logging + +from aiohttp import ClientError, ClientTimeout, web +from reolink_aio.enums import VodRequestType +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.media_source import Unresolvable +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.ssl import SSLCipherList + +from .util import get_host + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_generate_playback_proxy_url( + config_entry_id: str, channel: int, filename: str, stream_res: str, vod_type: str +) -> str: + """Generate proxy URL for event video.""" + + url_format = PlaybackProxyView.url + return url_format.format( + config_entry_id=config_entry_id, + channel=channel, + filename=urlsafe_b64encode(filename.encode("utf-8")).decode("utf-8"), + stream_res=stream_res, + vod_type=vod_type, + ) + + +class PlaybackProxyView(HomeAssistantView): + """View to proxy playback video from Reolink.""" + + requires_auth = True + url = "/api/reolink/video/{config_entry_id}/{channel}/{stream_res}/{vod_type}/{filename}" + name = "api:reolink_playback" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize a proxy view.""" + self.hass = hass + self.session = async_get_clientsession( + hass, + verify_ssl=False, + ssl_cipher=SSLCipherList.INSECURE, + ) + + async def get( + self, + request: web.Request, + config_entry_id: str, + channel: str, + stream_res: str, + vod_type: str, + filename: str, + retry: int = 2, + ) -> web.StreamResponse: + """Get playback proxy video response.""" + retry = retry - 1 + + filename_decoded = urlsafe_b64decode(filename.encode("utf-8")).decode("utf-8") + ch = int(channel) + try: + host = get_host(self.hass, config_entry_id) + except Unresolvable: + err_str = f"Reolink playback proxy could not find config entry id: {config_entry_id}" + _LOGGER.warning(err_str) + return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) + + try: + mime_type, reolink_url = await host.api.get_vod_source( + ch, filename_decoded, stream_res, VodRequestType(vod_type) + ) + except ReolinkError as err: + _LOGGER.warning("Reolink playback proxy error: %s", str(err)) + return web.Response(body=str(err), status=HTTPStatus.BAD_REQUEST) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Opening VOD stream from %s: %s", + host.api.camera_name(ch), + host.api.hide_password(reolink_url), + ) + + try: + reolink_response = await self.session.get( + reolink_url, + timeout=ClientTimeout( + connect=15, sock_connect=15, sock_read=5, total=None + ), + ) + except ClientError as err: + err_str = host.api.hide_password( + f"Reolink playback error while getting mp4: {err!s}" + ) + if retry <= 0: + _LOGGER.warning(err_str) + return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) + _LOGGER.debug("%s, renewing token", err_str) + await host.api.expire_session(unsubscribe=False) + return await self.get( + request, config_entry_id, channel, stream_res, vod_type, filename, retry + ) + + # Reolink typo "apolication/octet-stream" instead of "application/octet-stream" + if reolink_response.content_type not in [ + "video/mp4", + "application/octet-stream", + "apolication/octet-stream", + ]: + err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}" + _LOGGER.error(err_str) + return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) + + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "video/mp4", + }, + ) + + if reolink_response.content_length is not None: + response.content_length = reolink_response.content_length + + await response.prepare(request) + + try: + async for chunk in reolink_response.content.iter_chunked(65536): + await response.write(chunk) + except TimeoutError: + _LOGGER.debug( + "Timeout while reading Reolink playback from %s, writing EOF", + host.api.nvr_name, + ) + + reolink_response.release() + await response.write_eof() + return response diff --git a/homeassistant/components/repairs/__init__.py b/homeassistant/components/repairs/__init__.py index 8d3fc429ce0..8ee09c9ed3d 100644 --- a/homeassistant/components/repairs/__init__.py +++ b/homeassistant/components/repairs/__init__.py @@ -12,11 +12,11 @@ from .issue_handler import ConfirmRepairFlow, RepairsFlowManager from .models import RepairsFlow __all__ = [ - "ConfirmRepairFlow", "DOMAIN", - "repairs_flow_manager", + "ConfirmRepairFlow", "RepairsFlow", "RepairsFlowManager", + "repairs_flow_manager", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 866d9ecb1bb..6ce7d88f9f0 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -209,10 +209,7 @@ class RfxtrxOptionsFlow(OptionsFlow): except ValueError: errors[CONF_COMMAND_OFF] = "invalid_input_2262_off" - try: - off_delay = none_or_int(user_input.get(CONF_OFF_DELAY), 10) - except ValueError: - errors[CONF_OFF_DELAY] = "invalid_input_off_delay" + off_delay = user_input.get(CONF_OFF_DELAY) if not errors: devices = {} @@ -252,11 +249,11 @@ class RfxtrxOptionsFlow(OptionsFlow): vol.Optional( CONF_OFF_DELAY, description={"suggested_value": device_data[CONF_OFF_DELAY]}, - ): str, + ): int, } else: off_delay_schema = { - vol.Optional(CONF_OFF_DELAY): str, + vol.Optional(CONF_OFF_DELAY): int, } data_schema.update(off_delay_schema) diff --git a/homeassistant/components/rfxtrx/entity.py b/homeassistant/components/rfxtrx/entity.py index b5752e366bc..f0cc193023c 100644 --- a/homeassistant/components/rfxtrx/entity.py +++ b/homeassistant/components/rfxtrx/entity.py @@ -46,7 +46,7 @@ class RfxtrxEntity(RestoreEntity): self._attr_device_info = DeviceInfo( identifiers=_get_identifiers_from_device_tuple(device_id), model=device.type_string, - name=f"{device.type_string} {device.id_string}", + name=f"{device.type_string} {device_id.id_string}", ) self._attr_unique_id = "_".join(x for x in device_id) self._device = device @@ -54,7 +54,7 @@ class RfxtrxEntity(RestoreEntity): self._device_id = device_id # If id_string is 213c7f2:1, the group_id is 213c7f2, and the device will respond to # group events regardless of their group indices. - (self._group_id, _, _) = cast(str, device.id_string).partition(":") + (self._group_id, _, _) = device_id.id_string.partition(":") async def async_added_to_hass(self) -> None: """Restore RFXtrx device state (ON/OFF).""" diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index cc195c9944e..13f3c012af8 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -57,7 +57,7 @@ def _rssi_convert(value: int | None) -> str | None: """Rssi is given as dBm value.""" if value is None: return None - return f"{value*8-120}" + return f"{value * 8 - 120}" @dataclass(frozen=True) @@ -182,13 +182,11 @@ SENSOR_TYPES = ( key="Count", translation_key="count", state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Counter value", translation_key="counter_value", state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Chill", diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index aeb4b2395d3..db4efad5bb4 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -54,7 +54,7 @@ "data": { "off_delay": "Off delay", "off_delay_enabled": "Enable off delay", - "data_bit": "Number of data bits", + "data_bits": "Number of data bits", "command_on": "Data bits value for command on", "command_off": "Data bits value for command off", "venetian_blind_mode": "Venetian blind mode", @@ -68,7 +68,6 @@ "invalid_event_code": "Invalid event code", "invalid_input_2262_on": "Invalid input for command on", "invalid_input_2262_off": "Invalid input for command off", - "invalid_input_off_delay": "Invalid input for off delay", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 1464cccb5c4..cd17e71f4f0 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -35,8 +35,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool: isinstance(event.device, rfxtrxmod.LightingDevice) and not event.device.known_to_be_dimmable and not event.device.known_to_be_rollershutter - or isinstance(event.device, rfxtrxmod.RfyDevice) - ) + ) or isinstance(event.device, rfxtrxmod.RfyDevice) async def async_setup_entry( diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 85a916e95cd..2c458985498 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -30,6 +30,9 @@ from .entity import ( async_check_create_deprecated, ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RingBinarySensorEntityDescription( diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py index b9d5cceb373..30600237847 100644 --- a/homeassistant/components/ring/button.py +++ b/homeassistant/components/ring/button.py @@ -12,6 +12,10 @@ from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + BUTTON_DESCRIPTION = ButtonEntityDescription( key="open_door", translation_key="open_door" ) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index ccd91c163d6..c1a4e67ffd4 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -34,6 +34,10 @@ from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingDeviceT, RingEntity, exception_wrap +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + FORCE_REFRESH_INTERVAL = timedelta(minutes=3) MOTION_DETECTION_CAPABILITY = "motion_detection" diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index a1024186349..a23fd8f73de 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -8,7 +8,6 @@ import uuid from ring_doorbell import Auth, AuthenticationError, Requires2FAError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -26,6 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import get_auth_user_agent from .const import CONF_2FA, CONF_CONFIG_ENTRY_MINOR_VERSION, DOMAIN @@ -78,7 +78,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): hardware_id: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" # Ring has a single config entry per cloud username rather than per device diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index b93a7f35322..d48cc35a4f5 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any, Concatenate, Generic, cast +from typing import Any, Concatenate, Generic, TypeVar, cast from ring_doorbell import ( AuthenticationError, @@ -11,7 +11,6 @@ from ring_doorbell import ( RingGeneric, RingTimeout, ) -from typing_extensions import TypeVar from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py index 71a4bc8aea5..4d7a6277579 100644 --- a/homeassistant/components/ring/event.py +++ b/homeassistant/components/ring/event.py @@ -18,6 +18,9 @@ from . import RingConfigEntry from .coordinator import RingListenCoordinator from .entity import RingBaseEntity, RingDeviceT +# Event entity does not perform updates or actions. +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RingEventEntityDescription(EventEntityDescription, Generic[RingDeviceT]): diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 9e29373a3aa..9ae0bac1004 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -18,6 +18,9 @@ from .entity import RingEntity, exception_wrap _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 # It takes a few seconds for the API to correctly return an update indicating # that the changes have been made. Once we request a change (i.e. a light diff --git a/homeassistant/components/ring/number.py b/homeassistant/components/ring/number.py index 91aabb6c800..b920ff7edc7 100644 --- a/homeassistant/components/ring/number.py +++ b/homeassistant/components/ring/number.py @@ -20,6 +20,10 @@ from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingDeviceT, RingEntity, refresh_after +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index dee67882857..cf851a113bc 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -41,6 +41,9 @@ from .entity import ( async_check_create_deprecated, ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index b1452f7aeb5..05fa07c39eb 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -36,6 +36,10 @@ from .entity import ( _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class RingSirenEntityDescription( diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 0ac31fec209..e81d483adf3 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -27,6 +27,10 @@ from .entity import ( _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + IN_HOME_CHIME_IS_PRESENT = {v for k, v in DOORBELL_EXISTING_TYPE.items() if k != 2} diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index c226c1c590d..149b8761589 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/risco", "iot_class": "local_push", "loggers": ["pyrisco"], - "requirements": ["pyrisco==0.6.4"] + "requirements": ["pyrisco==0.6.5"] } diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 8fd437e7e1d..ac6c66bb6d2 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -271,11 +271,9 @@ class RMVDepartureData: if not dest_found: continue - if ( - self._lines - and journey["number"] not in self._lines - or journey["minutes"] < self._time_offset - ): + if (self._lines and journey["number"] not in self._lines) or journey[ + "minutes" + ] < self._time_offset: continue for attr in ("direction", "departure_time", "product", "minutes"): diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index d02dddece42..9ab9226c9a5 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -9,7 +9,13 @@ from datetime import timedelta import logging from typing import Any -from roborock import HomeDataRoom, RoborockException, RoborockInvalidCredentials +from roborock import ( + HomeDataRoom, + RoborockException, + RoborockInvalidCredentials, + RoborockInvalidUserAgreement, + RoborockNoUserAgreement, +) from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockMqttClientA01 @@ -60,12 +66,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_domain=DOMAIN, translation_key="invalid_credentials", ) from err + except RoborockInvalidUserAgreement as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="invalid_user_agreement", + ) from err + except RoborockNoUserAgreement as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="no_user_agreement", + ) from err except RoborockException as err: raise ConfigEntryNotReady( "Failed to get Roborock home data", translation_domain=DOMAIN, translation_key="home_data_fail", ) from err + _LOGGER.debug("Got home data %s", home_data) all_devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices device_map: dict[str, HomeDataDevice] = { @@ -188,14 +205,6 @@ async def setup_device_v1( coordinator = RoborockDataUpdateCoordinator( hass, device, networking, product_info, mqtt_client, home_data_rooms ) - # Verify we can communicate locally - if we can't, switch to cloud api - await coordinator.verify_api() - coordinator.api.is_available = True - try: - await coordinator.get_maps() - except RoborockException as err: - _LOGGER.warning("Failed to get map data") - _LOGGER.debug(err) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady as ex: diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 200614b024e..1a6b67286bb 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -60,7 +60,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: username = user_input[CONF_USERNAME] await self.async_set_unique_id(username.lower()) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(error="already_configured_account") self._username = username _LOGGER.debug("Requesting code for Roborock account") self._client = RoborockApiClient(username) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 834b25965c3..4a9bd14bfe1 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -49,3 +49,5 @@ IMAGE_CACHE_INTERVAL = 90 MAP_SLEEP = 3 GET_MAPS_SERVICE_NAME = "get_maps" +SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" +GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position" diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index fe592074f71..d34ba49da52 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import timedelta import logging -from propcache import cached_property +from propcache.api import cached_property from roborock import HomeDataRoom from roborock.code_mappings import RoborockCategory from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo @@ -73,7 +73,27 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} - async def verify_api(self) -> None: + async def _async_setup(self) -> None: + """Set up the coordinator.""" + # Verify we can communicate locally - if we can't, switch to cloud api + await self._verify_api() + self.api.is_available = True + + try: + maps = await self.api.get_multi_maps_list() + except RoborockException as err: + raise UpdateFailed("Failed to get map data: {err}") from err + # Rooms names populated later with calls to `set_current_map_rooms` for each map + self.maps = { + roborock_map.mapFlag: RoborockMapInfo( + flag=roborock_map.mapFlag, + name=roborock_map.name or f"Map {roborock_map.mapFlag}", + rooms={}, + ) + for roborock_map in (maps.map_info if (maps and maps.map_info) else ()) + } + + async def _verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" if isinstance(self.api, RoborockLocalClientV1): try: @@ -96,12 +116,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): async def _update_device_prop(self) -> None: """Update device properties.""" - device_prop = await self.api.get_prop() - if device_prop: - if self.roborock_device_info.props: - self.roborock_device_info.props.update(device_prop) - else: - self.roborock_device_info.props = device_prop + if (device_prop := await self.api.get_prop()) is not None: + self.roborock_device_info.props.update(device_prop) async def _async_update_data(self) -> DeviceProp: """Update data via library.""" @@ -111,7 +127,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Set the new map id from the updated device props self._set_current_map() # Get the rooms for that map id. - await self.get_rooms() + await self.set_current_map_rooms() except RoborockException as ex: raise UpdateFailed(ex) from ex return self.roborock_device_info.props @@ -127,27 +143,18 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.roborock_device_info.props.status.map_status - 3 ) // 4 - async def get_maps(self) -> None: - """Add a map to the coordinators mapping.""" - maps = await self.api.get_multi_maps_list() - if maps and maps.map_info: - for roborock_map in maps.map_info: - self.maps[roborock_map.mapFlag] = RoborockMapInfo( - flag=roborock_map.mapFlag, name=roborock_map.name, rooms={} - ) - - async def get_rooms(self) -> None: - """Get all of the rooms for the current map.""" + async def set_current_map_rooms(self) -> None: + """Fetch all of the rooms for the current map and set on RoborockMapInfo.""" # The api is only able to access rooms for the currently selected map # So it is important this is only called when you have the map you care # about selected. - if self.current_map in self.maps: - iot_rooms = await self.api.get_room_mapping() - if iot_rooms is not None: - for room in iot_rooms: - self.maps[self.current_map].rooms[room.segment_id] = ( - self._home_data_rooms.get(room.iot_id, "Unknown") - ) + if self.current_map is None or self.current_map not in self.maps: + return + room_mapping = await self.api.get_room_mapping() + self.maps[self.current_map].rooms = { + room.segment_id: self._home_data_rooms.get(room.iot_id, "Unknown") + for room in room_mapping or () + } @cached_property def duid(self) -> str: diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json index c7df6d35460..6a96b04e12e 100644 --- a/homeassistant/components/roborock/icons.json +++ b/homeassistant/components/roborock/icons.json @@ -61,6 +61,9 @@ "total_cleaning_area": { "default": "mdi:texture-box" }, + "total_cleaning_count": { + "default": "mdi:counter" + }, "vacuum_error": { "default": "mdi:alert-circle" }, @@ -121,6 +124,12 @@ "services": { "get_maps": { "service": "mdi:floor-plan" + }, + "set_vacuum_goto_position": { + "service": "mdi:map-marker" + }, + "get_vacuum_current_position": { + "service": "mdi:map-marker" } } } diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index ee48656290f..8717920b907 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -121,7 +121,10 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): """Update the image if it is not cached.""" if self.is_map_valid(): response = await asyncio.gather( - *(self.cloud_api.get_map_v1(), self.coordinator.get_rooms()), + *( + self.cloud_api.get_map_v1(), + self.coordinator.set_current_map_rooms(), + ), return_exceptions=True, ) if not isinstance(response[0], bytes): @@ -174,7 +177,8 @@ async def create_coordinator_maps( await asyncio.sleep(MAP_SLEEP) # Get the map data map_update = await asyncio.gather( - *[coord.cloud_api.get_map_v1(), coord.get_rooms()], return_exceptions=True + *[coord.cloud_api.get_map_v1(), coord.set_current_map_rooms()], + return_exceptions=True, ) # If we fail to get the map, we should set it to empty byte, # still create it, and set it as unavailable. diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index c305e4710fc..d104ebff12a 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -1,13 +1,13 @@ { "domain": "roborock", "name": "Roborock", - "codeowners": ["@Lash-L"], + "codeowners": ["@Lash-L", "@allenporter"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.7.2", + "python-roborock==2.8.4", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 47849ed5cc5..e01a03d7720 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -24,6 +24,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant @@ -112,6 +113,13 @@ SENSOR_DESCRIPTIONS = [ value_fn=lambda data: data.clean_summary.clean_time, entity_category=EntityCategory.DIAGNOSTIC, ), + RoborockSensorDescription( + key="total_cleaning_count", + translation_key="total_cleaning_count", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.clean_summary.clean_count, + entity_category=EntityCategory.DIAGNOSTIC, + ), RoborockSensorDescription( key="status", device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/roborock/services.yaml b/homeassistant/components/roborock/services.yaml index 18de5c98c7b..eebda66fac7 100644 --- a/homeassistant/components/roborock/services.yaml +++ b/homeassistant/components/roborock/services.yaml @@ -1,4 +1,28 @@ get_maps: target: entity: + integration: roborock + domain: vacuum +set_vacuum_goto_position: + target: + entity: + integration: roborock + domain: vacuum + fields: + x: + example: 27500 + required: true + selector: + text: + type: number + y: + example: 32000 + required: true + selector: + text: + type: number +get_vacuum_current_position: + target: + entity: + integration: roborock domain: vacuum diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 8ff82cae393..7005344614c 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -28,7 +28,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, @@ -228,6 +228,9 @@ "total_cleaning_area": { "name": "Total cleaning area" }, + "total_cleaning_count": { + "name": "Total cleaning count" + }, "vacuum_error": { "name": "Vacuum error", "state": { @@ -422,12 +425,36 @@ }, "update_options_failed": { "message": "Failed to update Roborock options" + }, + "invalid_user_agreement": { + "message": "User agreement must be accepted again. Open your Roborock app and accept the agreement." + }, + "no_user_agreement": { + "message": "You have not valid user agreement. Open your Roborock app and accept the agreement." } }, "services": { "get_maps": { "name": "Get maps", "description": "Get the map and room information of your device." + }, + "set_vacuum_goto_position": { + "name": "Go to position", + "description": "Send the vacuum to a specific position.", + "fields": { + "x": { + "name": "X-coordinate", + "description": "Coordinates are relative to the dock. x=25500,y=25500 is the dock position." + }, + "y": { + "name": "Y-coordinate", + "description": "[%key:component::roborock::services::set_vacuum_goto_position::fields::x::description%]" + } + } + }, + "get_vacuum_current_position": { + "name": "Get current position", + "description": "Get the current position of the vacuum." } } } diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 3b873f259e4..7582dadad16 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -6,50 +6,53 @@ from typing import Any from roborock.code_mappings import RoborockStateCode from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand +import voluptuous as vol from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse -from homeassistant.helpers import entity_platform +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RoborockConfigEntry -from .const import DOMAIN, GET_MAPS_SERVICE_NAME +from .const import ( + DOMAIN, + GET_MAPS_SERVICE_NAME, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, +) from .coordinator import RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +from .image import ColorsPalette, ImageConfig, RoborockMapDataParser, Sizes STATE_CODE_TO_STATE = { - RoborockStateCode.starting: STATE_IDLE, # "Starting" - RoborockStateCode.charger_disconnected: STATE_IDLE, # "Charger disconnected" - RoborockStateCode.idle: STATE_IDLE, # "Idle" - RoborockStateCode.remote_control_active: STATE_CLEANING, # "Remote control active" - RoborockStateCode.cleaning: STATE_CLEANING, # "Cleaning" - RoborockStateCode.returning_home: STATE_RETURNING, # "Returning home" - RoborockStateCode.manual_mode: STATE_CLEANING, # "Manual mode" - RoborockStateCode.charging: STATE_DOCKED, # "Charging" - RoborockStateCode.charging_problem: STATE_ERROR, # "Charging problem" - RoborockStateCode.paused: STATE_PAUSED, # "Paused" - RoborockStateCode.spot_cleaning: STATE_CLEANING, # "Spot cleaning" - RoborockStateCode.error: STATE_ERROR, # "Error" - RoborockStateCode.shutting_down: STATE_IDLE, # "Shutting down" - RoborockStateCode.updating: STATE_DOCKED, # "Updating" - RoborockStateCode.docking: STATE_RETURNING, # "Docking" - RoborockStateCode.going_to_target: STATE_CLEANING, # "Going to target" - RoborockStateCode.zoned_cleaning: STATE_CLEANING, # "Zoned cleaning" - RoborockStateCode.segment_cleaning: STATE_CLEANING, # "Segment cleaning" - RoborockStateCode.emptying_the_bin: STATE_DOCKED, # "Emptying the bin" on s7+ - RoborockStateCode.washing_the_mop: STATE_DOCKED, # "Washing the mop" on s7maxV - RoborockStateCode.going_to_wash_the_mop: STATE_RETURNING, # "Going to wash the mop" on s7maxV - RoborockStateCode.charging_complete: STATE_DOCKED, # "Charging complete" - RoborockStateCode.device_offline: STATE_ERROR, # "Device offline" + RoborockStateCode.starting: VacuumActivity.IDLE, # "Starting" + RoborockStateCode.charger_disconnected: VacuumActivity.IDLE, # "Charger disconnected" + RoborockStateCode.idle: VacuumActivity.IDLE, # "Idle" + RoborockStateCode.remote_control_active: VacuumActivity.CLEANING, # "Remote control active" + RoborockStateCode.cleaning: VacuumActivity.CLEANING, # "Cleaning" + RoborockStateCode.returning_home: VacuumActivity.RETURNING, # "Returning home" + RoborockStateCode.manual_mode: VacuumActivity.CLEANING, # "Manual mode" + RoborockStateCode.charging: VacuumActivity.DOCKED, # "Charging" + RoborockStateCode.charging_problem: VacuumActivity.ERROR, # "Charging problem" + RoborockStateCode.paused: VacuumActivity.PAUSED, # "Paused" + RoborockStateCode.spot_cleaning: VacuumActivity.CLEANING, # "Spot cleaning" + RoborockStateCode.error: VacuumActivity.ERROR, # "Error" + RoborockStateCode.shutting_down: VacuumActivity.IDLE, # "Shutting down" + RoborockStateCode.updating: VacuumActivity.DOCKED, # "Updating" + RoborockStateCode.docking: VacuumActivity.RETURNING, # "Docking" + RoborockStateCode.going_to_target: VacuumActivity.CLEANING, # "Going to target" + RoborockStateCode.zoned_cleaning: VacuumActivity.CLEANING, # "Zoned cleaning" + RoborockStateCode.segment_cleaning: VacuumActivity.CLEANING, # "Segment cleaning" + RoborockStateCode.emptying_the_bin: VacuumActivity.DOCKED, # "Emptying the bin" on s7+ + RoborockStateCode.washing_the_mop: VacuumActivity.DOCKED, # "Washing the mop" on s7maxV + RoborockStateCode.going_to_wash_the_mop: VacuumActivity.RETURNING, # "Going to wash the mop" on s7maxV + RoborockStateCode.charging_complete: VacuumActivity.DOCKED, # "Charging complete" + RoborockStateCode.device_offline: VacuumActivity.ERROR, # "Device offline" } @@ -74,6 +77,25 @@ async def async_setup_entry( supports_response=SupportsResponse.ONLY, ) + platform.async_register_entity_service( + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + None, + RoborockVacuum.get_vacuum_current_position.__name__, + supports_response=SupportsResponse.ONLY, + ) + + platform.async_register_entity_service( + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, + cv.make_entity_service_schema( + { + vol.Required("x"): vol.Coerce(int), + vol.Required("y"): vol.Coerce(int), + }, + ), + RoborockVacuum.async_set_vacuum_goto_position.__name__, + supports_response=SupportsResponse.NONE, + ) + class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): """General Representation of a Roborock vacuum.""" @@ -112,7 +134,7 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): self._attr_fan_speed_list = self._device_status.fan_power_options @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Return the status of the vacuum cleaner.""" assert self._device_status.state is not None return STATE_CODE_TO_STATE.get(self._device_status.state) @@ -163,6 +185,10 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): [self._device_status.get_fan_speed_code(fan_speed)], ) + async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: + """Send vacuum to a specific target point.""" + await self.send(RoborockCommand.APP_GOTO_TARGET, [x, y]) + async def async_send_command( self, command: str, @@ -179,3 +205,21 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): asdict(vacuum_map) for vacuum_map in self.coordinator.maps.values() ] } + + async def get_vacuum_current_position(self) -> ServiceResponse: + """Get the current position of the vacuum from the map.""" + + map_data = await self.coordinator.cloud_api.get_map_v1() + if not isinstance(map_data, bytes): + raise HomeAssistantError("Failed to retrieve map data.") + parser = RoborockMapDataParser(ColorsPalette(), Sizes(), [], ImageConfig(), []) + parsed_map = parser.parse(map_data) + robot_position = parsed_map.vacuum_position + + if robot_position is None: + raise HomeAssistantError("Robot position not found") + + return { + "x": robot_position.x, + "y": robot_position.y, + } diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index b318a91e4c7..e6b92d91335 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN +from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID from .coordinator import RokuDataUpdateCoordinator PLATFORMS = [ @@ -17,8 +17,10 @@ PLATFORMS = [ Platform.SENSOR, ] +type RokuConfigEntry = ConfigEntry[RokuDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool: """Set up Roku from a config entry.""" if (device_id := entry.unique_id) is None: device_id = entry.entry_id @@ -33,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -42,13 +44,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> None: """Reload the config entry when it changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py index 0f5f29f63f6..2e7fd12788c 100644 --- a/homeassistant/components/roku/binary_sensor.py +++ b/homeassistant/components/roku/binary_sensor.py @@ -11,14 +11,16 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import RokuConfigEntry from .entity import RokuEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RokuBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -56,15 +58,13 @@ BINARY_SENSORS: tuple[RokuBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Roku binary sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( RokuBinarySensorEntity( - coordinator=coordinator, + coordinator=entry.runtime_data, description=description, ) for description in BINARY_SENSORS diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 18e3b3ed68a..2fb016b5467 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -9,9 +9,8 @@ from urllib.parse import urlparse from rokuecp import Roku, RokuError import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ( - ConfigEntry, + SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -19,7 +18,14 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from . import RokuConfigEntry from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) @@ -57,20 +63,38 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): self.discovery_info = {} @callback - def _show_form(self, errors: dict[str, Any] | None = None) -> ConfigFlowResult: + def _show_form( + self, + user_input: dict[str, Any] | None, + errors: dict[str, Any] | None = None, + ) -> ConfigFlowResult: """Show the form to the user.""" + suggested_values = user_input + if suggested_values is None and self.source == SOURCE_RECONFIGURE: + suggested_values = { + CONF_HOST: self._get_reconfigure_entry().data[CONF_HOST] + } + return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, suggested_values + ), errors=errors or {}, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + return await self.async_step_user(user_input) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" if not user_input: - return self._show_form() + return self._show_form(user_input) errors = {} @@ -79,18 +103,26 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): except RokuError: _LOGGER.debug("Roku Error", exc_info=True) errors["base"] = ERROR_CANNOT_CONNECT - return self._show_form(errors) + return self._show_form(user_input, errors) except Exception: _LOGGER.exception("Unknown error trying to connect") return self.async_abort(reason=ERROR_UNKNOWN) await self.async_set_unique_id(info["serial_number"]) - self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) + + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_HOST: user_input[CONF_HOST]}, + ) + + self._abort_if_unique_id_configured() return self.async_create_entry(title=info["title"], data=user_input) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by homekit discovery.""" @@ -120,12 +152,12 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" host = urlparse(discovery_info.ssdp_location).hostname - name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] - serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] + serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) @@ -164,7 +196,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RokuConfigEntry, ) -> RokuOptionsFlowHandler: """Create the options flow.""" return RokuOptionsFlowHandler() diff --git a/homeassistant/components/roku/diagnostics.py b/homeassistant/components/roku/diagnostics.py index 6c6809ee33a..e98837ca442 100644 --- a/homeassistant/components/roku/diagnostics.py +++ b/homeassistant/components/roku/diagnostics.py @@ -4,25 +4,21 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import RokuDataUpdateCoordinator +from . import RokuConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, entry: RokuConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - return { "entry": { "data": { - **config_entry.data, + **entry.data, }, - "unique_id": config_entry.unique_id, + "unique_id": entry.unique_id, }, - "data": coordinator.data.as_dict(), + "data": entry.runtime_data.data.as_dict(), } diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 35f01553cdd..0c1f92521af 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -23,13 +23,13 @@ from homeassistant.components.media_player import ( async_process_play_media_url, ) from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType +from . import RokuConfigEntry from .browse_media import async_browse_media from .const import ( ATTR_ARTIST_NAME, @@ -38,7 +38,6 @@ from .const import ( ATTR_KEYWORD, ATTR_MEDIA_TYPE, ATTR_THUMBNAIL, - DOMAIN, SERVICE_SEARCH, ) from .coordinator import RokuDataUpdateCoordinator @@ -47,7 +46,6 @@ from .helpers import format_channel_name, roku_exception_handler _LOGGER = logging.getLogger(__name__) - STREAM_FORMAT_TO_MEDIA_TYPE = { "dash": MediaType.VIDEO, "hls": MediaType.VIDEO, @@ -81,17 +79,17 @@ ATTRS_TO_PLAY_ON_ROKU_AUDIO_PARAMS = { SEARCH_SCHEMA: VolDictType = {vol.Required(ATTR_KEYWORD): str} +PARALLEL_UPDATES = 1 + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Roku config entry.""" - coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( [ RokuMediaPlayer( - coordinator=coordinator, + coordinator=entry.runtime_data, ) ], True, diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index fa351e021e8..f7916fb23a2 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -6,28 +6,26 @@ from collections.abc import Iterable from typing import Any from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import RokuDataUpdateCoordinator +from . import RokuConfigEntry from .entity import RokuEntity from .helpers import roku_exception_handler +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Load Roku remote based on a config entry.""" - coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( [ RokuRemote( - coordinator=coordinator, + coordinator=entry.runtime_data, ) ], True, diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index 5f3b9d4049b..360d4e25415 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -9,15 +9,15 @@ from rokuecp import Roku from rokuecp.models import Device as RokuDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import RokuDataUpdateCoordinator +from . import RokuConfigEntry from .entity import RokuEntity from .helpers import format_channel_name, roku_exception_handler +PARALLEL_UPDATES = 1 + def _get_application_name(device: RokuDevice) -> str | None: if device.app is None or device.app.name is None: @@ -108,16 +108,15 @@ CHANNEL_ENTITY = RokuSelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Roku select based on a config entry.""" - coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - device: RokuDevice = coordinator.data + device: RokuDevice = entry.runtime_data.data entities: list[RokuSelectEntity] = [ RokuSelectEntity( - coordinator=coordinator, + coordinator=entry.runtime_data, description=description, ) for description in ENTITIES @@ -126,7 +125,7 @@ async def async_setup_entry( if len(device.channels) > 0: entities.append( RokuSelectEntity( - coordinator=coordinator, + coordinator=entry.runtime_data, description=CHANNEL_ENTITY, ) ) diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py index ed134cc4c2a..870386945a6 100644 --- a/homeassistant/components/roku/sensor.py +++ b/homeassistant/components/roku/sensor.py @@ -8,15 +8,16 @@ from dataclasses import dataclass from rokuecp.models import Device as RokuDevice from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import RokuDataUpdateCoordinator +from . import RokuConfigEntry from .entity import RokuEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RokuSensorEntityDescription(SensorEntityDescription): @@ -43,15 +44,13 @@ SENSORS: tuple[RokuSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Roku sensor based on a config entry.""" - coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( RokuSensorEntity( - coordinator=coordinator, + coordinator=entry.runtime_data, description=description, ) for description in SENSORS diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 9d657be6d61..04348bc3bfb 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -21,7 +21,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_device": "This Roku device does not match the existing device ID. Please make sure you entered the correct host information." } }, "options": { diff --git a/homeassistant/components/romy/config_flow.py b/homeassistant/components/romy/config_flow.py index e571ff41c9a..6bb5c337b29 100644 --- a/homeassistant/components/romy/config_flow.py +++ b/homeassistant/components/romy/config_flow.py @@ -5,10 +5,10 @@ from __future__ import annotations import romy import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -84,7 +84,7 @@ class RomyConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py index de74d371f0e..49129daabbd 100644 --- a/homeassistant/components/romy/vacuum.py +++ b/homeassistant/components/romy/vacuum.py @@ -6,7 +6,11 @@ https://home-assistant.io/components/vacuum.romy/. from typing import Any -from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature +from homeassistant.components.vacuum import ( + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -75,7 +79,14 @@ class RomyVacuumEntity(RomyEntity, StateVacuumEntity): """Handle updated data from the coordinator.""" self._attr_fan_speed = FAN_SPEEDS[self.romy.fan_speed] self._attr_battery_level = self.romy.battery_level - self._attr_state = self.romy.status + if (status := self.romy.status) is None: + self._attr_activity = None + self.async_write_ha_state() + return + try: + self._attr_activity = VacuumActivity(status) + except ValueError: + self._attr_activity = None self.async_write_ha_state() diff --git a/homeassistant/components/roomba/braava.py b/homeassistant/components/roomba/braava.py deleted file mode 100644 index 8744561b2c5..00000000000 --- a/homeassistant/components/roomba/braava.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Class for Braava devices.""" - -import logging - -from homeassistant.components.vacuum import VacuumEntityFeature - -from .entity import SUPPORT_IROBOT, IRobotVacuum - -_LOGGER = logging.getLogger(__name__) - -ATTR_DETECTED_PAD = "detected_pad" -ATTR_LID_CLOSED = "lid_closed" -ATTR_TANK_PRESENT = "tank_present" -ATTR_TANK_LEVEL = "tank_level" -ATTR_PAD_WETNESS = "spray_amount" - -OVERLAP_STANDARD = 67 -OVERLAP_DEEP = 85 -OVERLAP_EXTENDED = 25 -MOP_STANDARD = "Standard" -MOP_DEEP = "Deep" -MOP_EXTENDED = "Extended" -BRAAVA_MOP_BEHAVIORS = [MOP_STANDARD, MOP_DEEP, MOP_EXTENDED] -BRAAVA_SPRAY_AMOUNT = [1, 2, 3] - -# Braava Jets can set mopping behavior through fanspeed -SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED - - -class BraavaJet(IRobotVacuum): # pylint: disable=hass-enforce-class-module - """Braava Jet.""" - - _attr_supported_features = SUPPORT_BRAAVA - - def __init__(self, roomba, blid): - """Initialize the Roomba handler.""" - super().__init__(roomba, blid) - - # Initialize fan speed list - self._attr_fan_speed_list = [ - f"{behavior}-{spray}" - for behavior in BRAAVA_MOP_BEHAVIORS - for spray in BRAAVA_SPRAY_AMOUNT - ] - - @property - def fan_speed(self): - """Return the fan speed of the vacuum cleaner.""" - # Mopping behavior and spray amount as fan speed - rank_overlap = self.vacuum_state.get("rankOverlap", {}) - behavior = None - if rank_overlap == OVERLAP_STANDARD: - behavior = MOP_STANDARD - elif rank_overlap == OVERLAP_DEEP: - behavior = MOP_DEEP - elif rank_overlap == OVERLAP_EXTENDED: - behavior = MOP_EXTENDED - pad_wetness = self.vacuum_state.get("padWetness", {}) - # "disposable" and "reusable" values are always the same - pad_wetness_value = pad_wetness.get("disposable") - return f"{behavior}-{pad_wetness_value}" - - async def async_set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed.""" - try: - split = fan_speed.split("-", 1) - behavior = split[0] - spray = int(split[1]) - if behavior.capitalize() in BRAAVA_MOP_BEHAVIORS: - behavior = behavior.capitalize() - except IndexError: - _LOGGER.error( - "Fan speed error: expected {behavior}-{spray_amount}, got '%s'", - fan_speed, - ) - return - except ValueError: - _LOGGER.error("Spray amount error: expected integer, got '%s'", split[1]) - return - if behavior not in BRAAVA_MOP_BEHAVIORS: - _LOGGER.error( - "Mop behavior error: expected one of %s, got '%s'", - str(BRAAVA_MOP_BEHAVIORS), - behavior, - ) - return - if spray not in BRAAVA_SPRAY_AMOUNT: - _LOGGER.error( - "Spray amount error: expected one of %s, got '%d'", - str(BRAAVA_SPRAY_AMOUNT), - spray, - ) - return - - overlap = 0 - if behavior == MOP_STANDARD: - overlap = OVERLAP_STANDARD - elif behavior == MOP_DEEP: - overlap = OVERLAP_DEEP - else: - overlap = OVERLAP_EXTENDED - await self.hass.async_add_executor_job( - self.vacuum.set_preference, "rankOverlap", overlap - ) - await self.hass.async_add_executor_job( - self.vacuum.set_preference, - "padWetness", - {"disposable": spray, "reusable": spray}, - ) - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - state_attrs = super().extra_state_attributes - - # Get Braava state - state = self.vacuum_state - detected_pad = state.get("detectedPad") - mop_ready = state.get("mopReady", {}) - lid_closed = mop_ready.get("lidClosed") - tank_present = mop_ready.get("tankPresent") - tank_level = state.get("tankLvl") - state_attrs[ATTR_DETECTED_PAD] = detected_pad - state_attrs[ATTR_LID_CLOSED] = lid_closed - state_attrs[ATTR_TANK_PRESENT] = tank_present - state_attrs[ATTR_TANK_LEVEL] = tank_level - - return state_attrs diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index d040074246a..b7d259e3131 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -11,7 +11,6 @@ from roombapy.discovery import RoombaDiscovery from roombapy.getpassword import RoombaPassword import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,8 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import CannotConnect, async_connect_or_timeout, async_disconnect_or_timeout from .const import ( @@ -95,7 +96,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN): return RoombaOptionsFlowHandler() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" return await self._async_step_discovery( @@ -103,7 +104,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" return await self._async_step_discovery( diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index 10c3d36de12..d55a260e53a 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -2,62 +2,15 @@ from __future__ import annotations -import asyncio -import logging - -from homeassistant.components.vacuum import ( - ATTR_STATUS, - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_RETURNING, - StateVacuumEntity, - VacuumEntityFeature, -) -from homeassistant.const import ATTR_CONNECTIONS, STATE_IDLE, STATE_PAUSED +from homeassistant.const import ATTR_CONNECTIONS import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import METRIC_SYSTEM from . import roomba_reported_state from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - -ATTR_CLEANING_TIME = "cleaning_time" -ATTR_CLEANED_AREA = "cleaned_area" -ATTR_ERROR = "error" -ATTR_ERROR_CODE = "error_code" -ATTR_POSITION = "position" -ATTR_SOFTWARE_VERSION = "software_version" - -# Commonly supported features -SUPPORT_IROBOT = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.PAUSE - | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.SEND_COMMAND - | VacuumEntityFeature.START - | VacuumEntityFeature.STATE - | VacuumEntityFeature.STOP - | VacuumEntityFeature.LOCATE -) - -STATE_MAP = { - "": STATE_IDLE, - "charge": STATE_DOCKED, - "evac": STATE_RETURNING, # Emptying at cleanbase - "hmMidMsn": STATE_CLEANING, # Recharging at the middle of a cycle - "hmPostMsn": STATE_RETURNING, # Cycle finished - "hmUsrDock": STATE_RETURNING, - "pause": STATE_PAUSED, - "run": STATE_CLEANING, - "stop": STATE_IDLE, - "stuck": STATE_ERROR, -} - class IRobotEntity(Entity): """Base class for iRobot Entities.""" @@ -65,7 +18,7 @@ class IRobotEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, roomba, blid): + def __init__(self, roomba, blid) -> None: """Initialize the iRobot handler.""" self.vacuum = roomba self._blid = blid @@ -127,20 +80,6 @@ class IRobotEntity(Entity): return None return dt_util.utc_from_timestamp(ts) - @property - def _robot_state(self): - """Return the state of the vacuum cleaner.""" - clean_mission_status = self.vacuum_state.get("cleanMissionStatus", {}) - cycle = clean_mission_status.get("cycle") - phase = clean_mission_status.get("phase") - try: - state = STATE_MAP[phase] - except KeyError: - return STATE_ERROR - if cycle != "none" and state in (STATE_IDLE, STATE_DOCKED): - state = STATE_PAUSED - return state - async def async_added_to_hass(self): """Register callback function.""" self.vacuum.register_on_message_callback(self.on_message) @@ -154,125 +93,3 @@ class IRobotEntity(Entity): state = json_data.get("state", {}).get("reported", {}) if self.new_state_filter(state): self.schedule_update_ha_state() - - -class IRobotVacuum(IRobotEntity, StateVacuumEntity): # pylint: disable=hass-enforce-class-module - """Base class for iRobot robots.""" - - _attr_name = None - _attr_supported_features = SUPPORT_IROBOT - _attr_available = True # Always available, otherwise setup will fail - - def __init__(self, roomba, blid): - """Initialize the iRobot handler.""" - super().__init__(roomba, blid) - self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 - - @property - def state(self): - """Return the state of the vacuum cleaner.""" - return self._robot_state - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - state = self.vacuum_state - - # Roomba software version - software_version = state.get("softwareVer") - - # Set properties that are to appear in the GUI - state_attrs = {ATTR_SOFTWARE_VERSION: software_version} - - # Set legacy status to avoid break changes - state_attrs[ATTR_STATUS] = self.vacuum.current_state - - # Only add cleaning time and cleaned area attrs when the vacuum is - # currently on - if self.state == STATE_CLEANING: - # Get clean mission status - ( - state_attrs[ATTR_CLEANING_TIME], - state_attrs[ATTR_CLEANED_AREA], - ) = self.get_cleaning_status(state) - - # Error - if self.vacuum.error_code != 0: - state_attrs[ATTR_ERROR] = self.vacuum.error_message - state_attrs[ATTR_ERROR_CODE] = self.vacuum.error_code - - # Not all Roombas expose position data - # https://github.com/koalazak/dorita980/issues/48 - if self._cap_position: - pos_state = state.get("pose", {}) - position = None - pos_x = pos_state.get("point", {}).get("x") - pos_y = pos_state.get("point", {}).get("y") - theta = pos_state.get("theta") - if all(item is not None for item in (pos_x, pos_y, theta)): - position = f"({pos_x}, {pos_y}, {theta})" - state_attrs[ATTR_POSITION] = position - - return state_attrs - - def get_cleaning_status(self, state) -> tuple[int, int]: - """Return the cleaning time and cleaned area from the device.""" - if not (mission_state := state.get("cleanMissionStatus")): - return (0, 0) - - if cleaning_time := mission_state.get("mssnM", 0): - pass - elif start_time := mission_state.get("mssnStrtTm"): - now = dt_util.as_timestamp(dt_util.utcnow()) - if now > start_time: - cleaning_time = (now - start_time) // 60 - - if cleaned_area := mission_state.get("sqft", 0): # Imperial - # Convert to m2 if the unit_system is set to metric - if self.hass.config.units is METRIC_SYSTEM: - cleaned_area = round(cleaned_area * 0.0929) - - return (cleaning_time, cleaned_area) - - def on_message(self, json_data): - """Update state on message change.""" - state = json_data.get("state", {}).get("reported", {}) - if self.new_state_filter(state): - _LOGGER.debug("Got new state from the vacuum: %s", json_data) - self.schedule_update_ha_state() - - async def async_start(self): - """Start or resume the cleaning task.""" - if self.state == STATE_PAUSED: - await self.hass.async_add_executor_job(self.vacuum.send_command, "resume") - else: - await self.hass.async_add_executor_job(self.vacuum.send_command, "start") - - async def async_stop(self, **kwargs): - """Stop the vacuum cleaner.""" - await self.hass.async_add_executor_job(self.vacuum.send_command, "stop") - - async def async_pause(self): - """Pause the cleaning cycle.""" - await self.hass.async_add_executor_job(self.vacuum.send_command, "pause") - - async def async_return_to_base(self, **kwargs): - """Set the vacuum cleaner to return to the dock.""" - if self.state == STATE_CLEANING: - await self.async_pause() - for _ in range(10): - if self.state == STATE_PAUSED: - break - await asyncio.sleep(1) - await self.hass.async_add_executor_job(self.vacuum.send_command, "dock") - - async def async_locate(self, **kwargs): - """Located vacuum.""" - await self.hass.async_add_executor_job(self.vacuum.send_command, "find") - - async def async_send_command(self, command, params=None, **kwargs): - """Send raw command.""" - _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs) - await self.hass.async_add_executor_job( - self.vacuum.send_command, command, params - ) diff --git a/homeassistant/components/roomba/roomba.py b/homeassistant/components/roomba/roomba.py deleted file mode 100644 index 917fbb2bfff..00000000000 --- a/homeassistant/components/roomba/roomba.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Class for Roomba devices.""" - -import logging - -from homeassistant.components.vacuum import VacuumEntityFeature - -from .entity import SUPPORT_IROBOT, IRobotVacuum - -_LOGGER = logging.getLogger(__name__) - -ATTR_BIN_FULL = "bin_full" -ATTR_BIN_PRESENT = "bin_present" - -FAN_SPEED_AUTOMATIC = "Automatic" -FAN_SPEED_ECO = "Eco" -FAN_SPEED_PERFORMANCE = "Performance" -FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE] - -# Only Roombas with CarpetBost can set their fanspeed -SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED - - -class RoombaVacuum(IRobotVacuum): # pylint: disable=hass-enforce-class-module - """Basic Roomba robot (without carpet boost).""" - - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - state_attrs = super().extra_state_attributes - - # Get bin state - bin_raw_state = self.vacuum_state.get("bin", {}) - bin_state = {} - if bin_raw_state.get("present") is not None: - bin_state[ATTR_BIN_PRESENT] = bin_raw_state.get("present") - if bin_raw_state.get("full") is not None: - bin_state[ATTR_BIN_FULL] = bin_raw_state.get("full") - state_attrs.update(bin_state) - - return state_attrs - - -class RoombaVacuumCarpetBoost(RoombaVacuum): # pylint: disable=hass-enforce-class-module - """Roomba robot with carpet boost.""" - - _attr_fan_speed_list = FAN_SPEEDS - _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST - - @property - def fan_speed(self): - """Return the fan speed of the vacuum cleaner.""" - fan_speed = None - carpet_boost = self.vacuum_state.get("carpetBoost") - high_perf = self.vacuum_state.get("vacHigh") - if carpet_boost is not None and high_perf is not None: - if carpet_boost: - fan_speed = FAN_SPEED_AUTOMATIC - elif high_perf: - fan_speed = FAN_SPEED_PERFORMANCE - else: # carpet_boost and high_perf are False - fan_speed = FAN_SPEED_ECO - return fan_speed - - async def async_set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed.""" - if fan_speed.capitalize() in FAN_SPEEDS: - fan_speed = fan_speed.capitalize() - _LOGGER.debug("Set fan speed to: %s", fan_speed) - high_perf = None - carpet_boost = None - if fan_speed == FAN_SPEED_AUTOMATIC: - high_perf = False - carpet_boost = True - elif fan_speed == FAN_SPEED_ECO: - high_perf = False - carpet_boost = False - elif fan_speed == FAN_SPEED_PERFORMANCE: - high_perf = True - carpet_boost = False - else: - _LOGGER.error("No such fan speed available: %s", fan_speed) - return - # The set_preference method does only accept string values - await self.hass.async_add_executor_job( - self.vacuum.set_preference, "carpetBoost", str(carpet_boost) - ) - await self.hass.async_add_executor_job( - self.vacuum.set_preference, "vacHigh", str(high_perf) - ) diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index a45b8eea632..92063f74afa 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -2,16 +2,88 @@ from __future__ import annotations +import asyncio +import logging +from typing import Any + +from homeassistant.components.vacuum import ( + ATTR_STATUS, + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util +from homeassistant.util.unit_system import METRIC_SYSTEM from . import roomba_reported_state -from .braava import BraavaJet from .const import DOMAIN -from .entity import IRobotVacuum +from .entity import IRobotEntity from .models import RoombaData -from .roomba import RoombaVacuum, RoombaVacuumCarpetBoost + +SUPPORT_IROBOT = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.SEND_COMMAND + | VacuumEntityFeature.START + | VacuumEntityFeature.STATE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.LOCATE +) + +STATE_MAP = { + "": VacuumActivity.IDLE, + "charge": VacuumActivity.DOCKED, + "evac": VacuumActivity.RETURNING, # Emptying at cleanbase + "hmMidMsn": VacuumActivity.CLEANING, # Recharging at the middle of a cycle + "hmPostMsn": VacuumActivity.RETURNING, # Cycle finished + "hmUsrDock": VacuumActivity.RETURNING, + "pause": VacuumActivity.PAUSED, + "run": VacuumActivity.CLEANING, + "stop": VacuumActivity.IDLE, + "stuck": VacuumActivity.ERROR, +} + +_LOGGER = logging.getLogger(__name__) +ATTR_SOFTWARE_VERSION = "software_version" +ATTR_CLEANING_TIME = "cleaning_time" +ATTR_CLEANED_AREA = "cleaned_area" +ATTR_ERROR = "error" +ATTR_ERROR_CODE = "error_code" +ATTR_POSITION = "position" +ATTR_SOFTWARE_VERSION = "software_version" + +ATTR_BIN_FULL = "bin_full" +ATTR_BIN_PRESENT = "bin_present" + +FAN_SPEED_AUTOMATIC = "Automatic" +FAN_SPEED_ECO = "Eco" +FAN_SPEED_PERFORMANCE = "Performance" +FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE] + +# Only Roombas with CarpetBost can set their fanspeed +SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED + +ATTR_DETECTED_PAD = "detected_pad" +ATTR_LID_CLOSED = "lid_closed" +ATTR_TANK_PRESENT = "tank_present" +ATTR_TANK_LEVEL = "tank_level" +ATTR_PAD_WETNESS = "spray_amount" + +OVERLAP_STANDARD = 67 +OVERLAP_DEEP = 85 +OVERLAP_EXTENDED = 25 +MOP_STANDARD = "Standard" +MOP_DEEP = "Deep" +MOP_EXTENDED = "Extended" +BRAAVA_MOP_BEHAVIORS = [MOP_STANDARD, MOP_DEEP, MOP_EXTENDED] +BRAAVA_SPRAY_AMOUNT = [1, 2, 3] + +# Braava Jets can set mopping behavior through fanspeed +SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED async def async_setup_entry( @@ -39,3 +111,304 @@ async def async_setup_entry( roomba_vac = constructor(roomba, blid) async_add_entities([roomba_vac]) + + +class IRobotVacuum(IRobotEntity, StateVacuumEntity): + """Base class for iRobot robots.""" + + _attr_name = None + _attr_supported_features = SUPPORT_IROBOT + _attr_available = True # Always available, otherwise setup will fail + + def __init__(self, roomba, blid) -> None: + """Initialize the iRobot handler.""" + super().__init__(roomba, blid) + self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 + + @property + def activity(self): + """Return the state of the vacuum cleaner.""" + clean_mission_status = self.vacuum_state.get("cleanMissionStatus", {}) + cycle = clean_mission_status.get("cycle") + phase = clean_mission_status.get("phase") + try: + state = STATE_MAP[phase] + except KeyError: + return VacuumActivity.ERROR + if cycle != "none" and state in (VacuumActivity.IDLE, VacuumActivity.DOCKED): + state = VacuumActivity.PAUSED + return state + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the device.""" + state = self.vacuum_state + + # Roomba software version + software_version = state.get("softwareVer") + + # Set properties that are to appear in the GUI + state_attrs = {ATTR_SOFTWARE_VERSION: software_version} + + # Set legacy status to avoid break changes + state_attrs[ATTR_STATUS] = self.vacuum.current_state + + # Only add cleaning time and cleaned area attrs when the vacuum is + # currently on + if self.state == VacuumActivity.CLEANING: + # Get clean mission status + ( + state_attrs[ATTR_CLEANING_TIME], + state_attrs[ATTR_CLEANED_AREA], + ) = self.get_cleaning_status(state) + + # Error + if self.vacuum.error_code != 0: + state_attrs[ATTR_ERROR] = self.vacuum.error_message + state_attrs[ATTR_ERROR_CODE] = self.vacuum.error_code + + # Not all Roombas expose position data + # https://github.com/koalazak/dorita980/issues/48 + if self._cap_position: + pos_state = state.get("pose", {}) + position = None + pos_x = pos_state.get("point", {}).get("x") + pos_y = pos_state.get("point", {}).get("y") + theta = pos_state.get("theta") + if all(item is not None for item in (pos_x, pos_y, theta)): + position = f"({pos_x}, {pos_y}, {theta})" + state_attrs[ATTR_POSITION] = position + + return state_attrs + + def get_cleaning_status(self, state) -> tuple[int, int]: + """Return the cleaning time and cleaned area from the device.""" + if not (mission_state := state.get("cleanMissionStatus")): + return (0, 0) + + if cleaning_time := mission_state.get("mssnM", 0): + pass + elif start_time := mission_state.get("mssnStrtTm"): + now = dt_util.as_timestamp(dt_util.utcnow()) + if now > start_time: + cleaning_time = (now - start_time) // 60 + + if cleaned_area := mission_state.get("sqft", 0): # Imperial + # Convert to m2 if the unit_system is set to metric + if self.hass.config.units is METRIC_SYSTEM: + cleaned_area = round(cleaned_area * 0.0929) + + return (cleaning_time, cleaned_area) + + def on_message(self, json_data): + """Update state on message change.""" + state = json_data.get("state", {}).get("reported", {}) + if self.new_state_filter(state): + _LOGGER.debug("Got new state from the vacuum: %s", json_data) + self.schedule_update_ha_state() + + async def async_start(self) -> None: + """Start or resume the cleaning task.""" + if self.state == VacuumActivity.PAUSED: + await self.hass.async_add_executor_job(self.vacuum.send_command, "resume") + else: + await self.hass.async_add_executor_job(self.vacuum.send_command, "start") + + async def async_stop(self, **kwargs): + """Stop the vacuum cleaner.""" + await self.hass.async_add_executor_job(self.vacuum.send_command, "stop") + + async def async_pause(self) -> None: + """Pause the cleaning cycle.""" + await self.hass.async_add_executor_job(self.vacuum.send_command, "pause") + + async def async_return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + if self.state == VacuumActivity.CLEANING: + await self.async_pause() + for _ in range(10): + if self.state == VacuumActivity.PAUSED: + break + await asyncio.sleep(1) + await self.hass.async_add_executor_job(self.vacuum.send_command, "dock") + + async def async_locate(self, **kwargs): + """Located vacuum.""" + await self.hass.async_add_executor_job(self.vacuum.send_command, "find") + + async def async_send_command(self, command, params=None, **kwargs): + """Send raw command.""" + _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs) + await self.hass.async_add_executor_job( + self.vacuum.send_command, command, params + ) + + +class RoombaVacuum(IRobotVacuum): + """Basic Roomba robot (without carpet boost).""" + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the device.""" + state_attrs = super().extra_state_attributes + + # Get bin state + bin_raw_state = self.vacuum_state.get("bin", {}) + bin_state = {} + if bin_raw_state.get("present") is not None: + bin_state[ATTR_BIN_PRESENT] = bin_raw_state.get("present") + if bin_raw_state.get("full") is not None: + bin_state[ATTR_BIN_FULL] = bin_raw_state.get("full") + state_attrs.update(bin_state) + + return state_attrs + + +class RoombaVacuumCarpetBoost(RoombaVacuum): + """Roomba robot with carpet boost.""" + + _attr_fan_speed_list = FAN_SPEEDS + _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + fan_speed = None + carpet_boost = self.vacuum_state.get("carpetBoost") + high_perf = self.vacuum_state.get("vacHigh") + if carpet_boost is not None and high_perf is not None: + if carpet_boost: + fan_speed = FAN_SPEED_AUTOMATIC + elif high_perf: + fan_speed = FAN_SPEED_PERFORMANCE + else: # carpet_boost and high_perf are False + fan_speed = FAN_SPEED_ECO + return fan_speed + + async def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + if fan_speed.capitalize() in FAN_SPEEDS: + fan_speed = fan_speed.capitalize() + _LOGGER.debug("Set fan speed to: %s", fan_speed) + high_perf = None + carpet_boost = None + if fan_speed == FAN_SPEED_AUTOMATIC: + high_perf = False + carpet_boost = True + elif fan_speed == FAN_SPEED_ECO: + high_perf = False + carpet_boost = False + elif fan_speed == FAN_SPEED_PERFORMANCE: + high_perf = True + carpet_boost = False + else: + _LOGGER.error("No such fan speed available: %s", fan_speed) + return + # The set_preference method does only accept string values + await self.hass.async_add_executor_job( + self.vacuum.set_preference, "carpetBoost", str(carpet_boost) + ) + await self.hass.async_add_executor_job( + self.vacuum.set_preference, "vacHigh", str(high_perf) + ) + + +class BraavaJet(IRobotVacuum): + """Braava Jet.""" + + _attr_supported_features = SUPPORT_BRAAVA + + def __init__(self, roomba, blid) -> None: + """Initialize the Roomba handler.""" + super().__init__(roomba, blid) + + # Initialize fan speed list + self._attr_fan_speed_list = [ + f"{behavior}-{spray}" + for behavior in BRAAVA_MOP_BEHAVIORS + for spray in BRAAVA_SPRAY_AMOUNT + ] + + @property + def fan_speed(self): + """Return the fan speed of the vacuum cleaner.""" + # Mopping behavior and spray amount as fan speed + rank_overlap = self.vacuum_state.get("rankOverlap", {}) + behavior = None + if rank_overlap == OVERLAP_STANDARD: + behavior = MOP_STANDARD + elif rank_overlap == OVERLAP_DEEP: + behavior = MOP_DEEP + elif rank_overlap == OVERLAP_EXTENDED: + behavior = MOP_EXTENDED + pad_wetness = self.vacuum_state.get("padWetness", {}) + # "disposable" and "reusable" values are always the same + pad_wetness_value = pad_wetness.get("disposable") + return f"{behavior}-{pad_wetness_value}" + + async def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + try: + split = fan_speed.split("-", 1) + behavior = split[0] + spray = int(split[1]) + if behavior.capitalize() in BRAAVA_MOP_BEHAVIORS: + behavior = behavior.capitalize() + except IndexError: + _LOGGER.error( + "Fan speed error: expected {behavior}-{spray_amount}, got '%s'", + fan_speed, + ) + return + except ValueError: + _LOGGER.error("Spray amount error: expected integer, got '%s'", split[1]) + return + if behavior not in BRAAVA_MOP_BEHAVIORS: + _LOGGER.error( + "Mop behavior error: expected one of %s, got '%s'", + str(BRAAVA_MOP_BEHAVIORS), + behavior, + ) + return + if spray not in BRAAVA_SPRAY_AMOUNT: + _LOGGER.error( + "Spray amount error: expected one of %s, got '%d'", + str(BRAAVA_SPRAY_AMOUNT), + spray, + ) + return + + overlap = 0 + if behavior == MOP_STANDARD: + overlap = OVERLAP_STANDARD + elif behavior == MOP_DEEP: + overlap = OVERLAP_DEEP + else: + overlap = OVERLAP_EXTENDED + await self.hass.async_add_executor_job( + self.vacuum.set_preference, "rankOverlap", overlap + ) + await self.hass.async_add_executor_job( + self.vacuum.set_preference, + "padWetness", + {"disposable": spray, "reusable": spray}, + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the device.""" + state_attrs = super().extra_state_attributes + + # Get Braava state + state = self.vacuum_state + detected_pad = state.get("detectedPad") + mop_ready = state.get("mopReady", {}) + lid_closed = mop_ready.get("lidClosed") + tank_present = mop_ready.get("tankPresent") + tank_level = state.get("tankLvl") + state_attrs[ATTR_DETECTED_PAD] = detected_pad + state_attrs[ATTR_LID_CLOSED] = lid_closed + state_attrs[ATTR_TANK_PRESENT] = tank_present + state_attrs[ATTR_TANK_LEVEL] = tank_level + + return state_attrs diff --git a/homeassistant/components/roon/strings.json b/homeassistant/components/roon/strings.json index 85cb53b9010..463f0431891 100644 --- a/homeassistant/components/roon/strings.json +++ b/homeassistant/components/roon/strings.json @@ -10,8 +10,8 @@ } }, "link": { - "title": "Authorize HomeAssistant in Roon", - "description": "You must authorize Home Assistant in Roon. After you select **Submit**, go to the Roon Core application, open **Settings** and enable HomeAssistant on the **Extensions** tab." + "title": "Authorize Home Assistant in Roon", + "description": "You must authorize Home Assistant in Roon. After you select **Submit**, go to the Roon Core application, open **Settings** and enable Home Assistant on the **Extensions** tab." } }, "error": { diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index 784629ea0bc..65fbd89e203 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -1,6 +1,5 @@ """The russound_rio component.""" -import asyncio import logging from aiorussound import RussoundClient, RussoundTcpConnectionHandler @@ -11,7 +10,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONNECT_TIMEOUT, DOMAIN, RUSSOUND_RIO_EXCEPTIONS +from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS PLATFORMS = [Platform.MEDIA_PLAYER] @@ -40,8 +39,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> await client.register_state_update_callbacks(_connection_update_callback) try: - async with asyncio.timeout(CONNECT_TIMEOUT): - await client.connect() + await client.connect() + await client.load_zone_source_metadata() except RUSSOUND_RIO_EXCEPTIONS as err: raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -58,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): await entry.runtime_data.disconnect() diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py index 15d002b3f49..edf542b5de2 100644 --- a/homeassistant/components/russound_rio/config_flow.py +++ b/homeassistant/components/russound_rio/config_flow.py @@ -2,18 +2,22 @@ from __future__ import annotations -import asyncio import logging from typing import Any from aiorussound import RussoundClient, RussoundTcpConnectionHandler import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import CONNECT_TIMEOUT, DOMAIN, RUSSOUND_RIO_EXCEPTIONS +from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS DATA_SCHEMA = vol.Schema( { @@ -30,6 +34,53 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self.data[CONF_HOST] = host = discovery_info.host + self.data[CONF_PORT] = port = discovery_info.port or 9621 + + client = RussoundClient(RussoundTcpConnectionHandler(host, port)) + try: + await client.connect() + controller = client.controllers[1] + await client.disconnect() + except RUSSOUND_RIO_EXCEPTIONS: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(controller.mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + self.data[CONF_NAME] = controller.controller_type + + self.context["title_placeholders"] = { + "name": self.data[CONF_NAME], + } + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.data[CONF_NAME], + data={CONF_HOST: self.data[CONF_HOST], CONF_PORT: self.data[CONF_PORT]}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + "name": self.data[CONF_NAME], + }, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -41,15 +92,22 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): client = RussoundClient(RussoundTcpConnectionHandler(host, port)) try: - async with asyncio.timeout(CONNECT_TIMEOUT): - await client.connect() - controller = client.controllers[1] - await client.disconnect() + await client.connect() + controller = client.controllers[1] + await client.disconnect() except RUSSOUND_RIO_EXCEPTIONS: _LOGGER.exception("Could not connect to Russound RIO") errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(controller.mac_address) + await self.async_set_unique_id( + controller.mac_address, raise_on_progress=False + ) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + ) self._abort_if_unique_id_configured() data = {CONF_HOST: host, CONF_PORT: port} return self.async_create_entry( @@ -60,26 +118,13 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Attempt to import the existing configuration.""" - self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]}) - host = import_data[CONF_HOST] - port = import_data.get(CONF_PORT, 9621) - - # Connection logic is repeated here since this method will be removed in future releases - client = RussoundClient(RussoundTcpConnectionHandler(host, port)) - try: - async with asyncio.timeout(CONNECT_TIMEOUT): - await client.connect() - controller = client.controllers[1] - await client.disconnect() - except RUSSOUND_RIO_EXCEPTIONS: - _LOGGER.exception("Could not connect to Russound RIO") - return self.async_abort( - reason="cannot_connect", description_placeholders={} + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + if not user_input: + return self.async_show_form( + step_id="reconfigure", + data_schema=DATA_SCHEMA, ) - else: - await self.async_set_unique_id(controller.mac_address) - self._abort_if_unique_id_configured() - data = {CONF_HOST: host, CONF_PORT: port} - return self.async_create_entry(title=controller.controller_type, data=data) + return await self.async_step_user(user_input) diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py index af52e89d399..9647c419da0 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -3,9 +3,6 @@ import asyncio from aiorussound import CommandError -from aiorussound.const import FeatureFlag - -from homeassistant.components.media_player import MediaPlayerEntityFeature DOMAIN = "russound_rio" @@ -15,10 +12,3 @@ RUSSOUND_RIO_EXCEPTIONS = ( TimeoutError, asyncio.CancelledError, ) - - -CONNECT_TIMEOUT = 15 - -MP_FEATURES_BY_FLAG = { - FeatureFlag.COMMANDS_ZONE_MUTE_OFF_ON: MediaPlayerEntityFeature.VOLUME_MUTE -} diff --git a/homeassistant/components/russound_rio/diagnostics.py b/homeassistant/components/russound_rio/diagnostics.py new file mode 100644 index 00000000000..0e96413c41a --- /dev/null +++ b/homeassistant/components/russound_rio/diagnostics.py @@ -0,0 +1,14 @@ +"""Diagnostics platform for Russound RIO.""" + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import RussoundConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: RussoundConfigEntry +) -> dict[str, Any]: + """Return diagnostics for the provided config entry.""" + return entry.runtime_data.state diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 2cd153c232c..f91406e8a4b 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -6,5 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/russound_rio", "iot_class": "local_push", "loggers": ["aiorussound"], - "requirements": ["aiorussound==4.1.0"] + "quality_scale": "silver", + "requirements": ["aiorussound==4.4.0"], + "zeroconf": ["_rio._tcp.local."] } diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 45818d3e25b..346f4903f6a 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -2,11 +2,15 @@ from __future__ import annotations +import datetime as dt import logging +from typing import TYPE_CHECKING from aiorussound import Controller +from aiorussound.const import FeatureFlag from aiorussound.models import PlayStatus, Source from aiorussound.rio import ZoneControlSurface +from aiorussound.util import is_feature_supported from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -15,66 +19,15 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import RussoundConfigEntry -from .const import DOMAIN, MP_FEATURES_BY_FLAG from .entity import RussoundBaseEntity, command _LOGGER = logging.getLogger(__name__) - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Russound RIO platform.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - if ( - result["type"] is FlowResultType.CREATE_ENTRY - or result["reason"] == "single_instance_allowed" - ): - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Russound RIO", - }, - ) - return - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Russound RIO", - }, - ) +PARALLEL_UPDATES = 0 async def async_setup_entry( @@ -101,9 +54,11 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SEEK ) def __init__( @@ -116,9 +71,6 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): self._sources = sources self._attr_name = _zone.name self._attr_unique_id = f"{self._primary_mac_address}-{_zone.device_str}" - for flag, feature in MP_FEATURES_BY_FLAG.items(): - if flag in self._client.supported_features: - self._attr_supported_features |= feature @property def _zone(self) -> ZoneControlSurface: @@ -146,37 +98,65 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): return MediaPlayerState.ON @property - def source(self): + def source(self) -> str: """Get the currently selected source.""" return self._source.name @property - def source_list(self): + def source_list(self) -> list[str]: """Return a list of available input sources.""" - return [x.name for x in self._sources.values()] + if TYPE_CHECKING: + assert self._client.rio_version + available_sources = ( + [ + source + for source_id, source in self._sources.items() + if source_id in self._zone.enabled_sources + ] + if is_feature_supported( + self._client.rio_version, FeatureFlag.SUPPORT_ZONE_SOURCE_EXCLUSION + ) + else self._sources.values() + ) + return [x.name for x in available_sources] @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" return self._source.song_name @property - def media_artist(self): + def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" return self._source.artist_name @property - def media_album_name(self): + def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" return self._source.album_name @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" return self._source.cover_art_url @property - def volume_level(self): + def media_duration(self) -> int | None: + """Duration of the current media.""" + return self._source.track_time + + @property + def media_position(self) -> int | None: + """Position of the current media.""" + return self._source.play_time + + @property + def media_position_updated_at(self) -> dt.datetime: + """Last time the media position was updated.""" + return self._source.position_last_updated + + @property + def volume_level(self) -> float: """Volume level of the media player (0..1). Value is returned based on a range (0..50). @@ -184,6 +164,11 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): """ return self._zone.volume / 50.0 + @property + def is_volume_muted(self) -> bool: + """Return whether zone is muted.""" + return self._zone.is_mute + @command async def async_turn_off(self) -> None: """Turn off the zone.""" @@ -218,3 +203,21 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): async def async_volume_down(self) -> None: """Step the volume down.""" await self._zone.volume_down() + + @command + async def async_mute_volume(self, mute: bool) -> None: + """Mute the media player.""" + if FeatureFlag.COMMANDS_ZONE_MUTE_OFF_ON in self._client.supported_features: + if mute: + await self._zone.mute() + else: + await self._zone.unmute() + return + + if mute != self.is_volume_muted: + await self._zone.toggle_mute() + + @command + async def async_media_seek(self, position: float) -> None: + """Seek to a position in the current media.""" + await self._zone.set_seek_time(int(position)) diff --git a/homeassistant/components/russound_rio/quality_scale.yaml b/homeassistant/components/russound_rio/quality_scale.yaml new file mode 100644 index 00000000000..02b1eaa6aae --- /dev/null +++ b/homeassistant/components/russound_rio/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration uses a push API. No polling required. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + # Gold + entity-translations: + status: exempt + comment: | + There are no entities to translate. + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: + status: exempt + comment: | + This integration doesn't have enough / noisy entities that warrant being disabled by default. + discovery: done + stale-devices: todo + diagnostics: done + exception-translations: done + icon-translations: + status: exempt + comment: | + There are no entities that require icons. + reconfiguration-flow: done + dynamic-devices: todo + discovery-update-info: done + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: todo + docs-data-update: done + docs-known-limitations: + status: exempt + comment: | + There are no known limitations beyond the push API delay noted in Troubleshooting. + docs-troubleshooting: done + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration uses telnet exclusively and does not make http calls. + strict-typing: done diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index b8c29c08301..eba66856302 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -9,6 +9,24 @@ "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The IP address of the Russound controller.", + "port": "The port of the Russound controller." + } + }, + "discovery_confirm": { + "description": "Do you want to set up {name}?" + }, + "reconfigure": { + "description": "Reconfigure your Russound controller.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::russound_rio::config::step::user::data_description::host%]", + "port": "[%key:component::russound_rio::config::step::user::data_description::port%]" } } }, @@ -17,21 +35,9 @@ }, "abort": { "cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - } - }, - "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "title": "The {integration_title} YAML configuration import cannot connect to the Russound device", - "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nPlease make sure {integration_title} is turned on, and restart Home Assistant to try importing again. Otherwise, please remove the YAML from your configuration and add the integration manually." - }, - "deprecated_yaml_import_issue_no_primary_controller": { - "title": "The {integration_title} YAML configuration import cannot configure the Russound Device.", - "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nNo primary controller was detected for the Russound device. Please make sure that the target Russound device has it's controller ID set to 1 (using the selector on the back of the unit)." - }, - "deprecated_yaml_import_issue_unknown": { - "title": "[%key:component::russound_rio::issues::deprecated_yaml_import_issue_cannot_connect::title%]", - "description": "[%key:component::russound_rio::issues::deprecated_yaml_import_issue_cannot_connect::description%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "This Russound controller does not match the existing device ID. Please make sure you entered the correct IP address." } }, "exceptions": { diff --git a/homeassistant/components/russound_rnet/manifest.json b/homeassistant/components/russound_rnet/manifest.json index 27fbfbca57f..58925b4b1ff 100644 --- a/homeassistant/components/russound_rnet/manifest.json +++ b/homeassistant/components/russound_rnet/manifest.json @@ -1,7 +1,7 @@ { "domain": "russound_rnet", "name": "Russound RNET", - "codeowners": [], + "codeowners": ["@noahhusby"], "documentation": "https://www.home-assistant.io/integrations/russound_rnet", "iot_class": "local_polling", "loggers": ["russound"], diff --git a/homeassistant/components/russound_rnet/quality_scale.yaml b/homeassistant/components/russound_rnet/quality_scale.yaml new file mode 100644 index 00000000000..b82ef6f4643 --- /dev/null +++ b/homeassistant/components/russound_rnet/quality_scale.yaml @@ -0,0 +1,95 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: todo + brands: done + common-modules: todo + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: + status: todo + comment: | + CI pipeline for publishing is not on GH repo. + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: todo + entity-unique-id: todo + has-entity-name: todo + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: todo + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: todo + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + The device does not support discovery. + discovery: + status: exempt + comment: | + The device does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration is not a hub and only represents a single device. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: + status: exempt + comment: | + There are no entities to translate. + exception-translations: todo + icon-translations: + status: exempt + comment: | + There are no entities that require icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. An issue will be implemented for yaml import once a config flow is added. + stale-devices: + status: exempt + comment: | + This integration is not a hub and only represents a single device. + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: | + This integration uses telnet or serial exclusively and does not make http calls. + strict-typing: todo diff --git a/homeassistant/components/ruuvi_gateway/config_flow.py b/homeassistant/components/ruuvi_gateway/config_flow.py index c22f100e87a..05ca93de9f2 100644 --- a/homeassistant/components/ruuvi_gateway/config_flow.py +++ b/homeassistant/components/ruuvi_gateway/config_flow.py @@ -8,11 +8,11 @@ from typing import Any import aioruuvigateway.api as gw_api from aioruuvigateway.excs import CannotConnect, InvalidAuth -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import DOMAIN from .schemata import CONFIG_SCHEMA, get_config_schema_with_default_host @@ -82,7 +82,7 @@ class RuuviConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Ruuvi Gateway.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 19b114a4525..fee459340f3 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -8,12 +8,12 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_API_KEY, @@ -24,10 +24,10 @@ from .const import ( SERVICE_RESUME, SERVICE_SET_SPEED, ) -from .coordinator import SabnzbdUpdateCoordinator +from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator from .helpers import get_client -PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) SERVICES = ( @@ -48,7 +48,7 @@ SERVICE_SPEED_SCHEMA = SERVICE_BASE_SCHEMA.extend( } ) -type SabnzbdConfigEntry = ConfigEntry[SabnzbdUpdateCoordinator] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @callback @@ -65,17 +65,9 @@ def async_get_entry_for_service_call( raise ValueError(f"No api for API key: {call_data_api_key}") -async def async_setup_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the SabNzbd Component.""" - sab_api = await get_client(hass, entry.data) - if not sab_api: - raise ConfigEntryNotReady - - coordinator = SabnzbdUpdateCoordinator(hass, entry, sab_api) - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator - @callback def extract_api( func: Callable[ @@ -149,11 +141,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> b (SERVICE_RESUME, async_resume_queue, SERVICE_BASE_SCHEMA), (SERVICE_SET_SPEED, async_set_queue_speed, SERVICE_SPEED_SCHEMA), ): - if hass.services.has_service(DOMAIN, service): - continue - hass.services.async_register(DOMAIN, service, method, schema=schema) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> bool: + """Set up the SabNzbd Component.""" + + sab_api = await get_client(hass, entry.data) + if not sab_api: + raise ConfigEntryNotReady + + coordinator = SabnzbdUpdateCoordinator(hass, entry, sab_api) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -161,17 +164,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> bool: """Unload a Sabnzbd config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: - # If this is the last loaded instance of Sabnzbd, deregister any services - # defined during integration setup: - for service_name in SERVICES: - hass.services.async_remove(DOMAIN, service_name) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sabnzbd/binary_sensor.py b/homeassistant/components/sabnzbd/binary_sensor.py new file mode 100644 index 00000000000..1d65bf01211 --- /dev/null +++ b/homeassistant/components/sabnzbd/binary_sensor.py @@ -0,0 +1,61 @@ +"""Binary sensor platform for SABnzbd.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import SabnzbdConfigEntry +from .entity import SabnzbdEntity + + +@dataclass(frozen=True, kw_only=True) +class SabnzbdBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Sabnzbd binary sensor entity.""" + + is_on_fn: Callable[[dict[str, Any]], bool] + + +BINARY_SENSORS: tuple[SabnzbdBinarySensorEntityDescription, ...] = ( + SabnzbdBinarySensorEntityDescription( + key="warnings", + translation_key="warnings", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: data["have_warnings"] != "0", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SabnzbdConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Sabnzbd sensor entry.""" + coordinator = config_entry.runtime_data + + async_add_entities( + [SabnzbdBinarySensor(coordinator, sensor) for sensor in BINARY_SENSORS] + ) + + +class SabnzbdBinarySensor(SabnzbdEntity, BinarySensorEntity): + """Representation of an SABnzbd binary sensor.""" + + entity_description: SabnzbdBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return latest sensor data.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/sabnzbd/button.py b/homeassistant/components/sabnzbd/button.py index 79038e84775..1ff26b41655 100644 --- a/homeassistant/components/sabnzbd/button.py +++ b/homeassistant/components/sabnzbd/button.py @@ -11,9 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SabnzbdConfigEntry from .const import DOMAIN -from .coordinator import SabnzbdUpdateCoordinator +from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator from .entity import SabnzbdEntity diff --git a/homeassistant/components/sabnzbd/const.py b/homeassistant/components/sabnzbd/const.py index 991490f5716..f05b3f19e98 100644 --- a/homeassistant/components/sabnzbd/const.py +++ b/homeassistant/components/sabnzbd/const.py @@ -1,15 +1,11 @@ """Constants for the Sabnzbd component.""" DOMAIN = "sabnzbd" -DATA_SABNZBD = "sabnzbd" ATTR_SPEED = "speed" ATTR_API_KEY = "api_key" -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 8080 DEFAULT_SPEED_LIMIT = "100" -DEFAULT_SSL = False SERVICE_PAUSE = "pause" SERVICE_RESUME = "resume" diff --git a/homeassistant/components/sabnzbd/coordinator.py b/homeassistant/components/sabnzbd/coordinator.py index 14f7c18e38c..dac8d8a8e95 100644 --- a/homeassistant/components/sabnzbd/coordinator.py +++ b/homeassistant/components/sabnzbd/coordinator.py @@ -12,16 +12,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +type SabnzbdConfigEntry = ConfigEntry[SabnzbdUpdateCoordinator] + class SabnzbdUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The SABnzbd update coordinator.""" - config_entry: ConfigEntry + config_entry: SabnzbdConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SabnzbdConfigEntry, sab_api: SabnzbdApi, ) -> None: """Initialize the SABnzbd update coordinator.""" diff --git a/homeassistant/components/sabnzbd/manifest.json b/homeassistant/components/sabnzbd/manifest.json index afc35a2340e..f1b8a17134b 100644 --- a/homeassistant/components/sabnzbd/manifest.json +++ b/homeassistant/components/sabnzbd/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/sabnzbd", "iot_class": "local_polling", "loggers": ["pysabnzbd"], + "quality_scale": "bronze", "requirements": ["pysabnzbd==1.1.1"] } diff --git a/homeassistant/components/sabnzbd/number.py b/homeassistant/components/sabnzbd/number.py index d8536cb6b37..53c8d462f11 100644 --- a/homeassistant/components/sabnzbd/number.py +++ b/homeassistant/components/sabnzbd/number.py @@ -17,9 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SabnzbdConfigEntry from .const import DOMAIN -from .coordinator import SabnzbdUpdateCoordinator +from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator from .entity import SabnzbdEntity diff --git a/homeassistant/components/sabnzbd/quality_scale.yaml b/homeassistant/components/sabnzbd/quality_scale.yaml new file mode 100644 index 00000000000..a1d6fc076b2 --- /dev/null +++ b/homeassistant/components/sabnzbd/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration has deprecated the actions, thus the documentation has been removed. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: todo + comment: | + Raise ServiceValidationError in async_get_entry_for_service_call. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + The integration does not provide any additional options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: + status: todo + comment: | + Coverage for loading and unloading config entries is missing. + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration cannot be discovered. + discovery: + status: exempt + comment: | + This integration cannot be discovered. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: + status: todo + comment: | + Describe the state of the sensor and make it a enum sensor. + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + The integration connects to a single service per configuration entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration connect to a single service per configuration entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index 115b9de3793..662ae739d15 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import SabnzbdConfigEntry +from .coordinator import SabnzbdConfigEntry from .entity import SabnzbdEntity diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 186682e78e7..0ac8b93c57f 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -22,6 +22,11 @@ } }, "entity": { + "binary_sensor": { + "warnings": { + "name": "Warnings" + } + }, "button": { "pause": { "name": "[%key:common::action::pause%]" diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 837651f9900..3f34520e87a 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -12,7 +12,6 @@ import getmac from samsungtvws.encrypted.authenticator import SamsungTVEncryptedWSAsyncAuthenticator import voluptuous as vol -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -32,6 +31,14 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .bridge import SamsungTVBridge, async_get_device_info, mac_from_device_info from .const import ( @@ -59,7 +66,7 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): def _strip_uuid(udn: str) -> str: - return udn[5:] if udn.startswith("uuid:") else udn + return udn.removeprefix("uuid:") def _entry_is_complete( @@ -439,11 +446,11 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): raise AbortFlow(RESULT_NOT_SUPPORTED) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by ssdp discovery.""" LOGGER.debug("Samsung device found via SSDP: %s", discovery_info) - model_name: str = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "" + model_name: str = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "" if discovery_info.ssdp_st == UPNP_SVC_RENDERING_CONTROL: self._ssdp_rendering_control_location = discovery_info.ssdp_location LOGGER.debug( @@ -456,12 +463,10 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): "Set SSDP MainTvAgent location to: %s", self._ssdp_main_tv_agent_location, ) - self._udn = self._upnp_udn = _strip_uuid( - discovery_info.upnp[ssdp.ATTR_UPNP_UDN] - ) + self._udn = self._upnp_udn = _strip_uuid(discovery_info.upnp[ATTR_UPNP_UDN]) if hostname := urlparse(discovery_info.ssdp_location or "").hostname: self._host = hostname - self._manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) + self._manufacturer = discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) self._abort_if_manufacturer_is_not_samsung() # Set defaults, in case they cannot be extracted from device_info @@ -486,7 +491,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by dhcp discovery.""" LOGGER.debug("Samsung device found via DHCP: %s", discovery_info) @@ -498,7 +503,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Samsung device found via ZEROCONF: %s", discovery_info) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index bc4ba900028..6a30efd64f8 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -35,11 +35,11 @@ "iot_class": "local_push", "loggers": ["samsungctl", "samsungtvws"], "requirements": [ - "getmac==0.9.4", + "getmac==0.9.5", "samsungctl[websocket]==0.7.1", - "samsungtvws[async,encrypted]==2.6.0", + "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==2.1.0", - "async-upnp-client==0.41.0" + "async-upnp-client==0.43.0" ], "ssdp": [ { diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 39c0d6b876d..41b2d0d561b 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -69,6 +69,7 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity): def __init__(self, controller, name, arm_home_mode, partition_id): """Initialize the alarm panel.""" self._attr_name = name + self._attr_unique_id = f"satel_alarm_panel_{partition_id}" self._arm_home_mode = arm_home_mode self._partition_id = partition_id self._satel = controller diff --git a/homeassistant/components/satel_integra/switch.py b/homeassistant/components/satel_integra/switch.py index 6ce82908de7..9135b58bc50 100644 --- a/homeassistant/components/satel_integra/switch.py +++ b/homeassistant/components/satel_integra/switch.py @@ -58,6 +58,7 @@ class SatelIntegraSwitch(SwitchEntity): def __init__(self, controller, device_number, device_name, code): """Initialize the binary_sensor.""" self._device_number = device_number + self._attr_unique_id = f"satel_switch_{device_number}" self._name = device_name self._state = False self._code = code diff --git a/homeassistant/components/scene/strings.json b/homeassistant/components/scene/strings.json index 3fa750bf4ef..4c3e7ad43fe 100644 --- a/homeassistant/components/scene/strings.json +++ b/homeassistant/components/scene/strings.json @@ -38,12 +38,12 @@ "description": "The entity ID of the new scene." }, "entities": { - "name": "Entities state", - "description": "List of entities and their target state. If your entities are already in the target state right now, use `snapshot_entities` instead." + "name": "Entity states", + "description": "List of entities and their target state. If your entities are already in the target state right now, use 'Entities snapshot' instead." }, "snapshot_entities": { - "name": "Snapshot entities", - "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine the `snapshot_entities` with `entities`." + "name": "Entities snapshot", + "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine 'Entities snapshot' with 'Entity states'." } } }, @@ -54,7 +54,7 @@ }, "exceptions": { "entity_not_scene": { - "message": "{entity_id} is not a valid scene entity_id." + "message": "{entity_id} is not a valid entity ID of a scene." }, "entity_not_dynamically_created": { "message": "The scene {entity_id} is not created with action `scene.create`." diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 24ce4f3b3fa..30ca44fe3ee 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -73,7 +73,7 @@ def valid_schedule(schedule: list[dict[str, str]]) -> list[dict[str, str]]: ) # Check if the from time of the event is after the to time of the previous event - if previous_to is not None and previous_to > time_range[CONF_FROM]: # type: ignore[unreachable] + if previous_to is not None and previous_to > time_range[CONF_FROM]: raise vol.Invalid("Overlapping times found in schedule") previous_to = time_range[CONF_TO] diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index e9fb24f1309..6eae69d9542 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -10,7 +10,6 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import DOMAIN from .coordinator import SchlageDataUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -21,8 +20,10 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, ] +type SchlageConfigEntry = ConfigEntry[SchlageDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> bool: """Set up Schlage from a config entry.""" username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] @@ -32,15 +33,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed from ex coordinator = SchlageDataUpdateCoordinator(hass, username, pyschlage.Schlage(auth)) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py index bc1ee666f9e..f928d42b3ee 100644 --- a/homeassistant/components/schlage/binary_sensor.py +++ b/homeassistant/components/schlage/binary_sensor.py @@ -10,12 +10,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import SchlageConfigEntry from .coordinator import LockData, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -40,11 +39,11 @@ _DESCRIPTIONS: tuple[SchlageBinarySensorEntityDescription] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SchlageConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up binary_sensors based on a config entry.""" - coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data def _add_new_locks(locks: dict[str, LockData]) -> None: async_add_entities( diff --git a/homeassistant/components/schlage/config_flow.py b/homeassistant/components/schlage/config_flow.py index f359f7dda71..6e8f94473dd 100644 --- a/homeassistant/components/schlage/config_flow.py +++ b/homeassistant/components/schlage/config_flow.py @@ -40,6 +40,7 @@ class SchlageConfigFlow(ConfigFlow, domain=DOMAIN): return self._show_user_form(errors) await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() return self.async_create_entry( title=username, data={ diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index 53bb43751a9..b319b21be0c 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -44,6 +44,7 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): super().__init__( hass, LOGGER, name=f"{DOMAIN} ({username})", update_interval=UPDATE_INTERVAL ) + self.data = SchlageData(locks={}) self.api = api self.new_locks_callbacks: list[Callable[[dict[str, LockData]], None]] = [] self.async_add_listener(self._add_remove_locks) @@ -55,7 +56,9 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): except NotAuthorizedError as ex: raise ConfigEntryAuthFailed from ex except SchlageError as ex: - raise UpdateFailed("Failed to refresh Schlage data") from ex + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="schlage_refresh_failed" + ) from ex lock_data = await asyncio.gather( *( self.hass.async_add_executor_job(self._get_lock_data, lock) @@ -83,9 +86,6 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): @callback def _add_remove_locks(self) -> None: """Add newly discovered locks and remove nonexistent locks.""" - if self.data is None: - return - device_registry = dr.async_get(self.hass) devices = dr.async_entries_for_config_entry( device_registry, self.config_entry.entry_id diff --git a/homeassistant/components/schlage/diagnostics.py b/homeassistant/components/schlage/diagnostics.py index af1bf311676..ec4d9c489e3 100644 --- a/homeassistant/components/schlage/diagnostics.py +++ b/homeassistant/components/schlage/diagnostics.py @@ -4,19 +4,17 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import SchlageDataUpdateCoordinator +from . import SchlageConfigEntry async def async_get_config_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SchlageConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # NOTE: Schlage diagnostics are already redacted. return { "locks": [ld.lock.get_diagnostics() for ld in coordinator.data.locks.values()] diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index 97dbfc78d41..d203913191d 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -5,22 +5,21 @@ from __future__ import annotations from typing import Any from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import SchlageConfigEntry from .coordinator import LockData, SchlageDataUpdateCoordinator from .entity import SchlageEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SchlageConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Schlage WiFi locks based on a config entry.""" - coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data def _add_new_locks(locks: dict[str, LockData]) -> None: async_add_entities( diff --git a/homeassistant/components/schlage/select.py b/homeassistant/components/schlage/select.py index 6d93eccaa85..6cf0853835f 100644 --- a/homeassistant/components/schlage/select.py +++ b/homeassistant/components/schlage/select.py @@ -3,12 +3,11 @@ from __future__ import annotations from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import SchlageConfigEntry from .coordinator import LockData, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -33,11 +32,11 @@ _DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SchlageConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up selects based on a config entry.""" - coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data def _add_new_locks(locks: dict[str, LockData]) -> None: async_add_entities( diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py index 115412882a2..a15d1740b91 100644 --- a/homeassistant/components/schlage/sensor.py +++ b/homeassistant/components/schlage/sensor.py @@ -13,7 +13,6 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN from .coordinator import LockData, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -34,7 +33,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors based on a config entry.""" - coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data def _add_new_locks(locks: dict[str, LockData]) -> None: async_add_entities( diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 5c8cd0826a9..56e72c2d2c0 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -53,5 +53,10 @@ "name": "1-Touch Locking" } } + }, + "exceptions": { + "schlage_refresh_failed": { + "message": "Failed to refresh Schlage data" + } } } diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py index aaed57fc741..39fe6dbbc99 100644 --- a/homeassistant/components/schlage/switch.py +++ b/homeassistant/components/schlage/switch.py @@ -19,7 +19,6 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN from .coordinator import LockData, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -61,7 +60,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches based on a config entry.""" - coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data def _add_new_locks(locks: dict[str, LockData]) -> None: async_add_entities( diff --git a/homeassistant/components/schluter/climate.py b/homeassistant/components/schluter/climate.py index 6f0a49e6eb9..7db15d3923c 100644 --- a/homeassistant/components/schluter/climate.py +++ b/homeassistant/components/schluter/climate.py @@ -82,7 +82,6 @@ class SchluterThermostat(CoordinatorEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, serial_number, api, session_id): """Initialize the thermostat.""" diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index dd84767ad41..5ee837f32d1 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -158,6 +158,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti self._index = index self._value_template = value_template self._attr_native_value = None + self._available = True if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)): self._attr_name = None self._attr_has_entity_name = True @@ -172,6 +173,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti """Parse the html extraction in the executor.""" raw_data = self.coordinator.data value: str | list[str] | None + self._available = True try: if self._attr is not None: value = raw_data.select(self._select)[self._index][self._attr] @@ -184,11 +186,13 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti except IndexError: _LOGGER.warning("Index '%s' not found in %s", self._index, self.entity_id) value = None + self._available = False except KeyError: _LOGGER.warning( "Attribute '%s' not found in %s", self._attr, self.entity_id ) value = None + self._available = False _LOGGER.debug("Parsed value: %s", value) return value @@ -196,6 +200,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti """Ensure the data from the initial update is reflected in the state.""" await super().async_added_to_hass() self._async_update_from_rest_data() + self.async_write_ha_state() def _async_update_from_rest_data(self) -> None: """Update state from the rest data.""" @@ -210,21 +215,22 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti SensorDeviceClass.TIMESTAMP, }: self._attr_native_value = value + self._attr_available = self._available self._process_manual_data(raw_value) return self._attr_native_value = async_parse_date_datetime( value, self.entity_id, self.device_class ) + self._attr_available = self._available self._process_manual_data(raw_value) - self.async_write_ha_state() @property def available(self) -> bool: """Return if entity is available.""" available1 = CoordinatorEntity.available.fget(self) # type: ignore[attr-defined] available2 = ManualTriggerEntity.available.fget(self) # type: ignore[attr-defined] - return bool(available1 and available2) + return bool(available1 and available2 and self._attr_available) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 42cf3001b75..27115836157 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -141,8 +141,10 @@ "options": { "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 6f58e9b3666..972837f7d75 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -4,6 +4,7 @@ import logging from typing import Any from screenlogicpy import ScreenLogicError, ScreenLogicGateway +from screenlogicpy.const.common import ScreenLogicConnectionError from screenlogicpy.const.data import SHARED_VALUES from homeassistant.config_entries import ConfigEntry @@ -64,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ScreenLogicConfigEntry) try: await gateway.async_connect(**connect_info) await gateway.async_update() - except ScreenLogicError as ex: + except (ScreenLogicConnectionError, ScreenLogicError) as ex: raise ConfigEntryNotReady(ex.msg) from ex coordinator = ScreenlogicDataUpdateCoordinator( diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index fda1c348edf..4a178c60d81 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -49,26 +49,31 @@ SUPPORTED_CORE_SENSORS = [ data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), key=VALUE.ACTIVE_ALERT, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="active_alert", ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.STATUS_CHANGED, data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), key=VALUE.CLEANER_DELAY, + translation_key="cleaner_delay", ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.STATUS_CHANGED, data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), key=VALUE.FREEZE_MODE, + translation_key="freeze_mode", ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.STATUS_CHANGED, data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), key=VALUE.POOL_DELAY, + translation_key="pool_delay", ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.STATUS_CHANGED, data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), key=VALUE.SPA_DELAY, + translation_key="spa_delay", ), ] @@ -85,75 +90,96 @@ SUPPORTED_INTELLICHEM_SENSORS = [ data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), key=VALUE.FLOW_ALARM, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="flow_alarm", ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), key=VALUE.ORP_HIGH_ALARM, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="chem_high_alarm", + translation_placeholders={"chem": "ORP"}, ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), key=VALUE.ORP_LOW_ALARM, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="chem_low_alarm", + translation_placeholders={"chem": "ORP"}, ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), key=VALUE.ORP_SUPPLY_ALARM, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="chem_supply_alarm", + translation_placeholders={"chem": "ORP"}, ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), key=VALUE.PH_HIGH_ALARM, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="chem_high_alarm", + translation_placeholders={"chem": "pH"}, ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), key=VALUE.PH_LOW_ALARM, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="chem_low_alarm", + translation_placeholders={"chem": "pH"}, ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), key=VALUE.PH_SUPPLY_ALARM, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="chem_supply_alarm", + translation_placeholders={"chem": "pH"}, ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), key=VALUE.PROBE_FAULT_ALARM, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="probe_fault_alarm", ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), key=VALUE.ORP_LIMIT, + translation_key="chem_limit", + translation_placeholders={"chem": "ORP"}, ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), key=VALUE.PH_LIMIT, + translation_key="chem_limit", + translation_placeholders={"chem": "pH"}, ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), key=VALUE.PH_LOCKOUT, + translation_key="ph_lockout", ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.WATER_BALANCE), key=VALUE.CORROSIVE, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="corosive", ), ScreenLogicPushBinarySensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.WATER_BALANCE), key=VALUE.SCALING, device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="scaling", ), ] @@ -161,6 +187,7 @@ SUPPORTED_SCG_SENSORS = [ ScreenLogicBinarySensorDescription( data_root=(DEVICE.SCG, GROUP.SENSOR), key=VALUE.STATE, + translation_key="scg_state", ) ] diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 4d93dcf81d3..e44d9b18ae1 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -56,6 +56,7 @@ async def async_setup_entry( subscription_code=CODE.STATUS_CHANGED, data_root=(DEVICE.BODY,), key=body_index, + translation_key=f"body_{body_index}", ), ) for body_index in gateway.get_data(DEVICE.BODY) @@ -80,7 +81,6 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, entity_description) -> None: """Initialize a ScreenLogic climate entity.""" @@ -93,12 +93,11 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): ) self._configured_heat_modes.append(HEAT_MODE.HEATER) self._attr_preset_modes = [ - HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes + HEAT_MODE(mode_num).name.lower() for mode_num in self._configured_heat_modes ] self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT] self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT] - self._attr_name = self.entity_data[VALUE.HEAT_STATE][ATTR.NAME] self._last_preset = None @property @@ -138,8 +137,8 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): def preset_mode(self) -> str: """Return current/last preset mode.""" if self.hvac_mode == HVACMode.OFF: - return HEAT_MODE(self._last_preset).title - return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).title + return HEAT_MODE(self._last_preset).name.lower() + return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).name.lower() async def async_set_temperature(self, **kwargs: Any) -> None: """Change the setpoint of the heater.""" diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 19db89dc03d..54067055a69 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -10,7 +10,6 @@ from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWA from screenlogicpy.requests import login import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -21,6 +20,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL @@ -91,7 +91,7 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_gateway_select() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" mac = format_mac(discovery_info.macaddress) diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 0f7530b7289..746abc2fde6 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -55,7 +55,8 @@ class ScreenLogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): self._data_path = (*self.entity_description.data_root, self._data_key) mac = self.mac self._attr_unique_id = f"{mac}_{generate_unique_id(*self._data_path)}" - self._attr_name = self.entity_data[ATTR.NAME] + if not entity_description.translation_key: + self._attr_name = self.entity_data[ATTR.NAME] assert mac is not None self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, mac)}, diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index d0eb6a71ec8..3634147e509 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -57,6 +57,7 @@ SUPPORTED_INTELLICHEM_NUMBERS = [ key=VALUE.CALCIUM_HARDNESS, entity_category=EntityCategory.CONFIG, mode=NumberMode.BOX, + translation_key="calcium_hardness", ), ScreenLogicPushNumberDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -64,6 +65,7 @@ SUPPORTED_INTELLICHEM_NUMBERS = [ key=VALUE.CYA, entity_category=EntityCategory.CONFIG, mode=NumberMode.BOX, + translation_key="cya", ), ScreenLogicPushNumberDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -71,6 +73,7 @@ SUPPORTED_INTELLICHEM_NUMBERS = [ key=VALUE.TOTAL_ALKALINITY, entity_category=EntityCategory.CONFIG, mode=NumberMode.BOX, + translation_key="total_alkalinity", ), ScreenLogicPushNumberDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -78,6 +81,7 @@ SUPPORTED_INTELLICHEM_NUMBERS = [ key=VALUE.SALT_TDS_PPM, entity_category=EntityCategory.CONFIG, mode=NumberMode.BOX, + translation_key="salt_tds_ppm", ), ] @@ -86,11 +90,13 @@ SUPPORTED_SCG_NUMBERS = [ data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.POOL_SETPOINT, entity_category=EntityCategory.CONFIG, + translation_key="pool_setpoint", ), ScreenLogicNumberDescription( data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.SPA_SETPOINT, entity_category=EntityCategory.CONFIG, + translation_key="spa_setpoint", ), ] diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index c580204221f..7a5e910923c 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -9,7 +9,7 @@ from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.chemistry import DOSE_STATE from screenlogicpy.device_const.pump import PUMP_TYPE -from screenlogicpy.device_const.system import EQUIPMENT_FLAG +from screenlogicpy.device_const.system import CONTROLLER_STATE, EQUIPMENT_FLAG from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -41,7 +41,7 @@ class ScreenLogicSensorDescription( ): """Describes a ScreenLogic sensor.""" - value_mod: Callable[[int | str], int | str] | None = None + value_mod: Callable[[int | str], int | str | None] | None = None @dataclasses.dataclass(frozen=True, kw_only=True) @@ -58,6 +58,19 @@ SUPPORTED_CORE_SENSORS = [ key=VALUE.AIR_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + translation_key="air_temperature", + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.STATE, + device_class=SensorDeviceClass.ENUM, + options=["ready", "sync", "service"], + value_mod=lambda val: ( + CONTROLLER_STATE(val).name.lower() if val in [1, 2, 3] else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="controller_state", ), ] @@ -97,12 +110,16 @@ SUPPORTED_INTELLICHEM_SENSORS = [ data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), key=VALUE.ORP_NOW, state_class=SensorStateClass.MEASUREMENT, + translation_key="chem_now", + translation_placeholders={"chem": "ORP"}, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), key=VALUE.PH_NOW, state_class=SensorStateClass.MEASUREMENT, + translation_key="chem_now", + translation_placeholders={"chem": "pH"}, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -110,6 +127,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [ key=VALUE.ORP_SUPPLY_LEVEL, state_class=SensorStateClass.MEASUREMENT, value_mod=lambda val: int(val) - 1, + translation_key="chem_supply_level", + translation_placeholders={"chem": "ORP"}, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -117,6 +136,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [ key=VALUE.PH_SUPPLY_LEVEL, state_class=SensorStateClass.MEASUREMENT, value_mod=lambda val: int(val) - 1, + translation_key="chem_supply_level", + translation_placeholders={"chem": "pH"}, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -124,54 +145,66 @@ SUPPORTED_INTELLICHEM_SENSORS = [ key=VALUE.PH_PROBE_WATER_TEMP, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + translation_key="ph_probe_water_temp", ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), key=VALUE.SATURATION, state_class=SensorStateClass.MEASUREMENT, + translation_key="saturation", ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), key=VALUE.CALCIUM_HARDNESS, entity_registry_enabled_default=False, # Superseded by number entity + translation_key="calcium_hardness", ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), key=VALUE.CYA, entity_registry_enabled_default=False, # Superseded by number entity + translation_key="cya", ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), key=VALUE.ORP_SETPOINT, + translation_key="chem_setpoint", + translation_placeholders={"chem": "ORP"}, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), key=VALUE.PH_SETPOINT, + translation_key="chem_setpoint", + translation_placeholders={"chem": "pH"}, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), key=VALUE.TOTAL_ALKALINITY, entity_registry_enabled_default=False, # Superseded by number entity + translation_key="total_alkalinity", ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), key=VALUE.SALT_TDS_PPM, entity_registry_enabled_default=False, # Superseded by number entity + translation_key="salt_tds_ppm", ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), key=VALUE.ORP_DOSING_STATE, device_class=SensorDeviceClass.ENUM, - options=["Dosing", "Mixing", "Monitoring"], - value_mod=lambda val: DOSE_STATE(val).title, + options=["dosing", "mixing", "monitoring"], + value_mod=lambda val: DOSE_STATE(val).name.lower(), + translation_key="chem_dose_state", + translation_placeholders={"chem": "ORP"}, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -179,6 +212,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [ key=VALUE.ORP_LAST_DOSE_TIME, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.TOTAL_INCREASING, + translation_key="chem_last_dose_time", + translation_placeholders={"chem": "ORP"}, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -186,14 +221,18 @@ SUPPORTED_INTELLICHEM_SENSORS = [ key=VALUE.ORP_LAST_DOSE_VOLUME, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, + translation_key="chem_last_dose_volume", + translation_placeholders={"chem": "ORP"}, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), key=VALUE.PH_DOSING_STATE, device_class=SensorDeviceClass.ENUM, - options=["Dosing", "Mixing", "Monitoring"], - value_mod=lambda val: DOSE_STATE(val).title, + options=["dosing", "mixing", "monitoring"], + value_mod=lambda val: DOSE_STATE(val).name.lower(), + translation_key="chem_dose_state", + translation_placeholders={"chem": "pH"}, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -201,6 +240,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [ key=VALUE.PH_LAST_DOSE_TIME, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.TOTAL_INCREASING, + translation_key="chem_last_dose_time", + translation_placeholders={"chem": "pH"}, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -208,6 +249,8 @@ SUPPORTED_INTELLICHEM_SENSORS = [ key=VALUE.PH_LAST_DOSE_VOLUME, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, + translation_key="chem_last_dose_volume", + translation_placeholders={"chem": "pH"}, ), ] @@ -216,10 +259,12 @@ SUPPORTED_SCG_SENSORS = [ data_root=(DEVICE.SCG, GROUP.SENSOR), key=VALUE.SALT_PPM, state_class=SensorStateClass.MEASUREMENT, + translation_key="salt_ppm", ), ScreenLogicSensorDescription( data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.SUPER_CHLOR_TIMER, + translation_key="super_chlor_timer", ), ] @@ -311,7 +356,7 @@ class ScreenLogicSensor(ScreenLogicEntity, SensorEntity): ) @property - def native_value(self) -> str | int | float: + def native_value(self) -> str | int | float | None: """State of the sensor.""" val = self.entity_data[ATTR.VALUE] value_mod = self.entity_description.value_mod diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index 91395a0e86d..09e64808dfe 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -1,4 +1,12 @@ { + "common": { + "service_config_entry_name": "Config entry", + "service_config_entry_description": "The config entry to use for this action.", + "climate_preset_solar": "Solar", + "climate_preset_solar_preferred": "Solar Preferred", + "climate_preset_heater": "Heater", + "climate_preset_dont_change": "Don't Change" + }, "config": { "flow_title": "{name}", "error": { @@ -42,8 +50,8 @@ "description": "Sets the color mode for all color-capable lights attached to this ScreenLogic gateway.", "fields": { "config_entry": { - "name": "Config Entry", - "description": "The config entry to use for this action." + "name": "[%key:component::screenlogic::common::service_config_entry_name%]", + "description": "[%key:component::screenlogic::common::service_config_entry_description%]" }, "color_mode": { "name": "Color Mode", @@ -56,8 +64,8 @@ "description": "Begins super chlorination, running for the specified period or 24 hours if none is specified.", "fields": { "config_entry": { - "name": "Config Entry", - "description": "The config entry to use for this action." + "name": "[%key:component::screenlogic::common::service_config_entry_name%]", + "description": "[%key:component::screenlogic::common::service_config_entry_description%]" }, "runtime": { "name": "Run Time", @@ -70,10 +78,167 @@ "description": "Stops super chlorination.", "fields": { "config_entry": { - "name": "Config Entry", - "description": "The config entry to use for this action." + "name": "[%key:component::screenlogic::common::service_config_entry_name%]", + "description": "[%key:component::screenlogic::common::service_config_entry_description%]" } } } + }, + "entity": { + "binary_sensor": { + "active_alert": { + "name": "Active alert" + }, + "pool_delay": { + "name": "Pool delay" + }, + "spa_delay": { + "name": "Spa delay" + }, + "cleaner_delay": { + "name": "Cleaner delay" + }, + "freeze_mode": { + "name": "Freeze mode" + }, + "flow_alarm": { + "name": "Flow alarm" + }, + "chem_high_alarm": { + "name": "{chem} high alarm" + }, + "chem_low_alarm": { + "name": "{chem} low alarm" + }, + "chem_supply_alarm": { + "name": "{chem} supply alarm" + }, + "probe_fault_alarm": { + "name": "Probe fault" + }, + "chem_limit": { + "name": "{chem} dose limit reached" + }, + "ph_lockout": { + "name": "pH lockout" + }, + "corosive": { + "name": "SI corrosive" + }, + "scaling": { + "name": "SI scaling" + }, + "scg_state": { + "name": "Chlorinator" + } + }, + "climate": { + "body_0": { + "name": "Pool heat", + "state_attributes": { + "preset_mode": { + "state": { + "solar": "[%key:component::screenlogic::common::climate_preset_solar%]", + "solar_preferred": "[%key:component::screenlogic::common::climate_preset_solar_preferred%]", + "heater": "[%key:component::screenlogic::common::climate_preset_heater%]", + "dont_change": "[%key:component::screenlogic::common::climate_preset_dont_change%]" + } + } + } + }, + "body_1": { + "name": "Spa heat", + "state_attributes": { + "preset_mode": { + "state": { + "solar": "[%key:component::screenlogic::common::climate_preset_solar%]", + "solar_preferred": "[%key:component::screenlogic::common::climate_preset_solar_preferred%]", + "heater": "[%key:component::screenlogic::common::climate_preset_heater%]", + "dont_change": "[%key:component::screenlogic::common::climate_preset_dont_change%]" + } + } + } + } + }, + "number": { + "calcium_hardness": { + "name": "Calcium hardness" + }, + "cya": { + "name": "Cyanuric acid" + }, + "total_alkalinity": { + "name": "Total alkalinity" + }, + "salt_tds_ppm": { + "name": "Salt/TDS" + }, + "pool_setpoint": { + "name": "Pool chlorinator setpoint" + }, + "spa_setpoint": { + "name": "Spa chlorinator setpoint" + } + }, + "sensor": { + "air_temperature": { + "name": "Air temperature" + }, + "controller_state": { + "name": "Controller state", + "state": { + "ready": "Ready", + "sync": "Sync", + "service": "Service" + } + }, + "chem_now": { + "name": "{chem} now" + }, + "chem_supply_level": { + "name": "{chem} supply level" + }, + "ph_probe_water_temp": { + "name": "pH probe water temperature" + }, + "saturation": { + "name": "Saturation index" + }, + "chem_setpoint": { + "name": "{chem} setpoint" + }, + "calcium_hardness": { + "name": "[%key:component::screenlogic::entity::number::calcium_hardness::name%]" + }, + "cya": { + "name": "[%key:component::screenlogic::entity::number::cya::name%]" + }, + "total_alkalinity": { + "name": "[%key:component::screenlogic::entity::number::total_alkalinity::name%]" + }, + "salt_tds_ppm": { + "name": "[%key:component::screenlogic::entity::number::salt_tds_ppm::name%]" + }, + "chem_dose_state": { + "name": "{chem} dosing state", + "state": { + "dosing": "Dosing", + "mixing": "Mixing", + "monitoring": "Monitoring" + } + }, + "chem_last_dose_time": { + "name": "{chem} last dose time" + }, + "chem_last_dose_volume": { + "name": "{chem} last dose volume" + }, + "salt_ppm": { + "name": "Chlorinator salt" + }, + "super_chlor_timer": { + "name": "Super chlorination timer" + } + } } } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index c0d79c446bb..14104ad0219 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -8,7 +8,7 @@ from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import websocket_api diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index d233b0680f8..d4fb227cf86 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -64,7 +64,7 @@ }, "toggle": { "name": "[%key:common::action::toggle%]", - "description": "Toggle a script. Starts it, if isn't running, stops it otherwise." + "description": "Starts a script if it isn't running, stops it otherwise." } } } diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 3834dc4a0c7..4196106edd2 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -45,15 +45,15 @@ __all__ = [ "ATTR_OPTION", "ATTR_OPTIONS", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", - "SelectEntity", - "SelectEntityDescription", + "PLATFORM_SCHEMA_BASE", "SERVICE_SELECT_FIRST", "SERVICE_SELECT_LAST", "SERVICE_SELECT_NEXT", "SERVICE_SELECT_OPTION", "SERVICE_SELECT_PREVIOUS", + "SelectEntity", + "SelectEntityDescription", ] # mypy: disallow-any-generics diff --git a/homeassistant/components/sense/entity.py b/homeassistant/components/sense/entity.py index 248be53ceb7..35c556a51f2 100644 --- a/homeassistant/components/sense/entity.py +++ b/homeassistant/components/sense/entity.py @@ -12,7 +12,7 @@ from .coordinator import SenseCoordinator def sense_to_mdi(sense_icon: str) -> str: """Convert sense icon to mdi icon.""" - return f"mdi:{MDI_ICONS.get(sense_icon, "power-plug")}" + return f"mdi:{MDI_ICONS.get(sense_icon, 'power-plug')}" class SenseEntity(CoordinatorEntity[SenseCoordinator]): diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index b2b6ac15958..06b5ea6588a 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -21,7 +21,7 @@ type SensiboConfigEntry = ConfigEntry[SensiboDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> bool: """Set up Sensibo from a config entry.""" - coordinator = SensiboDataUpdateCoordinator(hass) + coordinator = SensiboDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -30,12 +30,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> bool: """Unload Sensibo config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> bool: """Migrate old entry.""" # Change entry unique id from api_key to username if entry.version == 1: @@ -57,7 +57,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_config_entry_device( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: SensiboConfigEntry, device: DeviceEntry ) -> bool: """Remove Sensibo config entry from a device.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 6d1acd99166..a66ab46c882 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import logging from typing import TYPE_CHECKING from pysensibo.model import MotionSensor, SensiboDevice @@ -18,6 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SensiboConfigEntry +from .const import LOGGER from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity @@ -26,14 +28,14 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class SensiboMotionBinarySensorEntityDescription(BinarySensorEntityDescription): - """Describes Sensibo Motion sensor entity.""" + """Describes Sensibo Motion binary sensor entity.""" value_fn: Callable[[MotionSensor], bool | None] @dataclass(frozen=True, kw_only=True) class SensiboDeviceBinarySensorEntityDescription(BinarySensorEntityDescription): - """Describes Sensibo Motion sensor entity.""" + """Describes Sensibo Motion binary sensor entity.""" value_fn: Callable[[SensiboDevice], bool | None] @@ -122,32 +124,55 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + added_devices: set[str] = set() - for device_id, device_data in coordinator.data.parsed.items(): - if device_data.motion_sensors: + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + nonlocal added_devices + new_devices, remove_devices, added_devices = coordinator.get_devices( + added_devices + ) + + if LOGGER.isEnabledFor(logging.DEBUG): + LOGGER.debug( + "New devices: %s, Removed devices: %s, Existing devices: %s", + new_devices, + remove_devices, + added_devices, + ) + + if new_devices: entities.extend( SensiboMotionSensor( coordinator, device_id, sensor_id, sensor_data, description ) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors for sensor_id, sensor_data in device_data.motion_sensors.items() + if sensor_id in new_devices for description in MOTION_SENSOR_TYPES ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for description in MOTION_DEVICE_SENSOR_TYPES - for device_id, device_data in coordinator.data.parsed.items() - if device_data.motion_sensors - ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SENSOR_TYPES - ) - ) - async_add_entities(entities) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors and device_id in new_devices + for description in MOTION_DEVICE_SENSOR_TYPES + ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SENSOR_TYPES + ) + ) + + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboMotionSensor(SensiboMotionBaseEntity, BinarySensorEntity): diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index 9ac504537fa..df8d4625840 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -37,18 +37,30 @@ async def async_setup_entry( entry: SensiboConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Sensibo binary sensor platform.""" + """Set up Sensibo button platform.""" coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES) - for device_id, device_data in coordinator.data.parsed.items() - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES) + for device_id in coordinator.data.parsed + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceButton(SensiboDeviceBaseEntity, ButtonEntity): - """Representation of a Sensibo Device Binary Sensor.""" + """Representation of a Sensibo Device button.""" entity_description: SensiboButtonEntityDescription diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 390ebc080b8..5d1c6ff9e79 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -1,4 +1,4 @@ -"""Support for Sensibo wifi-enabled home thermostats.""" +"""Support for Sensibo climate devices.""" from __future__ import annotations @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.climate import ( ATTR_FAN_MODE, + ATTR_HVAC_MODE, ATTR_SWING_MODE, ClimateEntity, ClimateEntityFeature, @@ -21,7 +22,7 @@ from homeassistant.const import ( PRECISION_TENTHS, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,6 +40,7 @@ SERVICE_ENABLE_PURE_BOOST = "enable_pure_boost" SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost" SERVICE_FULL_STATE = "full_state" SERVICE_ENABLE_CLIMATE_REACT = "enable_climate_react" +SERVICE_GET_DEVICE_CAPABILITIES = "get_device_capabilities" ATTR_HIGH_TEMPERATURE_THRESHOLD = "high_temperature_threshold" ATTR_HIGH_TEMPERATURE_STATE = "high_temperature_state" ATTR_LOW_TEMPERATURE_THRESHOLD = "low_temperature_threshold" @@ -79,12 +81,28 @@ AVAILABLE_SWING_MODES = { "horizontal", "both", } +AVAILABLE_HORIZONTAL_SWING_MODES = { + "stopped", + "fixedleft", + "fixedcenterleft", + "fixedcenter", + "fixedcenterright", + "fixedright", + "fixedleftright", + "rangecenter", + "rangefull", + "rangeleft", + "rangeright", + "horizontal", + "both", +} PARALLEL_UPDATES = 0 FIELD_TO_FLAG = { "fanLevel": ClimateEntityFeature.FAN_MODE, "swing": ClimateEntityFeature.SWING_MODE, + "horizontalSwing": ClimateEntityFeature.SWING_HORIZONTAL_MODE, "targetTemperature": ClimateEntityFeature.TARGET_TEMPERATURE, } @@ -105,10 +123,11 @@ AC_STATE_TO_DATA = { "on": "device_on", "mode": "hvac_mode", "swing": "swing_mode", + "horizontalSwing": "horizontal_swing_mode", } -def _find_valid_target_temp(target: int, valid_targets: list[int]) -> int: +def _find_valid_target_temp(target: float, valid_targets: list[int]) -> int: if target <= valid_targets[0]: return valid_targets[0] if target >= valid_targets[-1]: @@ -125,12 +144,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities = [ - SensiboClimate(coordinator, device_id) - for device_id, device_data in coordinator.data.parsed.items() - ] + added_devices: set[str] = set() - async_add_entities(entities) + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboClimate(coordinator, device_id) + for device_id in coordinator.data.parsed + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -154,7 +183,7 @@ async def async_setup_entry( vol.Required(ATTR_GEO_INTEGRATION): bool, vol.Required(ATTR_INDOOR_INTEGRATION): bool, vol.Required(ATTR_OUTDOOR_INTEGRATION): bool, - vol.Required(ATTR_SENSITIVITY): vol.In(["Normal", "Sensitive"]), + vol.Required(ATTR_SENSITIVITY): vol.In(["normal", "sensitive"]), }, "async_enable_pure_boost", ) @@ -168,11 +197,10 @@ async def async_setup_entry( vol.Optional(ATTR_FAN_MODE): str, vol.Optional(ATTR_SWING_MODE): str, vol.Optional(ATTR_HORIZONTAL_SWING_MODE): str, - vol.Optional(ATTR_LIGHT): vol.In(["on", "off"]), + vol.Optional(ATTR_LIGHT): vol.In(["on", "off", "dim"]), }, "async_full_ac_state", ) - platform.async_register_entity_service( SERVICE_ENABLE_CLIMATE_REACT, { @@ -181,20 +209,25 @@ async def async_setup_entry( vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float), vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict, vol.Required(ATTR_SMART_TYPE): vol.In( - ["temperature", "feelsLike", "humidity"] + ["temperature", "feelslike", "humidity"] ), }, "async_enable_climate_react", ) + platform.async_register_entity_service( + SERVICE_GET_DEVICE_CAPABILITIES, + {vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)}, + "async_get_device_capabilities", + supports_response=SupportsResponse.ONLY, + ) class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): - """Representation of a Sensibo device.""" + """Representation of a Sensibo climate device.""" _attr_name = None _attr_precision = PRECISION_TENTHS _attr_translation_key = "climate_device" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: SensiboDataUpdateCoordinator, device_id: str @@ -207,12 +240,12 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): if self.device_data.temp_unit == "C" else UnitOfTemperature.FAHRENHEIT ) - self._attr_supported_features = self.get_features() - def get_features(self) -> ClimateEntityFeature: - """Get supported features.""" + @property + def supported_features(self) -> ClimateEntityFeature: + """Return the list of supported features.""" features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON - for key in self.device_data.full_features: + for key in self.device_data.active_features: if key in FIELD_TO_FLAG: features |= FIELD_TO_FLAG[key] return features @@ -234,8 +267,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Return the list of available hvac operation modes.""" if TYPE_CHECKING: assert self.device_data.hvac_modes - hvac_modes = [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes] - return hvac_modes if hvac_modes else [HVACMode.OFF] + return [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes] @property def current_temperature(self) -> float | None: @@ -260,72 +292,56 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - target_temp: int | None = self.device_data.target_temp - return target_temp + return self.device_data.target_temp @property def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" - target_temp_step: int = self.device_data.temp_step - return target_temp_step + return self.device_data.temp_step @property def fan_mode(self) -> str | None: """Return the fan setting.""" - fan_mode: str | None = self.device_data.fan_mode - return fan_mode + return self.device_data.fan_mode @property def fan_modes(self) -> list[str] | None: """Return the list of available fan modes.""" - if self.device_data.fan_modes: - return self.device_data.fan_modes - return None + return self.device_data.fan_modes @property def swing_mode(self) -> str | None: """Return the swing setting.""" - swing_mode: str | None = self.device_data.swing_mode - return swing_mode + return self.device_data.swing_mode @property def swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" - if self.device_data.swing_modes: - return self.device_data.swing_modes - return None + return self.device_data.swing_modes + + @property + def swing_horizontal_mode(self) -> str | None: + """Return the horizontal swing setting.""" + return self.device_data.horizontal_swing_mode + + @property + def swing_horizontal_modes(self) -> list[str] | None: + """Return the list of available horizontal swing modes.""" + return self.device_data.horizontal_swing_modes @property def min_temp(self) -> float: """Return the minimum temperature.""" - min_temp: int = self.device_data.temp_list[0] - return min_temp + return self.device_data.temp_list[0] @property def max_temp(self) -> float: """Return the maximum temperature.""" - max_temp: int = self.device_data.temp_list[-1] - return max_temp - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.device_data.available and super().available + return self.device_data.temp_list[-1] async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if "targetTemperature" not in self.device_data.active_features: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="no_target_temperature_in_features", - ) - - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_target_temperature", - ) - + temperature: float = kwargs[ATTR_TEMPERATURE] if temperature == self.target_temperature: return @@ -339,11 +355,6 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - if "fanLevel" not in self.device_data.active_features: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="no_fan_level_in_features", - ) if fan_mode not in AVAILABLE_FAN_MODES: raise HomeAssistantError( translation_domain=DOMAIN, @@ -389,11 +400,6 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" - if "swing" not in self.device_data.active_features: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="no_swing_in_features", - ) if swing_mode not in AVAILABLE_SWING_MODES: raise HomeAssistantError( translation_domain=DOMAIN, @@ -410,6 +416,26 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): transformation=transformation, ) + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new target horizontal swing operation.""" + if swing_horizontal_mode not in AVAILABLE_HORIZONTAL_SWING_MODES: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="horizontal_swing_not_supported", + translation_placeholders={ + "horizontal_swing_mode": swing_horizontal_mode + }, + ) + + transformation = self.device_data.horizontal_swing_modes_translated + await self.async_send_api_call( + key=AC_STATE_TO_DATA["horizontalSwing"], + value=swing_horizontal_mode, + name="horizontalSwing", + assumed_state=False, + transformation=transformation, + ) + async def async_turn_on(self) -> None: """Turn Sensibo unit on.""" await self.async_send_api_call( @@ -428,6 +454,26 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): assumed_state=False, ) + async def async_get_device_capabilities( + self, hvac_mode: HVACMode + ) -> dict[str, Any]: + """Get capabilities from device.""" + active_features = self.device_data.active_features + mode_capabilities: dict[str, Any] | None = self.device_data.full_capabilities[ + "modes" + ].get(hvac_mode.value) + if not mode_capabilities: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="mode_not_exist" + ) + remote_capabilities: dict[str, Any] = {} + for active_feature in active_features: + if active_feature in mode_capabilities: + remote_capabilities[active_feature.lower()] = mode_capabilities[ + active_feature + ] + return remote_capabilities + async def async_assume_state(self, state: str) -> None: """Sync state with api.""" await self.async_send_api_call( @@ -495,7 +541,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): "enabled": True, } if sensitivity is not None: - params["sensitivity"] = sensitivity[0] + params["sensitivity"] = sensitivity[0].upper() if indoor_integration is not None: params["measurementsIntegration"] = indoor_integration if ac_integration is not None: @@ -535,6 +581,9 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): UnitOfTemperature.CELSIUS, ) + if smart_type == "feelslike": + smart_type = "feelsLike" + params: dict[str, str | bool | float | dict] = { "enabled": True, "deviceUid": self._device_id, diff --git a/homeassistant/components/sensibo/config_flow.py b/homeassistant/components/sensibo/config_flow.py index b8b1029f141..e3d9f70d2c3 100644 --- a/homeassistant/components/sensibo/config_flow.py +++ b/homeassistant/components/sensibo/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers.selector import TextSelector -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN from .util import NoDevicesError, NoUsernameError, async_validate_api DATA_SCHEMA = vol.Schema( @@ -77,6 +77,9 @@ class SensiboConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=DATA_SCHEMA, errors=errors, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/sensibo/#prerequisites" + }, ) async def async_step_reconfigure( @@ -103,6 +106,9 @@ class SensiboConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=DATA_SCHEMA, errors=errors, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/sensibo/#prerequisites" + }, ) async def async_step_user( @@ -120,7 +126,7 @@ class SensiboConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry( - title=DEFAULT_NAME, + title=username, data={CONF_API_KEY: api_key}, ) @@ -128,4 +134,7 @@ class SensiboConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/sensibo/#prerequisites" + }, ) diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index d654a7cb072..e19f24295b9 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -12,6 +12,7 @@ from pysensibo.model import SensiboData from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -29,11 +30,12 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): config_entry: SensiboConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: SensiboConfigEntry) -> None: """Initialize the Sensibo coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), # We don't want an immediate refresh since the device @@ -47,16 +49,61 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): session=async_get_clientsession(hass), timeout=TIMEOUT, ) + self.previous_devices: set[str] = set() + + def get_devices( + self, added_devices: set[str] + ) -> tuple[set[str], set[str], set[str]]: + """Addition and removal of devices.""" + data = self.data + motion_sensors = { + sensor_id + for device_data in data.parsed.values() + if device_data.motion_sensors + for sensor_id in device_data.motion_sensors + } + devices: set[str] = set(data.parsed) + new_devices: set[str] = motion_sensors | devices - added_devices + remove_devices = added_devices - devices - motion_sensors + added_devices = (added_devices - remove_devices) | new_devices + + return (new_devices, remove_devices, added_devices) async def _async_update_data(self) -> SensiboData: """Fetch data from Sensibo.""" try: data = await self.client.async_get_devices_data() except AuthenticationError as error: - raise ConfigEntryAuthFailed from error + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from error except SensiboError as error: - raise UpdateFailed from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(error)}, + ) from error if not data.raw: - raise UpdateFailed("No devices found") + raise UpdateFailed(translation_domain=DOMAIN, translation_key="no_data") + + current_devices = set(data.parsed) + for device_data in data.parsed.values(): + if device_data.motion_sensors: + for motion_sensor_id in device_data.motion_sensors: + current_devices.add(motion_sensor_id) + + if stale_devices := self.previous_devices - current_devices: + LOGGER.debug("Removing stale devices: %s", stale_devices) + device_registry = dr.async_get(self.hass) + for _id in stale_devices: + device = device_registry.async_get_device(identifiers={(DOMAIN, _id)}) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + self.previous_devices = current_devices + return data diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index b13a5f82111..f9ffc4b31c5 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -75,6 +75,11 @@ class SensiboBaseEntity(CoordinatorEntity[SensiboDataUpdateCoordinator]): """Return data for device.""" return self.coordinator.data.parsed[self._device_id] + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.device_data.available and super().available + class SensiboDeviceBaseEntity(SensiboBaseEntity): """Representation of a Sensibo Device.""" @@ -125,8 +130,13 @@ class SensiboMotionBaseEntity(SensiboBaseEntity): ) @property - def sensor_data(self) -> MotionSensor | None: + def sensor_data(self) -> MotionSensor: """Return data for Motion Sensor.""" if TYPE_CHECKING: assert self.device_data.motion_sensors return self.device_data.motion_sensors[self._sensor_id] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return bool(self.sensor_data.alive) and super().available diff --git a/homeassistant/components/sensibo/icons.json b/homeassistant/components/sensibo/icons.json index ccab3c198d2..f97f0f1e80d 100644 --- a/homeassistant/components/sensibo/icons.json +++ b/homeassistant/components/sensibo/icons.json @@ -59,6 +59,9 @@ }, "enable_climate_react": { "service": "mdi:wizard-hat" + }, + "get_device_capabilities": { + "service": "mdi:shape-outline" } } } diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index baa056f0eea..aa46c7f8c1e 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -71,11 +71,23 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboNumber(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DEVICE_NUMBER_TYPES - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboNumber(coordinator, device_id, description) + for device_id in coordinator.data.parsed + for description in DEVICE_NUMBER_TYPES + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboNumber(SensiboDeviceBaseEntity, NumberEntity): diff --git a/homeassistant/components/sensibo/quality_scale.yaml b/homeassistant/components/sensibo/quality_scale.yaml new file mode 100644 index 00000000000..c21cf100e9d --- /dev/null +++ b/homeassistant/components/sensibo/quality_scale.yaml @@ -0,0 +1,85 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: | + Entities doesn't subscribe to events. + dependency-transparency: done + action-setup: + status: exempt + comment: | + No integrations services. + common-modules: done + docs-high-level-description: todo + docs-installation-instructions: done + docs-removal-instructions: todo + docs-actions: done + brands: done + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: + status: done + comment: | + Move to base entity for common handling + action-exceptions: done + reauthentication-flow: done + parallel-updates: done + test-coverage: + status: done + comment: | + Tests are very complex and needs a rewrite for future additions + integration-owner: done + docs-installation-parameters: + status: todo + comment: configuration_basic + docs-configuration-parameters: + status: exempt + comment: | + This integration has no options flow. + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: done + diagnostics: + status: done + comment: | + Change to only use redact once + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: done + discovery-update-info: + status: exempt + comment: | + No local network connection, cloud based. + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + docs-use-cases: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-data-update: todo + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index cd0499aabc0..51521b59f03 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -8,10 +8,21 @@ from typing import TYPE_CHECKING, Any from pysensibo.model import SensiboDevice -from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import SensiboConfigEntry from .const import DOMAIN @@ -31,15 +42,17 @@ class SensiboSelectEntityDescription(SelectEntityDescription): transformation: Callable[[SensiboDevice], dict | None] +HORIZONTAL_SWING_MODE_TYPE = SensiboSelectEntityDescription( + key="horizontalSwing", + data_key="horizontal_swing_mode", + value_fn=lambda data: data.horizontal_swing_mode, + options_fn=lambda data: data.horizontal_swing_modes, + translation_key="horizontalswing", + transformation=lambda data: data.horizontal_swing_modes_translated, + entity_registry_enabled_default=False, +) + DEVICE_SELECT_TYPES = ( - SensiboSelectEntityDescription( - key="horizontalSwing", - data_key="horizontal_swing_mode", - value_fn=lambda data: data.horizontal_swing_mode, - options_fn=lambda data: data.horizontal_swing_modes, - translation_key="horizontalswing", - transformation=lambda data: data.horizontal_swing_modes_translated, - ), SensiboSelectEntityDescription( key="light", data_key="light_mode", @@ -56,16 +69,65 @@ async def async_setup_entry( entry: SensiboConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Sensibo number platform.""" + """Set up Sensibo select platform.""" coordinator = entry.runtime_data - async_add_entities( - SensiboSelect(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DEVICE_SELECT_TYPES - if description.key in device_data.full_features - ) + entities: list[SensiboSelect] = [] + + entity_registry = er.async_get(hass) + for device_id, device_data in coordinator.data.parsed.items(): + if entity_id := entity_registry.async_get_entity_id( + SELECT_DOMAIN, DOMAIN, f"{device_id}-horizontalSwing" + ): + entity = entity_registry.async_get(entity_id) + if entity and entity.disabled: + entity_registry.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + "deprecated_entity_horizontalswing", + ) + elif entity and HORIZONTAL_SWING_MODE_TYPE.key in device_data.full_features: + entities.append( + SensiboSelect(coordinator, device_id, HORIZONTAL_SWING_MODE_TYPE) + ) + if automations_with_entity(hass, entity_id) or scripts_with_entity( + hass, entity_id + ): + async_create_issue( + hass, + DOMAIN, + "deprecated_entity_horizontalswing", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_entity_horizontalswing", + translation_placeholders={ + "name": str(entity.name or entity.original_name), + "entity": entity_id, + }, + ) + async_add_entities(entities) + + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboSelect(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DEVICE_SELECT_TYPES + if description.key in device_data.full_features + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): @@ -84,6 +146,13 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.entity_description.key not in self.device_data.active_features: + return False + return super().available + @property def current_option(self) -> str | None: """Return the current selected option.""" @@ -99,17 +168,6 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Set state to the selected option.""" - if self.entity_description.key not in self.device_data.active_features: - hvac_mode = self.device_data.hvac_mode if self.device_data.hvac_mode else "" - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="select_option_not_available", - translation_placeholders={ - "hvac_mode": hvac_mode, - "key": self.entity_description.key, - }, - ) - await self.async_send_api_call( key=self.entity_description.data_key, value=option, diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index b395f8eb1ee..b242f38febe 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -36,6 +36,13 @@ from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity PARALLEL_UPDATES = 0 +def _smart_type_name(_type: str | None) -> str | None: + """Return a lowercase name of smart type.""" + if _type and _type == "feelsLike": + return "feelslike" + return _type + + @dataclass(frozen=True, kw_only=True) class SensiboMotionSensorEntityDescription(SensorEntityDescription): """Describes Sensibo Motion sensor entity.""" @@ -153,7 +160,7 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( SensiboDeviceSensorEntityDescription( key="climate_react_type", translation_key="smart_type", - value_fn=lambda data: data.smart_type, + value_fn=lambda data: _smart_type_name(data.smart_type), extra_fn=None, entity_registry_enabled_default=False, ), @@ -239,25 +246,40 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + added_devices: set[str] = set() - for device_id, device_data in coordinator.data.parsed.items(): - if device_data.motion_sensors: + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + + entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + nonlocal added_devices + new_devices, remove_devices, added_devices = coordinator.get_devices( + added_devices + ) + + if new_devices: entities.extend( SensiboMotionSensor( coordinator, device_id, sensor_id, sensor_data, description ) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors for sensor_id, sensor_data in device_data.motion_sensors.items() + if sensor_id in new_devices for description in MOTION_SENSOR_TYPES ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SENSOR_TYPES - ) - ) - async_add_entities(entities) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SENSOR_TYPES + ) + ) + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity): diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml index 7f8252af820..071f8c65609 100644 --- a/homeassistant/components/sensibo/services.yaml +++ b/homeassistant/components/sensibo/services.yaml @@ -12,6 +12,7 @@ assume_state: options: - "on" - "off" + translation_key: assume_state enable_timer: target: entity: @@ -58,8 +59,9 @@ enable_pure_boost: selector: select: options: - - "Normal" - - "Sensitive" + - "normal" + - "sensitive" + translation_key: sensitivity full_state: target: entity: @@ -78,6 +80,7 @@ full_state: - "auto" - "dry" - "off" + translation_key: hvac_mode target_temperature: required: false example: 23 @@ -113,6 +116,7 @@ full_state: - "on" - "off" - "dim" + translation_key: light_mode enable_climate_react: target: entity: @@ -152,5 +156,24 @@ enable_climate_react: select: options: - "temperature" - - "feelsLike" + - "feelslike" - "humidity" + translation_key: smart_type +get_device_capabilities: + target: + entity: + integration: sensibo + domain: climate + fields: + hvac_mode: + required: true + example: "heat" + selector: + select: + options: + - "auto" + - "cool" + - "dry" + - "fan" + - "heat" + translation_key: hvac_mode diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index bec402bee18..c5ff0f135e6 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -8,9 +8,9 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "no_devices": "No devices discovered", - "no_username": "Could not get username", - "incorrect_api_key": "Invalid API key for selected account" + "no_devices": "No devices found, ensure your Sensibo devices are correctly set up and have a remote defined", + "no_username": "Could not retrieve username, ensure your Sensibo account has a proper username and try again", + "incorrect_api_key": "The provided API key does not match for this account" }, "step": { "user": { @@ -18,7 +18,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "Follow the documentation to get your api key" + "api_key": "Follow the [documentation]({url}) to get your api key" } }, "reauth_confirm": { @@ -387,6 +387,21 @@ "horizontal": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::horizontal%]", "both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]" } + }, + "swing_horizontal_mode": { + "state": { + "stopped": "[%key:common::state::off%]", + "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", + "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", + "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", + "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", + "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", + "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", + "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", + "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", + "rangeleft": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeleft%]", + "rangeright": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeright%]" + } } } } @@ -470,8 +485,8 @@ } }, "enable_climate_react": { - "name": "Enable climate react", - "description": "Enables and configures climate react.", + "name": "Enable Climate React", + "description": "Enables and configures Climate React.", "fields": { "high_temperature_threshold": { "name": "Threshold high", @@ -494,38 +509,92 @@ "description": "Choose between temperature/feels like/humidity." } } + }, + "get_device_capabilities": { + "name": "Get device mode capabilities", + "description": "Retrieves the device capabilities for a specific device according to API requirements.", + "fields": { + "hvac_mode": { + "name": "[%key:component::climate::services::set_hvac_mode::fields::hvac_mode::name%]", + "description": "[%key:component::climate::services::set_hvac_mode::fields::hvac_mode::description%]" + } + } + } + }, + "selector": { + "sensitivity": { + "options": { + "normal": "[%key:component::sensibo::entity::sensor::sensitivity::state::n%]", + "sensitive": "[%key:component::sensibo::entity::sensor::sensitivity::state::s%]" + } + }, + "assume_state": { + "options": { + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]" + } + }, + "hvac_mode": { + "options": { + "cool": "[%key:component::climate::entity_component::_::state::cool%]", + "heat": "[%key:component::climate::entity_component::_::state::heat%]", + "fan": "[%key:component::climate::entity_component::_::state::fan_only%]", + "auto": "[%key:component::climate::entity_component::_::state::auto%]", + "dry": "[%key:component::climate::entity_component::_::state::dry%]", + "off": "[%key:common::state::off%]" + } + }, + "light_mode": { + "options": { + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]", + "dim": "[%key:component::sensibo::entity::select::light::state::dim%]" + } + }, + "smart_type": { + "options": { + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "feelslike": "[%key:component::sensibo::entity::switch::climate_react_switch::state_attributes::type::state::feelslike%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]" + } } }, "exceptions": { - "no_target_temperature_in_features": { - "message": "Current mode doesn't support setting target temperature" - }, - "no_target_temperature": { - "message": "No target temperature provided" - }, - "no_fan_level_in_features": { - "message": "Current mode doesn't support setting fan level" - }, "fan_mode_not_supported": { "message": "Climate fan mode {fan_mode} is not supported by the integration, please open an issue" }, - "no_swing_in_features": { - "message": "Current mode doesn't support setting swing" - }, "swing_not_supported": { "message": "Climate swing mode {swing_mode} is not supported by the integration, please open an issue" }, + "horizontal_swing_not_supported": { + "message": "Climate horizontal swing mode {horizontal_swing_mode} is not supported by the integration, please open an issue" + }, "service_result_not_true": { "message": "Could not perform action for {name}" }, "service_raised": { "message": "Could not perform action for {name} with error {error}" }, - "select_option_not_available": { - "message": "Current mode {hvac_mode} doesn't support setting {key}" - }, "climate_react_not_available": { - "message": "Use Sensibo Enable Climate React action once to enable switch or the Sensibo app" + "message": "Use the Sensibo 'Enable Climate React' action once to enable the switch, or use the Sensibo app" + }, + "auth_error": { + "message": "Authentication failed, please update your API key" + }, + "update_error": { + "message": "There was an error updating from the Sensibo API with the error: {error}" + }, + "no_data": { + "message": "[%key:component::sensibo::config::error::no_devices%]" + }, + "mode_not_exist": { + "message": "The entity does not support the chosen mode" + } + }, + "issues": { + "deprecated_entity_horizontalswing": { + "title": "The Sensibo {name} entity is deprecated", + "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\n, Disable the `{entity}` and reload the config entry or restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 46906ac1871..0bc2c55a706 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -84,13 +84,25 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceSwitch(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SWITCH_TYPES - ) - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboDeviceSwitch(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SWITCH_TYPES + ) + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index d52565564a6..0b02264b3e0 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -51,12 +51,24 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceUpdate(coordinator, device_id, description) - for description in DEVICE_SENSOR_TYPES - for device_id, device_data in coordinator.data.parsed.items() - if description.value_available(device_data) is not None - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, added_devices = coordinator.get_devices(added_devices) + + if new_devices: + async_add_entities( + SensiboDeviceUpdate(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DEVICE_SENSOR_TYPES + if description.value_available(device_data) is not None + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceUpdate(SensiboDeviceBaseEntity, UpdateEntity): diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 31626b0b761..89f39d4fb8c 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -8,43 +8,14 @@ from contextlib import suppress from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta from decimal import Decimal, InvalidOperation as DecimalInvalidOperation -from functools import partial import logging from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final, override -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( # noqa: F401 - _DEPRECATED_DEVICE_CLASS_AQI, - _DEPRECATED_DEVICE_CLASS_BATTERY, - _DEPRECATED_DEVICE_CLASS_CO, - _DEPRECATED_DEVICE_CLASS_CO2, - _DEPRECATED_DEVICE_CLASS_CURRENT, - _DEPRECATED_DEVICE_CLASS_DATE, - _DEPRECATED_DEVICE_CLASS_ENERGY, - _DEPRECATED_DEVICE_CLASS_FREQUENCY, - _DEPRECATED_DEVICE_CLASS_GAS, - _DEPRECATED_DEVICE_CLASS_HUMIDITY, - _DEPRECATED_DEVICE_CLASS_ILLUMINANCE, - _DEPRECATED_DEVICE_CLASS_MONETARY, - _DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE, - _DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE, - _DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE, - _DEPRECATED_DEVICE_CLASS_OZONE, - _DEPRECATED_DEVICE_CLASS_PM1, - _DEPRECATED_DEVICE_CLASS_PM10, - _DEPRECATED_DEVICE_CLASS_PM25, - _DEPRECATED_DEVICE_CLASS_POWER, - _DEPRECATED_DEVICE_CLASS_POWER_FACTOR, - _DEPRECATED_DEVICE_CLASS_PRESSURE, - _DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH, - _DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE, - _DEPRECATED_DEVICE_CLASS_TEMPERATURE, - _DEPRECATED_DEVICE_CLASS_TIMESTAMP, - _DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - _DEPRECATED_DEVICE_CLASS_VOLTAGE, ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT, EntityCategory, @@ -53,11 +24,6 @@ from homeassistant.const import ( # noqa: F401 from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.deprecation import ( - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform @@ -68,9 +34,6 @@ from homeassistant.util.enum import try_parse_enum from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 - _DEPRECATED_STATE_CLASS_MEASUREMENT, - _DEPRECATED_STATE_CLASS_TOTAL, - _DEPRECATED_STATE_CLASS_TOTAL_INCREASING, ATTR_LAST_RESET, ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -104,8 +67,8 @@ __all__ = [ "CONF_STATE_CLASS", "DEVICE_CLASS_STATE_CLASSES", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "RestoreSensor", "SensorDeviceClass", "SensorEntity", @@ -531,7 +494,20 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ): return self.hass.config.units.temperature_unit - # Fourth priority: Native unit + # Fourth priority: Unit translation + if (translation_key := self._unit_of_measurement_translation_key) and ( + unit_of_measurement + := self.platform.default_language_platform_translations.get(translation_key) + ): + if native_unit_of_measurement is not None: + raise ValueError( + f"Sensor {type(self)} from integration '{self.platform.platform_name}' " + f"has a translation key for unit_of_measurement '{unit_of_measurement}', " + f"but also has a native_unit_of_measurement '{native_unit_of_measurement}'" + ) + return unit_of_measurement + + # Lowest priority: Native unit return native_unit_of_measurement @final @@ -966,13 +942,3 @@ def async_rounded_state(hass: HomeAssistant, entity_id: str, state: State) -> st value = f"{numerical_value:z.{precision}f}" return value - - -# As we import deprecated constants from the const module, we need to add these two functions -# otherwise this module will be logged for using deprecated constants and not the custom component -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 87012c3631a..aaa14f4637c 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -3,7 +3,6 @@ from __future__ import annotations from enum import StrEnum -from functools import partial from typing import Final import voluptuous as vol @@ -41,12 +40,6 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, @@ -198,7 +191,7 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring energy consumption, for example electric energy consumption. - Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ ENERGY_STORAGE = "energy_storage" @@ -207,7 +200,7 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring stored energy, for example the amount of electric energy currently stored in a battery or the capacity of a battery. - Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ FREQUENCY = "frequency" @@ -315,7 +308,7 @@ class SensorDeviceClass(StrEnum): POWER = "power" """Power. - Unit of measurement: `W`, `kW`, `MW`, `GW`, `TW` + Unit of measurement: `mW`, `W`, `kW`, `MW`, `GW`, `TW`, `BTU/h` """ PRECIPITATION = "precipitation" @@ -399,7 +392,7 @@ class SensorDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV`, `µV` + Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV` """ VOLUME = "volume" @@ -494,17 +487,6 @@ class SensorStateClass(StrEnum): STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass)) -# STATE_CLASS* is deprecated as of 2021.12 -# use the SensorStateClass enum instead. -_DEPRECATED_STATE_CLASS_MEASUREMENT: Final = DeprecatedConstantEnum( - SensorStateClass.MEASUREMENT, "2025.1" -) -_DEPRECATED_STATE_CLASS_TOTAL: Final = DeprecatedConstantEnum( - SensorStateClass.TOTAL, "2025.1" -) -_DEPRECATED_STATE_CLASS_TOTAL_INCREASING: Final = DeprecatedConstantEnum( - SensorStateClass.TOTAL_INCREASING, "2025.1" -) STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { @@ -579,7 +561,13 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, - SensorDeviceClass.POWER: {UnitOfPower.WATT, UnitOfPower.KILO_WATT}, + SensorDeviceClass.POWER: { + UnitOfPower.WATT, + UnitOfPower.KILO_WATT, + UnitOfPower.MEGA_WATT, + UnitOfPower.GIGA_WATT, + UnitOfPower.TERA_WATT, + }, SensorDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), SensorDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), SensorDeviceClass.PRESSURE: set(UnitOfPressure), @@ -683,10 +671,3 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { }, SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT}, } - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 0bc370398b5..d44d621f82d 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -23,7 +23,7 @@ "is_illuminance": "Current {entity_name} illuminance", "is_irradiance": "Current {entity_name} irradiance", "is_moisture": "Current {entity_name} moisture", - "is_monetary": "Current {entity_name} money", + "is_monetary": "Current {entity_name} balance", "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", @@ -75,7 +75,7 @@ "illuminance": "{entity_name} illuminance changes", "irradiance": "{entity_name} irradiance changes", "moisture": "{entity_name} moisture changes", - "monetary": "{entity_name} money changes", + "monetary": "{entity_name} balance changes", "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index 3b834654ca6..d5749a3f040 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -46,7 +46,6 @@ class SENZClimate(CoordinatorEntity, ClimateEntity): _attr_min_temp = 5 _attr_has_entity_name = True _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json index cb5dc9ee100..cfe9196f596 100644 --- a/homeassistant/components/serial/manifest.json +++ b/homeassistant/components/serial/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/serial", "iot_class": "local_polling", - "requirements": ["pyserial-asyncio-fast==0.13"] + "requirements": ["pyserial-asyncio-fast==0.14"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index bf98140a4d6..cdc3b16f95d 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["Pillow==11.0.0"] + "requirements": ["Pillow==11.1.0"] } diff --git a/homeassistant/components/seventeentrack/const.py b/homeassistant/components/seventeentrack/const.py index 6b888590600..19e2d3083c9 100644 --- a/homeassistant/components/seventeentrack/const.py +++ b/homeassistant/components/seventeentrack/const.py @@ -47,6 +47,3 @@ SERVICE_ARCHIVE_PACKAGE = "archive_package" ATTR_PACKAGE_STATE = "package_state" ATTR_PACKAGE_TRACKING_NUMBER = "package_tracking_number" ATTR_CONFIG_ENTRY_ID = "config_entry_id" - - -DEPRECATED_KEY = "deprecated" diff --git a/homeassistant/components/seventeentrack/repairs.py b/homeassistant/components/seventeentrack/repairs.py deleted file mode 100644 index ce72960ea91..00000000000 --- a/homeassistant/components/seventeentrack/repairs.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Repairs for the SeventeenTrack integration.""" - -import voluptuous as vol - -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult - -from .const import DEPRECATED_KEY - - -class SensorDeprecationRepairFlow(RepairsFlow): - """Handler for an issue fixing flow.""" - - def __init__(self, entry: ConfigEntry) -> None: - """Create flow.""" - self.entry = entry - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> FlowResult: - """Handle the first step of a fix flow.""" - return await self.async_step_confirm() - - async def async_step_confirm( - self, user_input: dict[str, str] | None = None - ) -> FlowResult: - """Handle the confirm step of a fix flow.""" - if user_input is not None: - data = {**self.entry.data, DEPRECATED_KEY: True} - self.hass.config_entries.async_update_entry(self.entry, data=data) - return self.async_create_entry(data={}) - - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - ) - - -async def async_create_fix_flow( - hass: HomeAssistant, issue_id: str, data: dict -) -> RepairsFlow: - """Create flow.""" - if issue_id.startswith("deprecate_sensor_") and ( - entry := hass.config_entries.async_get_entry(data["entry_id"]) - ): - return SensorDeprecationRepairFlow(entry) - return ConfirmRepairFlow() diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index 4e561a87961..dade9efb67c 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -4,12 +4,10 @@ from __future__ import annotations from typing import Any -from homeassistant.components import persistent_notification from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -17,23 +15,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SeventeenTrackCoordinator from .const import ( - ATTR_DESTINATION_COUNTRY, ATTR_INFO_TEXT, - ATTR_ORIGIN_COUNTRY, - ATTR_PACKAGE_TYPE, ATTR_PACKAGES, ATTR_STATUS, ATTR_TIMESTAMP, - ATTR_TRACKING_INFO_LANGUAGE, ATTR_TRACKING_NUMBER, ATTRIBUTION, - DEPRECATED_KEY, DOMAIN, - LOGGER, - NOTIFICATION_DELIVERED_MESSAGE, - NOTIFICATION_DELIVERED_TITLE, - UNIQUE_ID_TEMPLATE, - VALUE_DELIVERED, ) @@ -45,63 +33,12 @@ async def async_setup_entry( """Set up a 17Track sensor entry.""" coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][config_entry.entry_id] - previous_tracking_numbers: set[str] = set() - - # This has been deprecated in 2024.8, will be removed in 2025.2 - @callback - def _async_create_remove_entities(): - if config_entry.data.get(DEPRECATED_KEY): - remove_packages(hass, coordinator.account_id, previous_tracking_numbers) - return - live_tracking_numbers = set(coordinator.data.live_packages.keys()) - - new_tracking_numbers = live_tracking_numbers - previous_tracking_numbers - old_tracking_numbers = previous_tracking_numbers - live_tracking_numbers - - previous_tracking_numbers.update(live_tracking_numbers) - - packages_to_add = [ - coordinator.data.live_packages[tracking_number] - for tracking_number in new_tracking_numbers - ] - - for package_data in coordinator.data.live_packages.values(): - if ( - package_data.status == VALUE_DELIVERED - and not coordinator.show_delivered - ): - old_tracking_numbers.add(package_data.tracking_number) - notify_delivered( - hass, - package_data.friendly_name, - package_data.tracking_number, - ) - - remove_packages(hass, coordinator.account_id, old_tracking_numbers) - - async_add_entities( - SeventeenTrackPackageSensor( - coordinator, - package_data.tracking_number, - ) - for package_data in packages_to_add - if not ( - not coordinator.show_delivered and package_data.status == "Delivered" - ) - ) async_add_entities( SeventeenTrackSummarySensor(status, coordinator) for status, summary_data in coordinator.data.summary.items() ) - if not config_entry.data.get(DEPRECATED_KEY): - deprecate_sensor_issue(hass, config_entry.entry_id) - _async_create_remove_entities() - config_entry.async_on_unload( - coordinator.async_add_listener(_async_create_remove_entities) - ) - class SeventeenTrackSensor(CoordinatorEntity[SeventeenTrackCoordinator], SensorEntity): """Define a 17Track sensor.""" @@ -163,96 +100,3 @@ class SeventeenTrackSummarySensor(SeventeenTrackSensor): for package in packages ] } - - -# The dynamic package sensors have been replaced by the seventeentrack.get_packages service -class SeventeenTrackPackageSensor(SeventeenTrackSensor): - """Define an individual package sensor.""" - - _attr_translation_key = "package" - - def __init__( - self, - coordinator: SeventeenTrackCoordinator, - tracking_number: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self._tracking_number = tracking_number - self._previous_status = coordinator.data.live_packages[tracking_number].status - self._attr_unique_id = UNIQUE_ID_TEMPLATE.format( - coordinator.account_id, tracking_number - ) - package = coordinator.data.live_packages[tracking_number] - if not (name := package.friendly_name): - name = tracking_number - self._attr_translation_placeholders = {"name": name} - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - return self._tracking_number in self.coordinator.data.live_packages - - @property - def native_value(self) -> StateType: - """Return the state.""" - return self.coordinator.data.live_packages[self._tracking_number].status - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - package = self.coordinator.data.live_packages[self._tracking_number] - return { - ATTR_DESTINATION_COUNTRY: package.destination_country, - ATTR_INFO_TEXT: package.info_text, - ATTR_TIMESTAMP: package.timestamp, - ATTR_LOCATION: package.location, - ATTR_ORIGIN_COUNTRY: package.origin_country, - ATTR_PACKAGE_TYPE: package.package_type, - ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, - ATTR_TRACKING_NUMBER: package.tracking_number, - } - - -def remove_packages(hass: HomeAssistant, account_id: str, packages: set[str]) -> None: - """Remove entity itself.""" - reg = er.async_get(hass) - for package in packages: - entity_id = reg.async_get_entity_id( - "sensor", - "seventeentrack", - UNIQUE_ID_TEMPLATE.format(account_id, package), - ) - if entity_id: - reg.async_remove(entity_id) - - -def notify_delivered(hass: HomeAssistant, friendly_name: str, tracking_number: str): - """Notify when package is delivered.""" - LOGGER.debug("Package delivered: %s", tracking_number) - - identification = friendly_name if friendly_name else tracking_number - message = NOTIFICATION_DELIVERED_MESSAGE.format(identification, tracking_number) - title = NOTIFICATION_DELIVERED_TITLE.format(identification) - notification_id = NOTIFICATION_DELIVERED_TITLE.format(tracking_number) - - persistent_notification.create( - hass, message, title=title, notification_id=notification_id - ) - - -@callback -def deprecate_sensor_issue(hass: HomeAssistant, entry_id: str) -> None: - """Ensure an issue is registered.""" - ir.async_create_issue( - hass, - DOMAIN, - f"deprecate_sensor_{entry_id}", - breaks_in_ha_version="2025.2.0", - issue_domain=DOMAIN, - is_fixable=True, - is_persistent=True, - translation_key="deprecate_sensor", - severity=ir.IssueSeverity.WARNING, - data={"entry_id": entry_id}, - ) diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index bbd01ed3055..982b15ab629 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -37,19 +37,6 @@ } } }, - "issues": { - "deprecate_sensor": { - "title": "17Track package sensors are being deprecated", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::seventeentrack::issues::deprecate_sensor::title%]", - "description": "17Track package sensors are deprecated and will be removed.\nPlease update your automations and scripts to get data using the `seventeentrack.get_packages` action." - } - } - } - } - }, "entity": { "sensor": { "not_found": { diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 8f0547980c3..873d3fbd290 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -9,12 +9,8 @@ from sharkiq import OperatingModes, PowerModes, Properties, SharkIqVacuum import voluptuous as vol from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -30,10 +26,10 @@ from .const import DOMAIN, LOGGER, SERVICE_CLEAN_ROOM, SHARK from .coordinator import SharkIqUpdateCoordinator OPERATING_STATE_MAP = { - OperatingModes.PAUSE: STATE_PAUSED, - OperatingModes.START: STATE_CLEANING, - OperatingModes.STOP: STATE_IDLE, - OperatingModes.RETURN: STATE_RETURNING, + OperatingModes.PAUSE: VacuumActivity.PAUSED, + OperatingModes.START: VacuumActivity.CLEANING, + OperatingModes.STOP: VacuumActivity.IDLE, + OperatingModes.RETURN: VacuumActivity.RETURNING, } FAN_SPEEDS_MAP = { @@ -150,19 +146,13 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum return None return self.sharkiq.error_text - @property - def operating_mode(self) -> str | None: - """Operating mode.""" - op_mode = self.sharkiq.get_property_value(Properties.OPERATING_MODE) - return OPERATING_STATE_MAP.get(op_mode) - @property def recharging_to_resume(self) -> int | None: """Return True if vacuum set to recharge and resume cleaning.""" return self.sharkiq.get_property_value(Properties.RECHARGING_TO_RESUME) @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Get the current vacuum state. NB: Currently, we do not return an error state because they can be very, very stale. @@ -170,8 +160,9 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum user a notification. """ if self.sharkiq.get_property_value(Properties.CHARGING_STATUS): - return STATE_DOCKED - return self.operating_mode + return VacuumActivity.DOCKED + op_mode = self.sharkiq.get_property_value(Properties.OPERATING_MODE) + return OPERATING_STATE_MAP.get(op_mode) @property def available(self) -> bool: diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index e0d9d17d55d..5ca58ec7d01 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -15,6 +15,7 @@ from aioshelly.exceptions import ( from aioshelly.rpc_device import RpcDevice import voluptuous as vol +from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -331,3 +332,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> b return await hass.config_entries.async_unload_platforms( entry, runtime_data.platforms ) + + +async def async_remove_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> None: + """Remove a config entry.""" + if get_device_entry_gen(entry) in RPC_GENERATIONS and ( + mac_address := entry.unique_id + ): + async_remove_scanner(hass, mac_address) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 556274aa51a..108a8236733 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -15,11 +15,12 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import CONF_SLEEP_PERIOD -from .coordinator import ShellyConfigEntry +from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, RestEntityDescription, @@ -59,6 +60,36 @@ class RestBinarySensorDescription(RestEntityDescription, BinarySensorEntityDescr """Class to describe a REST binary sensor.""" +class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity): + """Represent a RPC binary sensor entity.""" + + entity_description: RpcBinarySensorDescription + + @property + def is_on(self) -> bool: + """Return true if RPC sensor state is on.""" + return bool(self.attribute_value) + + +class RpcBluTrvBinarySensor(RpcBinarySensor): + """Represent a RPC BluTrv binary sensor.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcBinarySensorDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator, key, attribute, description) + ble_addr: str = coordinator.device.config[key]["addr"] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) + + SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = { ("device", "overtemp"): BlockBinarySensorDescription( key="device|overtemp", @@ -232,6 +263,15 @@ RPC_SENSORS: Final = { sub_key="value", has_entity_name=True, ), + "calibration": RpcBinarySensorDescription( + key="blutrv", + sub_key="errors", + name="Calibration", + device_class=BinarySensorDeviceClass.PROBLEM, + value=lambda status, _: False if status is None else "not_calibrated" in status, + entity_category=EntityCategory.DIAGNOSTIC, + entity_class=RpcBluTrvBinarySensor, + ), } @@ -320,17 +360,6 @@ class RestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): return bool(self.attribute_value) -class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity): - """Represent a RPC binary sensor entity.""" - - entity_description: RpcBinarySensorDescription - - @property - def is_on(self) -> bool: - """Return true if RPC sensor state is on.""" - return bool(self.attribute_value) - - class BlockSleepingBinarySensor( ShellySleepingBlockAttributeEntity, BinarySensorEntity, RestoreEntity ): diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index f2b71d19d61..5200ec9b913 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -28,7 +28,13 @@ async def async_connect_scanner( source = format_mac(coordinator.mac).upper() scanner = create_scanner(source, entry.title) unload_callbacks = [ - async_register_scanner(hass, scanner), + async_register_scanner( + hass, + scanner, + source_domain=entry.domain, + source_model=coordinator.model, + source_config_entry_id=entry.entry_id, + ), scanner.async_setup(), coordinator.async_subscribe_events(scanner.async_on_event), ] diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index b77f45afb3f..f8e157a6a5d 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -7,7 +7,7 @@ from dataclasses import asdict, dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_NAME, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -22,7 +22,11 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity @@ -31,6 +35,7 @@ from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( + BLU_TRV_TEMPERATURE_SETTINGS, DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, @@ -124,6 +129,7 @@ def async_setup_rpc_entry( coordinator = config_entry.runtime_data.rpc assert coordinator climate_key_ids = get_rpc_key_ids(coordinator.device.status, "thermostat") + blutrv_key_ids = get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER) climate_ids = [] for id_ in climate_key_ids: @@ -139,10 +145,11 @@ def async_setup_rpc_entry( unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "switch", unique_id) - if not climate_ids: - return + if climate_ids: + async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) - async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) + if blutrv_key_ids: + async_add_entities(RpcBluTrvClimate(coordinator, id_) for id_ in blutrv_key_ids) @dataclass @@ -172,7 +179,6 @@ class BlockSleepingClimate( ) _attr_target_temperature_step = SHTRV_01_TEMPERATURE_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -456,7 +462,6 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): ) _attr_target_temperature_step = RPC_THERMOSTAT_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: """Initialize.""" @@ -528,3 +533,75 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): await self.call_rpc( "Thermostat.SetConfig", {"config": {"id": self._id, "enable": mode}} ) + + +class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): + """Entity that controls a thermostat on RPC based Shelly devices.""" + + _attr_max_temp = BLU_TRV_TEMPERATURE_SETTINGS["max"] + _attr_min_temp = BLU_TRV_TEMPERATURE_SETTINGS["min"] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON + ) + _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_mode = HVACMode.HEAT + _attr_target_temperature_step = BLU_TRV_TEMPERATURE_SETTINGS["step"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + + def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: + """Initialize.""" + + super().__init__(coordinator, f"{BLU_TRV_IDENTIFIER}:{id_}") + self._id = id_ + self._config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"] + ble_addr: str = self._config["addr"] + self._attr_unique_id = f"{ble_addr}-{self.key}" + name = self._config["name"] or f"shellyblutrv-{ble_addr.replace(':', '')}" + model_id = self._config.get("local_name") + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)}, + identifiers={(DOMAIN, ble_addr)}, + via_device=(DOMAIN, self.coordinator.mac), + manufacturer="Shelly", + model=BLU_TRV_MODEL_NAME.get(model_id), + model_id=model_id, + name=name, + ) + # Added intentionally to the constructor to avoid double name from base class + self._attr_name = None + + @property + def target_temperature(self) -> float | None: + """Set target temperature.""" + if not self._config["enable"]: + return None + + return cast(float, self.status["target_C"]) + + @property + def current_temperature(self) -> float | None: + """Return current temperature.""" + return cast(float, self.status["current_C"]) + + @property + def hvac_action(self) -> HVACAction: + """HVAC current action.""" + if not self.status["pos"]: + return HVACAction.IDLE + + return HVACAction.HEATING + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + await self.call_rpc( + "BluTRV.Call", + { + "id": self._id, + "method": "Trv.SetTarget", + "params": {"id": 0, "target_C": target_temp}, + }, + ) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 55686464637..f53da8bd766 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -17,7 +17,6 @@ from aioshelly.exceptions import ( from aioshelly.rpc_device import RpcDevice import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -34,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_BLE_SCANNER_MODE, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 88d8c1f5f17..f81ba5ca7f7 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -187,6 +187,13 @@ RPC_THERMOSTAT_SETTINGS: Final = { "step": 0.5, } +BLU_TRV_TEMPERATURE_SETTINGS: Final = { + "min": 4, + "max": 30, + "step": 0.1, + "default": 20.0, +} + # Kelvin value for colorTemp KELVIN_MAX_VALUE: Final = 6500 KELVIN_MIN_VALUE_WHITE: Final = 2700 @@ -230,6 +237,7 @@ OTA_SUCCESS = "ota_success" GEN1_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen1/#changelog" GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/" +GEN2_BETA_RELEASE_URL = f"{GEN2_RELEASE_URL}#unreleased" DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( MODEL_WALL_DISPLAY, MODEL_MOTION, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f20b283cacf..d5071c4e849 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -18,8 +18,9 @@ from aioshelly.exceptions import ( RpcCallError, ) from aioshelly.rpc_device import RpcDevice, RpcUpdateType -from propcache import cached_property +from propcache.api import cached_property +from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, @@ -30,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .bluetooth import async_connect_scanner @@ -154,6 +155,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( config_entry_id=self.entry.entry_id, name=self.name, connections={(CONNECTION_NETWORK_MAC, self.mac)}, + identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", model=MODEL_NAMES.get(self.model), model_id=self.model, @@ -371,7 +373,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): try: await self.device.update() except DeviceConnectionError as err: - raise UpdateFailed(f"Error fetching data: {err!r}") from err + raise UpdateFailed(repr(err)) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() @@ -456,7 +458,7 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]): return await self.device.update_shelly() except (DeviceConnectionError, MacAddressMismatchError) as err: - raise UpdateFailed(f"Error fetching data: {err!r}") from err + raise UpdateFailed(repr(err)) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() else: @@ -696,6 +698,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ) if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) + async_remove_scanner(self.hass, format_mac(self.mac).upper()) return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index aea060e09e2..8c9044aeaff 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -196,10 +196,16 @@ def async_setup_rpc_attribute_entities( elif description.use_polling_coordinator: if not sleep_period: entities.append( - sensor_class(polling_coordinator, key, sensor_id, description) + get_entity_class(sensor_class, description)( + polling_coordinator, key, sensor_id, description + ) ) else: - entities.append(sensor_class(coordinator, key, sensor_id, description)) + entities.append( + get_entity_class(sensor_class, description)( + coordinator, key, sensor_id, description + ) + ) if not entities: return @@ -232,7 +238,9 @@ def async_restore_rpc_attribute_entities( if description := sensors.get(attribute): entities.append( - sensor_class(coordinator, key, attribute, description, entry) + get_entity_class(sensor_class, description)( + coordinator, key, attribute, description, entry + ) ) if not entities: @@ -293,6 +301,7 @@ class RpcEntityDescription(EntityDescription): supported: Callable = lambda _: False unit: Callable[[dict], str | None] | None = None options_fn: Callable[[dict], list[str]] | None = None + entity_class: Callable | None = None @dataclass(frozen=True) @@ -673,3 +682,13 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): "Entity %s comes from a sleeping device, update is not possible", self.entity_id, ) + + +def get_entity_class( + sensor_class: Callable, description: RpcEntityDescription +) -> Callable: + """Return entity class.""" + if description.entity_class is not None: + return description.entity_class + + return sensor_class diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index 1baf61acf3b..f93abf6b854 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -12,6 +12,9 @@ } }, "number": { + "external_temperature": { + "default": "mdi:thermometer-check" + }, "valve_position": { "default": "mdi:pipe-valve" } @@ -29,6 +32,9 @@ "tilt": { "default": "mdi:angle-acute" }, + "valve_position": { + "default": "mdi:pipe-valve" + }, "valve_status": { "default": "mdi:valve" } diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index fbf72e6ebe8..e18cd7ca465 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -42,7 +42,7 @@ def async_describe_events( if click_type in RPC_INPUTS_EVENTS_TYPES: rpc_coordinator = get_rpc_coordinator_by_device_id(hass, device_id) if rpc_coordinator and rpc_coordinator.device.initialized: - key = f"input:{channel-1}" + key = f"input:{channel - 1}" input_name = get_rpc_entity_name(rpc_coordinator.device, key) elif click_type in BLOCK_INPUTS_EVENTS_TYPES: diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index c4a22a77739..cf5c59da5e3 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.0.1"], + "requirements": ["aioshelly==12.3.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 2aed38fb723..fb61c885423 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -18,9 +18,10 @@ from homeassistant.components.number import ( NumberMode, RestoreNumber, ) -from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry @@ -57,6 +58,74 @@ class RpcNumberDescription(RpcEntityDescription, NumberEntityDescription): min_fn: Callable[[dict], float] | None = None step_fn: Callable[[dict], float] | None = None mode_fn: Callable[[dict], NumberMode] | None = None + method: str + method_params_fn: Callable[[int, float], dict] + + +class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): + """Represent a RPC number entity.""" + + entity_description: RpcNumberDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcNumberDescription, + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator, key, attribute, description) + + if description.max_fn is not None: + self._attr_native_max_value = description.max_fn( + coordinator.device.config[key] + ) + if description.min_fn is not None: + self._attr_native_min_value = description.min_fn( + coordinator.device.config[key] + ) + if description.step_fn is not None: + self._attr_native_step = description.step_fn(coordinator.device.config[key]) + if description.mode_fn is not None: + self._attr_mode = description.mode_fn(coordinator.device.config[key]) + + @property + def native_value(self) -> float | None: + """Return value of number.""" + if TYPE_CHECKING: + assert isinstance(self.attribute_value, float | None) + + return self.attribute_value + + async def async_set_native_value(self, value: float) -> None: + """Change the value.""" + if TYPE_CHECKING: + assert isinstance(self._id, int) + + await self.call_rpc( + self.entity_description.method, + self.entity_description.method_params_fn(self._id, value), + ) + + +class RpcBluTrvNumber(RpcNumber): + """Represent a RPC BluTrv number.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcNumberDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator, key, attribute, description) + ble_addr: str = coordinator.device.config[key]["addr"] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { @@ -78,6 +147,25 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { RPC_NUMBERS: Final = { + "external_temperature": RpcNumberDescription( + key="blutrv", + sub_key="current_C", + translation_key="external_temperature", + name="External temperature", + native_min_value=-50, + native_max_value=50, + native_step=0.1, + mode=NumberMode.BOX, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + method="BluTRV.Call", + method_params_fn=lambda idx, value: { + "id": idx, + "method": "Trv.SetExternalTemperature", + "params": {"id": 0, "t_C": value}, + }, + entity_class=RpcBluTrvNumber, + ), "number": RpcNumberDescription( key="number", sub_key="value", @@ -92,6 +180,28 @@ RPC_NUMBERS: Final = { unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] else None, + method="Number.Set", + method_params_fn=lambda idx, value: {"id": idx, "value": value}, + ), + "valve_position": RpcNumberDescription( + key="blutrv", + sub_key="pos", + translation_key="valve_position", + name="Valve position", + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_unit_of_measurement=PERCENTAGE, + method="BluTRV.Call", + method_params_fn=lambda idx, value: { + "id": idx, + "method": "Trv.SetPosition", + "params": {"id": 0, "pos": value}, + }, + removal_condition=lambda config, _status, key: config[key].get("enable", True) + is True, + entity_class=RpcBluTrvNumber, ), } @@ -190,44 +300,3 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber): ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() - - -class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): - """Represent a RPC number entity.""" - - entity_description: RpcNumberDescription - - def __init__( - self, - coordinator: ShellyRpcCoordinator, - key: str, - attribute: str, - description: RpcNumberDescription, - ) -> None: - """Initialize sensor.""" - super().__init__(coordinator, key, attribute, description) - - if description.max_fn is not None: - self._attr_native_max_value = description.max_fn( - coordinator.device.config[key] - ) - if description.min_fn is not None: - self._attr_native_min_value = description.min_fn( - coordinator.device.config[key] - ) - if description.step_fn is not None: - self._attr_native_step = description.step_fn(coordinator.device.config[key]) - if description.mode_fn is not None: - self._attr_mode = description.mode_fn(coordinator.device.config[key]) - - @property - def native_value(self) -> float | None: - """Return value of number.""" - if TYPE_CHECKING: - assert isinstance(self.attribute_value, float | None) - - return self.attribute_value - - async def async_set_native_value(self, value: float) -> None: - """Change the value.""" - await self.call_rpc("Number.Set", {"id": self._id, "value": value}) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index dd0ace9a6b9..6d000556cf3 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -33,6 +33,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType @@ -76,6 +77,57 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): """Class to describe a REST sensor.""" +class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): + """Represent a RPC sensor.""" + + entity_description: RpcSensorDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcSensorDescription, + ) -> None: + """Initialize select.""" + super().__init__(coordinator, key, attribute, description) + + if self.option_map: + self._attr_options = list(self.option_map.values()) + + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + attribute_value = self.attribute_value + + if not self.option_map: + return attribute_value + + if not isinstance(attribute_value, str): + return None + + return self.option_map[attribute_value] + + +class RpcBluTrvSensor(RpcSensor): + """Represent a RPC BluTrv sensor.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcSensorDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator, key, attribute, description) + ble_addr: str = coordinator.device.config[key]["addr"] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) + + SENSORS: dict[tuple[str, str], BlockSensorDescription] = { ("device", "battery"): BlockSensorDescription( key="device|battery", @@ -770,6 +822,18 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "ret_energy_pm1": RpcSensorDescription( + key="pm1", + sub_key="ret_aenergy", + name="Total active returned energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), "energy_cct": RpcSensorDescription( key="cct", sub_key="aenergy", @@ -1116,6 +1180,15 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, available=lambda status: status is not None, ), + "voltmeter_value": RpcSensorDescription( + key="voltmeter", + sub_key="xvoltage", + name="Voltmeter value", + removal_condition=lambda _config, status, key: ( + status[key].get("xvoltage") is None + ), + unit=lambda config: config["xvoltage"]["unit"] or None, + ), "analoginput": RpcSensorDescription( key="input", sub_key="percent", @@ -1201,6 +1274,38 @@ RPC_SENSORS: Final = { options_fn=lambda config: config["options"], device_class=SensorDeviceClass.ENUM, ), + "valve_position": RpcSensorDescription( + key="blutrv", + sub_key="pos", + name="Valve position", + translation_key="valve_position", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + removal_condition=lambda config, _status, key: config[key].get("enable", False) + is False, + entity_class=RpcBluTrvSensor, + ), + "blutrv_battery": RpcSensorDescription( + key="blutrv", + sub_key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_class=RpcBluTrvSensor, + ), + "blutrv_rssi": RpcSensorDescription( + key="blutrv", + sub_key="rssi", + name="Signal strength", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_class=RpcBluTrvSensor, + ), } @@ -1306,38 +1411,6 @@ class RestSensor(ShellyRestAttributeEntity, SensorEntity): return self.attribute_value -class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): - """Represent a RPC sensor.""" - - entity_description: RpcSensorDescription - - def __init__( - self, - coordinator: ShellyRpcCoordinator, - key: str, - attribute: str, - description: RpcSensorDescription, - ) -> None: - """Initialize select.""" - super().__init__(coordinator, key, attribute, description) - - if self.option_map: - self._attr_options = list(self.option_map.values()) - - @property - def native_value(self) -> StateType: - """Return value of sensor.""" - attribute_value = self.attribute_value - - if not self.option_map: - return attribute_value - - if not isinstance(attribute_value, str): - return None - - return self.option_map[attribute_value] - - class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, RestoreSensor): """Represent a block sleeping sensor.""" diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 134704cb0ff..8a33dae0938 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -120,9 +120,8 @@ def async_setup_block_entry( relay_blocks = [] assert coordinator.device.blocks for block in coordinator.device.blocks: - if ( - block.type != "relay" - or block.channel is not None + if block.type != "relay" or ( + block.channel is not None and is_block_channel_type_light( coordinator.device.settings, int(block.channel) ) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index df374624e3d..81766c65388 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -50,6 +50,7 @@ from .const import ( DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID, GEN1_RELEASE_URL, + GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, LOGGER, RPC_INPUTS_EVENTS_TYPES, @@ -137,7 +138,7 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str: else: base = ord("1") - return f"{entity_name} channel {chr(int(block.channel)+base)}" + return f"{entity_name} channel {chr(int(block.channel) + base)}" def is_block_momentary_input( @@ -200,7 +201,7 @@ def get_block_input_triggers( subtype = "button" else: assert block.channel - subtype = f"button{int(block.channel)+1}" + subtype = f"button{int(block.channel) + 1}" if device.settings["device"]["type"] in SHBTN_MODELS: trigger_types = SHBTN_INPUTS_EVENTS_TYPES @@ -409,7 +410,7 @@ def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: continue for trigger_type in RPC_INPUTS_EVENTS_TYPES: - subtype = f"button{id_+1}" + subtype = f"button{id_ + 1}" triggers.append((trigger_type, subtype)) return triggers @@ -453,9 +454,14 @@ def mac_address_from_name(name: str) -> str | None: def get_release_url(gen: int, model: str, beta: bool) -> str | None: """Return release URL or None.""" - if beta or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: + if ( + beta and gen in BLOCK_GENERATIONS + ) or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: return None + if beta: + return GEN2_BETA_RELEASE_URL + return GEN1_RELEASE_URL if gen in BLOCK_GENERATIONS else GEN2_RELEASE_URL diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json index c184a1d2227..8618d9241b4 100644 --- a/homeassistant/components/shopping_list/strings.json +++ b/homeassistant/components/shopping_list/strings.json @@ -62,7 +62,7 @@ }, "clear_completed_items": { "name": "Clear completed items", - "description": "Clears completed items from the shopping list." + "description": "Removes completed items from the shopping list." }, "sort": { "name": "Sort all items", diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 1efd572425b..e1226fd344d 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["simplehound"], "quality_scale": "legacy", - "requirements": ["Pillow==11.0.0", "simplehound==0.3"] + "requirements": ["Pillow==11.1.0", "simplehound==0.3"] } diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index b72519f9734..2f19c5117a4 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -485,7 +485,7 @@ class SimpliSafe: except Exception as err: # noqa: BLE001 LOGGER.error("Unknown exception while connecting to websocket: %s", err) - LOGGER.warning("Reconnecting to websocket") + LOGGER.debug("Reconnecting to websocket") await self._async_cancel_websocket_loop() self._websocket_reconnect_task = self._hass.async_create_task( self._async_start_websocket_loop() diff --git a/homeassistant/components/simulated/__init__.py b/homeassistant/components/simulated/__init__.py deleted file mode 100644 index 35c6d106d03..00000000000 --- a/homeassistant/components/simulated/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The simulated component.""" diff --git a/homeassistant/components/simulated/manifest.json b/homeassistant/components/simulated/manifest.json deleted file mode 100644 index e76bf142086..00000000000 --- a/homeassistant/components/simulated/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "simulated", - "name": "Simulated", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/simulated", - "iot_class": "local_polling", - "quality_scale": "internal" -} diff --git a/homeassistant/components/simulated/sensor.py b/homeassistant/components/simulated/sensor.py deleted file mode 100644 index 22ce4bd7cea..00000000000 --- a/homeassistant/components/simulated/sensor.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Adds a simulated sensor.""" - -from __future__ import annotations - -from datetime import datetime -import math -from random import Random - -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorEntity, -) -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util - -CONF_AMP = "amplitude" -CONF_FWHM = "spread" -CONF_MEAN = "mean" -CONF_PERIOD = "period" -CONF_PHASE = "phase" -CONF_SEED = "seed" -CONF_UNIT = "unit" -CONF_RELATIVE_TO_EPOCH = "relative_to_epoch" - -DEFAULT_AMP = 1 -DEFAULT_FWHM = 0 -DEFAULT_MEAN = 0 -DEFAULT_NAME = "simulated" -DEFAULT_PERIOD = 60 -DEFAULT_PHASE = 0 -DEFAULT_SEED = 999 -DEFAULT_UNIT = "value" -DEFAULT_RELATIVE_TO_EPOCH = True - -DOMAIN = "simulated" - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), - vol.Optional(CONF_FWHM, default=DEFAULT_FWHM): vol.Coerce(float), - vol.Optional(CONF_MEAN, default=DEFAULT_MEAN): vol.Coerce(float), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.positive_int, - vol.Optional(CONF_PHASE, default=DEFAULT_PHASE): vol.Coerce(float), - vol.Optional(CONF_SEED, default=DEFAULT_SEED): cv.positive_int, - vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): cv.string, - vol.Optional( - CONF_RELATIVE_TO_EPOCH, default=DEFAULT_RELATIVE_TO_EPOCH - ): cv.boolean, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the simulated sensor.""" - # Simulated has been deprecated and will be removed in 2025.1 - - ir.async_create_issue( - hass, - DOMAIN, - DOMAIN, - breaks_in_ha_version="2025.1.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="simulated_deprecation", - translation_placeholders={"integration": DOMAIN}, - learn_more_url="https://www.home-assistant.io/integrations/simulated", - ) - - name = config.get(CONF_NAME) - unit = config.get(CONF_UNIT) - amp = config.get(CONF_AMP) - mean = config.get(CONF_MEAN) - period = config.get(CONF_PERIOD) - phase = config.get(CONF_PHASE) - fwhm = config.get(CONF_FWHM) - seed = config.get(CONF_SEED) - relative_to_epoch = config.get(CONF_RELATIVE_TO_EPOCH) - - sensor = SimulatedSensor( - name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch - ) - async_add_entities([sensor], True) - - -class SimulatedSensor(SensorEntity): - """Class for simulated sensor.""" - - _attr_icon = "mdi:chart-line" - - def __init__( - self, name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch - ): - """Init the class.""" - self._name = name - self._unit = unit - self._amp = amp - self._mean = mean - self._period = period - self._phase = phase # phase in degrees - self._fwhm = fwhm - self._seed = seed - self._random = Random(seed) # A local seeded Random - self._start_time = ( - datetime(1970, 1, 1, tzinfo=dt_util.UTC) - if relative_to_epoch - else dt_util.utcnow() - ) - self._relative_to_epoch = relative_to_epoch - self._state = None - - def time_delta(self): - """Return the time delta.""" - dt0 = self._start_time - dt1 = dt_util.utcnow() - return dt1 - dt0 - - def signal_calc(self): - """Calculate the signal.""" - mean = self._mean - amp = self._amp - time_delta = self.time_delta().total_seconds() * 1e6 # to milliseconds - period = self._period * 1e6 # to milliseconds - fwhm = self._fwhm / 2 - phase = math.radians(self._phase) - if period == 0: - periodic = 0 - else: - periodic = amp * (math.sin((2 * math.pi * time_delta / period) + phase)) - noise = self._random.gauss(mu=0, sigma=fwhm) - return round(mean + periodic + noise, 3) - - async def async_update(self) -> None: - """Update the sensor.""" - self._state = self.signal_calc() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return self._unit - - @property - def extra_state_attributes(self): - """Return other details about the sensor state.""" - return { - "amplitude": self._amp, - "mean": self._mean, - "period": self._period, - "phase": self._phase, - "spread": self._fwhm, - "seed": self._seed, - "relative_to_epoch": self._relative_to_epoch, - } diff --git a/homeassistant/components/simulated/strings.json b/homeassistant/components/simulated/strings.json deleted file mode 100644 index d25a84f48a5..00000000000 --- a/homeassistant/components/simulated/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "simulated_deprecation": { - "description": "The {integration} integration is deprecated", - "title": "The {integration} integration has been deprecated and will be removed in 2025.1. Please remove the {integration} from your configuration.yaml settings and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 91456d6fa3b..65d7848c618 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -3,33 +3,22 @@ from __future__ import annotations from datetime import timedelta -from functools import partial import logging from typing import Any, TypedDict, cast, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.deprecation import ( - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.util.hass_dict import HassKey -from .const import ( # noqa: F401 - _DEPRECATED_SUPPORT_DURATION, - _DEPRECATED_SUPPORT_TONES, - _DEPRECATED_SUPPORT_TURN_OFF, - _DEPRECATED_SUPPORT_TURN_ON, - _DEPRECATED_SUPPORT_VOLUME_SET, +from .const import ( ATTR_AVAILABLE_TONES, ATTR_DURATION, ATTR_TONE, @@ -79,10 +68,8 @@ def process_turn_on_params( isinstance(siren.available_tones, dict) and tone in siren.available_tones.values() ) - if ( - not siren.available_tones - or tone not in siren.available_tones - and not is_tone_dict_value + if not siren.available_tones or ( + tone not in siren.available_tones and not is_tone_dict_value ): raise ValueError( f"Invalid tone specified for entity {siren.entity_id}: {tone}, " @@ -202,19 +189,4 @@ class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @cached_property def supported_features(self) -> SirenEntityFeature: """Return the list of supported features.""" - features = self._attr_supported_features - if type(features) is int: # noqa: E721 - new_features = SirenEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - - -# As we import deprecated constants from the const module, we need to add these two functions -# otherwise this module will be logged for using deprecated constants and not the custom component -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) + return self._attr_supported_features diff --git a/homeassistant/components/siren/const.py b/homeassistant/components/siren/const.py index 9e46d8dc997..26a158bd8ea 100644 --- a/homeassistant/components/siren/const.py +++ b/homeassistant/components/siren/const.py @@ -1,16 +1,8 @@ """Constants for the siren component.""" from enum import IntFlag -from functools import partial from typing import Final -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) - DOMAIN: Final = "siren" ATTR_TONE: Final = "tone" @@ -28,29 +20,3 @@ class SirenEntityFeature(IntFlag): TONES = 4 VOLUME_SET = 8 DURATION = 16 - - -# These constants are deprecated as of Home Assistant 2022.5 -# Please use the SirenEntityFeature enum instead. -_DEPRECATED_SUPPORT_TURN_ON: Final = DeprecatedConstantEnum( - SirenEntityFeature.TURN_ON, "2025.1" -) -_DEPRECATED_SUPPORT_TURN_OFF: Final = DeprecatedConstantEnum( - SirenEntityFeature.TURN_OFF, "2025.1" -) -_DEPRECATED_SUPPORT_TONES: Final = DeprecatedConstantEnum( - SirenEntityFeature.TONES, "2025.1" -) -_DEPRECATED_SUPPORT_VOLUME_SET: Final = DeprecatedConstantEnum( - SirenEntityFeature.VOLUME_SET, "2025.1" -) -_DEPRECATED_SUPPORT_DURATION: Final = DeprecatedConstantEnum( - SirenEntityFeature.DURATION, "2025.1" -) - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index 6fce38e4774..aa67739016d 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -5,8 +5,8 @@ from __future__ import annotations import logging from aiohttp.client_exceptions import ClientError -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform @@ -40,7 +40,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Slack from a config entry.""" session = aiohttp_client.async_get_clientsession(hass) - slack = WebClient(token=entry.data[CONF_API_KEY], run_async=True, session=session) + slack = AsyncWebClient( + token=entry.data[CONF_API_KEY], session=session + ) # No run_async try: res = await slack.auth_test() @@ -49,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Invalid API key") return False raise ConfigEntryNotReady("Error while setting up integration") from ex + data = { DATA_CLIENT: slack, ATTR_URL: res[ATTR_URL], diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py index 7f6d7288606..fcdc2e8b362 100644 --- a/homeassistant/components/slack/config_flow.py +++ b/homeassistant/components/slack/config_flow.py @@ -4,8 +4,8 @@ from __future__ import annotations import logging -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -57,10 +57,10 @@ class SlackFlowHandler(ConfigFlow, domain=DOMAIN): async def _async_try_connect( self, token: str - ) -> tuple[str, None] | tuple[None, dict[str, str]]: + ) -> tuple[str, None] | tuple[None, AsyncSlackResponse]: """Try connecting to Slack.""" session = aiohttp_client.async_get_clientsession(self.hass) - client = WebClient(token=token, run_async=True, session=session) + client = AsyncWebClient(token=token, session=session) # No run_async try: info = await client.auth_test() diff --git a/homeassistant/components/slack/entity.py b/homeassistant/components/slack/entity.py index 7147186ee9b..30218360054 100644 --- a/homeassistant/components/slack/entity.py +++ b/homeassistant/components/slack/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from slack import WebClient +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -14,21 +14,18 @@ from .const import ATTR_URL, ATTR_USER_ID, DATA_CLIENT, DEFAULT_NAME, DOMAIN class SlackEntity(Entity): """Representation of a Slack entity.""" - _attr_attribution = "Data provided by Slack" - _attr_has_entity_name = True - def __init__( self, - data: dict[str, str | WebClient], + data: dict[str, AsyncWebClient], description: EntityDescription, entry: ConfigEntry, ) -> None: """Initialize a Slack entity.""" - self._client = data[DATA_CLIENT] + self._client: AsyncWebClient = data[DATA_CLIENT] self.entity_description = description self._attr_unique_id = f"{data[ATTR_USER_ID]}_{description.key}" self._attr_device_info = DeviceInfo( - configuration_url=data[ATTR_URL], + configuration_url=str(data[ATTR_URL]), entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry.entry_id)}, manufacturer=DEFAULT_NAME, diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 1b35db6f061..3b2322283fe 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["slack"], - "requirements": ["slackclient==2.5.0"] + "requirements": ["slack_sdk==3.33.4"] } diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 28f9dd203ff..16dd212301a 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -5,13 +5,13 @@ from __future__ import annotations import asyncio import logging import os -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from urllib.parse import urlparse -from aiohttp import BasicAuth, FormData +from aiohttp import BasicAuth from aiohttp.client_exceptions import ClientError -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient import voluptuous as vol from homeassistant.components.notify import ( @@ -38,6 +38,7 @@ from .const import ( DATA_CLIENT, SLACK_DATA, ) +from .utils import upload_file_to_slack _LOGGER = logging.getLogger(__name__) @@ -136,7 +137,7 @@ class SlackNotificationService(BaseNotificationService): def __init__( self, hass: HomeAssistant, - client: WebClient, + client: AsyncWebClient, config: dict[str, str], ) -> None: """Initialize.""" @@ -160,17 +161,23 @@ class SlackNotificationService(BaseNotificationService): parsed_url = urlparse(path) filename = os.path.basename(parsed_url.path) - try: - await self._client.files_upload( - channels=",".join(targets), - file=path, - filename=filename, - initial_comment=message, - title=title or filename, - thread_ts=thread_ts or "", - ) - except (SlackApiError, ClientError) as err: - _LOGGER.error("Error while uploading file-based message: %r", err) + channel_ids = [await self._async_get_channel_id(target) for target in targets] + channel_ids = [cid for cid in channel_ids if cid] # Remove None values + + if not channel_ids: + _LOGGER.error("No valid channel IDs resolved for targets: %s", targets) + return + + await upload_file_to_slack( + client=self._client, + channel_ids=channel_ids, + file_content=None, + file_path=path, + filename=filename, + title=title, + message=message, + thread_ts=thread_ts, + ) async def _async_send_remote_file_message( self, @@ -183,12 +190,7 @@ class SlackNotificationService(BaseNotificationService): username: str | None = None, password: str | None = None, ) -> None: - """Upload a remote file (with message) to Slack. - - Note that we bypass the python-slackclient WebClient and use aiohttp directly, - as the former would require us to download the entire remote file into memory - first before uploading it to Slack. - """ + """Upload a remote file (with message) to Slack.""" if not self._hass.config.is_allowed_external_url(url): _LOGGER.error("URL is not allowed: %s", url) return @@ -196,36 +198,35 @@ class SlackNotificationService(BaseNotificationService): filename = _async_get_filename_from_url(url) session = aiohttp_client.async_get_clientsession(self._hass) + # Fetch the remote file kwargs: AuthDictT = {} - if username and password is not None: + if username and password: kwargs = {"auth": BasicAuth(username, password=password)} - resp = await session.request("get", url, **kwargs) - try: - resp.raise_for_status() + async with session.get(url, **kwargs) as resp: + resp.raise_for_status() + file_content = await resp.read() except ClientError as err: _LOGGER.error("Error while retrieving %s: %r", url, err) return - form_data: FormDataT = { - "channels": ",".join(targets), - "filename": filename, - "initial_comment": message, - "title": title or filename, - "token": self._client.token, - } + channel_ids = [await self._async_get_channel_id(target) for target in targets] + channel_ids = [cid for cid in channel_ids if cid] # Remove None values - if thread_ts: - form_data["thread_ts"] = thread_ts + if not channel_ids: + _LOGGER.error("No valid channel IDs resolved for targets: %s", targets) + return - data = FormData(form_data, charset="utf-8") - data.add_field("file", resp.content, filename=filename) - - try: - await session.post("https://slack.com/api/files.upload", data=data) - except ClientError as err: - _LOGGER.error("Error while uploading file message: %r", err) + await upload_file_to_slack( + client=self._client, + channel_ids=channel_ids, + file_content=file_content, + filename=filename, + title=title, + message=message, + thread_ts=thread_ts, + ) async def _async_send_text_only_message( self, @@ -327,3 +328,46 @@ class SlackNotificationService(BaseNotificationService): title, thread_ts=data.get(ATTR_THREAD_TS), ) + + async def _async_get_channel_id(self, channel_name: str) -> str | None: + """Get the Slack channel ID from the channel name. + + This method retrieves the channel ID for a given Slack channel name by + querying the Slack API. It handles both public and private channels. + Including this so users can provide channel names instead of IDs. + + Args: + channel_name (str): The name of the Slack channel. + + Returns: + str | None: The ID of the Slack channel if found, otherwise None. + + Raises: + SlackApiError: If there is an error while communicating with the Slack API. + + """ + try: + # Remove # if present + channel_name = channel_name.lstrip("#") + + # Get channel list + # Multiple types is not working. Tested here: https://api.slack.com/methods/conversations.list/test + # response = await self._client.conversations_list(types="public_channel,private_channel") + # + # Workaround for the types parameter not working + channels = [] + for channel_type in ("public_channel", "private_channel"): + response = await self._client.conversations_list(types=channel_type) + channels.extend(response["channels"]) + + # Find channel ID + for channel in channels: + if channel["name"] == channel_name: + return cast(str, channel["id"]) + + _LOGGER.error("Channel %s not found", channel_name) + + except SlackApiError as err: + _LOGGER.error("Error getting channel ID: %r", err) + + return None diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index 9e3beaadd8b..d53555ba82a 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from slack import WebClient +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.components.sensor import ( SensorDeviceClass, @@ -43,7 +43,7 @@ async def async_setup_entry( class SlackSensorEntity(SlackEntity, SensorEntity): """Representation of a Slack sensor.""" - _client: WebClient + _client: AsyncWebClient async def async_update(self) -> None: """Get the latest status.""" diff --git a/homeassistant/components/slack/utils.py b/homeassistant/components/slack/utils.py new file mode 100644 index 00000000000..7619d7d265f --- /dev/null +++ b/homeassistant/components/slack/utils.py @@ -0,0 +1,62 @@ +"""Utils for the Slack integration.""" + +import logging + +import aiofiles +from slack_sdk.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient + +_LOGGER = logging.getLogger(__name__) + + +async def upload_file_to_slack( + client: AsyncWebClient, + channel_ids: list[str | None], + file_content: bytes | str | None, + filename: str, + title: str | None, + message: str, + thread_ts: str | None, + file_path: str | None = None, # Allow passing a file path +) -> None: + """Upload a file to Slack for the specified channel IDs. + + Args: + client (AsyncWebClient): The Slack WebClient instance. + channel_ids (list[str | None]): List of channel IDs to upload the file to. + file_content (Union[bytes, str, None]): Content of the file (local or remote). If None, file_path is used. + filename (str): The file's name. + title (str | None): Title of the file in Slack. + message (str): Initial comment to accompany the file. + thread_ts (str | None): Thread timestamp for threading messages. + file_path (str | None): Path to the local file to be read if file_content is None. + + Raises: + SlackApiError: If the Slack API call fails. + OSError: If there is an error reading the file. + + """ + if file_content is None and file_path: + # Read file asynchronously if file_content is not provided + try: + async with aiofiles.open(file_path, "rb") as file: + file_content = await file.read() + except OSError as os_err: + _LOGGER.error("Error reading file %s: %r", file_path, os_err) + return + + for channel_id in channel_ids: + try: + await client.files_upload_v2( + channel=channel_id, + file=file_content, + filename=filename, + title=title or filename, + initial_comment=message, + thread_ts=thread_ts or "", + ) + _LOGGER.info("Successfully uploaded file to channel %s", channel_id) + except SlackApiError as err: + _LOGGER.error( + "Error while uploading file to channel %s: %r", channel_id, err + ) diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index e4fa60a4a43..905ceab18bd 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -58,14 +58,14 @@ def _get_actuator_name(bed: SleepIQBed, actuator: SleepIQActuator) -> str: f" {bed.name} {actuator.side_full} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}" ) - return f"SleepNumber {bed.name} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}" # type: ignore[unreachable] + return f"SleepNumber {bed.name} {actuator.actuator_full} {ENTITY_TYPES[ACTUATOR]}" def _get_actuator_unique_id(bed: SleepIQBed, actuator: SleepIQActuator) -> str: if actuator.side: return f"{bed.id}_{actuator.side.value}_{actuator.actuator}" - return f"{bed.id}_{actuator.actuator}" # type: ignore[unreachable] + return f"{bed.id}_{actuator.actuator}" def _get_sleeper_name(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str: diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py new file mode 100644 index 00000000000..5b4867bf337 --- /dev/null +++ b/homeassistant/components/slide_local/__init__.py @@ -0,0 +1,38 @@ +"""Component for the Slide local API.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import SlideCoordinator + +PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SWITCH] +type SlideConfigEntry = ConfigEntry[SlideCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: + """Set up the slide_local integration.""" + + coordinator = SlideCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def update_listener(hass: HomeAssistant, entry: SlideConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py new file mode 100644 index 00000000000..faca7cb3f2b --- /dev/null +++ b/homeassistant/components/slide_local/button.py @@ -0,0 +1,62 @@ +"""Support for Slide button.""" + +from __future__ import annotations + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, +) + +from homeassistant.components.button import ButtonEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SlideConfigEntry +from .const import DOMAIN +from .coordinator import SlideCoordinator +from .entity import SlideEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SlideConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up button for Slide platform.""" + + coordinator = entry.runtime_data + + async_add_entities([SlideButton(coordinator)]) + + +class SlideButton(SlideEntity, ButtonEntity): + """Defines a Slide button.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "calibrate" + + def __init__(self, coordinator: SlideCoordinator) -> None: + """Initialize the slide button.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data['mac']}-calibrate" + + async def async_press(self) -> None: + """Send out a calibrate command.""" + try: + await self.coordinator.slide.slide_calibrate(self.coordinator.host) + except ( + ClientConnectionError, + AuthenticationFailed, + ClientTimeoutError, + DigestAuthCalcError, + ) as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="calibration_error", + ) from ex diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py new file mode 100644 index 00000000000..4ceb347568f --- /dev/null +++ b/homeassistant/components/slide_local/config_flow.py @@ -0,0 +1,255 @@ +"""Config flow for slide_local integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, + GoSlideLocal as SlideLocalApi, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from . import SlideConfigEntry +from .const import CONF_INVERT_POSITION, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class SlideConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for slide_local.""" + + _mac: str = "" + _host: str = "" + _api_version: int | None = None + + VERSION = 1 + MINOR_VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: SlideConfigEntry, + ) -> SlideOptionsFlowHandler: + """Get the options flow for this handler.""" + return SlideOptionsFlowHandler() + + async def async_test_connection( + self, user_input: dict[str, str | int] + ) -> dict[str, str]: + """Reusable Auth Helper.""" + slide = SlideLocalApi() + + # first test, if API version 2 is working + await slide.slide_add( + user_input[CONF_HOST], + user_input.get(CONF_PASSWORD, ""), + 2, + ) + + try: + result = await slide.slide_info(user_input[CONF_HOST]) + except (ClientConnectionError, ClientTimeoutError): + return {"base": "cannot_connect"} + except (AuthenticationFailed, DigestAuthCalcError): + return {"base": "invalid_auth"} + except Exception: + _LOGGER.exception("Exception occurred during connection test") + return {"base": "unknown"} + + if result is not None: + self._api_version = 2 + self._mac = format_mac(result["mac"]) + return {} + + # API version 2 is not working, try API version 1 instead + await slide.slide_add( + user_input[CONF_HOST], + user_input.get(CONF_PASSWORD, ""), + 1, + ) + + try: + result = await slide.slide_info(user_input[CONF_HOST]) + except (ClientConnectionError, ClientTimeoutError): + return {"base": "cannot_connect"} + except (AuthenticationFailed, DigestAuthCalcError): + return {"base": "invalid_auth"} + except Exception: + _LOGGER.exception("Exception occurred during connection test") + return {"base": "unknown"} + + if result is None: + # API version 1 isn't working either + return {"base": "unknown"} + + self._api_version = 1 + self._mac = format_mac(result["mac"]) + + return {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + errors: dict[str, str] = {} + if user_input is not None: + if not (errors := await self.async_test_connection(user_input)): + await self.async_set_unique_id(self._mac) + self._abort_if_unique_id_configured() + user_input |= { + CONF_MAC: self._mac, + CONF_API_VERSION: self._api_version, + } + + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + options={CONF_INVERT_POSITION: False}, + ) + + if user_input is not None and user_input.get(CONF_HOST) is not None: + self._host = user_input[CONF_HOST] + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PASSWORD): str, + } + ), + {CONF_HOST: self._host}, + ), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + + if user_input is not None: + if not (errors := await self.async_test_connection(user_input)): + await self.async_set_unique_id(self._mac) + self._abort_if_unique_id_mismatch( + description_placeholders={CONF_MAC: self._mac} + ) + user_input |= { + CONF_API_VERSION: self._api_version, + } + + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + ) + + entry: SlideConfigEntry = self._get_reconfigure_entry() + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + } + ), + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PASSWORD: entry.data.get(CONF_PASSWORD, ""), + }, + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + # id is in the format 'slide_000000000000' + self._mac = format_mac(str(discovery_info.properties.get("id"))[6:]) + + await self.async_set_unique_id(self._mac) + + ip = str(discovery_info.ip_address) + _LOGGER.debug("Slide device discovered, ip %s", ip) + + self._abort_if_unique_id_configured({CONF_HOST: ip}, reload_on_update=True) + + errors = {} + if errors := await self.async_test_connection( + { + CONF_HOST: ip, + } + ): + return self.async_abort( + reason="discovery_connection_failed", + description_placeholders={ + "error": errors["base"], + }, + ) + + self._host = ip + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + + if user_input is not None: + user_input |= { + CONF_HOST: self._host, + CONF_API_VERSION: 2, + CONF_MAC: format_mac(self._mac), + } + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + options={CONF_INVERT_POSITION: False}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + "host": self._host, + }, + ) + + +class SlideOptionsFlowHandler(OptionsFlow): + """Handle a options flow for slide_local.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_INVERT_POSITION): bool, + } + ), + {CONF_INVERT_POSITION: self.config_entry.options[CONF_INVERT_POSITION]}, + ), + ) diff --git a/homeassistant/components/slide_local/const.py b/homeassistant/components/slide_local/const.py new file mode 100644 index 00000000000..9dc6d4ac925 --- /dev/null +++ b/homeassistant/components/slide_local/const.py @@ -0,0 +1,13 @@ +"""Define constants for the Slide component.""" + +API_LOCAL = "api_local" +ATTR_TOUCHGO = "touchgo" +CONF_INVERT_POSITION = "invert_position" +CONF_VERIFY_SSL = "verify_ssl" +DOMAIN = "slide_local" +SLIDES = "slides" +SLIDES_LOCAL = "slides_local" +DEFAULT_OFFSET = 0.15 +DEFAULT_RETRY = 120 +SERVICE_CALIBRATE = "calibrate" +SERVICE_TOUCHGO = "touchgo" diff --git a/homeassistant/components/slide_local/coordinator.py b/homeassistant/components/slide_local/coordinator.py new file mode 100644 index 00000000000..e5311967198 --- /dev/null +++ b/homeassistant/components/slide_local/coordinator.py @@ -0,0 +1,112 @@ +"""DataUpdateCoordinator for slide_local integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, Any + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, + GoSlideLocal as SlideLocalApi, +) + +from homeassistant.const import ( + CONF_API_VERSION, + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_OFFSET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +if TYPE_CHECKING: + from . import SlideConfigEntry + + +class SlideCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Get and update the latest data.""" + + def __init__(self, hass: HomeAssistant, entry: SlideConfigEntry) -> None: + """Initialize the data object.""" + super().__init__( + hass, _LOGGER, name="Slide", update_interval=timedelta(seconds=15) + ) + self.slide = SlideLocalApi() + self.api_version = entry.data[CONF_API_VERSION] + self.mac = entry.data[CONF_MAC] + self.host = entry.data[CONF_HOST] + self.password = entry.data[CONF_PASSWORD] if self.api_version == 1 else "" + + async def _async_setup(self) -> None: + """Do initialization logic for Slide coordinator.""" + _LOGGER.debug("Initializing Slide coordinator") + + await self.slide.slide_add( + self.host, + self.password, + self.api_version, + ) + + _LOGGER.debug("Slide coordinator initialized") + + async def _async_update_data(self) -> dict[str, Any]: + """Update the data from the Slide device.""" + _LOGGER.debug("Start data update") + + try: + data = await self.slide.slide_info(self.host) + except ( + ClientConnectionError, + AuthenticationFailed, + ClientTimeoutError, + DigestAuthCalcError, + ) as ex: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + ) from ex + + if data is None: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + ) + + if "pos" in data: + if self.data is None: + oldpos = None + else: + oldpos = self.data.get("pos") + + data["pos"] = max(0, min(1, data["pos"])) + + if oldpos is None or oldpos == data["pos"]: + data["state"] = ( + STATE_CLOSED if data["pos"] > (1 - DEFAULT_OFFSET) else STATE_OPEN + ) + elif oldpos < data["pos"]: + data["state"] = ( + STATE_CLOSED + if data["pos"] >= (1 - DEFAULT_OFFSET) + else STATE_CLOSING + ) + else: + data["state"] = ( + STATE_OPEN if data["pos"] <= DEFAULT_OFFSET else STATE_OPENING + ) + + _LOGGER.debug("Data successfully updated: %s", data) + + return data diff --git a/homeassistant/components/slide_local/cover.py b/homeassistant/components/slide_local/cover.py new file mode 100644 index 00000000000..cf04f46d139 --- /dev/null +++ b/homeassistant/components/slide_local/cover.py @@ -0,0 +1,113 @@ +"""Support for Slide covers.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SlideConfigEntry +from .const import CONF_INVERT_POSITION, DEFAULT_OFFSET +from .coordinator import SlideCoordinator +from .entity import SlideEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SlideConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up cover(s) for Slide platform.""" + + coordinator = entry.runtime_data + + async_add_entities( + [ + SlideCoverLocal( + coordinator, + entry, + ) + ] + ) + + +class SlideCoverLocal(SlideEntity, CoverEntity): + """Representation of a Slide Local API cover.""" + + _attr_assumed_state = True + _attr_device_class = CoverDeviceClass.CURTAIN + + def __init__( + self, + coordinator: SlideCoordinator, + entry: SlideConfigEntry, + ) -> None: + """Initialize the cover.""" + super().__init__(coordinator) + + self._attr_name = None + self.invert = entry.options[CONF_INVERT_POSITION] + self._attr_unique_id = coordinator.data["mac"] + + @property + def is_opening(self) -> bool: + """Return if the cover is opening or not.""" + return self.coordinator.data["state"] == STATE_OPENING + + @property + def is_closing(self) -> bool: + """Return if the cover is closing or not.""" + return self.coordinator.data["state"] == STATE_CLOSING + + @property + def is_closed(self) -> bool: + """Return None if status is unknown, True if closed, else False.""" + return self.coordinator.data["state"] == STATE_CLOSED + + @property + def current_cover_position(self) -> int | None: + """Return the current position of cover shutter.""" + pos = self.coordinator.data["pos"] + if pos is not None: + if (1 - pos) <= DEFAULT_OFFSET or pos <= DEFAULT_OFFSET: + pos = round(pos) + if not self.invert: + pos = 1 - pos + pos = int(pos * 100) + return pos + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + self.coordinator.data["state"] = STATE_OPENING + await self.coordinator.slide.slide_open(self.coordinator.host) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + self.coordinator.data["state"] = STATE_CLOSING + await self.coordinator.slide.slide_close(self.coordinator.host) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self.coordinator.slide.slide_stop(self.coordinator.host) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] / 100 + if not self.invert: + position = 1 - position + + if self.coordinator.data["pos"] is not None: + if position > self.coordinator.data["pos"]: + self.coordinator.data["state"] = STATE_CLOSING + else: + self.coordinator.data["state"] = STATE_OPENING + + await self.coordinator.slide.slide_set_position(self.coordinator.host, position) diff --git a/homeassistant/components/slide_local/diagnostics.py b/homeassistant/components/slide_local/diagnostics.py new file mode 100644 index 00000000000..2655cb5fada --- /dev/null +++ b/homeassistant/components/slide_local/diagnostics.py @@ -0,0 +1,27 @@ +"""Provides diagnostics for slide_local.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from . import SlideConfigEntry + +TO_REDACT = [ + CONF_PASSWORD, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: SlideConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = config_entry.runtime_data.data + + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "slide_data": data, + } diff --git a/homeassistant/components/slide_local/entity.py b/homeassistant/components/slide_local/entity.py new file mode 100644 index 00000000000..51269649add --- /dev/null +++ b/homeassistant/components/slide_local/entity.py @@ -0,0 +1,27 @@ +"""Entities for slide_local integration.""" + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import SlideCoordinator + + +class SlideEntity(CoordinatorEntity[SlideCoordinator]): + """Base class of a Slide local API cover.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: SlideCoordinator) -> None: + """Initialize the Slide device.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + manufacturer="Innovation in Motion", + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.data["mac"])}, + name=coordinator.data["device_name"], + sw_version=coordinator.api_version, + hw_version=coordinator.data["board_rev"], + serial_number=coordinator.data["mac"], + configuration_url=f"http://{coordinator.host}", + ) diff --git a/homeassistant/components/slide_local/icons.json b/homeassistant/components/slide_local/icons.json new file mode 100644 index 00000000000..70d53e7f7a3 --- /dev/null +++ b/homeassistant/components/slide_local/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "button": { + "calibrate": { + "default": "mdi:tape-measure" + } + } + } +} diff --git a/homeassistant/components/slide_local/manifest.json b/homeassistant/components/slide_local/manifest.json new file mode 100644 index 00000000000..7e524c54a25 --- /dev/null +++ b/homeassistant/components/slide_local/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "slide_local", + "name": "Slide Local", + "codeowners": ["@dontinelli"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/slide_local", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "gold", + "requirements": ["goslide-api==0.7.0"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "slide*" + } + ] +} diff --git a/homeassistant/components/slide_local/quality_scale.yaml b/homeassistant/components/slide_local/quality_scale.yaml new file mode 100644 index 00000000000..0bb30ee8269 --- /dev/null +++ b/homeassistant/components/slide_local/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: No explicit event subscriptions. + dependency-transparency: done + action-setup: done + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: + status: exempt + comment: | + The password used is the device code and can't change. No reauth required. + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: done + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: + status: done + comment: | + Slide_local represents a single physical device, no removal stale devices required (besides removal of instance itself). + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: + status: exempt + comment: | + Slide_local represents a single physical device, no dynamic changes of devices possible (besides removal of instance itself). + discovery-update-info: done + repair-issues: + status: exempt + comment: No issues/repairs. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: + status: exempt + comment: | + This integration doesn't have known issues that could be resolved by the user. + docs-examples: done + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/slide_local/strings.json b/homeassistant/components/slide_local/strings.json new file mode 100644 index 00000000000..67514ff0d50 --- /dev/null +++ b/homeassistant/components/slide_local/strings.json @@ -0,0 +1,80 @@ +{ + "config": { + "step": { + "user": { + "description": "Provide information to connect to the Slide device", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your local Slide", + "password": "The device code of your Slide (inside of the Slide or in the box, length is 8 characters). If your Slide runs firmware version 2 this is optional, as it is not used by the local API." + } + }, + "reconfigure": { + "description": "Reconfigure the information for your Slide device", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "[%key:component::slide_local::config::step::user::data_description::host%]", + "password": "[%key:component::slide_local::config::step::user::data_description::password%]" + } + }, + "zeroconf_confirm": { + "title": "Confirm setup for Slide", + "description": "Do you want to setup {host}?" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "discovery_connection_failed": "The setup of the discovered device failed with the following error: {error}. Please try to set it up manually.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The MAC address of the device ({mac}) does not match the previous MAC address." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "title": "Configure Slide", + "description": "Reconfigure the Slide device", + "data": { + "invert_position": "Invert position" + }, + "data_description": { + "invert_position": "Inverts the position of your Slide cover." + } + } + } + }, + "entity": { + "button": { + "calibrate": { + "name": "Calibrate" + } + }, + "switch": { + "touchgo": { + "name": "TouchGo" + } + } + }, + "exceptions": { + "calibration_error": { + "message": "Error while sending the calibration request to the device." + }, + "touchgo_error": { + "message": "Error while sending the request setting Touch&Go to {state} to the device." + }, + "update_error": { + "message": "Error while updating data from the API." + } + } +} diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py new file mode 100644 index 00000000000..0471dfcc4e6 --- /dev/null +++ b/homeassistant/components/slide_local/switch.py @@ -0,0 +1,93 @@ +"""Support for Slide switch.""" + +from __future__ import annotations + +from typing import Any + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, +) + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SlideConfigEntry +from .const import DOMAIN +from .coordinator import SlideCoordinator +from .entity import SlideEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SlideConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switch for Slide platform.""" + + coordinator = entry.runtime_data + + async_add_entities([SlideSwitch(coordinator)]) + + +class SlideSwitch(SlideEntity, SwitchEntity): + """Defines a Slide switch.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "touchgo" + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, coordinator: SlideCoordinator) -> None: + """Initialize the slide switch.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data['mac']}-touchgo" + + @property + def is_on(self) -> bool: + """Return if switch is on.""" + return self.coordinator.data["touch_go"] + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off touchgo.""" + try: + await self.coordinator.slide.slide_set_touchgo(self.coordinator.host, False) + except ( + ClientConnectionError, + AuthenticationFailed, + ClientTimeoutError, + DigestAuthCalcError, + ) as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="touchgo_error", + translation_placeholders={ + "state": "off", + }, + ) from ex + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on touchgo.""" + try: + await self.coordinator.slide.slide_set_touchgo(self.coordinator.host, True) + except ( + ClientConnectionError, + AuthenticationFailed, + ClientTimeoutError, + DigestAuthCalcError, + ) as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="touchgo_error", + translation_placeholders={ + "state": "on", + }, + ) from ex + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 37fb4d72284..6aae74922e4 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -72,6 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=sma_device_info["type"], name=sma_device_info["name"], sw_version=sma_device_info["sw_version"], + serial_number=sma_device_info["serial"], ) # Define the coordinator diff --git a/homeassistant/components/sma/diagnostics.py b/homeassistant/components/sma/diagnostics.py new file mode 100644 index 00000000000..9c17cb0d2a9 --- /dev/null +++ b/homeassistant/components/sma/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for SMA.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +TO_REDACT = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for the config entry.""" + ent_reg = er.async_get(hass) + entities = [ + entity.entity_id + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + ] + + entity_states = {entity: hass.states.get(entity) for entity in entities} + + entry_dict = entry.as_dict() + if "data" in entry_dict: + entry_dict["data"] = async_redact_data(entry_dict["data"], TO_REDACT) + + return { + "entry": entry_dict, + "entities": entity_states, + } diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 070320fa976..8024aad82d6 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -1,10 +1,10 @@ { "domain": "sma", "name": "SMA Solar", - "codeowners": ["@kellerza", "@rklomp"], + "codeowners": ["@kellerza", "@rklomp", "@erwindouna"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", "iot_class": "local_polling", "loggers": ["pysma"], - "requirements": ["pysma==0.7.3"] + "requirements": ["pysma==0.7.5"] } diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 302c4f6b197..863f15a9a17 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -48,6 +48,12 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), + "operating_status": SensorEntityDescription( + key="operating_status", + name="Operating Status", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), "inverter_condition": SensorEntityDescription( key="inverter_condition", name="Inverter Condition", diff --git a/homeassistant/components/smappee/config_flow.py b/homeassistant/components/smappee/config_flow.py index 4f7a71218ab..01b69a76b28 100644 --- a/homeassistant/components/smappee/config_flow.py +++ b/homeassistant/components/smappee/config_flow.py @@ -6,10 +6,10 @@ from typing import Any from pysmappee import helper, mqtt import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import api from .const import ( @@ -43,7 +43,7 @@ class SmappeeFlowHandler( return logging.getLogger(__name__) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index bcc752ff173..2914851ccbf 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -10,12 +10,16 @@ import logging from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from pysmartapp.event import EVENT_TYPE_DEVICE -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -106,7 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # to import the modules. await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) - remove_entry = False try: # See if the app is already setup. This occurs when there are # installs in multiple SmartThings locations (valid use-case) @@ -175,34 +178,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: broker.connect() hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker + except APIInvalidGrant as ex: + raise ConfigEntryAuthFailed from ex except ClientResponseError as ex: if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - _LOGGER.exception( - ( - "Unable to setup configuration entry '%s' - please reconfigure the" - " integration" - ), - entry.title, - ) - remove_entry = True - else: - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex + raise ConfigEntryError( + "The access token is no longer valid. Please remove the integration and set up again." + ) from ex + _LOGGER.debug(ex, exc_info=True) + raise ConfigEntryNotReady from ex except (ClientConnectionError, RuntimeWarning) as ex: _LOGGER.debug(ex, exc_info=True) raise ConfigEntryNotReady from ex - if remove_entry: - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - # only create new flow if there isn't a pending one for SmartThings. - if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - return False - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 073a1470c21..d9535272295 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -164,8 +164,6 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" - _enable_turn_on_off_backwards_compatibility = False - def __init__(self, device): """Init the class.""" super().__init__(device) @@ -347,7 +345,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" _hvac_modes: list[HVACMode] - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device) -> None: """Init the class.""" diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 081f833787e..7b49854740a 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure SmartThings.""" +from collections.abc import Mapping from http import HTTPStatus import logging from typing import Any @@ -9,7 +10,7 @@ from pysmartthings import APIResponseError, AppOAuth, SmartThings from pysmartthings.installedapp import format_install_url import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -213,7 +214,10 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): url = format_install_url(self.app_id, self.location_id) return self.async_external_step(step_id="authorize", url=url) - return self.async_external_step_done(next_step_id="install") + next_step_id = "install" + if self.source == SOURCE_REAUTH: + next_step_id = "update" + return self.async_external_step_done(next_step_id=next_step_id) def _show_step_pat(self, errors): if self.access_token is None: @@ -240,6 +244,41 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + self.app_id = self._get_reauth_entry().data[CONF_APP_ID] + self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID] + self._set_confirm_only() + return await self.async_step_authorize() + + async def async_step_update( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + return await self.async_step_update_confirm() + + async def async_step_update_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + if user_input is None: + self._set_confirm_only() + return self.async_show_form(step_id="update_confirm") + entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token} + ) + async def async_step_install( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 131cccdd869..61e30589273 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -70,7 +70,6 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" _attr_speed_count = int_states_in_range(SPEED_RANGE) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device): """Init the class.""" diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index fd4b87f0ee7..eb7c9af246b 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -10,7 +10,7 @@ from pysmartthings import Capability from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, ColorMode, @@ -21,7 +21,6 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util from .const import DATA_BROKERS, DOMAIN from .entity import SmartThingsEntity @@ -79,12 +78,12 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): # SmartThings does not expose this attribute, instead it's # implemented within each device-type handler. This value is the # lowest kelvin found supported across 20+ handlers. - _attr_max_mireds = 500 # 2000K + _attr_min_color_temp_kelvin = 2000 # 500 mireds # SmartThings does not expose this attribute, instead it's # implemented within each device-type handler. This value is the # highest kelvin found supported across 20+ handlers. - _attr_min_mireds = 111 # 9000K + _attr_max_color_temp_kelvin = 9000 # 111 mireds def __init__(self, device): """Initialize a SmartThingsLight.""" @@ -122,8 +121,8 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): """Turn the light on.""" tasks = [] # Color temperature - if ATTR_COLOR_TEMP in kwargs: - tasks.append(self.async_set_color_temp(kwargs[ATTR_COLOR_TEMP])) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + tasks.append(self.async_set_color_temp(kwargs[ATTR_COLOR_TEMP_KELVIN])) # Color if ATTR_HS_COLOR in kwargs: tasks.append(self.async_set_color(kwargs[ATTR_HS_COLOR])) @@ -164,9 +163,7 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._attr_color_temp = color_util.color_temperature_kelvin_to_mired( - self._device.status.color_temperature - ) + self._attr_color_temp_kelvin = self._device.status.color_temperature # Color if ColorMode.HS in self._attr_supported_color_modes: self._attr_hs_color = ( @@ -181,10 +178,9 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): saturation = max(min(float(hs_color[1]), 100.0), 0.0) await self._device.set_color(hue, saturation, set_status=True) - async def async_set_color_temp(self, value: float): + async def async_set_color_temp(self, value: int): """Set the color temperature of the device.""" - kelvin = color_util.color_temperature_mired_to_kelvin(value) - kelvin = max(min(kelvin, 30000), 1) + kelvin = max(min(value, 30000), 1) await self._device.set_color_temperature(kelvin, set_status=True) async def async_set_level(self, brightness: int, transition: int): diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 6b0da00b132..76b6804075f 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -1,5 +1,7 @@ """SmartApp functionality to receive cloud-push notifications.""" +from __future__ import annotations + import asyncio import functools import logging @@ -27,6 +29,7 @@ from pysmartthings import ( ) from homeassistant.components import cloud, webhook +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -400,7 +403,7 @@ async def smartapp_sync_subscriptions( _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) -async def _continue_flow( +async def _find_and_continue_flow( hass: HomeAssistant, app_id: str, location_id: str, @@ -418,24 +421,34 @@ async def _continue_flow( None, ) if flow is not None: - await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_REFRESH_TOKEN: refresh_token, - }, - ) - _LOGGER.debug( - "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - installed_app_id, - app_id, - ) + await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow) + + +async def _continue_flow( + hass: HomeAssistant, + app_id: str, + installed_app_id: str, + refresh_token: str, + flow: ConfigFlowResult, +) -> None: + await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_REFRESH_TOKEN: refresh_token, + }, + ) + _LOGGER.debug( + "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + installed_app_id, + app_id, + ) async def smartapp_install(hass: HomeAssistant, req, resp, app): """Handle a SmartApp installation and continue the config flow.""" - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( @@ -447,6 +460,27 @@ async def smartapp_install(hass: HomeAssistant, req, resp, app): async def smartapp_update(hass: HomeAssistant, req, resp, app): """Handle a SmartApp update and either update the entry or continue the flow.""" + unique_id = format_unique_id(app.app_id, req.location_id) + flow = next( + ( + flow + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if flow["context"].get("unique_id") == unique_id + and flow["step_id"] == "authorize" + ), + None, + ) + if flow is not None: + await _continue_flow( + hass, app.app_id, req.installed_app_id, req.refresh_token, flow + ) + _LOGGER.debug( + "Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'", + flow["flow_id"], + req.installed_app_id, + app.app_id, + ) + return entry = next( ( entry @@ -466,7 +500,7 @@ async def smartapp_update(hass: HomeAssistant, req, resp, app): app.app_id, ) - await _continue_flow( + await _find_and_continue_flow( hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) _LOGGER.debug( diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 7fbf966fa89..31a552be149 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -7,27 +7,36 @@ }, "pat": { "title": "Enter Personal Access Token", - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.", + "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**", "data": { "access_token": "[%key:common::config_flow::data::access_token%]" } }, "select_location": { "title": "Select Location", - "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", + "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", "data": { "location_id": "[%key:common::config_flow::data::location%]" } }, - "authorize": { "title": "Authorize Home Assistant" } + "authorize": { "title": "Authorize Home Assistant" }, + "reauth_confirm": { + "title": "Reauthorize Home Assistant", + "description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again." + }, + "update_confirm": { + "title": "Finish reauthentication", + "description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process." + } }, "abort": { "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", - "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant." + "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.", + "reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings." }, "error": { "token_invalid_format": "The token must be in the UID/GUID format", "token_unauthorized": "The token is invalid or no longer authorized.", "token_forbidden": "The token does not have the required OAuth scopes.", - "app_setup_error": "Unable to set up the SmartApp. Please try again.", + "app_setup_error": "Unable to set up the SmartApp. Please try again.", "webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again." } } diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index f0bb84b3390..7f3163834e0 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -68,7 +68,6 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_modes = list(PRESET_MODES.values()) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, spa): """Initialize the entity.""" diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index 378585a33e1..2804f14ee15 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -48,7 +48,6 @@ class SmartyFan(SmartyEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: SmartyCoordinator) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 94bdfcc4559..59b32948879 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -32,6 +32,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" + + if entry.version > 3: + # Downgrade from future version + return False + if entry.version == 1: new_data = { CONF_NAME: entry.data[CONF_NAME], @@ -40,8 +45,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_LONGITUDE: entry.data[CONF_LONGITUDE], }, } + hass.config_entries.async_update_entry(entry, data=new_data, version=2) - if not hass.config_entries.async_update_entry(entry, data=new_data, version=2): - return False + if entry.version == 2: + new_data = entry.data.copy() + new_data.pop(CONF_NAME) + hass.config_entries.async_update_entry(entry, data=new_data, version=3) return True diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index 2992b176f24..2521df3a333 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import ( aiohttp_client, @@ -38,7 +38,7 @@ async def async_check_location( class SmhiFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for SMHI component.""" - VERSION = 2 + VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -58,10 +58,6 @@ class SmhiFlowHandler(ConfigFlow, domain=DOMAIN): ): name = HOME_LOCATION_NAME - user_input[CONF_NAME] = ( - HOME_LOCATION_NAME if name == HOME_LOCATION_NAME else DEFAULT_NAME - ) - await self.async_set_unique_id(f"{lat}-{lon}") self._abort_if_unique_id_configured() return self.async_create_entry(title=name, data=user_input) diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index 76f9812e815..645ace41cab 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smhi", "iot_class": "cloud_polling", "loggers": ["smhi"], - "requirements": ["smhi-pkg==1.0.18"] + "requirements": ["smhi-pkg==1.0.19"] } diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 3d5642a2784..d43ca4465ae 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -48,7 +48,6 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, - CONF_NAME, UnitOfLength, UnitOfPrecipitationDepth, UnitOfPressure, @@ -60,7 +59,7 @@ from homeassistant.helpers import aiohttp_client, sun from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle, dt as dt_util, slugify +from homeassistant.util import Throttle, dt as dt_util from .const import ATTR_SMHI_THUNDER_PROBABILITY, DOMAIN, ENTITY_ID_SENSOR_FORMAT @@ -103,17 +102,15 @@ async def async_setup_entry( ) -> None: """Add a weather entity from map location.""" location = config_entry.data - name = slugify(location[CONF_NAME]) session = aiohttp_client.async_get_clientsession(hass) entity = SmhiWeather( - location[CONF_NAME], location[CONF_LOCATION][CONF_LATITUDE], location[CONF_LOCATION][CONF_LONGITUDE], session=session, ) - entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name) + entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(config_entry.title) async_add_entities([entity], True) @@ -136,7 +133,6 @@ class SmhiWeather(WeatherEntity): def __init__( self, - name: str, latitude: str, longitude: str, session: aiohttp.ClientSession, @@ -152,7 +148,6 @@ class SmhiWeather(WeatherEntity): identifiers={(DOMAIN, f"{latitude}, {longitude}")}, manufacturer="SMHI", model="v2", - name=name, configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", ) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 92b543e0441..dee81264fa4 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -6,14 +6,15 @@ from collections.abc import Mapping from typing import Any from pysmlight import Api2 +from pysmlight.const import Devices from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -51,6 +52,11 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self.client = Api2(self.host, session=async_get_clientsession(self.hass)) try: + info = await self.client.get_info() + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + if not await self._async_check_auth_required(user_input): return await self._async_complete_entry(user_input) except SmlightConnectionError: @@ -70,6 +76,11 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: + info = await self.client.get_info() + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + if not await self._async_check_auth_required(user_input): return await self._async_complete_entry(user_input) except SmlightConnectionError: @@ -82,7 +93,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Lan coordinator.""" local_name = discovery_info.hostname[:-1] @@ -116,6 +127,11 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: user_input[CONF_HOST] = self.host try: + info = await self.client.get_info() + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + if not await self._async_check_auth_required(user_input): return await self._async_complete_entry(user_input) diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index 669094b2441..0a45363f8ad 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -9,7 +9,7 @@ ATTR_MANUFACTURER = "SMLIGHT" DATA_COORDINATOR = "data" FIRMWARE_COORDINATOR = "firmware" -SCAN_FIRMWARE_INTERVAL = timedelta(hours=6) +SCAN_FIRMWARE_INTERVAL = timedelta(hours=24) LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=300) SCAN_INTERNET_INTERVAL = timedelta(minutes=15) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index c1eca45871b..3a8578c8a59 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.3"], + "requirements": ["pysmlight==0.1.6"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 1e6a533beef..21ff5098d27 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -38,7 +38,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "reauth_failed": "[%key:common::config_flow::error::invalid_auth%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unsupported_device": "This device is not yet supported by the SMLIGHT integration" } }, "entity": { diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index a4163355944..b853535b525 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -1,37 +1,28 @@ """Snapcast Integration.""" -import logging - -import snapcast.control - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN, PLATFORMS -from .server import HomeAssistantSnapcast - -_LOGGER = logging.getLogger(__name__) +from .coordinator import SnapcastUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Snapcast from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] + coordinator = SnapcastUpdateCoordinator(hass, host, port) + try: - server = await snapcast.control.create_server( - hass.loop, host, port, reconnect=True - ) + await coordinator.async_config_entry_first_refresh() except OSError as ex: raise ConfigEntryNotReady( f"Could not connect to Snapcast server at {host}:{port}" ) from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast( - hass, server, f"{host}:{port}", entry.entry_id - ) - + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/snapcast/coordinator.py b/homeassistant/components/snapcast/coordinator.py new file mode 100644 index 00000000000..5bb9ae4e51f --- /dev/null +++ b/homeassistant/components/snapcast/coordinator.py @@ -0,0 +1,72 @@ +"""Data update coordinator for Snapcast server.""" + +from __future__ import annotations + +import logging + +from snapcast.control.server import Snapserver + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): + """Data update coordinator for pushed data from Snapcast server.""" + + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=f"{host}:{port}", + update_interval=None, # Disable update interval as server pushes + ) + + self._server = Snapserver(hass.loop, host, port, True) + self.last_update_success = False + + self._server.set_on_update_callback(self._on_update) + self._server.set_new_client_callback(self._on_update) + self._server.set_on_connect_callback(self._on_connect) + self._server.set_on_disconnect_callback(self._on_disconnect) + + def _on_update(self) -> None: + """Snapserver on_update callback.""" + # Assume availability if an update is received. + self.last_update_success = True + self.async_update_listeners() + + def _on_connect(self) -> None: + """Snapserver on_connect callback.""" + self.last_update_success = True + self.async_update_listeners() + + def _on_disconnect(self, ex): + """Snapsever on_disconnect callback.""" + self.async_set_update_error(ex) + + async def _async_setup(self) -> None: + """Perform async setup for the coordinator.""" + # Start the server + try: + await self._server.start() + except OSError as ex: + raise UpdateFailed from ex + + async def _async_update_data(self) -> None: + """Empty update method since data is pushed.""" + + async def disconnect(self) -> None: + """Disconnect from the server.""" + self._server.set_on_update_callback(None) + self._server.set_on_connect_callback(None) + self._server.set_on_disconnect_callback(None) + self._server.set_new_client_callback(None) + self._server.stop() + + @property + def server(self) -> Snapserver: + """Get the Snapserver object.""" + return self._server diff --git a/homeassistant/components/snapcast/entity.py b/homeassistant/components/snapcast/entity.py new file mode 100644 index 00000000000..cceeb6227fd --- /dev/null +++ b/homeassistant/components/snapcast/entity.py @@ -0,0 +1,11 @@ +"""Coordinator entity for Snapcast server.""" + +from __future__ import annotations + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import SnapcastUpdateCoordinator + + +class SnapcastCoordinatorEntity(CoordinatorEntity[SnapcastUpdateCoordinator]): + """Coordinator entity for Snapcast.""" diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index bda411acde3..0ec27c1ad9c 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -2,18 +2,29 @@ from __future__ import annotations -from snapcast.control.server import Snapserver +from collections.abc import Mapping +import logging +from typing import Any + +from snapcast.control.client import Snapclient +from snapcast.control.group import Snapgroup import voluptuous as vol from homeassistant.components.media_player import ( + DOMAIN as MEDIA_PLAYER_DOMAIN, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + entity_registry as er, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -30,6 +41,8 @@ from .const import ( SERVICE_SNAPSHOT, SERVICE_UNJOIN, ) +from .coordinator import SnapcastUpdateCoordinator +from .entity import SnapcastCoordinatorEntity STREAM_STATUS = { "idle": MediaPlayerState.IDLE, @@ -37,21 +50,23 @@ STREAM_STATUS = { "unknown": None, } +_LOGGER = logging.getLogger(__name__) -def register_services(): + +def register_services() -> None: """Register snapcast services.""" platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_SNAPSHOT, None, "snapshot") platform.async_register_entity_service(SERVICE_RESTORE, None, "async_restore") platform.async_register_entity_service( - SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, handle_async_join + SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join" ) - platform.async_register_entity_service(SERVICE_UNJOIN, None, handle_async_unjoin) + platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin") platform.async_register_entity_service( SERVICE_SET_LATENCY, {vol.Required(ATTR_LATENCY): cv.positive_int}, - handle_set_latency, + "async_set_latency", ) @@ -61,51 +76,103 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the snapcast config entry.""" - snapcast_server: Snapserver = hass.data[DOMAIN][config_entry.entry_id].server + + # Fetch coordinator from global data + coordinator: SnapcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # Create an ID for the Snapserver + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] + host_id = f"{host}:{port}" register_services() - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] - hpid = f"{host}:{port}" + _known_group_ids: set[str] = set() + _known_client_ids: set[str] = set() - groups: list[MediaPlayerEntity] = [ - SnapcastGroupDevice(group, hpid, config_entry.entry_id) - for group in snapcast_server.groups - ] - clients: list[MediaPlayerEntity] = [ - SnapcastClientDevice(client, hpid, config_entry.entry_id) - for client in snapcast_server.clients - ] - async_add_entities(clients + groups) - hass.data[DOMAIN][ - config_entry.entry_id - ].hass_async_add_entities = async_add_entities + @callback + def _check_entities() -> None: + nonlocal _known_group_ids, _known_client_ids + + def _update_known_ids(known_ids, ids) -> tuple[set[str], set[str]]: + ids_to_add = ids - known_ids + ids_to_remove = known_ids - ids + + # Update known IDs + known_ids.difference_update(ids_to_remove) + known_ids.update(ids_to_add) + + return ids_to_add, ids_to_remove + + group_ids = {g.identifier for g in coordinator.server.groups} + groups_to_add, groups_to_remove = _update_known_ids(_known_group_ids, group_ids) + + client_ids = {c.identifier for c in coordinator.server.clients} + clients_to_add, clients_to_remove = _update_known_ids( + _known_client_ids, client_ids + ) + + # Exit early if no changes + if not (groups_to_add | groups_to_remove | clients_to_add | clients_to_remove): + return + + _LOGGER.debug( + "New clients: %s", + str([coordinator.server.client(c).friendly_name for c in clients_to_add]), + ) + _LOGGER.debug( + "New groups: %s", + str([coordinator.server.group(g).friendly_name for g in groups_to_add]), + ) + _LOGGER.debug( + "Remove client IDs: %s", + str([list(clients_to_remove)]), + ) + _LOGGER.debug( + "Remove group IDs: %s", + str(list(groups_to_remove)), + ) + + # Add new entities + async_add_entities( + [ + SnapcastGroupDevice( + coordinator, coordinator.server.group(group_id), host_id + ) + for group_id in groups_to_add + ] + + [ + SnapcastClientDevice( + coordinator, coordinator.server.client(client_id), host_id + ) + for client_id in clients_to_add + ] + ) + + # Remove stale entities + entity_registry = er.async_get(hass) + for group_id in groups_to_remove: + if entity_id := entity_registry.async_get_entity_id( + MEDIA_PLAYER_DOMAIN, + DOMAIN, + SnapcastGroupDevice.get_unique_id(host_id, group_id), + ): + entity_registry.async_remove(entity_id) + + for client_id in clients_to_remove: + if entity_id := entity_registry.async_get_entity_id( + MEDIA_PLAYER_DOMAIN, + DOMAIN, + SnapcastClientDevice.get_unique_id(host_id, client_id), + ): + entity_registry.async_remove(entity_id) + + coordinator.async_add_listener(_check_entities) + _check_entities() -async def handle_async_join(entity, service_call): - """Handle the entity service join.""" - if not isinstance(entity, SnapcastClientDevice): - raise TypeError("Entity is not a client. Can only join clients.") - await entity.async_join(service_call.data[ATTR_MASTER]) - - -async def handle_async_unjoin(entity, service_call): - """Handle the entity service unjoin.""" - if not isinstance(entity, SnapcastClientDevice): - raise TypeError("Entity is not a client. Can only unjoin clients.") - await entity.async_unjoin() - - -async def handle_set_latency(entity, service_call): - """Handle the entity service set_latency.""" - if not isinstance(entity, SnapcastClientDevice): - raise TypeError("Latency can only be set for a Snapcast client.") - await entity.async_set_latency(service_call.data[ATTR_LATENCY]) - - -class SnapcastGroupDevice(MediaPlayerEntity): - """Representation of a Snapcast group device.""" +class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): + """Base class representing a Snapcast device.""" _attr_should_poll = False _attr_supported_features = ( @@ -114,166 +181,172 @@ class SnapcastGroupDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, group, uid_part, entry_id): - """Initialize the Snapcast group device.""" - self._attr_available = True - self._group = group - self._entry_id = entry_id - self._attr_unique_id = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}" + def __init__( + self, + coordinator: SnapcastUpdateCoordinator, + device: Snapgroup | Snapclient, + host_id: str, + ) -> None: + """Initialize the base device.""" + super().__init__(coordinator) + + self._device = device + self._attr_unique_id = self.get_unique_id(host_id, device.identifier) + + @classmethod + def get_unique_id(cls, host, id) -> str: + """Build a unique ID.""" + raise NotImplementedError + + @property + def _current_group(self) -> Snapgroup: + """Return the group.""" + raise NotImplementedError async def async_added_to_hass(self) -> None: - """Subscribe to group events.""" - self._group.set_callback(self.schedule_update_ha_state) - self.hass.data[DOMAIN][self._entry_id].groups.append(self) + """Subscribe to events.""" + await super().async_added_to_hass() + self._device.set_callback(self.schedule_update_ha_state) async def async_will_remove_from_hass(self) -> None: - """Disconnect group object when removed.""" - self._group.set_callback(None) - self.hass.data[DOMAIN][self._entry_id].groups.remove(self) + """Disconnect object when removed.""" + self._device.set_callback(None) - def set_availability(self, available: bool) -> None: - """Set availability of group.""" - self._attr_available = available - self.schedule_update_ha_state() + @property + def identifier(self) -> str: + """Return the snapcast identifier.""" + return self._device.identifier + + @property + def source(self) -> str | None: + """Return the current input source.""" + return self._current_group.stream + + @property + def source_list(self) -> list[str]: + """List of available input sources.""" + return list(self._current_group.streams_by_name().keys()) + + async def async_select_source(self, source: str) -> None: + """Set input source.""" + streams = self._current_group.streams_by_name() + if source in streams: + await self._current_group.set_stream(streams[source].identifier) + self.async_write_ha_state() + + @property + def is_volume_muted(self) -> bool: + """Volume muted.""" + return self._device.muted + + async def async_mute_volume(self, mute: bool) -> None: + """Send the mute command.""" + await self._device.set_muted(mute) + self.async_write_ha_state() + + @property + def volume_level(self) -> float: + """Return the volume level.""" + return self._device.volume / 100 + + async def async_set_volume_level(self, volume: float) -> None: + """Set the volume level.""" + await self._device.set_volume(round(volume * 100)) + self.async_write_ha_state() + + def snapshot(self) -> None: + """Snapshot the group state.""" + self._device.snapshot() + + async def async_restore(self) -> None: + """Restore the group state.""" + await self._device.restore() + self.async_write_ha_state() + + async def async_set_latency(self, latency) -> None: + """Handle the set_latency service.""" + raise NotImplementedError + + async def async_join(self, master) -> None: + """Handle the join service.""" + raise NotImplementedError + + async def async_unjoin(self) -> None: + """Handle the unjoin service.""" + raise NotImplementedError + + +class SnapcastGroupDevice(SnapcastBaseDevice): + """Representation of a Snapcast group device.""" + + _device: Snapgroup + + @classmethod + def get_unique_id(cls, host, id) -> str: + """Get a unique ID for a group.""" + return f"{GROUP_PREFIX}{host}_{id}" + + @property + def _current_group(self) -> Snapgroup: + """Return the group.""" + return self._device + + @property + def name(self) -> str: + """Return the name of the device.""" + return f"{self._device.friendly_name} {GROUP_SUFFIX}" @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" if self.is_volume_muted: return MediaPlayerState.IDLE - return STREAM_STATUS.get(self._group.stream_status) + return STREAM_STATUS.get(self._device.stream_status) - @property - def identifier(self): - """Return the snapcast identifier.""" - return self._group.identifier + async def async_set_latency(self, latency) -> None: + """Handle the set_latency service.""" + raise ServiceValidationError("Latency can only be set for a Snapcast client.") - @property - def name(self): - """Return the name of the device.""" - return f"{self._group.friendly_name} {GROUP_SUFFIX}" + async def async_join(self, master) -> None: + """Handle the join service.""" + raise ServiceValidationError("Entity is not a client. Can only join clients.") - @property - def source(self): - """Return the current input source.""" - return self._group.stream - - @property - def volume_level(self): - """Return the volume level.""" - return self._group.volume / 100 - - @property - def is_volume_muted(self): - """Volume muted.""" - return self._group.muted - - @property - def source_list(self): - """List of available input sources.""" - return list(self._group.streams_by_name().keys()) - - async def async_select_source(self, source: str) -> None: - """Set input source.""" - streams = self._group.streams_by_name() - if source in streams: - await self._group.set_stream(streams[source].identifier) - self.async_write_ha_state() - - async def async_mute_volume(self, mute: bool) -> None: - """Send the mute command.""" - await self._group.set_muted(mute) - self.async_write_ha_state() - - async def async_set_volume_level(self, volume: float) -> None: - """Set the volume level.""" - await self._group.set_volume(round(volume * 100)) - self.async_write_ha_state() - - def snapshot(self): - """Snapshot the group state.""" - self._group.snapshot() - - async def async_restore(self): - """Restore the group state.""" - await self._group.restore() - self.async_write_ha_state() + async def async_unjoin(self) -> None: + """Handle the unjoin service.""" + raise ServiceValidationError("Entity is not a client. Can only unjoin clients.") -class SnapcastClientDevice(MediaPlayerEntity): +class SnapcastClientDevice(SnapcastBaseDevice): """Representation of a Snapcast client device.""" - _attr_should_poll = False - _attr_supported_features = ( - MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.SELECT_SOURCE - ) + _device: Snapclient - def __init__(self, client, uid_part, entry_id): - """Initialize the Snapcast client device.""" - self._attr_available = True - self._client = client - # Note: Host part is needed, when using multiple snapservers - self._attr_unique_id = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" - self._entry_id = entry_id - - async def async_added_to_hass(self) -> None: - """Subscribe to client events.""" - self._client.set_callback(self.schedule_update_ha_state) - self.hass.data[DOMAIN][self._entry_id].clients.append(self) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect client object when removed.""" - self._client.set_callback(None) - self.hass.data[DOMAIN][self._entry_id].clients.remove(self) - - def set_availability(self, available: bool) -> None: - """Set availability of group.""" - self._attr_available = available - self.schedule_update_ha_state() + @classmethod + def get_unique_id(cls, host, id) -> str: + """Get a unique ID for a client.""" + return f"{CLIENT_PREFIX}{host}_{id}" @property - def identifier(self): - """Return the snapcast identifier.""" - return self._client.identifier + def _current_group(self) -> Snapgroup: + """Return the group the client is associated with.""" + return self._device.group @property - def name(self): + def name(self) -> str: """Return the name of the device.""" - return f"{self._client.friendly_name} {CLIENT_SUFFIX}" - - @property - def source(self): - """Return the current input source.""" - return self._client.group.stream - - @property - def volume_level(self): - """Return the volume level.""" - return self._client.volume / 100 - - @property - def is_volume_muted(self): - """Volume muted.""" - return self._client.muted - - @property - def source_list(self): - """List of available input sources.""" - return list(self._client.group.streams_by_name().keys()) + return f"{self._device.friendly_name} {CLIENT_SUFFIX}" @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" - if self._client.connected: - if self.is_volume_muted or self._client.group.muted: + if self._device.connected: + if self.is_volume_muted or self._current_group.muted: return MediaPlayerState.IDLE - return STREAM_STATUS.get(self._client.group.stream_status) + return STREAM_STATUS.get(self._current_group.stream_status) return MediaPlayerState.STANDBY @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes.""" state_attrs = {} if self.latency is not None: @@ -281,60 +354,40 @@ class SnapcastClientDevice(MediaPlayerEntity): return state_attrs @property - def latency(self): + def latency(self) -> float | None: """Latency for Client.""" - return self._client.latency + return self._device.latency - async def async_select_source(self, source: str) -> None: - """Set input source.""" - streams = self._client.group.streams_by_name() - if source in streams: - await self._client.group.set_stream(streams[source].identifier) - self.async_write_ha_state() - - async def async_mute_volume(self, mute: bool) -> None: - """Send the mute command.""" - await self._client.set_muted(mute) + async def async_set_latency(self, latency) -> None: + """Set the latency of the client.""" + await self._device.set_latency(latency) self.async_write_ha_state() - async def async_set_volume_level(self, volume: float) -> None: - """Set the volume level.""" - await self._client.set_volume(round(volume * 100)) - self.async_write_ha_state() - - async def async_join(self, master): + async def async_join(self, master) -> None: """Join the group of the master player.""" - master_entity = next( - entity - for entity in self.hass.data[DOMAIN][self._entry_id].clients - if entity.entity_id == master - ) - if not isinstance(master_entity, SnapcastClientDevice): - raise TypeError("Master is not a client device. Can only join clients.") + entity_registry = er.async_get(self.hass) + master_entity = entity_registry.async_get(master) + if master_entity is None: + raise ServiceValidationError(f"Master entity '{master}' not found.") + # Validate master entity is a client + unique_id = master_entity.unique_id + if not unique_id.startswith(CLIENT_PREFIX): + raise ServiceValidationError( + "Master is not a client device. Can only join clients." + ) + + # Extract the client ID and locate it's group + identifier = unique_id.split("_")[-1] master_group = next( group - for group in self._client.groups_available() - if master_entity.identifier in group.clients + for group in self._device.groups_available() + if identifier in group.clients ) - await master_group.add_client(self._client.identifier) + await master_group.add_client(self._device.identifier) self.async_write_ha_state() - async def async_unjoin(self): + async def async_unjoin(self) -> None: """Unjoin the group the player is currently in.""" - await self._client.group.remove_client(self._client.identifier) - self.async_write_ha_state() - - def snapshot(self): - """Snapshot the client state.""" - self._client.snapshot() - - async def async_restore(self): - """Restore the client state.""" - await self._client.restore() - self.async_write_ha_state() - - async def async_set_latency(self, latency): - """Set the latency of the client.""" - await self._client.set_latency(latency) + await self._current_group.remove_client(self._device.identifier) self.async_write_ha_state() diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py deleted file mode 100644 index ab4091e30af..00000000000 --- a/homeassistant/components/snapcast/server.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Snapcast Integration.""" - -from __future__ import annotations - -import logging - -import snapcast.control -from snapcast.control.client import Snapclient - -from homeassistant.components.media_player import MediaPlayerEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .media_player import SnapcastClientDevice, SnapcastGroupDevice - -_LOGGER = logging.getLogger(__name__) - - -class HomeAssistantSnapcast: - """Snapcast server and data stored in the Home Assistant data object.""" - - hass: HomeAssistant - - def __init__( - self, - hass: HomeAssistant, - server: snapcast.control.Snapserver, - hpid: str, - entry_id: str, - ) -> None: - """Initialize the HomeAssistantSnapcast object. - - Parameters - ---------- - hass: HomeAssistant - hass object - server : snapcast.control.Snapserver - Snapcast server - hpid : str - host and port - entry_id: str - ConfigEntry entry_id - - Returns - ------- - None - - """ - self.hass: HomeAssistant = hass - self.server: snapcast.control.Snapserver = server - self.hpid: str = hpid - self._entry_id = entry_id - self.clients: list[SnapcastClientDevice] = [] - self.groups: list[SnapcastGroupDevice] = [] - self.hass_async_add_entities: AddEntitiesCallback - # connect callbacks - self.server.set_on_update_callback(self.on_update) - self.server.set_on_connect_callback(self.on_connect) - self.server.set_on_disconnect_callback(self.on_disconnect) - self.server.set_new_client_callback(self.on_add_client) - - async def disconnect(self) -> None: - """Disconnect from server.""" - self.server.set_on_update_callback(None) - self.server.set_on_connect_callback(None) - self.server.set_on_disconnect_callback(None) - self.server.set_new_client_callback(None) - self.server.stop() - - def on_update(self) -> None: - """Update all entities. - - Retrieve all groups/clients from server and add/update/delete entities. - """ - if not self.hass_async_add_entities: - return - new_groups: list[MediaPlayerEntity] = [] - groups: list[MediaPlayerEntity] = [] - hass_groups = {g.identifier: g for g in self.groups} - for group in self.server.groups: - if group.identifier in hass_groups: - groups.append(hass_groups[group.identifier]) - hass_groups[group.identifier].async_schedule_update_ha_state() - else: - new_groups.append(SnapcastGroupDevice(group, self.hpid, self._entry_id)) - new_clients: list[MediaPlayerEntity] = [] - clients: list[MediaPlayerEntity] = [] - hass_clients = {c.identifier: c for c in self.clients} - for client in self.server.clients: - if client.identifier in hass_clients: - clients.append(hass_clients[client.identifier]) - hass_clients[client.identifier].async_schedule_update_ha_state() - else: - new_clients.append( - SnapcastClientDevice(client, self.hpid, self._entry_id) - ) - del_entities: list[MediaPlayerEntity] = [ - x for x in self.groups if x not in groups - ] - del_entities.extend([x for x in self.clients if x not in clients]) - - _LOGGER.debug("New clients: %s", str([c.name for c in new_clients])) - _LOGGER.debug("New groups: %s", str([g.name for g in new_groups])) - _LOGGER.debug("Delete: %s", str(del_entities)) - - ent_reg = er.async_get(self.hass) - for entity in del_entities: - ent_reg.async_remove(entity.entity_id) - self.hass_async_add_entities(new_clients + new_groups) - - def on_connect(self) -> None: - """Activate all entities and update.""" - for client in self.clients: - client.set_availability(True) - for group in self.groups: - group.set_availability(True) - _LOGGER.debug("Server connected: %s", self.hpid) - self.on_update() - - def on_disconnect(self, ex: Exception | None) -> None: - """Deactivate all entities.""" - for client in self.clients: - client.set_availability(False) - for group in self.groups: - group.set_availability(False) - _LOGGER.warning( - "Server disconnected: %s. Trying to reconnect. %s", self.hpid, str(ex or "") - ) - - def on_add_client(self, client: Snapclient) -> None: - """Add a Snapcast client. - - Parameters - ---------- - client : Snapclient - Snapcast client to be added to HA. - - """ - if not self.hass_async_add_entities: - return - clients = [SnapcastClientDevice(client, self.hpid, self._entry_id)] - self.hass_async_add_entities(clients) diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index b5673910595..685b4a0dd11 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -24,7 +24,7 @@ "services": { "join": { "name": "Join", - "description": "Groups players together.", + "description": "Groups players together in a single group.", "fields": { "master": { "name": "Master", @@ -38,23 +38,23 @@ }, "unjoin": { "name": "Unjoin", - "description": "Unjoins the player from a group." + "description": "Removes one or more players from a group." }, "snapshot": { "name": "Snapshot", - "description": "Takes a snapshot of the media player." + "description": "Takes a snapshot of what is currently playing on a media player." }, "restore": { "name": "Restore", - "description": "Restores a snapshot of the media player." + "description": "Restores a previously taken snapshot of a media player." }, "set_latency": { "name": "Set latency", - "description": "Sets client set_latency.", + "description": "Sets the latency of a speaker.", "fields": { "latency": { "name": "Latency", - "description": "Latency in master." + "description": "Latency in milliseconds." } } } diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 3c4a0a0725c..4c2b2b25ad8 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -172,7 +172,7 @@ class SnmpScanner(DeviceScanner): _LOGGER.error( "SNMP error: %s at %s", errstatus.prettyPrint(), - errindex and res[int(errindex) - 1][0] or "?", + (errindex and res[int(errindex) - 1][0]) or "?", ) return None diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 92e27daed6c..2f9f8b0bfb7 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -264,7 +264,7 @@ class SnmpSwitch(SwitchEntity): _LOGGER.error( "SNMP error: %s at %s", errstatus.prettyPrint(), - errindex and restable[-1][int(errindex) - 1] or "?", + (errindex and restable[-1][int(errindex) - 1]) or "?", ) else: for resrow in restable: diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index 8c721432709..bfe773b4780 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -83,7 +83,6 @@ class SnoozFan(FanEntity, RestoreEntity): _attr_should_poll = False _is_on: bool | None = None _percentage: int | None = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, data: SnoozConfigurationData) -> None: """Initialize a Snooz fan entity.""" diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py index a61f825aa5e..767079ea1f8 100644 --- a/homeassistant/components/solarlog/config_flow.py +++ b/homeassistant/components/solarlog/config_flow.py @@ -1,7 +1,6 @@ """Config flow for solarlog integration.""" from collections.abc import Mapping -import logging from typing import Any from urllib.parse import ParseResult, urlparse @@ -14,12 +13,9 @@ from solarlog_cli.solarlog_exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD -from homeassistant.util import slugify +from homeassistant.const import CONF_HOST, CONF_PASSWORD -from .const import CONF_HAS_PWD, DEFAULT_HOST, DEFAULT_NAME, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_HAS_PWD, DEFAULT_HOST, DOMAIN class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): @@ -84,24 +80,21 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - user_input[CONF_NAME] = slugify(user_input[CONF_NAME]) - if await self._test_connection(user_input[CONF_HOST]): if user_input[CONF_HAS_PWD]: self._user_input = user_input return await self.async_step_password() return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input + title=user_input[CONF_HOST], data=user_input ) else: - user_input = {CONF_NAME: DEFAULT_NAME, CONF_HOST: DEFAULT_HOST} + user_input = {CONF_HOST: DEFAULT_HOST} return self.async_show_form( step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str, vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, vol.Required(CONF_HAS_PWD, default=False): bool, } @@ -120,7 +113,7 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): ): self._user_input |= user_input return self.async_create_entry( - title=self._user_input[CONF_NAME], data=self._user_input + title=self._user_input[CONF_HOST], data=self._user_input ) else: user_input = {CONF_PASSWORD: ""} diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index f86d103f830..3e814705589 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -6,6 +6,5 @@ DOMAIN = "solarlog" # Default config for solarlog. DEFAULT_HOST = "http://solar-log" -DEFAULT_NAME = "solarlog" CONF_HAS_PWD = "has_password" diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 6e8867c0f52..11f268db32a 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -52,7 +52,6 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): path = url.path if url.netloc else "" url = ParseResult("http", netloc, path, *url[3:]) self.unique_id = entry.entry_id - self.name = entry.title self.host = url.geturl() self.solarlog = SolarLogConnector( diff --git a/homeassistant/components/solarlog/entity.py b/homeassistant/components/solarlog/entity.py index b0f3ddf99f9..bfdc52dccf1 100644 --- a/homeassistant/components/solarlog/entity.py +++ b/homeassistant/components/solarlog/entity.py @@ -43,7 +43,7 @@ class SolarLogCoordinatorEntity(SolarLogBaseEntity): manufacturer="Solar-Log", model="Controller", identifiers={(DOMAIN, coordinator.unique_id)}, - name=coordinator.name, + name="SolarLog", configuration_url=coordinator.host, ) diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index 5bea017781d..486b30edfd3 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/solarlog", "iot_class": "local_polling", "loggers": ["solarlog_cli"], + "quality_scale": "platinum", "requirements": ["solarlog_cli==0.4.0"] } diff --git a/homeassistant/components/solarlog/quality_scale.yaml b/homeassistant/components/solarlog/quality_scale.yaml new file mode 100644 index 00000000000..543889ee18c --- /dev/null +++ b/homeassistant/components/solarlog/quality_scale.yaml @@ -0,0 +1,81 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: No explicit event subscriptions. + dependency-transparency: done + action-setup: + status: exempt + comment: No custom action. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: No custom action. + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No custom action. + reauthentication-flow: done + parallel-updates: + status: exempt + comment: Coordinator and sensor only platform. + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: No options flow. + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: + status: exempt + comment: Solar-Log device cannot be discovered. + stale-devices: done + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: done + discovery-update-info: + status: exempt + comment: Solar-Log device cannot be discovered. + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: + status: exempt + comment: | + This integration doesn't have known issues that could be resolved by the user. + docs-examples: done + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index fb724c02adb..bf87b0b0938 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -5,7 +5,6 @@ "title": "Define your Solar-Log connection", "data": { "host": "[%key:common::config_flow::data::host%]", - "name": "The prefix to be used for your Solar-Log sensors", "has_password": "I have the password for the Solar-Log user account." }, "data_description": { @@ -27,6 +26,10 @@ "data": { "has_password": "[%key:component::solarlog::config::step::user::data::has_password%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "has_password": "[%key:component::solarlog::config::step::user::data_description::has_password%]", + "password": "[%key:component::solarlog::config::step::password::data_description::password%]" } }, "reconfigure": { @@ -34,6 +37,10 @@ "data": { "has_password": "[%key:component::solarlog::config::step::user::data::has_password%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "has_password": "[%key:component::solarlog::config::step::user::data_description::has_password%]", + "password": "[%key:component::solarlog::config::step::password::data_description::password%]" } } }, diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index 631ace3792f..925f11e4c65 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/solax", "iot_class": "local_polling", "loggers": ["solax"], - "requirements": ["solax==3.2.1"] + "requirements": ["solax==3.2.3"] } diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 50f7d34e406..e64fee00f16 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -76,7 +76,7 @@ class SomaTilt(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while closing the cover ({self.name}): {response["msg"]}' + f"Error while closing the cover ({self.name}): {response['msg']}" ) self.set_position(0) @@ -85,7 +85,7 @@ class SomaTilt(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], -100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while opening the cover ({self.name}): {response["msg"]}' + f"Error while opening the cover ({self.name}): {response['msg']}" ) self.set_position(100) @@ -94,7 +94,7 @@ class SomaTilt(SomaEntity, CoverEntity): response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while stopping the cover ({self.name}): {response["msg"]}' + f"Error while stopping the cover ({self.name}): {response['msg']}" ) # Set cover position to some value where up/down are both enabled self.set_position(50) @@ -109,7 +109,7 @@ class SomaTilt(SomaEntity, CoverEntity): if not is_api_response_success(response): raise HomeAssistantError( f"Error while setting the cover position ({self.name}):" - f' {response["msg"]}' + f" {response['msg']}" ) self.set_position(kwargs[ATTR_TILT_POSITION]) @@ -152,7 +152,7 @@ class SomaShade(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while closing the cover ({self.name}): {response["msg"]}' + f"Error while closing the cover ({self.name}): {response['msg']}" ) def open_cover(self, **kwargs: Any) -> None: @@ -160,7 +160,7 @@ class SomaShade(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], 0) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while opening the cover ({self.name}): {response["msg"]}' + f"Error while opening the cover ({self.name}): {response['msg']}" ) def stop_cover(self, **kwargs: Any) -> None: @@ -168,7 +168,7 @@ class SomaShade(SomaEntity, CoverEntity): response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while stopping the cover ({self.name}): {response["msg"]}' + f"Error while stopping the cover ({self.name}): {response['msg']}" ) # Set cover position to some value where up/down are both enabled self.set_position(50) @@ -182,7 +182,7 @@ class SomaShade(SomaEntity, CoverEntity): if not is_api_response_success(response): raise HomeAssistantError( f"Error while setting the cover position ({self.name}):" - f' {response["msg"]}' + f" {response['msg']}" ) async def async_update(self) -> None: diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index c2d85160175..a806d581aec 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -9,7 +9,6 @@ from typing import Any from somfy_mylink_synergy import SomfyMyLinkSynergy import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -21,6 +20,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_REVERSE, @@ -69,7 +69,7 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): self.ip_address: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index bdb647de39c..f25c885ed84 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -67,7 +67,7 @@ def get_queue_attr(queue: SonarrQueue) -> dict[str, str]: remaining = 1 if item.size == 0 else item.sizeleft / item.size remaining_pct = 100 * (1 - remaining) identifier = ( - f"S{item.episode.seasonNumber:02d}E{item.episode. episodeNumber:02d}" + f"S{item.episode.seasonNumber:02d}E{item.episode.episodeNumber:02d}" ) attrs[f"{item.series.title} {identifier}"] = f"{remaining_pct:.2f}%" return attrs @@ -120,7 +120,8 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { value_fn=len, attributes_fn=lambda data: { i.title: ( - f"{getattr(i.statistics,'episodeFileCount', 0)}/{getattr(i.statistics, 'episodeCount', 0)} Episodes" + f"{getattr(i.statistics, 'episodeFileCount', 0)}/" + f"{getattr(i.statistics, 'episodeCount', 0)} Episodes" ) for i in data }, diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py index 41cc0763642..e71454f0aa8 100644 --- a/homeassistant/components/songpal/config_flow.py +++ b/homeassistant/components/songpal/config_flow.py @@ -9,9 +9,13 @@ from urllib.parse import urlparse from songpal import Device, SongpalException import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import CONF_ENDPOINT, DOMAIN @@ -99,15 +103,15 @@ class SongpalConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Songpal device.""" - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() _LOGGER.debug("Discovered: %s", discovery_info) - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] hostname = urlparse(discovery_info.ssdp_location).hostname scalarweb_info = discovery_info.upnp["X_ScalarWebAPI_DeviceInfo"] endpoint = scalarweb_info["X_ScalarWebAPI_BaseURL"] @@ -116,7 +120,7 @@ class SongpalConfigFlow(ConfigFlow, domain=DOMAIN): ] # Ignore Bravia TVs - if "videoScreen" in service_types: + if "videoScreen" in service_types or "video" in service_types: return self.async_abort(reason="not_songpal_device") if TYPE_CHECKING: diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 82e4a5ebfba..98bff8d2934 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -34,6 +34,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -500,7 +501,7 @@ class SonosDiscoveryManager: @callback def _async_ssdp_discovered_player( - self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: uid = info.upnp[ssdp.ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): @@ -529,7 +530,7 @@ class SonosDiscoveryManager: def async_discovered_player( self, source: str, - info: ssdp.SsdpServiceInfo, + info: SsdpServiceInfo, discovered_ip: str, uid: str, boot_seqnum: str | int | None, diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index a8ace6e35c5..057cdb8ec08 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -1,12 +1,12 @@ """Config flow for SONOS.""" from collections.abc import Awaitable -import dataclasses -from homeassistant.components import ssdp, zeroconf +from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN, UPNP_ST from .helpers import hostname_to_uid @@ -25,21 +25,21 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler[Awaitable[bool]], domain=DO super().__init__(DOMAIN, "Sonos", _async_has_devices) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf.""" hostname = discovery_info.hostname if hostname is None or not hostname.lower().startswith("sonos"): return self.async_abort(reason="not_sonos_device") - await self.async_set_unique_id(self._domain, raise_on_progress=False) - host = discovery_info.host - mdns_name = discovery_info.name - properties = discovery_info.properties - boot_seqnum = properties.get("bootseq") - model = properties.get("model") - uid = hostname_to_uid(hostname) if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER): + host = discovery_info.host + mdns_name = discovery_info.name + properties = discovery_info.properties + boot_seqnum = properties.get("bootseq") + model = properties.get("model") + uid = hostname_to_uid(hostname) discovery_manager.async_discovered_player( "Zeroconf", properties, host, uid, boot_seqnum, model, mdns_name ) - return await self.async_step_discovery(dataclasses.asdict(discovery_info)) + await self.async_set_unique_id(self._domain, raise_on_progress=False) + return await self.async_step_discovery({}) diff --git a/homeassistant/components/soundtouch/config_flow.py b/homeassistant/components/soundtouch/config_flow.py index af45b8f6bdc..f30065d1157 100644 --- a/homeassistant/components/soundtouch/config_flow.py +++ b/homeassistant/components/soundtouch/config_flow.py @@ -6,10 +6,10 @@ from libsoundtouch import soundtouch_device from requests import RequestException import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 9fc11f7788a..2544eeb14a9 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -27,8 +27,8 @@ "description": "Plays on all Bose SoundTouch devices.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices." + "name": "Leader", + "description": "The media player entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices." } } }, @@ -37,40 +37,40 @@ "description": "Creates a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that will coordinate the multi-room zone. Platform dependent." + "name": "Leader", + "description": "The media player entity that will coordinate the multi-room zone. Platform dependent." }, "slaves": { - "name": "Slaves", - "description": "Name of slaves entities to add to the new zone." + "name": "Follower", + "description": "The media player entities to add to the new zone." } } }, "add_zone_slave": { - "name": "Add zone slave", - "description": "Adds a slave to a SoundTouch multi-room zone.", + "name": "Add zone follower", + "description": "Adds media players to a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that is coordinating the multi-room zone. Platform dependent." + "name": "[%key:component::soundtouch::services::create_zone::fields::master::name%]", + "description": "The media player entity that is coordinating the multi-room zone. Platform dependent." }, "slaves": { "name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]", - "description": "Name of slaves entities to add to the existing zone." + "description": "The media player entities to add to the existing zone." } } }, "remove_zone_slave": { - "name": "Remove zone slave", - "description": "Removes a slave from the SoundTouch multi-room zone.", + "name": "Remove zone follower", + "description": "Removes media players from a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", + "name": "[%key:component::soundtouch::services::create_zone::fields::master::name%]", "description": "[%key:component::soundtouch::services::add_zone_slave::fields::master::description%]" }, "slaves": { "name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]", - "description": "Name of slaves entities to remove from the existing zone." + "description": "The media player entities to remove from the existing zone." } } } diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 93d448bd17f..90281fe311c 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -1,6 +1,7 @@ """Support for the SpaceAPI.""" from contextlib import suppress +import math import voluptuous as vol @@ -254,7 +255,17 @@ class APISpaceApiView(HomeAssistantView): """Get data from a sensor.""" if not (sensor_state := hass.states.get(sensor)): return None - sensor_data = {ATTR_NAME: sensor_state.name, ATTR_VALUE: sensor_state.state} + + # SpaceAPI sensor values must be numbers + try: + state = float(sensor_state.state) + except ValueError: + state = math.nan + sensor_data = { + ATTR_NAME: sensor_state.name, + ATTR_VALUE: state, + } + if ATTR_SENSOR_LOCATION in sensor_state.attributes: sensor_data[ATTR_LOCATION] = sensor_state.attributes[ATTR_SENSOR_LOCATION] else: diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index cfcc9011b37..663b3f30caa 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -29,14 +29,14 @@ from .util import ( spotify_uri_from_media_browser_url, ) -PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR] +PLATFORMS = [Platform.MEDIA_PLAYER] __all__ = [ - "async_browse_media", "DOMAIN", - "spotify_uri_from_media_browser_url", + "async_browse_media", "is_spotify_media_type", "resolve_spotify_media_type", + "spotify_uri_from_media_browser_url", ] diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index 403ec608a7c..458525dde28 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -14,6 +14,7 @@ from spotifyaio import ( SpotifyClient, Track, ) +from spotifyaio.models import Episode, ItemType, SimplifiedEpisode import yarl from homeassistant.components.media_player import ( @@ -90,6 +91,16 @@ def _get_track_item_payload( } +def _get_episode_item_payload(episode: SimplifiedEpisode) -> ItemPayload: + return { + "id": episode.episode_id, + "name": episode.name, + "type": MediaType.EPISODE, + "uri": episode.uri, + "thumbnail": fetch_image_url(episode.images), + } + + class BrowsableMedia(StrEnum): """Enum of browsable media.""" @@ -101,8 +112,6 @@ class BrowsableMedia(StrEnum): CURRENT_USER_RECENTLY_PLAYED = "current_user_recently_played" CURRENT_USER_TOP_ARTISTS = "current_user_top_artists" CURRENT_USER_TOP_TRACKS = "current_user_top_tracks" - CATEGORIES = "categories" - FEATURED_PLAYLISTS = "featured_playlists" NEW_RELEASES = "new_releases" @@ -115,8 +124,6 @@ LIBRARY_MAP = { BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED.value: "Recently played", BrowsableMedia.CURRENT_USER_TOP_ARTISTS.value: "Top Artists", BrowsableMedia.CURRENT_USER_TOP_TRACKS.value: "Top Tracks", - BrowsableMedia.CATEGORIES.value: "Categories", - BrowsableMedia.FEATURED_PLAYLISTS.value: "Featured Playlists", BrowsableMedia.NEW_RELEASES.value: "New Releases", } @@ -153,18 +160,6 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str, Any] = { "parent": MediaClass.DIRECTORY, "children": MediaClass.TRACK, }, - BrowsableMedia.FEATURED_PLAYLISTS.value: { - "parent": MediaClass.DIRECTORY, - "children": MediaClass.PLAYLIST, - }, - BrowsableMedia.CATEGORIES.value: { - "parent": MediaClass.DIRECTORY, - "children": MediaClass.GENRE, - }, - "category_playlists": { - "parent": MediaClass.DIRECTORY, - "children": MediaClass.PLAYLIST, - }, BrowsableMedia.NEW_RELEASES.value: { "parent": MediaClass.DIRECTORY, "children": MediaClass.ALBUM, @@ -354,32 +349,6 @@ async def build_item_response( # noqa: C901 elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_TRACKS: if top_tracks := await spotify.get_top_tracks(): items = [_get_track_item_payload(track) for track in top_tracks] - elif media_content_type == BrowsableMedia.FEATURED_PLAYLISTS: - if featured_playlists := await spotify.get_featured_playlists(): - items = [ - _get_playlist_item_payload(playlist) for playlist in featured_playlists - ] - elif media_content_type == BrowsableMedia.CATEGORIES: - if categories := await spotify.get_categories(): - items = [ - { - "id": category.category_id, - "name": category.name, - "type": "category_playlists", - "uri": category.category_id, - "thumbnail": category.icons[0].url if category.icons else None, - } - for category in categories - ] - elif media_content_type == "category_playlists": - if ( - playlists := await spotify.get_category_playlists( - category_id=media_content_id - ) - ) and (category := await spotify.get_category(media_content_id)): - title = category.name - image = category.icons[0].url if category.icons else None - items = [_get_playlist_item_payload(playlist) for playlist in playlists] elif media_content_type == BrowsableMedia.NEW_RELEASES: if new_releases := await spotify.get_new_releases(): items = [_get_album_item_payload(album) for album in new_releases] @@ -387,10 +356,15 @@ async def build_item_response( # noqa: C901 if playlist := await spotify.get_playlist(media_content_id): title = playlist.name image = playlist.images[0].url if playlist.images else None - items = [ - _get_track_item_payload(playlist_track.track) - for playlist_track in playlist.tracks.items - ] + for playlist_item in playlist.tracks.items: + if playlist_item.track.type is ItemType.TRACK: + if TYPE_CHECKING: + assert isinstance(playlist_item.track, Track) + items.append(_get_track_item_payload(playlist_item.track)) + elif playlist_item.track.type is ItemType.EPISODE: + if TYPE_CHECKING: + assert isinstance(playlist_item.track, Episode) + items.append(_get_episode_item_payload(playlist_item.track)) elif media_content_type == MediaType.ALBUM: if album := await spotify.get_album(media_content_id): title = album.name @@ -412,16 +386,7 @@ async def build_item_response( # noqa: C901 ): title = show.name image = show.images[0].url if show.images else None - items = [ - { - "id": episode.episode_id, - "name": episode.name, - "type": MediaType.EPISODE, - "uri": episode.uri, - "thumbnail": fetch_image_url(episode.images), - } - for episode in show_episodes - ] + items = [_get_episode_item_payload(episode) for episode in show_episodes] try: media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type] @@ -429,36 +394,6 @@ async def build_item_response( # noqa: C901 _LOGGER.debug("Unknown media type received: %s", media_content_type) return None - if media_content_type == BrowsableMedia.CATEGORIES: - media_item = BrowseMedia( - can_expand=True, - can_play=False, - children_media_class=media_class["children"], - media_class=media_class["parent"], - media_content_id=media_content_id, - media_content_type=f"{MEDIA_PLAYER_PREFIX}{media_content_type}", - title=LIBRARY_MAP.get(media_content_id, "Unknown"), - ) - - media_item.children = [] - for item in items: - if (item_id := item["id"]) is None: - _LOGGER.debug("Missing ID for media item: %s", item) - continue - media_item.children.append( - BrowseMedia( - can_expand=True, - can_play=False, - children_media_class=MediaClass.TRACK, - media_class=MediaClass.PLAYLIST, - media_content_id=item_id, - media_content_type=f"{MEDIA_PLAYER_PREFIX}category_playlists", - thumbnail=item["thumbnail"], - title=item["name"], - ) - ) - return media_item - if title is None: title = LIBRARY_MAP.get(media_content_id, "Unknown") diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index 9e62d5f137e..a86544d883e 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -7,14 +7,13 @@ from typing import TYPE_CHECKING from spotifyaio import ( ContextType, - ItemType, PlaybackState, Playlist, SpotifyClient, SpotifyConnectionError, + SpotifyNotFoundError, UserProfile, ) -from spotifyaio.models import AudioFeatures from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -32,6 +31,9 @@ _LOGGER = logging.getLogger(__name__) type SpotifyConfigEntry = ConfigEntry[SpotifyData] +UPDATE_INTERVAL = timedelta(seconds=30) + + @dataclass class SpotifyCoordinatorData: """Class to hold Spotify data.""" @@ -39,7 +41,6 @@ class SpotifyCoordinatorData: current_playback: PlaybackState | None position_updated_at: datetime | None playlist: Playlist | None - audio_features: AudioFeatures | None dj_playlist: bool = False @@ -61,11 +62,11 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): hass, _LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=UPDATE_INTERVAL, ) self.client = client self._playlist: Playlist | None = None - self._currently_loaded_track: str | None = None + self._checked_playlist_id: str | None = None async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -75,6 +76,7 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): raise UpdateFailed("Error communicating with Spotify API") from err async def _async_update_data(self) -> SpotifyCoordinatorData: + self.update_interval = UPDATE_INTERVAL try: current = await self.client.get_playback() except SpotifyConnectionError as err: @@ -84,39 +86,36 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): current_playback=None, position_updated_at=None, playlist=None, - audio_features=None, ) # Record the last updated time, because Spotify's timestamp property is unreliable # and doesn't actually return the fetch time as is mentioned in the API description position_updated_at = dt_util.utcnow() - audio_features: AudioFeatures | None = None - if (item := current.item) is not None and item.type == ItemType.TRACK: - if item.uri != self._currently_loaded_track: - try: - audio_features = await self.client.get_audio_features(item.uri) - except SpotifyConnectionError: - _LOGGER.debug( - "Unable to load audio features for track '%s'. " - "Continuing without audio features", - item.uri, - ) - audio_features = None - else: - self._currently_loaded_track = item.uri - else: - audio_features = self.data.audio_features dj_playlist = False if (context := current.context) is not None: - if self._playlist is None or self._playlist.uri != context.uri: + dj_playlist = context.uri == SPOTIFY_DJ_PLAYLIST_URI + if not ( + context.uri + in ( + self._checked_playlist_id, + SPOTIFY_DJ_PLAYLIST_URI, + ) + or (self._playlist is None and context.uri == self._checked_playlist_id) + ): + self._checked_playlist_id = context.uri self._playlist = None - if context.uri == SPOTIFY_DJ_PLAYLIST_URI: - dj_playlist = True - elif context.context_type == ContextType.PLAYLIST: + if context.context_type == ContextType.PLAYLIST: # Make sure any playlist lookups don't break the current # playback state update try: self._playlist = await self.client.get_playlist(context.uri) + except SpotifyNotFoundError: + _LOGGER.debug( + "Spotify playlist '%s' not found. " + "Most likely a Spotify-created playlist", + context.uri, + ) + self._playlist = None except SpotifyConnectionError: _LOGGER.debug( "Unable to load spotify playlist '%s'. " @@ -124,10 +123,17 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): context.uri, ) self._playlist = None + self._checked_playlist_id = None + if current.is_playing and current.progress_ms is not None: + assert current.item is not None + time_left = timedelta( + milliseconds=current.item.duration_ms - current.progress_ms + ) + if time_left < UPDATE_INTERVAL: + self.update_interval = time_left + timedelta(seconds=1) return SpotifyCoordinatorData( current_playback=current, position_updated_at=position_updated_at, playlist=self._playlist, - audio_features=audio_features, dj_playlist=dj_playlist, ) diff --git a/homeassistant/components/spotify/icons.json b/homeassistant/components/spotify/icons.json index e1b08127e43..00c63141eae 100644 --- a/homeassistant/components/spotify/icons.json +++ b/homeassistant/components/spotify/icons.json @@ -4,41 +4,6 @@ "spotify": { "default": "mdi:spotify" } - }, - "sensor": { - "song_tempo": { - "default": "mdi:metronome" - }, - "danceability": { - "default": "mdi:dance-ballroom" - }, - "energy": { - "default": "mdi:lightning-bolt" - }, - "mode": { - "default": "mdi:music" - }, - "speechiness": { - "default": "mdi:speaker-message" - }, - "acousticness": { - "default": "mdi:guitar-acoustic" - }, - "instrumentalness": { - "default": "mdi:guitar-electric" - }, - "valence": { - "default": "mdi:emoticon-happy" - }, - "liveness": { - "default": "mdi:music-note" - }, - "time_signature": { - "default": "mdi:music-clef-treble" - }, - "key": { - "default": "mdi:music-clef-treble" - } } } } diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index e7b24cb3e1d..27b8da7cecf 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/spotify", "integration_type": "service", "iot_class": "cloud_polling", - "loggers": ["spotipy"], - "requirements": ["spotifyaio==0.8.8"], + "loggers": ["spotifyaio"], + "requirements": ["spotifyaio==0.8.11"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 7687936fe4c..20a634efb42 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -361,6 +361,8 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity): """Select playback device.""" for device in self.devices.data: if device.name == source: + if TYPE_CHECKING: + assert device.device_id is not None await self.coordinator.client.transfer_playback(device.device_id) return diff --git a/homeassistant/components/spotify/sensor.py b/homeassistant/components/spotify/sensor.py deleted file mode 100644 index 3486a911b0d..00000000000 --- a/homeassistant/components/spotify/sensor.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Sensor platform for Spotify.""" - -from collections.abc import Callable -from dataclasses import dataclass - -from spotifyaio.models import AudioFeatures, Key - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.const import PERCENTAGE -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .coordinator import SpotifyConfigEntry, SpotifyCoordinator -from .entity import SpotifyEntity - - -@dataclass(frozen=True, kw_only=True) -class SpotifyAudioFeaturesSensorEntityDescription(SensorEntityDescription): - """Describes Spotify sensor entity.""" - - value_fn: Callable[[AudioFeatures], float | str | None] - - -KEYS: dict[Key, str] = { - Key.C: "C", - Key.C_SHARP_D_FLAT: "C♯/D♭", - Key.D: "D", - Key.D_SHARP_E_FLAT: "D♯/E♭", - Key.E: "E", - Key.F: "F", - Key.F_SHARP_G_FLAT: "F♯/G♭", - Key.G: "G", - Key.G_SHARP_A_FLAT: "G♯/A♭", - Key.A: "A", - Key.A_SHARP_B_FLAT: "A♯/B♭", - Key.B: "B", -} - -KEY_OPTIONS = list(KEYS.values()) - - -def _get_key(audio_features: AudioFeatures) -> str | None: - if audio_features.key is None: - return None - return KEYS[audio_features.key] - - -AUDIO_FEATURE_SENSORS: tuple[SpotifyAudioFeaturesSensorEntityDescription, ...] = ( - SpotifyAudioFeaturesSensorEntityDescription( - key="bpm", - translation_key="song_tempo", - native_unit_of_measurement="bpm", - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.tempo, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="danceability", - translation_key="danceability", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.danceability * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="energy", - translation_key="energy", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.energy * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="mode", - translation_key="mode", - device_class=SensorDeviceClass.ENUM, - options=["major", "minor"], - value_fn=lambda audio_features: audio_features.mode.name.lower(), - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="speechiness", - translation_key="speechiness", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.speechiness * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="acousticness", - translation_key="acousticness", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.acousticness * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="instrumentalness", - translation_key="instrumentalness", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.instrumentalness * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="liveness", - translation_key="liveness", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.liveness * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="valence", - translation_key="valence", - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=lambda audio_features: audio_features.valence * 100, - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="time_signature", - translation_key="time_signature", - device_class=SensorDeviceClass.ENUM, - options=["3/4", "4/4", "5/4", "6/4", "7/4"], - value_fn=lambda audio_features: f"{audio_features.time_signature}/4", - entity_registry_enabled_default=False, - ), - SpotifyAudioFeaturesSensorEntityDescription( - key="key", - translation_key="key", - device_class=SensorDeviceClass.ENUM, - options=KEY_OPTIONS, - value_fn=_get_key, - entity_registry_enabled_default=False, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: SpotifyConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Spotify sensor based on a config entry.""" - coordinator = entry.runtime_data.coordinator - - async_add_entities( - SpotifyAudioFeatureSensor(coordinator, description) - for description in AUDIO_FEATURE_SENSORS - ) - - -class SpotifyAudioFeatureSensor(SpotifyEntity, SensorEntity): - """Representation of a Spotify sensor.""" - - entity_description: SpotifyAudioFeaturesSensorEntityDescription - - def __init__( - self, - coordinator: SpotifyCoordinator, - entity_description: SpotifyAudioFeaturesSensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_unique_id = ( - f"{coordinator.current_user.user_id}_{entity_description.key}" - ) - self.entity_description = entity_description - - @property - def native_value(self) -> float | str | None: - """Return the state of the sensor.""" - if (audio_features := self.coordinator.data.audio_features) is None: - return None - return self.entity_description.value_fn(audio_features) diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index faf20d740d9..90e573a1706 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -30,46 +30,5 @@ "info": { "api_endpoint_reachable": "Spotify API endpoint reachable" } - }, - "entity": { - "sensor": { - "song_tempo": { - "name": "Song tempo" - }, - "danceability": { - "name": "Song danceability" - }, - "energy": { - "name": "Song energy" - }, - "mode": { - "name": "Song mode", - "state": { - "minor": "Minor", - "major": "Major" - } - }, - "speechiness": { - "name": "Song speechiness" - }, - "acousticness": { - "name": "Song acousticness" - }, - "instrumentalness": { - "name": "Song instrumentalness" - }, - "valence": { - "name": "Song valence" - }, - "liveness": { - "name": "Song liveness" - }, - "time_signature": { - "name": "Song time signature" - }, - "key": { - "name": "Song key" - } - } } } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index dcb5f47829c..01c95d6c5e4 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.31", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.36", "sqlparse==0.5.0"] } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 1d033728c0d..312b0cd345e 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -331,9 +331,16 @@ class SQLSensor(ManualTriggerSensorEntity): entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, unique_id)}, manufacturer="SQL", - name=self.name, + name=self._rendered.get(CONF_NAME), ) + @property + def name(self) -> str | None: + """Name of the entity.""" + if self.has_entity_name: + return self._attr_name + return self._rendered.get(CONF_NAME) + async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index f466f3bcb62..f94ea118c6a 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -105,9 +105,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - lms.name = ( (STATUS_QUERY_LIBRARYNAME in status and status[STATUS_QUERY_LIBRARYNAME]) and status[STATUS_QUERY_LIBRARYNAME] - or host - ) - version = STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION] or None + ) or host + version = (STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION]) or None # mac can be missing mac_connect = ( {(CONNECTION_NETWORK_MAC, format_mac(status[STATUS_QUERY_MAC]))} diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 4d1c98bc4fc..331bf383c70 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -115,6 +115,7 @@ async def build_item_response( item_type = CONTENT_TYPE_TO_CHILD_TYPE[search_type] children = [] + list_playable = [] for item in result["items"]: item_id = str(item["id"]) item_thumbnail: str | None = None @@ -131,7 +132,7 @@ async def build_item_response( child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM] can_expand = True can_play = True - elif item["hasitems"]: + elif item["hasitems"] and not item["isaudio"]: child_item_type = "Favorites" child_media_class = CONTENT_TYPE_MEDIA_CLASS["Favorites"] can_expand = True @@ -139,8 +140,8 @@ async def build_item_response( else: child_item_type = "Favorites" child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK] - can_expand = False - can_play = True + can_expand = item["hasitems"] + can_play = item["isaudio"] and item.get("url") if artwork_track_id := item.get("artwork_track_id"): if internal_request: @@ -166,6 +167,7 @@ async def build_item_response( thumbnail=item_thumbnail, ) ) + list_playable.append(can_play) if children is None: raise BrowseError(f"Media not found: {search_type} / {search_id}") @@ -179,7 +181,7 @@ async def build_item_response( children_media_class=media_class["children"], media_content_id=search_id, media_content_type=search_type, - can_play=search_type != "Favorites", + can_play=any(list_playable), children=children, can_expand=True, ) diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index c372c7262d4..97eb848c21c 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any from pysqueezebox import Server, async_discover import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME @@ -18,6 +17,7 @@ from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN @@ -200,7 +200,7 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_edit() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery of a Squeezebox player.""" _LOGGER.debug( diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index aa595340d56..09eaa4026f4 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.10.0"] + "requirements": ["pysqueezebox==0.11.1"] } diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index ff9f86ccf1f..0ca33179f9f 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -33,12 +33,10 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_ALBUMS, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="albums", ), SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_ARTISTS, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="artists", ), SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_DURATION, @@ -49,12 +47,10 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_GENRES, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="genres", ), SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_SONGS, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="songs", ), SensorEntityDescription( key=STATUS_SENSOR_LASTSCAN, @@ -63,13 +59,11 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=STATUS_SENSOR_PLAYER_COUNT, state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="players", ), SensorEntityDescription( key=STATUS_SENSOR_OTHER_PLAYER_COUNT, state_class=SensorStateClass.TOTAL, entity_registry_visible_default=False, - native_unit_of_measurement="players", ), ) diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index b1b71cd8c1d..bce71ddb5f2 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -43,13 +43,13 @@ }, "parameters": { "name": "Parameters", - "description": "Array of additional parameters to pass to Lyrion Music Server (p1, ..., pN in the CLI documentation).\n." + "description": "Array of additional parameters to pass to Lyrion Music Server (p1, ..., pN in the CLI documentation)." } } }, "call_query": { "name": "Call query", - "description": "Calls a custom Squeezebox JSONRPC API. Result will be stored in 'query_result' attribute of the Squeezebox entity.\n.", + "description": "Calls a custom Squeezebox JSONRPC API. Result will be stored in 'query_result' attribute of the Squeezebox entity.", "fields": { "command": { "name": "Command", @@ -76,25 +76,31 @@ "name": "Last scan" }, "info_total_albums": { - "name": "Total albums" + "name": "Total albums", + "unit_of_measurement": "albums" }, "info_total_artists": { - "name": "Total artists" + "name": "Total artists", + "unit_of_measurement": "artists" }, "info_total_duration": { "name": "Total duration" }, "info_total_genres": { - "name": "Total genres" + "name": "Total genres", + "unit_of_measurement": "genres" }, "info_total_songs": { - "name": "Total songs" + "name": "Total songs", + "unit_of_measurement": "songs" }, "player_count": { - "name": "Player count" + "name": "Player count", + "unit_of_measurement": "players" }, "other_player_count": { - "name": "Player count off service" + "name": "Player count off service", + "unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]" } } } diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index ccd69961975..c5fb349ddbb 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Mapping -from dataclasses import dataclass, field from datetime import timedelta from enum import Enum from functools import partial @@ -44,13 +43,36 @@ from homeassistant.const import ( __version__ as current_version, ) from homeassistant.core import Event, HassJob, HomeAssistant, callback as core_callback -from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.instance_id import async_get as async_get_instance_id from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.service_info.ssdp import ( + ATTR_NT as _ATTR_NT, + ATTR_ST as _ATTR_ST, + ATTR_UPNP_DEVICE_TYPE as _ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME as _ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER as _ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL as _ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_DESCRIPTION as _ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_MODEL_NAME as _ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER as _ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_MODEL_URL as _ATTR_UPNP_MODEL_URL, + ATTR_UPNP_PRESENTATION_URL as _ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL as _ATTR_UPNP_SERIAL, + ATTR_UPNP_SERVICE_LIST as _ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN as _ATTR_UPNP_UDN, + ATTR_UPNP_UPC as _ATTR_UPNP_UPC, + SsdpServiceInfo as _SsdpServiceInfo, +) from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_ssdp, bind_hass @@ -77,30 +99,90 @@ ATTR_SSDP_SERVER = "ssdp_server" ATTR_SSDP_BOOTID = "BOOTID.UPNP.ORG" ATTR_SSDP_NEXTBOOTID = "NEXTBOOTID.UPNP.ORG" # Attributes for accessing info from retrieved UPnP device description -ATTR_ST = "st" -ATTR_NT = "nt" -ATTR_UPNP_DEVICE_TYPE = "deviceType" -ATTR_UPNP_FRIENDLY_NAME = "friendlyName" -ATTR_UPNP_MANUFACTURER = "manufacturer" -ATTR_UPNP_MANUFACTURER_URL = "manufacturerURL" -ATTR_UPNP_MODEL_DESCRIPTION = "modelDescription" -ATTR_UPNP_MODEL_NAME = "modelName" -ATTR_UPNP_MODEL_NUMBER = "modelNumber" -ATTR_UPNP_MODEL_URL = "modelURL" -ATTR_UPNP_SERIAL = "serialNumber" -ATTR_UPNP_SERVICE_LIST = "serviceList" -ATTR_UPNP_UDN = "UDN" -ATTR_UPNP_UPC = "UPC" -ATTR_UPNP_PRESENTATION_URL = "presentationURL" +_DEPRECATED_ATTR_ST = DeprecatedConstant( + _ATTR_ST, + "homeassistant.helpers.service_info.ssdp.ATTR_ST", + "2026.2", +) +_DEPRECATED_ATTR_NT = DeprecatedConstant( + _ATTR_NT, + "homeassistant.helpers.service_info.ssdp.ATTR_NT", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_DEVICE_TYPE = DeprecatedConstant( + _ATTR_UPNP_DEVICE_TYPE, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_DEVICE_TYPE", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_FRIENDLY_NAME = DeprecatedConstant( + _ATTR_UPNP_FRIENDLY_NAME, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_FRIENDLY_NAME", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MANUFACTURER = DeprecatedConstant( + _ATTR_UPNP_MANUFACTURER, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MANUFACTURER_URL = DeprecatedConstant( + _ATTR_UPNP_MANUFACTURER_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER_URL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_DESCRIPTION = DeprecatedConstant( + _ATTR_UPNP_MODEL_DESCRIPTION, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_DESCRIPTION", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_NAME = DeprecatedConstant( + _ATTR_UPNP_MODEL_NAME, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NAME", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_NUMBER = DeprecatedConstant( + _ATTR_UPNP_MODEL_NUMBER, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NUMBER", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_URL = DeprecatedConstant( + _ATTR_UPNP_MODEL_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_URL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_SERIAL = DeprecatedConstant( + _ATTR_UPNP_SERIAL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERIAL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_SERVICE_LIST = DeprecatedConstant( + _ATTR_UPNP_SERVICE_LIST, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERVICE_LIST", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_UDN = DeprecatedConstant( + _ATTR_UPNP_UDN, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UDN", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_UPC = DeprecatedConstant( + _ATTR_UPNP_UPC, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UPC", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_PRESENTATION_URL = DeprecatedConstant( + _ATTR_UPNP_PRESENTATION_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_PRESENTATION_URL", + "2026.2", +) # Attributes for accessing info added by Home Assistant ATTR_HA_MATCHING_DOMAINS = "x_homeassistant_matching_domains" PRIMARY_MATCH_KEYS = [ - ATTR_UPNP_MANUFACTURER, - ATTR_ST, - ATTR_UPNP_DEVICE_TYPE, - ATTR_NT, - ATTR_UPNP_MANUFACTURER_URL, + _ATTR_UPNP_MANUFACTURER, + _ATTR_ST, + _ATTR_UPNP_DEVICE_TYPE, + _ATTR_NT, + _ATTR_UPNP_MANUFACTURER_URL, ] _LOGGER = logging.getLogger(__name__) @@ -108,27 +190,16 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) - -@dataclass(slots=True) -class SsdpServiceInfo(BaseServiceInfo): - """Prepared info from ssdp/upnp entries.""" - - ssdp_usn: str - ssdp_st: str - upnp: Mapping[str, Any] - ssdp_location: str | None = None - ssdp_nt: str | None = None - ssdp_udn: str | None = None - ssdp_ext: str | None = None - ssdp_server: str | None = None - ssdp_headers: Mapping[str, Any] = field(default_factory=dict) - ssdp_all_locations: set[str] = field(default_factory=set) - x_homeassistant_matching_domains: set[str] = field(default_factory=set) +_DEPRECATED_SsdpServiceInfo = DeprecatedConstant( + _SsdpServiceInfo, + "homeassistant.helpers.service_info.ssdp.SsdpServiceInfo", + "2026.2", +) SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") type SsdpHassJobCallback = HassJob[ - [SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None + [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None ] SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { @@ -148,7 +219,9 @@ def _format_err(name: str, *args: Any) -> str: @bind_hass async def async_register_callback( hass: HomeAssistant, - callback: Callable[[SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None], + callback: Callable[ + [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None + ], match_dict: dict[str, str] | None = None, ) -> Callable[[], None]: """Register to receive a callback on ssdp broadcast. @@ -169,7 +242,7 @@ async def async_register_callback( @bind_hass async def async_get_discovery_info_by_udn_st( hass: HomeAssistant, udn: str, st: str -) -> SsdpServiceInfo | None: +) -> _SsdpServiceInfo | None: """Fetch the discovery info cache.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_udn_st(udn, st) @@ -178,7 +251,7 @@ async def async_get_discovery_info_by_udn_st( @bind_hass async def async_get_discovery_info_by_st( hass: HomeAssistant, st: str -) -> list[SsdpServiceInfo]: +) -> list[_SsdpServiceInfo]: """Fetch all the entries matching the st.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_st(st) @@ -187,7 +260,7 @@ async def async_get_discovery_info_by_st( @bind_hass async def async_get_discovery_info_by_udn( hass: HomeAssistant, udn: str -) -> list[SsdpServiceInfo]: +) -> list[_SsdpServiceInfo]: """Fetch all the entries matching the udn.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_udn(udn) @@ -200,7 +273,7 @@ async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6A for source_ip in await network.async_get_enabled_source_ips(hass) if not source_ip.is_loopback and not source_ip.is_global - and (source_ip.version == 6 and source_ip.scope_id or source_ip.version == 4) + and ((source_ip.version == 6 and source_ip.scope_id) or source_ip.version == 4) } @@ -227,7 +300,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def _async_process_callbacks( hass: HomeAssistant, callbacks: list[SsdpHassJobCallback], - discovery_info: SsdpServiceInfo, + discovery_info: _SsdpServiceInfo, ssdp_change: SsdpChange, ) -> None: for callback in callbacks: @@ -562,11 +635,11 @@ class Scanner: ) def _async_dismiss_discoveries( - self, byebye_discovery_info: SsdpServiceInfo + self, byebye_discovery_info: _SsdpServiceInfo ) -> None: """Dismiss all discoveries for the given address.""" for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - SsdpServiceInfo, + _SsdpServiceInfo, lambda service_info: bool( service_info.ssdp_st == byebye_discovery_info.ssdp_st and service_info.ssdp_location == byebye_discovery_info.ssdp_location @@ -589,7 +662,7 @@ class Scanner: async def _async_headers_to_discovery_info( self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict - ) -> SsdpServiceInfo: + ) -> _SsdpServiceInfo: """Combine the headers and description into discovery_info. Building this is a bit expensive so we only do it on demand. @@ -602,7 +675,7 @@ class Scanner: async def async_get_discovery_info_by_udn_st( self, udn: str, st: str - ) -> SsdpServiceInfo | None: + ) -> _SsdpServiceInfo | None: """Return discovery_info for a udn and st.""" for ssdp_device in self._ssdp_devices: if ssdp_device.udn == udn: @@ -612,7 +685,7 @@ class Scanner: ) return None - async def async_get_discovery_info_by_st(self, st: str) -> list[SsdpServiceInfo]: + async def async_get_discovery_info_by_st(self, st: str) -> list[_SsdpServiceInfo]: """Return matching discovery_infos for a st.""" return [ await self._async_headers_to_discovery_info(ssdp_device, headers) @@ -620,7 +693,7 @@ class Scanner: if (headers := ssdp_device.combined_headers(st)) ] - async def async_get_discovery_info_by_udn(self, udn: str) -> list[SsdpServiceInfo]: + async def async_get_discovery_info_by_udn(self, udn: str) -> list[_SsdpServiceInfo]: """Return matching discovery_infos for a udn.""" return [ await self._async_headers_to_discovery_info(ssdp_device, headers) @@ -665,7 +738,7 @@ def discovery_info_from_headers_and_description( ssdp_device: SsdpDevice, combined_headers: CaseInsensitiveDict, info_desc: Mapping[str, Any], -) -> SsdpServiceInfo: +) -> _SsdpServiceInfo: """Convert headers and description to discovery_info.""" ssdp_usn = combined_headers["usn"] ssdp_st = combined_headers.get_lower("st") @@ -681,11 +754,11 @@ def discovery_info_from_headers_and_description( ssdp_st = combined_headers["nt"] # Ensure UPnP "udn" is set - if ATTR_UPNP_UDN not in upnp_info: + if _ATTR_UPNP_UDN not in upnp_info: if udn := _udn_from_usn(ssdp_usn): - upnp_info[ATTR_UPNP_UDN] = udn + upnp_info[_ATTR_UPNP_UDN] = udn - return SsdpServiceInfo( + return _SsdpServiceInfo( ssdp_usn=ssdp_usn, ssdp_st=ssdp_st, ssdp_ext=combined_headers.get_lower("ext"), @@ -887,3 +960,11 @@ class Server: """Stop UPnP/SSDP servers.""" for server in self._upnp_servers: await server.async_stop() + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index e9d4f57d5fb..6e1fba8c3a3 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.41.0"] + "requirements": ["async-upnp-client==0.43.0"] } diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index 4b1425ae7d9..0fb5a367148 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -180,6 +180,7 @@ class StarlineAccount: "online": device.online, } + # Deprecated and should be removed in 2025.8 @staticmethod def engine_attrs(device: StarlineDevice) -> dict[str, Any]: """Attributes for engine switch.""" diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index 69f0ae06d02..ac1ad4f2b6e 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -43,8 +43,13 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( ), BinarySensorEntityDescription( key="run", - translation_key="is_running", - device_class=BinarySensorDeviceClass.RUNNING, + translation_key="ignition", + entity_registry_enabled_default=False, + ), + BinarySensorEntityDescription( + key="r_start", + translation_key="autostart", + entity_registry_enabled_default=False, ), BinarySensorEntityDescription( key="hfree", diff --git a/homeassistant/components/starline/icons.json b/homeassistant/components/starline/icons.json index d7d20ae03bd..07713a0cdfe 100644 --- a/homeassistant/components/starline/icons.json +++ b/homeassistant/components/starline/icons.json @@ -13,8 +13,11 @@ "moving_ban": { "default": "mdi:car-off" }, - "is_running": { - "default": "mdi:speedometer" + "ignition": { + "default": "mdi:key-variant" + }, + "autostart": { + "default": "mdi:auto-mode" } }, "button": { diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index 0a30ea5b5be..b3ce755778e 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -33,7 +33,7 @@ } }, "error": { - "error_auth_app": "Incorrect application id or secret", + "error_auth_app": "Incorrect application ID or secret", "error_auth_user": "Incorrect username or password", "error_auth_mfa": "Incorrect code" } @@ -64,8 +64,11 @@ "moving_ban": { "name": "Moving ban" }, - "is_running": { - "name": "Running" + "ignition": { + "name": "Ignition" + }, + "autostart": { + "name": "Autostart" } }, "device_tracker": { @@ -136,11 +139,11 @@ "services": { "update_state": { "name": "Update state", - "description": "Fetches the last state of the devices from the StarLine server.\n." + "description": "Fetches the last state of the devices from the StarLine server." }, "set_scan_interval": { "name": "Set scan interval", - "description": "Sets update frequency.", + "description": "Sets the update frequency for entities.", "fields": { "scan_interval": { "name": "Scan interval", @@ -150,7 +153,7 @@ }, "set_scan_obd_interval": { "name": "Set scan OBD interval", - "description": "Sets OBD info update frequency.", + "description": "Sets the update frequency for OBD information.", "fields": { "scan_interval": { "name": "Scan interval", diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 05193d98c8a..eb71f0b73b5 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -72,6 +72,7 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): def extra_state_attributes(self): """Return the state attributes of the switch.""" if self._key == "ign": + # Deprecated and should be removed in 2025.8 return self._account.engine_attrs(self._device) return None diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index a891941fb8e..89d03a4fadc 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -14,8 +14,11 @@ from starlink_grpc import ( GrpcError, LocationDict, ObstructionDict, + PowerDict, StatusDict, + UsageDict, get_sleep_config, + history_stats, location_data, reboot, set_sleep_config, @@ -39,6 +42,8 @@ class StarlinkData: status: StatusDict obstruction: ObstructionDict alert: AlertDict + usage: UsageDict + consumption: PowerDict class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): @@ -57,11 +62,15 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): def _get_starlink_data(self) -> StarlinkData: """Retrieve Starlink data.""" - channel_context = self.channel_context - status = status_data(channel_context) - location = location_data(channel_context) - sleep = get_sleep_config(channel_context) - return StarlinkData(location, sleep, *status) + context = self.channel_context + status = status_data(context) + location = location_data(context) + sleep = get_sleep_config(context) + status, obstruction, alert = status_data(context) + usage, consumption = history_stats(parse_samples=-1, context=context)[-2:] + return StarlinkData( + location, sleep, status, obstruction, alert, usage, consumption + ) async def _async_update_data(self) -> StarlinkData: async with asyncio.timeout(4): diff --git a/homeassistant/components/starlink/icons.json b/homeassistant/components/starlink/icons.json index 65cb273e24b..02de62aeb8a 100644 --- a/homeassistant/components/starlink/icons.json +++ b/homeassistant/components/starlink/icons.json @@ -18,6 +18,12 @@ }, "last_boot_time": { "default": "mdi:clock" + }, + "upload": { + "default": "mdi:upload" + }, + "download": { + "default": "mdi:download" } } } diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index 070cbf1b44c..15bad3ebc2e 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/starlink", "iot_class": "local_polling", - "requirements": ["starlink-grpc-core==1.2.0"] + "requirements": ["starlink-grpc-core==1.2.2"] } diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index 21f2400022c..5481e310fbd 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -18,6 +18,9 @@ from homeassistant.const import ( PERCENTAGE, EntityCategory, UnitOfDataRate, + UnitOfEnergy, + UnitOfInformation, + UnitOfPower, UnitOfTime, ) from homeassistant.core import HomeAssistant @@ -120,4 +123,36 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, value_fn=lambda data: data.status["pop_ping_drop_rate"] * 100, ), + StarlinkSensorEntityDescription( + key="upload", + translation_key="upload", + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + value_fn=lambda data: data.usage["upload_usage"], + ), + StarlinkSensorEntityDescription( + key="download", + translation_key="download", + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + value_fn=lambda data: data.usage["download_usage"], + ), + StarlinkSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda data: data.consumption["latest_power"], + ), + StarlinkSensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda data: data.consumption["total_energy"], + ), ) diff --git a/homeassistant/components/starlink/strings.json b/homeassistant/components/starlink/strings.json index 36a4f176e70..395b6288c71 100644 --- a/homeassistant/components/starlink/strings.json +++ b/homeassistant/components/starlink/strings.json @@ -70,6 +70,12 @@ }, "ping_drop_rate": { "name": "Ping drop rate" + }, + "upload": { + "name": "Upload" + }, + "download": { + "name": "Download" } }, "switch": { diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index 4280c92131a..4c78afbde9c 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -57,9 +57,9 @@ async def get_state_characteristics(handler: SchemaCommonFlowHandler) -> vol.Sch split_entity_id(handler.options[CONF_ENTITY_ID])[0] == BINARY_SENSOR_DOMAIN ) if is_binary: - options = STATS_BINARY_SUPPORT + options = list(STATS_BINARY_SUPPORT) else: - options = STATS_NUMERIC_SUPPORT + options = list(STATS_NUMERIC_SUPPORT) return vol.Schema( { diff --git a/homeassistant/components/statistics/manifest.json b/homeassistant/components/statistics/manifest.json index 24d4b4914cb..8eaed552edd 100644 --- a/homeassistant/components/statistics/manifest.json +++ b/homeassistant/components/statistics/manifest.json @@ -2,7 +2,7 @@ "domain": "statistics", "name": "Statistics", "after_dependencies": ["recorder"], - "codeowners": ["@ThomDietrich"], + "codeowners": ["@ThomDietrich", "@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/statistics", "integration_type": "helper", diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index b6f1844f774..5252c23fd3d 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta import logging import math import statistics +import time from typing import Any, cast import voluptuous as vol @@ -53,7 +54,7 @@ from homeassistant.helpers.event import ( async_track_state_report_event, ) from homeassistant.helpers.reload import async_setup_reload_service -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -97,47 +98,373 @@ STAT_VALUE_MAX = "value_max" STAT_VALUE_MIN = "value_min" STAT_VARIANCE = "variance" + +def _callable_characteristic_fn( + characteristic: str, binary: bool +) -> Callable[[deque[bool | float], deque[float], int], float | int | datetime | None]: + """Return the function callable of one characteristic function.""" + Callable[[deque[bool | float], deque[datetime], int], datetime | int | float | None] + if binary: + return STATS_BINARY_SUPPORT[characteristic] + return STATS_NUMERIC_SUPPORT[characteristic] + + +# Statistics for numeric sensor + + +def _stat_average_linear( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return states[0] + if len(states) >= 2: + area: float = 0 + for i in range(1, len(states)): + area += 0.5 * (states[i] + states[i - 1]) * (ages[i] - ages[i - 1]) + age_range_seconds = ages[-1] - ages[0] + return area / age_range_seconds + return None + + +def _stat_average_step( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return states[0] + if len(states) >= 2: + area: float = 0 + for i in range(1, len(states)): + area += states[i - 1] * (ages[i] - ages[i - 1]) + age_range_seconds = ages[-1] - ages[0] + return area / age_range_seconds + return None + + +def _stat_average_timeless( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + return _stat_mean(states, ages, percentile) + + +def _stat_change( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return states[-1] - states[0] + return None + + +def _stat_change_sample( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 1: + return (states[-1] - states[0]) / (len(states) - 1) + return None + + +def _stat_change_second( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 1: + age_range_seconds = ages[-1] - ages[0] + if age_range_seconds > 0: + return (states[-1] - states[0]) / age_range_seconds + return None + + +def _stat_count( + states: deque[bool | float], ages: deque[float], percentile: int +) -> int | None: + return len(states) + + +def _stat_datetime_newest( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + if len(states) > 0: + return dt_util.utc_from_timestamp(ages[-1]) + return None + + +def _stat_datetime_oldest( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + if len(states) > 0: + return dt_util.utc_from_timestamp(ages[0]) + return None + + +def _stat_datetime_value_max( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + if len(states) > 0: + return dt_util.utc_from_timestamp(ages[states.index(max(states))]) + return None + + +def _stat_datetime_value_min( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + if len(states) > 0: + return dt_util.utc_from_timestamp(ages[states.index(min(states))]) + return None + + +def _stat_distance_95_percent_of_values( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) >= 1: + return ( + 2 * 1.96 * cast(float, _stat_standard_deviation(states, ages, percentile)) + ) + return None + + +def _stat_distance_99_percent_of_values( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) >= 1: + return ( + 2 * 2.58 * cast(float, _stat_standard_deviation(states, ages, percentile)) + ) + return None + + +def _stat_distance_absolute( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return max(states) - min(states) + return None + + +def _stat_mean( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return statistics.mean(states) + return None + + +def _stat_mean_circular( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + sin_sum = sum(math.sin(math.radians(x)) for x in states) + cos_sum = sum(math.cos(math.radians(x)) for x in states) + return (math.degrees(math.atan2(sin_sum, cos_sum)) + 360) % 360 + return None + + +def _stat_median( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return statistics.median(states) + return None + + +def _stat_noisiness( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 0.0 + if len(states) >= 2: + return cast(float, _stat_sum_differences(states, ages, percentile)) / ( + len(states) - 1 + ) + return None + + +def _stat_percentile( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return states[0] + if len(states) >= 2: + percentiles = statistics.quantiles(states, n=100, method="exclusive") + return percentiles[percentile - 1] + return None + + +def _stat_standard_deviation( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 0.0 + if len(states) >= 2: + return statistics.stdev(states) + return None + + +def _stat_sum( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return sum(states) + return None + + +def _stat_sum_differences( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 0.0 + if len(states) >= 2: + return sum( + abs(j - i) for i, j in zip(list(states), list(states)[1:], strict=False) + ) + return None + + +def _stat_sum_differences_nonnegative( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 0.0 + if len(states) >= 2: + return sum( + (j - i if j >= i else j - 0) + for i, j in zip(list(states), list(states)[1:], strict=False) + ) + return None + + +def _stat_total( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + return _stat_sum(states, ages, percentile) + + +def _stat_value_max( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return max(states) + return None + + +def _stat_value_min( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return min(states) + return None + + +def _stat_variance( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 0.0 + if len(states) >= 2: + return statistics.variance(states) + return None + + +# Statistics for binary sensor + + +def _stat_binary_average_step( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) == 1: + return 100.0 * int(states[0] is True) + if len(states) >= 2: + on_seconds: float = 0 + for i in range(1, len(states)): + if states[i - 1] is True: + on_seconds += ages[i] - ages[i - 1] + age_range_seconds = ages[-1] - ages[0] + return 100 / age_range_seconds * on_seconds + return None + + +def _stat_binary_average_timeless( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + return _stat_binary_mean(states, ages, percentile) + + +def _stat_binary_count( + states: deque[bool | float], ages: deque[float], percentile: int +) -> int | None: + return len(states) + + +def _stat_binary_count_on( + states: deque[bool | float], ages: deque[float], percentile: int +) -> int | None: + return states.count(True) + + +def _stat_binary_count_off( + states: deque[bool | float], ages: deque[float], percentile: int +) -> int | None: + return states.count(False) + + +def _stat_binary_datetime_newest( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + return _stat_datetime_newest(states, ages, percentile) + + +def _stat_binary_datetime_oldest( + states: deque[bool | float], ages: deque[float], percentile: int +) -> datetime | None: + return _stat_datetime_oldest(states, ages, percentile) + + +def _stat_binary_mean( + states: deque[bool | float], ages: deque[float], percentile: int +) -> float | None: + if len(states) > 0: + return 100.0 / len(states) * states.count(True) + return None + + # Statistics supported by a sensor source (numeric) STATS_NUMERIC_SUPPORT = { - STAT_AVERAGE_LINEAR, - STAT_AVERAGE_STEP, - STAT_AVERAGE_TIMELESS, - STAT_CHANGE_SAMPLE, - STAT_CHANGE_SECOND, - STAT_CHANGE, - STAT_COUNT, - STAT_DATETIME_NEWEST, - STAT_DATETIME_OLDEST, - STAT_DATETIME_VALUE_MAX, - STAT_DATETIME_VALUE_MIN, - STAT_DISTANCE_95P, - STAT_DISTANCE_99P, - STAT_DISTANCE_ABSOLUTE, - STAT_MEAN, - STAT_MEAN_CIRCULAR, - STAT_MEDIAN, - STAT_NOISINESS, - STAT_PERCENTILE, - STAT_STANDARD_DEVIATION, - STAT_SUM, - STAT_SUM_DIFFERENCES, - STAT_SUM_DIFFERENCES_NONNEGATIVE, - STAT_TOTAL, - STAT_VALUE_MAX, - STAT_VALUE_MIN, - STAT_VARIANCE, + STAT_AVERAGE_LINEAR: _stat_average_linear, + STAT_AVERAGE_STEP: _stat_average_step, + STAT_AVERAGE_TIMELESS: _stat_average_timeless, + STAT_CHANGE_SAMPLE: _stat_change_sample, + STAT_CHANGE_SECOND: _stat_change_second, + STAT_CHANGE: _stat_change, + STAT_COUNT: _stat_count, + STAT_DATETIME_NEWEST: _stat_datetime_newest, + STAT_DATETIME_OLDEST: _stat_datetime_oldest, + STAT_DATETIME_VALUE_MAX: _stat_datetime_value_max, + STAT_DATETIME_VALUE_MIN: _stat_datetime_value_min, + STAT_DISTANCE_95P: _stat_distance_95_percent_of_values, + STAT_DISTANCE_99P: _stat_distance_99_percent_of_values, + STAT_DISTANCE_ABSOLUTE: _stat_distance_absolute, + STAT_MEAN: _stat_mean, + STAT_MEAN_CIRCULAR: _stat_mean_circular, + STAT_MEDIAN: _stat_median, + STAT_NOISINESS: _stat_noisiness, + STAT_PERCENTILE: _stat_percentile, + STAT_STANDARD_DEVIATION: _stat_standard_deviation, + STAT_SUM: _stat_sum, + STAT_SUM_DIFFERENCES: _stat_sum_differences, + STAT_SUM_DIFFERENCES_NONNEGATIVE: _stat_sum_differences_nonnegative, + STAT_TOTAL: _stat_total, + STAT_VALUE_MAX: _stat_value_max, + STAT_VALUE_MIN: _stat_value_min, + STAT_VARIANCE: _stat_variance, } # Statistics supported by a binary_sensor source STATS_BINARY_SUPPORT = { - STAT_AVERAGE_STEP, - STAT_AVERAGE_TIMELESS, - STAT_COUNT, - STAT_COUNT_BINARY_ON, - STAT_COUNT_BINARY_OFF, - STAT_DATETIME_NEWEST, - STAT_DATETIME_OLDEST, - STAT_MEAN, + STAT_AVERAGE_STEP: _stat_binary_average_step, + STAT_AVERAGE_TIMELESS: _stat_binary_average_timeless, + STAT_COUNT: _stat_binary_count, + STAT_COUNT_BINARY_ON: _stat_binary_count_on, + STAT_COUNT_BINARY_OFF: _stat_binary_count_off, + STAT_DATETIME_NEWEST: _stat_binary_datetime_newest, + STAT_DATETIME_OLDEST: _stat_binary_datetime_oldest, + STAT_MEAN: _stat_binary_mean, } STATS_NOT_A_NUMBER = { @@ -298,12 +625,8 @@ async def async_setup_entry( sampling_size = int(sampling_size) max_age = None - if max_age_input := entry.options.get(CONF_MAX_AGE): - max_age = timedelta( - hours=max_age_input["hours"], - minutes=max_age_input["minutes"], - seconds=max_age_input["seconds"], - ) + if max_age := entry.options.get(CONF_MAX_AGE): + max_age = timedelta(**max_age) async_add_entities( [ @@ -356,19 +679,22 @@ class StatisticsSensor(SensorEntity): ) self._state_characteristic: str = state_characteristic self._samples_max_buffer_size: int | None = samples_max_buffer_size - self._samples_max_age: timedelta | None = samples_max_age + self._samples_max_age: float | None = ( + samples_max_age.total_seconds() if samples_max_age else None + ) self.samples_keep_last: bool = samples_keep_last self._precision: int = precision self._percentile: int = percentile self._attr_available: bool = False - self.states: deque[float | bool] = deque(maxlen=self._samples_max_buffer_size) - self.ages: deque[datetime] = deque(maxlen=self._samples_max_buffer_size) + self.states: deque[float | bool] = deque(maxlen=samples_max_buffer_size) + self.ages: deque[float] = deque(maxlen=samples_max_buffer_size) self._attr_extra_state_attributes = {} - self._state_characteristic_fn: Callable[[], float | int | datetime | None] = ( - self._callable_characteristic_fn(self._state_characteristic) - ) + self._state_characteristic_fn: Callable[ + [deque[bool | float], deque[float], int], + float | int | datetime | None, + ] = _callable_characteristic_fn(state_characteristic, self.is_binary) self._update_listener: CALLBACK_TYPE | None = None self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None @@ -474,7 +800,7 @@ class StatisticsSensor(SensorEntity): self.states.append(new_state.state == "on") else: self.states.append(float(new_state.state)) - self.ages.append(new_state.last_reported) + self.ages.append(new_state.last_reported_timestamp) self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = True except ValueError: self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False @@ -507,27 +833,24 @@ class StatisticsSensor(SensorEntity): base_unit: str | None = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) unit: str | None = None - if self.is_binary and self._state_characteristic in STATS_BINARY_PERCENTAGE: + stat_type = self._state_characteristic + if self.is_binary and stat_type in STATS_BINARY_PERCENTAGE: unit = PERCENTAGE elif not base_unit: unit = None - elif self._state_characteristic in STATS_NUMERIC_RETAIN_UNIT: + elif stat_type in STATS_NUMERIC_RETAIN_UNIT: unit = base_unit - elif ( - self._state_characteristic in STATS_NOT_A_NUMBER - or self._state_characteristic - in ( - STAT_COUNT, - STAT_COUNT_BINARY_ON, - STAT_COUNT_BINARY_OFF, - ) + elif stat_type in STATS_NOT_A_NUMBER or stat_type in ( + STAT_COUNT, + STAT_COUNT_BINARY_ON, + STAT_COUNT_BINARY_OFF, ): unit = None - elif self._state_characteristic == STAT_VARIANCE: + elif stat_type == STAT_VARIANCE: unit = base_unit + "²" - elif self._state_characteristic == STAT_CHANGE_SAMPLE: + elif stat_type == STAT_CHANGE_SAMPLE: unit = base_unit + "/sample" - elif self._state_characteristic == STAT_CHANGE_SECOND: + elif stat_type == STAT_CHANGE_SECOND: unit = base_unit + "/s" return unit @@ -543,9 +866,10 @@ class StatisticsSensor(SensorEntity): """ device_class: SensorDeviceClass | None = None - if self._state_characteristic in STATS_DATETIME: + stat_type = self._state_characteristic + if stat_type in STATS_DATETIME: return SensorDeviceClass.TIMESTAMP - if self._state_characteristic in STATS_NUMERIC_RETAIN_UNIT: + if stat_type in STATS_NUMERIC_RETAIN_UNIT: device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) if device_class is None: return None @@ -584,55 +908,60 @@ class StatisticsSensor(SensorEntity): return None return SensorStateClass.MEASUREMENT - def _purge_old_states(self, max_age: timedelta) -> None: + def _purge_old_states(self, max_age: float) -> None: """Remove states which are older than a given age.""" - now = dt_util.utcnow() + now_timestamp = time.time() + debug = _LOGGER.isEnabledFor(logging.DEBUG) - _LOGGER.debug( - "%s: purging records older then %s(%s)(keep_last_sample: %s)", - self.entity_id, - dt_util.as_local(now - max_age), - self._samples_max_age, - self.samples_keep_last, - ) + if debug: + _LOGGER.debug( + "%s: purging records older then %s(%s)(keep_last_sample: %s)", + self.entity_id, + dt_util.as_local(dt_util.utc_from_timestamp(now_timestamp - max_age)), + self._samples_max_age, + self.samples_keep_last, + ) - while self.ages and (now - self.ages[0]) > max_age: + while self.ages and (now_timestamp - self.ages[0]) > max_age: if self.samples_keep_last and len(self.ages) == 1: # Under normal circumstance this will not be executed, as a purge will not # be scheduled for the last value if samples_keep_last is enabled. # If this happens to be called outside normal scheduling logic or a # source sensor update, this ensures the last value is preserved. - _LOGGER.debug( - "%s: preserving expired record with datetime %s(%s)", - self.entity_id, - dt_util.as_local(self.ages[0]), - (now - self.ages[0]), - ) + if debug: + _LOGGER.debug( + "%s: preserving expired record with datetime %s(%s)", + self.entity_id, + dt_util.as_local(dt_util.utc_from_timestamp(self.ages[0])), + dt_util.utc_from_timestamp(now_timestamp - self.ages[0]), + ) break - _LOGGER.debug( - "%s: purging record with datetime %s(%s)", - self.entity_id, - dt_util.as_local(self.ages[0]), - (now - self.ages[0]), - ) + if debug: + _LOGGER.debug( + "%s: purging record with datetime %s(%s)", + self.entity_id, + dt_util.as_local(dt_util.utc_from_timestamp(self.ages[0])), + dt_util.utc_from_timestamp(now_timestamp - self.ages[0]), + ) self.ages.popleft() self.states.popleft() @callback - def _async_next_to_purge_timestamp(self) -> datetime | None: + def _async_next_to_purge_timestamp(self) -> float | None: """Find the timestamp when the next purge would occur.""" if self.ages and self._samples_max_age: if self.samples_keep_last and len(self.ages) == 1: # Preserve the most recent entry if it is the only value. # Do not schedule another purge. When a new source # value is inserted it will restart purge cycle. - _LOGGER.debug( - "%s: skipping purge cycle for last record with datetime %s(%s)", - self.entity_id, - dt_util.as_local(self.ages[0]), - (dt_util.utcnow() - self.ages[0]), - ) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "%s: skipping purge cycle for last record with datetime %s(%s)", + self.entity_id, + dt_util.as_local(dt_util.utc_from_timestamp(self.ages[0])), + (dt_util.utcnow() - dt_util.utc_from_timestamp(self.ages[0])), + ) return None # Take the oldest entry from the ages list and add the configured max_age. # If executed after purging old states, the result is the next timestamp @@ -657,10 +986,17 @@ class StatisticsSensor(SensorEntity): # By basing updates off the timestamps of sampled data we avoid updating # when none of the observed entities change. if timestamp := self._async_next_to_purge_timestamp(): - _LOGGER.debug("%s: scheduling update at %s", self.entity_id, timestamp) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "%s: scheduling update at %s", + self.entity_id, + dt_util.utc_from_timestamp(timestamp), + ) self._async_cancel_update_listener() self._update_listener = async_track_point_in_utc_time( - self.hass, self._async_scheduled_update, timestamp + self.hass, + self._async_scheduled_update, + dt_util.utc_from_timestamp(timestamp), ) @callback @@ -684,9 +1020,11 @@ class StatisticsSensor(SensorEntity): """Fetch the states from the database.""" _LOGGER.debug("%s: initializing values from the database", self.entity_id) lower_entity_id = self._source_entity_id.lower() - if self._samples_max_age is not None: + if (max_age := self._samples_max_age) is not None: start_date = ( - dt_util.utcnow() - self._samples_max_age - timedelta(microseconds=1) + dt_util.utcnow() + - timedelta(seconds=max_age) + - timedelta(microseconds=1) ) _LOGGER.debug( "%s: retrieve records not older then %s", @@ -738,11 +1076,10 @@ class StatisticsSensor(SensorEntity): len(self.states) / self._samples_max_buffer_size, 2 ) - if self._samples_max_age is not None: + if (max_age := self._samples_max_age) is not None: if len(self.states) >= 1: self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = round( - (self.ages[-1] - self.ages[0]).total_seconds() - / self._samples_max_age.total_seconds(), + (self.ages[-1] - self.ages[0]) / max_age, 2, ) else: @@ -754,7 +1091,7 @@ class StatisticsSensor(SensorEntity): One of the _stat_*() functions is represented by self._state_characteristic_fn(). """ - value = self._state_characteristic_fn() + value = self._state_characteristic_fn(self.states, self.ages, self._percentile) _LOGGER.debug( "Updating value: states: %s, ages: %s => %s", self.states, self.ages, value ) @@ -764,225 +1101,3 @@ class StatisticsSensor(SensorEntity): if self._precision == 0: value = int(value) self._attr_native_value = value - - def _callable_characteristic_fn( - self, characteristic: str - ) -> Callable[[], float | int | datetime | None]: - """Return the function callable of one characteristic function.""" - function: Callable[[], float | int | datetime | None] = getattr( - self, - f"_stat_binary_{characteristic}" - if self.is_binary - else f"_stat_{characteristic}", - ) - return function - - # Statistics for numeric sensor - - def _stat_average_linear(self) -> StateType: - if len(self.states) == 1: - return self.states[0] - if len(self.states) >= 2: - area: float = 0 - for i in range(1, len(self.states)): - area += ( - 0.5 - * (self.states[i] + self.states[i - 1]) - * (self.ages[i] - self.ages[i - 1]).total_seconds() - ) - age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() - return area / age_range_seconds - return None - - def _stat_average_step(self) -> StateType: - if len(self.states) == 1: - return self.states[0] - if len(self.states) >= 2: - area: float = 0 - for i in range(1, len(self.states)): - area += ( - self.states[i - 1] - * (self.ages[i] - self.ages[i - 1]).total_seconds() - ) - age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() - return area / age_range_seconds - return None - - def _stat_average_timeless(self) -> StateType: - return self._stat_mean() - - def _stat_change(self) -> StateType: - if len(self.states) > 0: - return self.states[-1] - self.states[0] - return None - - def _stat_change_sample(self) -> StateType: - if len(self.states) > 1: - return (self.states[-1] - self.states[0]) / (len(self.states) - 1) - return None - - def _stat_change_second(self) -> StateType: - if len(self.states) > 1: - age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() - if age_range_seconds > 0: - return (self.states[-1] - self.states[0]) / age_range_seconds - return None - - def _stat_count(self) -> StateType: - return len(self.states) - - def _stat_datetime_newest(self) -> datetime | None: - if len(self.states) > 0: - return self.ages[-1] - return None - - def _stat_datetime_oldest(self) -> datetime | None: - if len(self.states) > 0: - return self.ages[0] - return None - - def _stat_datetime_value_max(self) -> datetime | None: - if len(self.states) > 0: - return self.ages[self.states.index(max(self.states))] - return None - - def _stat_datetime_value_min(self) -> datetime | None: - if len(self.states) > 0: - return self.ages[self.states.index(min(self.states))] - return None - - def _stat_distance_95_percent_of_values(self) -> StateType: - if len(self.states) >= 1: - return 2 * 1.96 * cast(float, self._stat_standard_deviation()) - return None - - def _stat_distance_99_percent_of_values(self) -> StateType: - if len(self.states) >= 1: - return 2 * 2.58 * cast(float, self._stat_standard_deviation()) - return None - - def _stat_distance_absolute(self) -> StateType: - if len(self.states) > 0: - return max(self.states) - min(self.states) - return None - - def _stat_mean(self) -> StateType: - if len(self.states) > 0: - return statistics.mean(self.states) - return None - - def _stat_mean_circular(self) -> StateType: - if len(self.states) > 0: - sin_sum = sum(math.sin(math.radians(x)) for x in self.states) - cos_sum = sum(math.cos(math.radians(x)) for x in self.states) - return (math.degrees(math.atan2(sin_sum, cos_sum)) + 360) % 360 - return None - - def _stat_median(self) -> StateType: - if len(self.states) > 0: - return statistics.median(self.states) - return None - - def _stat_noisiness(self) -> StateType: - if len(self.states) == 1: - return 0.0 - if len(self.states) >= 2: - return cast(float, self._stat_sum_differences()) / (len(self.states) - 1) - return None - - def _stat_percentile(self) -> StateType: - if len(self.states) == 1: - return self.states[0] - if len(self.states) >= 2: - percentiles = statistics.quantiles(self.states, n=100, method="exclusive") - return percentiles[self._percentile - 1] - return None - - def _stat_standard_deviation(self) -> StateType: - if len(self.states) == 1: - return 0.0 - if len(self.states) >= 2: - return statistics.stdev(self.states) - return None - - def _stat_sum(self) -> StateType: - if len(self.states) > 0: - return sum(self.states) - return None - - def _stat_sum_differences(self) -> StateType: - if len(self.states) == 1: - return 0.0 - if len(self.states) >= 2: - return sum( - abs(j - i) - for i, j in zip(list(self.states), list(self.states)[1:], strict=False) - ) - return None - - def _stat_sum_differences_nonnegative(self) -> StateType: - if len(self.states) == 1: - return 0.0 - if len(self.states) >= 2: - return sum( - (j - i if j >= i else j - 0) - for i, j in zip(list(self.states), list(self.states)[1:], strict=False) - ) - return None - - def _stat_total(self) -> StateType: - return self._stat_sum() - - def _stat_value_max(self) -> StateType: - if len(self.states) > 0: - return max(self.states) - return None - - def _stat_value_min(self) -> StateType: - if len(self.states) > 0: - return min(self.states) - return None - - def _stat_variance(self) -> StateType: - if len(self.states) == 1: - return 0.0 - if len(self.states) >= 2: - return statistics.variance(self.states) - return None - - # Statistics for binary sensor - - def _stat_binary_average_step(self) -> StateType: - if len(self.states) == 1: - return 100.0 * int(self.states[0] is True) - if len(self.states) >= 2: - on_seconds: float = 0 - for i in range(1, len(self.states)): - if self.states[i - 1] is True: - on_seconds += (self.ages[i] - self.ages[i - 1]).total_seconds() - age_range_seconds = (self.ages[-1] - self.ages[0]).total_seconds() - return 100 / age_range_seconds * on_seconds - return None - - def _stat_binary_average_timeless(self) -> StateType: - return self._stat_binary_mean() - - def _stat_binary_count(self) -> StateType: - return len(self.states) - - def _stat_binary_count_on(self) -> StateType: - return self.states.count(True) - - def _stat_binary_count_off(self) -> StateType: - return self.states.count(False) - - def _stat_binary_datetime_newest(self) -> datetime | None: - return self._stat_datetime_newest() - - def _stat_binary_datetime_oldest(self) -> datetime | None: - return self._stat_datetime_oldest() - - def _stat_binary_mean(self) -> StateType: - if len(self.states) > 0: - return 100.0 / len(self.states) * self.states.count(True) - return None diff --git a/homeassistant/components/steamist/config_flow.py b/homeassistant/components/steamist/config_flow.py index f22eafc6afd..cadcba118a1 100644 --- a/homeassistant/components/steamist/config_flow.py +++ b/homeassistant/components/steamist/config_flow.py @@ -9,12 +9,12 @@ from aiosteamist import Steamist from discovery30303 import Device30303, normalize_mac import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_NAME from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONNECTION_EXCEPTIONS, DISCOVER_SCAN_TIMEOUT, DOMAIN @@ -41,7 +41,7 @@ class SteamistConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device: Device30303 | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = Device30303( diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index a5e92312f3d..80c1dad3ee8 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -3,6 +3,7 @@ from datetime import timedelta import logging +from pymodbus.client import ModbusTcpClient from pystiebeleltron import pystiebeleltron import voluptuous as vol @@ -55,13 +56,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class StiebelEltronData: """Get the latest data and update the states.""" - def __init__(self, name, modbus_client): + def __init__(self, name: str, modbus_client: ModbusTcpClient) -> None: """Init the STIEBEL ELTRON data object.""" self.api = pystiebeleltron.StiebelEltronAPI(modbus_client, 1) @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def update(self) -> None: """Update unit data.""" if not self.api.update(): _LOGGER.warning("Modbus read failed") diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 41015ac16a4..4d302a0f70d 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as STE_DOMAIN +from . import DOMAIN as STE_DOMAIN, StiebelEltronData DEPENDENCIES = ["stiebel_eltron"] @@ -80,17 +80,16 @@ class StiebelEltron(ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False - def __init__(self, name, ste_data): + def __init__(self, name: str, ste_data: StiebelEltronData) -> None: """Initialize the unit.""" self._name = name - self._target_temperature = None - self._current_temperature = None - self._current_humidity = None - self._operation = None - self._filter_alarm = None - self._force_update = False + self._target_temperature: float | int | None = None + self._current_temperature: float | int | None = None + self._current_humidity: float | int | None = None + self._operation: str | None = None + self._filter_alarm: bool | None = None + self._force_update: bool = False self._ste_data = ste_data def update(self) -> None: @@ -109,59 +108,59 @@ class StiebelEltron(ClimateEntity): ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool | None]: """Return device specific state attributes.""" return {"filter_alarm": self._filter_alarm} @property - def name(self): + def name(self) -> str: """Return the name of the climate device.""" return self._name # Handle ClimateEntityFeature.TARGET_TEMPERATURE @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._current_temperature @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._target_temperature @property - def target_temperature_step(self): + def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" return 0.1 @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return 10.0 @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return 30.0 @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Return the current humidity.""" return float(f"{self._current_humidity:.1f}") @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - return STE_TO_HA_HVAC.get(self._operation) + return STE_TO_HA_HVAC.get(self._operation) if self._operation else None @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return STE_TO_HA_PRESET.get(self._operation) + return STE_TO_HA_PRESET.get(self._operation) if self._operation else None @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Return a list of available preset modes.""" return SUPPORT_PRESET diff --git a/homeassistant/components/stookalert/__init__.py b/homeassistant/components/stookalert/__init__.py deleted file mode 100644 index 0ef9c7fa845..00000000000 --- a/homeassistant/components/stookalert/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -"""The Stookalert integration.""" - -from __future__ import annotations - -import stookalert - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant - -from .const import CONF_PROVINCE, DOMAIN - -PLATFORMS = [Platform.BINARY_SENSOR] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Stookalert from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = stookalert.stookalert(entry.data[CONF_PROVINCE]) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload Stookalert config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok diff --git a/homeassistant/components/stookalert/binary_sensor.py b/homeassistant/components/stookalert/binary_sensor.py deleted file mode 100644 index a2fff52f2a3..00000000000 --- a/homeassistant/components/stookalert/binary_sensor.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Support for Stookalert Binary Sensor.""" - -from __future__ import annotations - -from datetime import timedelta - -import stookalert - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import CONF_PROVINCE, DOMAIN - -SCAN_INTERVAL = timedelta(minutes=60) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Stookalert binary sensor from a config entry.""" - client = hass.data[DOMAIN][entry.entry_id] - async_add_entities([StookalertBinarySensor(client, entry)], update_before_add=True) - - -class StookalertBinarySensor(BinarySensorEntity): - """Defines a Stookalert binary sensor.""" - - _attr_attribution = "Data provided by rivm.nl" - _attr_device_class = BinarySensorDeviceClass.SAFETY - _attr_has_entity_name = True - _attr_name = None - - def __init__(self, client: stookalert.stookalert, entry: ConfigEntry) -> None: - """Initialize a Stookalert device.""" - self._client = client - self._attr_unique_id = entry.unique_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{entry.entry_id}")}, - name=f"Stookalert {entry.data[CONF_PROVINCE]}", - manufacturer="RIVM", - model="Stookalert", - entry_type=DeviceEntryType.SERVICE, - configuration_url="https://www.rivm.nl/stookalert", - ) - - def update(self) -> None: - """Update the data from the Stookalert handler.""" - self._client.get_alerts() - self._attr_is_on = self._client.state == 1 diff --git a/homeassistant/components/stookalert/config_flow.py b/homeassistant/components/stookalert/config_flow.py deleted file mode 100644 index 0d3bc0c1761..00000000000 --- a/homeassistant/components/stookalert/config_flow.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Config flow to configure the Stookalert integration.""" - -from __future__ import annotations - -from typing import Any - -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult - -from .const import CONF_PROVINCE, DOMAIN, PROVINCES - - -class StookalertFlowHandler(ConfigFlow, domain=DOMAIN): - """Config flow for Stookalert.""" - - VERSION = 1 - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a flow initialized by the user.""" - if user_input is not None: - await self.async_set_unique_id(user_input[CONF_PROVINCE]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=user_input[CONF_PROVINCE], data=user_input - ) - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Required(CONF_PROVINCE): vol.In(PROVINCES)}), - ) diff --git a/homeassistant/components/stookalert/const.py b/homeassistant/components/stookalert/const.py deleted file mode 100644 index 9896eea212a..00000000000 --- a/homeassistant/components/stookalert/const.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Constants for the Stookalert integration.""" - -import logging -from typing import Final - -DOMAIN: Final = "stookalert" -LOGGER = logging.getLogger(__package__) - -CONF_PROVINCE: Final = "province" - -PROVINCES: Final = ( - "Drenthe", - "Flevoland", - "Friesland", - "Gelderland", - "Groningen", - "Limburg", - "Noord-Brabant", - "Noord-Holland", - "Overijssel", - "Utrecht", - "Zeeland", - "Zuid-Holland", -) diff --git a/homeassistant/components/stookalert/diagnostics.py b/homeassistant/components/stookalert/diagnostics.py deleted file mode 100644 index c15e808ae19..00000000000 --- a/homeassistant/components/stookalert/diagnostics.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Diagnostics support for Stookalert.""" - -from __future__ import annotations - -from typing import Any - -import stookalert - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from .const import DOMAIN - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - client: stookalert.stookalert = hass.data[DOMAIN][entry.entry_id] - return {"state": client.state} diff --git a/homeassistant/components/stookalert/manifest.json b/homeassistant/components/stookalert/manifest.json deleted file mode 100644 index 2bebc639720..00000000000 --- a/homeassistant/components/stookalert/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "stookalert", - "name": "RIVM Stookalert", - "codeowners": ["@fwestenberg", "@frenck"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/stookalert", - "integration_type": "service", - "iot_class": "cloud_polling", - "requirements": ["stookalert==0.1.4"] -} diff --git a/homeassistant/components/stookalert/strings.json b/homeassistant/components/stookalert/strings.json deleted file mode 100644 index a05ae4e61e7..00000000000 --- a/homeassistant/components/stookalert/strings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "province": "Province" - } - } - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" - } - } -} diff --git a/homeassistant/components/stookwijzer/quality_scale.yaml b/homeassistant/components/stookwijzer/quality_scale.yaml new file mode 100644 index 00000000000..20e64efaa92 --- /dev/null +++ b/homeassistant/components/stookwijzer/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration doesn't provide any additional service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration doesn't provide any additional service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + The integration doesn't subscribe to any events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: todo + test-before-setup: done + unique-config-entry: todo + + # Silver + action-exceptions: + status: exempt + comment: | + This integration is read-only and doesn't provide any actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: | + This integration is read-only and doesn't provide any actions. Querying + the service for data is handled centrally using a data update coordinator. + reauthentication-flow: + status: exempt + comment: | + This integration doesn't require re-authentication. + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + The integration cannot be discovered, as it is an external service. + discovery: + status: exempt + comment: | + The integration cannot be discovered, as it is an external service. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration provides a single device entry for the service. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration provides a single device entry for the service. + # Platinum + async-dependency: done + inject-websession: done + strict-typing: + status: todo + comment: | + Requirement 'stookwijzer==1.5.1' appears untyped diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 64c520150c2..2772fc2d30e 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -40,6 +40,7 @@ from homeassistant.util.async_ import create_eager_task from .const import ( ATTR_ENDPOINTS, + ATTR_PREFER_TCP, ATTR_SETTINGS, ATTR_STREAMS, CONF_EXTRA_PART_WAIT_TIME, @@ -60,6 +61,7 @@ from .const import ( SOURCE_TIMEOUT, STREAM_RESTART_INCREMENT, STREAM_RESTART_RESET_TIME, + StreamClientError, ) from .core import ( PROVIDERS, @@ -71,6 +73,7 @@ from .core import ( StreamSettings, ) from .diagnostics import Diagnostics +from .exceptions import StreamOpenClientError, StreamWorkerError from .hls import HlsStreamOutput, async_setup_hls if TYPE_CHECKING: @@ -87,14 +90,44 @@ __all__ = [ "OUTPUT_FORMATS", "RTSP_TRANSPORTS", "SOURCE_TIMEOUT", - "Stream", - "create_stream", "Orientation", + "Stream", + "StreamClientError", + "StreamOpenClientError", + "create_stream", ] _LOGGER = logging.getLogger(__name__) +async def async_check_stream_client_error( + hass: HomeAssistant, source: str, pyav_options: dict[str, str] | None = None +) -> None: + """Check if a stream can be successfully opened. + + Raise StreamOpenClientError if an http client error is encountered. + """ + await hass.loop.run_in_executor( + None, _check_stream_client_error, hass, source, pyav_options + ) + + +def _check_stream_client_error( + hass: HomeAssistant, source: str, options: dict[str, str] | None = None +) -> None: + """Check if a stream can be successfully opened. + + Raise StreamOpenClientError if an http client error is encountered. + """ + from .worker import try_open_stream # pylint: disable=import-outside-toplevel + + pyav_options, _ = _convert_stream_options(hass, source, options or {}) + try: + try_open_stream(source, pyav_options).close() + except StreamWorkerError as err: + raise StreamOpenClientError(str(err), err.error_code) from err + + def redact_credentials(url: str) -> str: """Redact credentials from string data.""" yurl = URL(url) @@ -108,6 +141,42 @@ def redact_credentials(url: str) -> str: return str(yurl.update_query(redacted_query_params)) +def _convert_stream_options( + hass: HomeAssistant, + stream_source: str, + stream_options: Mapping[str, str | bool | float], +) -> tuple[dict[str, str], StreamSettings]: + """Convert options from stream options into PyAV options and stream settings.""" + if DOMAIN not in hass.data: + raise HomeAssistantError("Stream integration is not set up.") + + stream_settings = copy.copy(hass.data[DOMAIN][ATTR_SETTINGS]) + pyav_options: dict[str, str] = {} + try: + STREAM_OPTIONS_SCHEMA(stream_options) + except vol.Invalid as exc: + raise HomeAssistantError(f"Invalid stream options: {exc}") from exc + + if extra_wait_time := stream_options.get(CONF_EXTRA_PART_WAIT_TIME): + stream_settings.hls_part_timeout += extra_wait_time + if rtsp_transport := stream_options.get(CONF_RTSP_TRANSPORT): + assert isinstance(rtsp_transport, str) + # The PyAV options currently match the stream CONF constants, but this + # will not necessarily always be the case, so they are hard coded here + pyav_options["rtsp_transport"] = rtsp_transport + if stream_options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): + pyav_options["use_wallclock_as_timestamps"] = "1" + + # For RTSP streams, prefer TCP + if isinstance(stream_source, str) and stream_source[:7] == "rtsp://": + pyav_options = { + "rtsp_flags": ATTR_PREFER_TCP, + "stimeout": "5000000", + **pyav_options, + } + return pyav_options, stream_settings + + def create_stream( hass: HomeAssistant, stream_source: str, @@ -123,41 +192,13 @@ def create_stream( The stream_label is a string used as an additional message in logging. """ - def convert_stream_options( - hass: HomeAssistant, stream_options: Mapping[str, str | bool | float] - ) -> tuple[dict[str, str], StreamSettings]: - """Convert options from stream options into PyAV options and stream settings.""" - stream_settings = copy.copy(hass.data[DOMAIN][ATTR_SETTINGS]) - pyav_options: dict[str, str] = {} - try: - STREAM_OPTIONS_SCHEMA(stream_options) - except vol.Invalid as exc: - raise HomeAssistantError("Invalid stream options") from exc - - if extra_wait_time := stream_options.get(CONF_EXTRA_PART_WAIT_TIME): - stream_settings.hls_part_timeout += extra_wait_time - if rtsp_transport := stream_options.get(CONF_RTSP_TRANSPORT): - assert isinstance(rtsp_transport, str) - # The PyAV options currently match the stream CONF constants, but this - # will not necessarily always be the case, so they are hard coded here - pyav_options["rtsp_transport"] = rtsp_transport - if stream_options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): - pyav_options["use_wallclock_as_timestamps"] = "1" - - return pyav_options, stream_settings - if DOMAIN not in hass.config.components: raise HomeAssistantError("Stream integration is not set up.") # Convert extra stream options into PyAV options and stream settings - pyav_options, stream_settings = convert_stream_options(hass, options) - # For RTSP streams, prefer TCP - if isinstance(stream_source, str) and stream_source[:7] == "rtsp://": - pyav_options = { - "rtsp_flags": "prefer_tcp", - "stimeout": "5000000", - **pyav_options, - } + pyav_options, stream_settings = _convert_stream_options( + hass, stream_source, options + ) stream = Stream( hass, @@ -420,7 +461,7 @@ class Stream: """Handle consuming streams and restart keepalive streams.""" # Keep import here so that we can import stream integration without installing reqs # pylint: disable-next=import-outside-toplevel - from .worker import StreamState, StreamWorkerError, stream_worker + from .worker import StreamState, stream_worker stream_state = StreamState(self.hass, self.outputs, self._diagnostics) wait_timeout = 0 diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index 66455ffad1a..c81d2f6cb18 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -2,6 +2,7 @@ from __future__ import annotations +from enum import IntEnum from typing import Final DOMAIN = "stream" @@ -48,7 +49,7 @@ CONF_LL_HLS = "ll_hls" CONF_PART_DURATION = "part_duration" CONF_SEGMENT_DURATION = "segment_duration" -CONF_PREFER_TCP = "prefer_tcp" +ATTR_PREFER_TCP = "prefer_tcp" CONF_RTSP_TRANSPORT = "rtsp_transport" # The first dict entry below may be used as the default when populating options RTSP_TRANSPORTS = { @@ -59,3 +60,18 @@ RTSP_TRANSPORTS = { } CONF_USE_WALLCLOCK_AS_TIMESTAMPS = "use_wallclock_as_timestamps" CONF_EXTRA_PART_WAIT_TIME = "extra_part_wait_time" + + +class StreamClientError(IntEnum): + """Enum for stream client errors. + + These are errors that can be returned by the stream client when trying to + open a stream. The caller should not interpret the int values directly, but + should use the enum values instead. + """ + + BadRequest = 400 + Unauthorized = 401 + Forbidden = 403 + NotFound = 404 + Other = 4 diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 4184b23b9a0..b804055a740 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -166,7 +166,7 @@ class Segment: self.hls_playlist_parts.append( f"#EXT-X-PART:DURATION={part.duration:.3f},URI=" f'"./segment/{self.sequence}.{part_num}.m4s"' - f'{",INDEPENDENT=YES" if part.has_keyframe else ""}' + f"{',INDEPENDENT=YES' if part.has_keyframe else ''}" ) if self.complete: # Construct the final playlist_template. The placeholder will share a diff --git a/homeassistant/components/stream/exceptions.py b/homeassistant/components/stream/exceptions.py new file mode 100644 index 00000000000..364ef6f3a02 --- /dev/null +++ b/homeassistant/components/stream/exceptions.py @@ -0,0 +1,32 @@ +"""Stream component exceptions.""" + +from homeassistant.exceptions import HomeAssistantError + +from .const import StreamClientError + + +class StreamOpenClientError(HomeAssistantError): + """Raised when client error received when trying to open a stream. + + :param stream_client_error: The type of client error + """ + + def __init__(self, message: str, error_code: StreamClientError) -> None: + """Initialize a stream open client error.""" + super().__init__(message) + self.error_code = error_code + + +class StreamWorkerError(Exception): + """An exception thrown while processing a stream.""" + + def __init__( + self, message: str, error_code: StreamClientError = StreamClientError.Other + ) -> None: + """Initialize a stream worker error.""" + super().__init__(message) + self.error_code = error_code + + +class StreamEndedError(StreamWorkerError): + """Raised when the stream is complete, exposed for facilitating testing.""" diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 16694822b01..32845840f38 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -188,9 +188,13 @@ class HlsPlaylistView(StreamView): if track.stream_settings.ll_hls: playlist.extend( [ - f"#EXT-X-PART-INF:PART-TARGET={track.stream_settings.part_target_duration:.3f}", - f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*track.stream_settings.part_target_duration:.3f}", - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*track.stream_settings.part_target_duration:.3f},PRECISE=YES", + "#EXT-X-PART-INF:PART-TARGET=" + f"{track.stream_settings.part_target_duration:.3f}", + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=" + f"{2 * track.stream_settings.part_target_duration:.3f}", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_LL_HLS * track.stream_settings.part_target_duration:.3f}" + ",PRECISE=YES", ] ) else: @@ -203,7 +207,9 @@ class HlsPlaylistView(StreamView): # which seems to take precedence for setting target delay. Yet it also # doesn't seem to hurt, so we can stick with it for now. playlist.append( - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*track.target_duration:.3f},PRECISE=YES" + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_NON_LL_HLS * track.target_duration:.3f}" + ",PRECISE=YES" ) last_stream_id = first_segment.stream_id diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index fdf81d99e65..a2fa18c4d98 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.1.3"] + "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.2"] } diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 8c9bb1b8e9e..c196e57baa4 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -15,6 +15,7 @@ from typing import Any, Self, cast import av import av.audio import av.container +from av.container import InputContainer import av.stream from homeassistant.core import HomeAssistant @@ -29,6 +30,7 @@ from .const import ( PACKETS_TO_WAIT_FOR_AUDIO, SEGMENT_CONTAINER_FORMAT, SOURCE_TIMEOUT, + StreamClientError, ) from .core import ( STREAM_SETTINGS_NON_LL_HLS, @@ -39,6 +41,7 @@ from .core import ( StreamSettings, ) from .diagnostics import Diagnostics +from .exceptions import StreamEndedError, StreamWorkerError from .fmp4utils import read_init from .hls import HlsStreamOutput @@ -46,10 +49,6 @@ _LOGGER = logging.getLogger(__name__) NEGATIVE_INF = float("-inf") -class StreamWorkerError(Exception): - """An exception thrown while processing a stream.""" - - def redact_av_error_string(err: av.FFmpegError) -> str: """Return an error string with credentials redacted from the url.""" parts = [str(err.type), err.strerror] # type: ignore[attr-defined] @@ -58,10 +57,6 @@ def redact_av_error_string(err: av.FFmpegError) -> str: return ", ".join(parts) -class StreamEndedError(StreamWorkerError): - """Raised when the stream is complete, exposed for facilitating testing.""" - - class StreamState: """Responsible for trakcing output and playback state for a stream. @@ -465,7 +460,7 @@ class TimestampValidator: if packet.dts is None: if self._missing_dts >= MAX_MISSING_DTS: # type: ignore[unreachable] raise StreamWorkerError( - f"No dts in {MAX_MISSING_DTS+1} consecutive packets" + f"No dts in {MAX_MISSING_DTS + 1} consecutive packets" ) self._missing_dts += 1 return False @@ -512,6 +507,47 @@ def get_audio_bitstream_filter( return None +def try_open_stream( + source: str, + pyav_options: dict[str, str], +) -> InputContainer: + """Try to open a stream. + + Will raise StreamOpenClientError if an http client error is encountered. + """ + + try: + return av.open(source, options=pyav_options, timeout=SOURCE_TIMEOUT) + except av.HTTPBadRequestError as err: + raise StreamWorkerError( + f"Bad Request Error opening stream ({redact_av_error_string(err)})", + error_code=StreamClientError.BadRequest, + ) from err + + except av.HTTPUnauthorizedError as err: + raise StreamWorkerError( + f"Unauthorized error opening stream ({redact_av_error_string(err)})", + error_code=StreamClientError.Unauthorized, + ) from err + + except av.HTTPForbiddenError as err: + raise StreamWorkerError( + f"Forbidden error opening stream ({redact_av_error_string(err)})", + error_code=StreamClientError.Forbidden, + ) from err + + except av.HTTPNotFoundError as err: + raise StreamWorkerError( + f"Not Found error opening stream ({redact_av_error_string(err)})", + error_code=StreamClientError.NotFound, + ) from err + + except av.FFmpegError as err: + raise StreamWorkerError( + f"Error opening stream ({redact_av_error_string(err)})" + ) from err + + def stream_worker( source: str, pyav_options: dict[str, str], @@ -526,12 +562,7 @@ def stream_worker( # the stimeout option was renamed to timeout as of ffmpeg 5.0 pyav_options["timeout"] = pyav_options["stimeout"] del pyav_options["stimeout"] - try: - container = av.open(source, options=pyav_options, timeout=SOURCE_TIMEOUT) - except av.FFmpegError as err: - raise StreamWorkerError( - f"Error opening stream ({redact_av_error_string(err)})" - ) from err + container = try_open_stream(source, pyav_options) try: video_stream = container.streams.video[0] except (KeyError, IndexError) as ex: diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index d3c85aba1e7..25ed29d3071 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -49,20 +49,20 @@ from .legacy import ( from .models import SpeechMetadata, SpeechResult __all__ = [ - "async_get_provider", - "async_get_speech_to_text_engine", - "async_get_speech_to_text_entity", + "DOMAIN", "AudioBitRates", "AudioChannels", "AudioCodecs", "AudioFormats", "AudioSampleRates", - "DOMAIN", "Provider", - "SpeechToTextEntity", "SpeechMetadata", "SpeechResult", "SpeechResultState", + "SpeechToTextEntity", + "async_get_provider", + "async_get_speech_to_text_engine", + "async_get_speech_to_text_entity", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 3762b16e58b..4068507ed14 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -49,7 +49,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Subaru from a config entry.""" config = entry.data - websession = aiohttp_client.async_get_clientsession(hass) + websession = aiohttp_client.async_create_clientsession(hass) try: controller = SubaruAPI( websession, diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 760e4ccd689..8c5dd605582 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.11"] + "requirements": ["subarulink==0.7.13"] } diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index 06f503b85c2..30f8c030c26 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -2,32 +2,65 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry +import logging + from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import SuezWaterCoordinator +from .const import CONF_COUNTER_ID +from .coordinator import SuezWaterConfigEntry, SuezWaterCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: SuezWaterConfigEntry) -> bool: """Set up Suez Water from a config entry.""" coordinator = SuezWaterCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SuezWaterConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unload_ok + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: SuezWaterConfigEntry +) -> bool: + """Migrate old suez water config entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 2: + return False + + if config_entry.version == 1: + # Migrate to version 2 + counter_id = config_entry.data.get(CONF_COUNTER_ID) + unique_id = str(counter_id) + + hass.config_entries.async_update_entry( + config_entry, + unique_id=unique_id, + version=2, + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py index ac09cf4a1d3..fb8bc2988d6 100644 --- a/homeassistant/components/suez_water/config_flow.py +++ b/homeassistant/components/suez_water/config_flow.py @@ -37,31 +37,33 @@ async def validate_input(data: dict[str, Any]) -> None: data[CONF_PASSWORD], counter_id, ) - if not await client.check_credentials(): - raise InvalidAuth - except PySuezError as ex: - raise CannotConnect from ex - - if counter_id is None: try: - data[CONF_COUNTER_ID] = await client.find_counter() + if not await client.check_credentials(): + raise InvalidAuth except PySuezError as ex: - raise CounterNotFound from ex + raise CannotConnect from ex + + if counter_id is None: + try: + data[CONF_COUNTER_ID] = await client.find_counter() + except PySuezError as ex: + raise CounterNotFound from ex + finally: + await client.close_session() class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Suez Water.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle the initial setup step.""" errors: dict[str, str] = {} + if user_input is not None: - await self.async_set_unique_id(user_input[CONF_USERNAME]) - self._abort_if_unique_id_configured() try: await validate_input(user_input) except CannotConnect: @@ -74,12 +76,16 @@ class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry( - title=user_input[CONF_USERNAME], data=user_input - ) + counter_id = str(user_input[CONF_COUNTER_ID]) + await self.async_set_unique_id(counter_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=counter_id, data=user_input) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={"tout_sur_mon_eau": "Tout sur mon Eau"}, ) @@ -92,4 +98,4 @@ class InvalidAuth(HomeAssistantError): class CounterNotFound(HomeAssistantError): - """Error to indicate we cannot automatically found the counter id.""" + """Error to indicate we failed to automatically find the counter id.""" diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index 224929c606e..38f94b8937e 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -1,9 +1,7 @@ """Suez water update coordinator.""" -from collections.abc import Mapping from dataclasses import dataclass from datetime import date -from typing import Any from pysuez import PySuezError, SuezClient @@ -20,11 +18,11 @@ from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN class SuezWaterAggregatedAttributes: """Class containing aggregated sensor extra attributes.""" - this_month_consumption: dict[date, float] - previous_month_consumption: dict[date, float] + this_month_consumption: dict[str, float] + previous_month_consumption: dict[str, float] last_year_overall: dict[str, float] this_year_overall: dict[str, float] - history: dict[date, float] + history: dict[str, float] highest_monthly_consumption: float @@ -33,17 +31,20 @@ class SuezWaterData: """Class used to hold all fetch data from suez api.""" aggregated_value: float - aggregated_attr: Mapping[str, Any] + aggregated_attr: SuezWaterAggregatedAttributes price: float +type SuezWaterConfigEntry = ConfigEntry[SuezWaterCoordinator] + + class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]): """Suez water coordinator.""" _suez_client: SuezClient - config_entry: ConfigEntry + config_entry: SuezWaterConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: SuezWaterConfigEntry) -> None: """Initialize suez water coordinator.""" super().__init__( hass, @@ -65,24 +66,25 @@ class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]): async def _async_update_data(self) -> SuezWaterData: """Fetch data from API endpoint.""" + + def map_dict(param: dict[date, float]) -> dict[str, float]: + return {str(key): value for key, value in param.items()} + try: aggregated = await self._suez_client.fetch_aggregated_data() data = SuezWaterData( aggregated_value=aggregated.value, - aggregated_attr={ - "this_month_consumption": aggregated.current_month, - "previous_month_consumption": aggregated.previous_month, - "highest_monthly_consumption": aggregated.highest_monthly_consumption, - "last_year_overall": aggregated.previous_year, - "this_year_overall": aggregated.current_year, - "history": aggregated.history, - }, + aggregated_attr=SuezWaterAggregatedAttributes( + this_month_consumption=map_dict(aggregated.current_month), + previous_month_consumption=map_dict(aggregated.previous_month), + highest_monthly_consumption=aggregated.highest_monthly_consumption, + last_year_overall=aggregated.previous_year, + this_year_overall=aggregated.current_year, + history=map_dict(aggregated.history), + ), price=(await self._suez_client.get_price()).price, ) except PySuezError as err: - _LOGGER.exception(err) - raise UpdateFailed( - f"Suez coordinator error communicating with API: {err}" - ) from err + raise UpdateFailed(f"Suez data update failed: {err}") from err _LOGGER.debug("Successfully fetched suez data") return data diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 240be0f37bd..5d317ea5ba3 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/suez_water", "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], - "requirements": ["pysuezV2==1.3.2"] + "quality_scale": "bronze", + "requirements": ["pysuezV2==2.0.3"] } diff --git a/homeassistant/components/suez_water/quality_scale.yaml b/homeassistant/components/suez_water/quality_scale.yaml new file mode 100644 index 00000000000..dae9002b7dd --- /dev/null +++ b/homeassistant/components/suez_water/quality_scale.yaml @@ -0,0 +1,98 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: no subscription to api + dependency-transparency: done + action-setup: + status: exempt + comment: no service action + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: no service action + brands: done + + # Silver + config-entry-unloading: + status: todo + comment: cleanly close session + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: no service action + reauthentication-flow: todo + parallel-updates: + status: exempt + comment: no service action and coordinator updates + test-coverage: done + integration-owner: done + docs-installation-parameters: + status: todo + comment: missing user/password + docs-configuration-parameters: + status: exempt + comment: no configuration option + + # Gold + entity-translations: done + entity-device-class: done + devices: + status: todo + comment: see https://github.com/home-assistant/core/pull/134027#discussion_r1898732463 + entity-category: + status: done + comment: default class is fine + entity-disabled-by-default: + status: todo + comment: price can always be disabled and yesterday usage after https://github.com/home-assistant/core/pull/131166 + discovery: + status: exempt + comment: api only, nothing on local network to discover services + stale-devices: + status: todo + comment: see devices + diagnostics: todo + exception-translations: todo + icon-translations: + status: exempt + comment: no custom icons + reconfiguration-flow: + status: todo + comment: reconfigure every configurations input + dynamic-devices: + status: todo + comment: see devices + discovery-update-info: + status: exempt + comment: devices are not network dependent and will not be updated during their lives + repair-issues: + status: exempt + comment: No repair issues to be raised + docs-use-cases: done + docs-supported-devices: todo + docs-supported-functions: done + docs-data-update: + status: todo + comment: make it clearer + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 2ba699a9af1..1152ebd551b 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -2,8 +2,8 @@ from __future__ import annotations -from collections.abc import Callable, Mapping -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import asdict, dataclass from typing import Any from pysuez.const import ATTRIBUTION @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -21,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COUNTER_ID, DOMAIN -from .coordinator import SuezWaterCoordinator, SuezWaterData +from .coordinator import SuezWaterConfigEntry, SuezWaterCoordinator, SuezWaterData @dataclass(frozen=True, kw_only=True) @@ -29,7 +28,7 @@ class SuezWaterSensorEntityDescription(SensorEntityDescription): """Describes Suez water sensor entity.""" value_fn: Callable[[SuezWaterData], float | str | None] - attr_fn: Callable[[SuezWaterData], Mapping[str, Any] | None] = lambda _: None + attr_fn: Callable[[SuezWaterData], dict[str, Any] | None] = lambda _: None SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = ( @@ -39,7 +38,7 @@ SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.WATER, value_fn=lambda suez_data: suez_data.aggregated_value, - attr_fn=lambda suez_data: suez_data.aggregated_attr, + attr_fn=lambda suez_data: asdict(suez_data.aggregated_attr), ), SuezWaterSensorEntityDescription( key="water_price", @@ -53,11 +52,11 @@ SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SuezWaterConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Suez Water sensor from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data counter_id = entry.data[CONF_COUNTER_ID] async_add_entities( @@ -94,6 +93,6 @@ class SuezWaterSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity): return self.entity_description.value_fn(self.coordinator.data) @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return extra state of the sensor.""" return self.entity_description.attr_fn(self.coordinator.data) diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index 6be2affab97..be2d4849e76 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -5,15 +5,21 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "counter_id": "Counter id" - } + "counter_id": "Meter id" + }, + "data_description": { + "username": "Enter your login associated with your {tout_sur_mon_eau} account", + "password": "Enter your password associated with your {tout_sur_mon_eau} account", + "counter_id": "Enter your meter id (ex: 12345678). Should be found automatically during setup, if not see integration documentation for more information" + }, + "description": "Connect your suez water {tout_sur_mon_eau} account to retrieve your water consumption" } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "counter_not_found": "Could not find counter id automatically" + "counter_not_found": "Could not find meter id automatically" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 8f6f3098ee8..f42f5450462 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -2,10 +2,13 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType # The sensor platform is pre-imported here to ensure @@ -23,6 +26,8 @@ from .entity import Sun, SunConfigEntry CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track the state of the sun.""" @@ -42,7 +47,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: SunConfigEntry) -> bool: """Set up from a config entry.""" - entry.runtime_data = sun = Sun(hass) + sun = Sun(hass) + component = EntityComponent[Sun](_LOGGER, DOMAIN, hass) + await component.async_add_entities([sun]) + entry.runtime_data = sun entry.async_on_unload(sun.remove_listeners) await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) return True @@ -53,6 +61,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: SunConfigEntry) -> bool if unload_ok := await hass.config_entries.async_unload_platforms( entry, [Platform.SENSOR] ): - sun = entry.runtime_data - hass.states.async_remove(sun.entity_id) + await entry.runtime_data.async_remove() return unload_ok diff --git a/homeassistant/components/sun/entity.py b/homeassistant/components/sun/entity.py index 10d328afde7..925845c8b4d 100644 --- a/homeassistant/components/sun/entity.py +++ b/homeassistant/components/sun/entity.py @@ -100,9 +100,6 @@ class Sun(Entity): _attr_name = "Sun" entity_id = ENTITY_ID - # This entity is legacy and does not have a platform. - # We can't fix this easily without breaking changes. - _no_platform_reported = True location: Location elevation: Elevation @@ -122,18 +119,16 @@ class Sun(Entity): self.hass = hass self.phase: str | None = None - # This is normally done by async_internal_added_to_hass which is not called - # for sun because sun has no platform - self._state_info = { - "unrecorded_attributes": self._Entity__combined_unrecorded_attributes # type: ignore[attr-defined] - } - self._config_listener: CALLBACK_TYPE | None = None self._update_events_listener: CALLBACK_TYPE | None = None self._update_sun_position_listener: CALLBACK_TYPE | None = None self._config_listener = self.hass.bus.async_listen( EVENT_CORE_CONFIG_UPDATE, self.update_location ) + + async def async_added_to_hass(self) -> None: + """Update after entity has been added.""" + await super().async_added_to_hass() self.update_location(initial=True) @callback diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index b422e40ef2d..3acd768cb30 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -135,8 +135,8 @@ class DeviceConnectivity(SurePetcareBinarySensor): self._attr_is_on = bool(state) if state: self._attr_extra_state_attributes = { - "device_rssi": f'{state["signal"]["device_rssi"]:.2f}', - "hub_rssi": f'{state["signal"]["hub_rssi"]:.2f}', + "device_rssi": f"{state['signal']['device_rssi']:.2f}", + "hub_rssi": f"{state['signal']['hub_rssi']:.2f}", } else: self._attr_extra_state_attributes = {} diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index bceac6007a2..628f6e95c2a 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -19,12 +19,22 @@ from homeassistant.helpers import ( from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import CONF_DESTINATION, CONF_START, CONF_VIA, DOMAIN, PLACEHOLDERS +from .const import ( + CONF_DESTINATION, + CONF_START, + CONF_TIME_FIXED, + CONF_TIME_OFFSET, + CONF_TIME_STATION, + CONF_VIA, + DEFAULT_TIME_STATION, + DOMAIN, + PLACEHOLDERS, +) from .coordinator import ( SwissPublicTransportConfigEntry, SwissPublicTransportDataUpdateCoordinator, ) -from .helper import unique_id_from_config +from .helper import offset_opendata, unique_id_from_config from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -50,8 +60,19 @@ async def async_setup_entry( start = config[CONF_START] destination = config[CONF_DESTINATION] + time_offset: dict[str, int] | None = config.get(CONF_TIME_OFFSET) + session = async_get_clientsession(hass) - opendata = OpendataTransport(start, destination, session, via=config.get(CONF_VIA)) + opendata = OpendataTransport( + start, + destination, + session, + via=config.get(CONF_VIA), + time=config.get(CONF_TIME_FIXED), + isArrivalTime=config.get(CONF_TIME_STATION, DEFAULT_TIME_STATION) == "arrival", + ) + if time_offset: + offset_opendata(opendata, time_offset) try: await opendata.async_get_data() @@ -75,7 +96,7 @@ async def async_setup_entry( }, ) from e - coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata) + coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata, time_offset) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -96,7 +117,7 @@ async def async_migrate_entry( """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) - if config_entry.version > 2: + if config_entry.version > 3: # This means the user has downgraded from a future version return False @@ -131,9 +152,9 @@ async def async_migrate_entry( config_entry, unique_id=new_unique_id, minor_version=2 ) - if config_entry.version < 2: - # Via stations now available, which are not backwards compatible if used, changes unique id - hass.config_entries.async_update_entry(config_entry, version=2, minor_version=1) + if config_entry.version < 3: + # Via stations and time/offset settings now available, which are not backwards compatible if used, changes unique id + hass.config_entries.async_update_entry(config_entry, version=3, minor_version=1) _LOGGER.debug( "Migration to version %s.%s successful", diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 74c6223f1d9..58d674f0c26 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -14,15 +14,35 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( + DurationSelector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, TextSelector, TextSelectorConfig, TextSelectorType, + TimeSelector, ) -from .const import CONF_DESTINATION, CONF_START, CONF_VIA, DOMAIN, MAX_VIA, PLACEHOLDERS -from .helper import unique_id_from_config +from .const import ( + CONF_DESTINATION, + CONF_START, + CONF_TIME_FIXED, + CONF_TIME_MODE, + CONF_TIME_OFFSET, + CONF_TIME_STATION, + CONF_VIA, + DEFAULT_TIME_MODE, + DEFAULT_TIME_STATION, + DOMAIN, + IS_ARRIVAL_OPTIONS, + MAX_VIA, + PLACEHOLDERS, + TIME_MODE_OPTIONS, +) +from .helper import offset_opendata, unique_id_from_config -DATA_SCHEMA = vol.Schema( +USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_START): cv.string, vol.Optional(CONF_VIA): TextSelector( @@ -32,8 +52,25 @@ DATA_SCHEMA = vol.Schema( ), ), vol.Required(CONF_DESTINATION): cv.string, + vol.Optional(CONF_TIME_MODE, default=DEFAULT_TIME_MODE): SelectSelector( + SelectSelectorConfig( + options=TIME_MODE_OPTIONS, + mode=SelectSelectorMode.DROPDOWN, + translation_key="time_mode", + ), + ), + vol.Optional(CONF_TIME_STATION, default=DEFAULT_TIME_STATION): SelectSelector( + SelectSelectorConfig( + options=IS_ARRIVAL_OPTIONS, + mode=SelectSelectorMode.DROPDOWN, + translation_key="time_station", + ), + ), } ) +ADVANCED_TIME_DATA_SCHEMA = {vol.Optional(CONF_TIME_FIXED): TimeSelector()} +ADVANCED_TIME_OFFSET_DATA_SCHEMA = {vol.Optional(CONF_TIME_OFFSET): DurationSelector()} + _LOGGER = logging.getLogger(__name__) @@ -41,39 +78,33 @@ _LOGGER = logging.getLogger(__name__) class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN): """Swiss public transport config flow.""" - VERSION = 2 + VERSION = 3 MINOR_VERSION = 1 + user_input: dict[str, Any] + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Async user step to set up the connection.""" errors: dict[str, str] = {} if user_input is not None: - unique_id = unique_id_from_config(user_input) - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - if CONF_VIA in user_input and len(user_input[CONF_VIA]) > MAX_VIA: errors["base"] = "too_many_via_stations" else: - session = async_get_clientsession(self.hass) - opendata = OpendataTransport( - user_input[CONF_START], - user_input[CONF_DESTINATION], - session, - via=user_input.get(CONF_VIA), - ) - try: - await opendata.async_get_data() - except OpendataTransportConnectionError: - errors["base"] = "cannot_connect" - except OpendataTransportError: - errors["base"] = "bad_config" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error") - errors["base"] = "unknown" + err = await self.fetch_connections(user_input) + if err: + errors["base"] = err else: + self.user_input = user_input + if user_input[CONF_TIME_MODE] == "fixed": + return await self.async_step_time_fixed() + if user_input[CONF_TIME_MODE] == "offset": + return await self.async_step_time_offset() + + unique_id = unique_id_from_config(user_input) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() return self.async_create_entry( title=unique_id, data=user_input, @@ -81,7 +112,85 @@ class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + data_schema=USER_DATA_SCHEMA, + suggested_values=user_input, + ), errors=errors, description_placeholders=PLACEHOLDERS, ) + + async def async_step_time_fixed( + self, time_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Async time step to set up the connection.""" + return await self._async_step_time_mode( + CONF_TIME_FIXED, vol.Schema(ADVANCED_TIME_DATA_SCHEMA), time_input + ) + + async def async_step_time_offset( + self, time_offset_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Async time offset step to set up the connection.""" + return await self._async_step_time_mode( + CONF_TIME_OFFSET, + vol.Schema(ADVANCED_TIME_OFFSET_DATA_SCHEMA), + time_offset_input, + ) + + async def _async_step_time_mode( + self, + step_id: str, + time_mode_schema: vol.Schema, + time_mode_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Async time mode step to set up the connection.""" + errors: dict[str, str] = {} + if time_mode_input is not None: + unique_id = unique_id_from_config({**self.user_input, **time_mode_input}) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + err = await self.fetch_connections( + {**self.user_input, **time_mode_input}, + time_mode_input.get(CONF_TIME_OFFSET), + ) + if err: + errors["base"] = err + else: + return self.async_create_entry( + title=unique_id, + data={**self.user_input, **time_mode_input}, + ) + + return self.async_show_form( + step_id=step_id, + data_schema=time_mode_schema, + errors=errors, + description_placeholders=PLACEHOLDERS, + ) + + async def fetch_connections( + self, input: dict[str, Any], time_offset: dict[str, int] | None = None + ) -> str | None: + """Fetch the connections and advancedly return an error.""" + try: + session = async_get_clientsession(self.hass) + opendata = OpendataTransport( + input[CONF_START], + input[CONF_DESTINATION], + session, + via=input.get(CONF_VIA), + time=input.get(CONF_TIME_FIXED), + ) + if time_offset: + offset_opendata(opendata, time_offset) + await opendata.async_get_data() + except OpendataTransportConnectionError: + return "cannot_connect" + except OpendataTransportError: + return "bad_config" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + return "unknown" + return None diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py index c02f36f2f25..10bfc0d0355 100644 --- a/homeassistant/components/swiss_public_transport/const.py +++ b/homeassistant/components/swiss_public_transport/const.py @@ -7,13 +7,21 @@ DOMAIN = "swiss_public_transport" CONF_DESTINATION: Final = "to" CONF_START: Final = "from" CONF_VIA: Final = "via" +CONF_TIME_STATION: Final = "time_station" +CONF_TIME_MODE: Final = "time_mode" +CONF_TIME_FIXED: Final = "time_fixed" +CONF_TIME_OFFSET: Final = "time_offset" DEFAULT_NAME = "Next Destination" DEFAULT_UPDATE_TIME = 90 +DEFAULT_TIME_STATION = "departure" +DEFAULT_TIME_MODE = "now" MAX_VIA = 5 CONNECTIONS_COUNT = 3 CONNECTIONS_MAX = 15 +IS_ARRIVAL_OPTIONS = ["departure", "arrival"] +TIME_MODE_OPTIONS = ["now", "fixed", "offset"] PLACEHOLDERS = { diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index e6413e6f772..c4cf2390dd0 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -19,6 +19,7 @@ import homeassistant.util.dt as dt_util from homeassistant.util.json import JsonValueType from .const import CONNECTIONS_COUNT, DEFAULT_UPDATE_TIME, DOMAIN +from .helper import offset_opendata _LOGGER = logging.getLogger(__name__) @@ -57,7 +58,12 @@ class SwissPublicTransportDataUpdateCoordinator( config_entry: SwissPublicTransportConfigEntry - def __init__(self, hass: HomeAssistant, opendata: OpendataTransport) -> None: + def __init__( + self, + hass: HomeAssistant, + opendata: OpendataTransport, + time_offset: dict[str, int] | None, + ) -> None: """Initialize the SwissPublicTransport data coordinator.""" super().__init__( hass, @@ -66,6 +72,7 @@ class SwissPublicTransportDataUpdateCoordinator( update_interval=timedelta(seconds=DEFAULT_UPDATE_TIME), ) self._opendata = opendata + self._time_offset = time_offset def remaining_time(self, departure) -> timedelta | None: """Calculate the remaining time for the departure.""" @@ -81,6 +88,9 @@ class SwissPublicTransportDataUpdateCoordinator( async def fetch_connections(self, limit: int) -> list[DataConnection]: """Fetch connections using the opendata api.""" self._opendata.limit = limit + if self._time_offset: + offset_opendata(self._opendata, self._time_offset) + try: await self._opendata.async_get_data() except OpendataTransportConnectionError as e: @@ -103,7 +113,7 @@ class SwissPublicTransportDataUpdateCoordinator( destination=self._opendata.to_name, remaining_time=str(self.remaining_time(connections[i]["departure"])), delay=connections[i]["delay"], - line=connections[i]["line"], + line=connections[i].get("line"), ) for i in range(limit) if len(connections) > i and connections[i] is not None @@ -124,7 +134,7 @@ class SwissPublicTransportDataUpdateCoordinator( "train_number": connection["train_number"], "transfers": connection["transfers"], "delay": connection["delay"], - "line": connection["line"], + "line": connection.get("line"), } for connection in await self.fetch_connections(limit) ] diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py index af03f7ad193..704479b77d6 100644 --- a/homeassistant/components/swiss_public_transport/helper.py +++ b/homeassistant/components/swiss_public_transport/helper.py @@ -1,15 +1,59 @@ """Helper functions for swiss_public_transport.""" +from datetime import timedelta from types import MappingProxyType from typing import Any -from .const import CONF_DESTINATION, CONF_START, CONF_VIA +from opendata_transport import OpendataTransport + +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_DESTINATION, + CONF_START, + CONF_TIME_FIXED, + CONF_TIME_OFFSET, + CONF_TIME_STATION, + CONF_VIA, + DEFAULT_TIME_STATION, +) + + +def offset_opendata(opendata: OpendataTransport, offset: dict[str, int]) -> None: + """In place offset the opendata connector.""" + + duration = timedelta(**offset) + if duration: + now_offset = dt_util.as_local(dt_util.now() + duration) + opendata.date = now_offset.date() + opendata.time = now_offset.time() + + +def dict_duration_to_str_duration( + d: dict[str, int], +) -> str: + """Build a string from a dict duration.""" + return f"{d['hours']:02d}:{d['minutes']:02d}:{d['seconds']:02d}" def unique_id_from_config(config: MappingProxyType[str, Any] | dict[str, Any]) -> str: """Build a unique id from a config entry.""" - return f"{config[CONF_START]} {config[CONF_DESTINATION]}" + ( - " via " + ", ".join(config[CONF_VIA]) - if CONF_VIA in config and len(config[CONF_VIA]) > 0 - else "" + return ( + f"{config[CONF_START]} {config[CONF_DESTINATION]}" + + ( + " via " + ", ".join(config[CONF_VIA]) + if CONF_VIA in config and len(config[CONF_VIA]) > 0 + else "" + ) + + ( + " arrival" + if config.get(CONF_TIME_STATION, DEFAULT_TIME_STATION) == "arrival" + else "" + ) + + (" at " + config[CONF_TIME_FIXED] if CONF_TIME_FIXED in config else "") + + ( + " in " + dict_duration_to_str_duration(config[CONF_TIME_OFFSET]) + if CONF_TIME_OFFSET in config + else "" + ) ) diff --git a/homeassistant/components/swiss_public_transport/quality_scale.yaml b/homeassistant/components/swiss_public_transport/quality_scale.yaml new file mode 100644 index 00000000000..75dc642d77f --- /dev/null +++ b/homeassistant/components/swiss_public_transport/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: done + comment: > + Polling interval is set to support one connection. + There is a rate limit at 10000 calls per day. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: todo + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No events implemented + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: + status: done + comment: Offloaded to coordinator + entity-unavailable: + status: done + comment: Offloaded to coordinator + action-exceptions: done + reauthentication-flow: + status: exempt + comment: No authentication needed + parallel-updates: done + test-coverage: todo + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: no options flow + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: + status: done + comment: No disabled entities implemented + discovery: + status: exempt + comment: Nothing to discover + stale-devices: + status: exempt + comment: Stale not possible + diagnostics: todo + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + dynamic-devices: + status: exempt + comment: No dynamic devices + discovery-update-info: + status: exempt + comment: Nothing to discover + repair-issues: + status: exempt + comment: Nothing to repair + docs-use-cases: todo + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 452ec31972f..a0131938a37 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -27,6 +27,8 @@ from .coordinator import ( SwissPublicTransportDataUpdateCoordinator, ) +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=90) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index b3bfd9aea8f..ef8cc5595e3 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -17,10 +17,39 @@ "data": { "from": "Start station", "to": "End station", - "via": "List of up to 5 via stations" + "via": "Via stations", + "time_station": "Select the relevant station", + "time_mode": "Select a time mode" + }, + "data_description": { + "from": "The station where the connection starts", + "to": "The station where the connection ends", + "via": "List of up to 5 stations the route must go through", + "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", + "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", "title": "Swiss Public Transport" + }, + "time_fixed": { + "data": { + "time_fixed": "Time of day" + }, + "data_description": { + "time_fixed": "The time of day for the connection" + }, + "description": "Please select the relevant time for the connection (e.g. 7:12:00 AM every morning).", + "title": "Swiss Public Transport" + }, + "time_offset": { + "data": { + "time_offset": "Time offset" + }, + "data_description": { + "time_offset": "The time offset added to the earliest possible connection" + }, + "description": "Please select the relevant offset to add to the earliest possible connection (e.g. add +00:05:00 offset, taking into account the time to walk to the station)", + "title": "Swiss Public Transport" } } }, @@ -84,5 +113,20 @@ "config_entry_not_found": { "message": "Swiss public transport integration instance \"{target}\" not found." } + }, + "selector": { + "time_station": { + "options": { + "departure": "Show departure time from start station", + "arrival": "Show arrival time at end station" + } + }, + "time_mode": { + "options": { + "now": "Now", + "fixed": "At a fixed time of day", + "offset": "At an offset from now" + } + } } } diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 9838d9501f7..3c173cf5b2a 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -4,10 +4,9 @@ from __future__ import annotations from datetime import timedelta from enum import StrEnum -from functools import partial import logging -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -19,12 +18,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -52,16 +45,8 @@ class SwitchDeviceClass(StrEnum): DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(SwitchDeviceClass)) - -# DEVICE_CLASS* below are deprecated as of 2021.12 -# use the SwitchDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in SwitchDeviceClass] -_DEPRECATED_DEVICE_CLASS_OUTLET = DeprecatedConstantEnum( - SwitchDeviceClass.OUTLET, "2025.1" -) -_DEPRECATED_DEVICE_CLASS_SWITCH = DeprecatedConstantEnum( - SwitchDeviceClass.SWITCH, "2025.1" -) + # mypy: disallow-any-generics @@ -124,11 +109,3 @@ class SwitchEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) if hasattr(self, "entity_description"): return self.entity_description.device_class return None - - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index 91d3a4d119a..858379e71df 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -46,7 +46,6 @@ class FanSwitch(BaseToggleEntity, FanEntity): """Represents a Switch as a Fan.""" _attr_supported_features = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - _enable_turn_on_off_backwards_compatibility = False @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 758698a7d67..b1a71665222 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -114,7 +114,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if match := re.match( rf"(?:{old_unique_id})-(?P\d+)", entity_entry.unique_id ): - entity_new_unique_id = f'{new_unique_id}-{match.group("id")}' + entity_new_unique_id = f"{new_unique_id}-{match.group('id')}" _LOGGER.debug( "Migrating entity %s from %s to new id %s", entity_entry.entity_id, diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index 7ec0ad4d88b..d946ed1761b 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -90,7 +90,6 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate _attr_fan_modes = SUPPORTED_FAN_MODES _attr_target_temperature_step = 1 - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index c2b4b2ad736..499a5073872 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -24,6 +24,7 @@ from .const import ( CONF_RETRY_COUNT, CONNECTABLE_SUPPORTED_MODEL_TYPES, DEFAULT_RETRY_COUNT, + ENCRYPTED_MODELS, HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL, SupportedModels, ) @@ -61,6 +62,9 @@ PLATFORMS_BY_TYPE = { Platform.SENSOR, ], SupportedModels.HUB2.value: [Platform.SENSOR], + SupportedModels.RELAY_SWITCH_1PM.value: [Platform.SWITCH, Platform.SENSOR], + SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH], + SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -73,6 +77,8 @@ CLASS_BY_DEVICE = { SupportedModels.LOCK.value: switchbot.SwitchbotLock, SupportedModels.LOCK_PRO.value: switchbot.SwitchbotLock, SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt, + SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch, + SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch, } @@ -116,9 +122,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> ) cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice) - if cls is switchbot.SwitchbotLock: + if switchbot_model in ENCRYPTED_MODELS: try: - device = switchbot.SwitchbotLock( + device = cls( device=ble_device, key_id=entry.data.get(CONF_KEY_ID), encryption_key=entry.data.get(CONF_ENCRYPTION_KEY), diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index a545ffd01ce..144872ff315 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -64,6 +64,11 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { translation_key="door_auto_lock_paused", entity_category=EntityCategory.DIAGNOSTIC, ), + "leak": BinarySensorEntityDescription( + key="leak", + name=None, + device_class=BinarySensorDeviceClass.MOISTURE, + ), } diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index a0e45169770..31c0c42168d 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -10,7 +10,7 @@ from switchbot import ( SwitchBotAdvertisement, SwitchbotApiError, SwitchbotAuthenticationError, - SwitchbotLock, + SwitchbotModel, parse_advertisement_data, ) import voluptuous as vol @@ -44,8 +44,9 @@ from .const import ( DEFAULT_LOCK_NIGHTLATCH, DEFAULT_RETRY_COUNT, DOMAIN, + ENCRYPTED_MODELS, + ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS, NON_CONNECTABLE_SUPPORTED_MODEL_TYPES, - SUPPORTED_LOCK_MODELS, SUPPORTED_MODEL_TYPES, SupportedModels, ) @@ -66,7 +67,7 @@ def short_address(address: str) -> str: def name_from_discovery(discovery: SwitchBotAdvertisement) -> str: """Get the name from a discovery.""" - return f'{discovery.data["modelFriendlyName"]} {short_address(discovery.address)}' + return f"{discovery.data['modelFriendlyName']} {short_address(discovery.address)}" class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): @@ -112,8 +113,8 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): "name": data["modelFriendlyName"], "address": short_address(discovery_info.address), } - if model_name in SUPPORTED_LOCK_MODELS: - return await self.async_step_lock_choose_method() + if model_name in ENCRYPTED_MODELS: + return await self.async_step_encrypted_choose_method() if self._discovered_adv.data["isEncrypted"]: return await self.async_step_password() return await self.async_step_confirm() @@ -171,7 +172,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_lock_auth( + async def async_step_encrypted_auth( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the SwitchBot API auth step.""" @@ -179,8 +180,10 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): assert self._discovered_adv is not None description_placeholders = {} if user_input is not None: + model: SwitchbotModel = self._discovered_adv.data["modelName"] + cls = ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS[model] try: - key_details = await SwitchbotLock.async_retrieve_encryption_key( + key_details = await cls.async_retrieve_encryption_key( async_get_clientsession(self.hass), self._discovered_adv.address, user_input[CONF_USERNAME], @@ -198,11 +201,11 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): errors = {"base": "auth_failed"} description_placeholders = {"error_detail": str(ex)} else: - return await self.async_step_lock_key(key_details) + return await self.async_step_encrypted_key(key_details) user_input = user_input or {} return self.async_show_form( - step_id="lock_auth", + step_id="encrypted_auth", errors=errors, data_schema=vol.Schema( { @@ -218,32 +221,34 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_lock_choose_method( + async def async_step_encrypted_choose_method( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the SwitchBot API chose method step.""" assert self._discovered_adv is not None return self.async_show_menu( - step_id="lock_choose_method", - menu_options=["lock_auth", "lock_key"], + step_id="encrypted_choose_method", + menu_options=["encrypted_auth", "encrypted_key"], description_placeholders={ "name": name_from_discovery(self._discovered_adv), }, ) - async def async_step_lock_key( + async def async_step_encrypted_key( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the encryption key step.""" errors = {} assert self._discovered_adv is not None if user_input is not None: - if not await SwitchbotLock.verify_encryption_key( + model: SwitchbotModel = self._discovered_adv.data["modelName"] + cls = ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS[model] + if not await cls.verify_encryption_key( self._discovered_adv.device, user_input[CONF_KEY_ID], user_input[CONF_ENCRYPTION_KEY], - model=self._discovered_adv.data["modelName"], + model=model, ): errors = { "base": "encryption_key_invalid", @@ -252,7 +257,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): return await self._async_create_entry_from_discovery(user_input) return self.async_show_form( - step_id="lock_key", + step_id="encrypted_key", errors=errors, data_schema=vol.Schema( { @@ -309,8 +314,8 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: device_adv = self._discovered_advs[user_input[CONF_ADDRESS]] await self._async_set_device(device_adv) - if device_adv.data.get("modelName") in SUPPORTED_LOCK_MODELS: - return await self.async_step_lock_choose_method() + if device_adv.data.get("modelName") in ENCRYPTED_MODELS: + return await self.async_step_encrypted_choose_method() if device_adv.data["isEncrypted"]: return await self.async_step_password() return await self._async_create_entry_from_discovery(user_input) @@ -321,8 +326,8 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): # or simply confirm it device_adv = list(self._discovered_advs.values())[0] await self._async_set_device(device_adv) - if device_adv.data.get("modelName") in SUPPORTED_LOCK_MODELS: - return await self.async_step_lock_choose_method() + if device_adv.data.get("modelName") in ENCRYPTED_MODELS: + return await self.async_step_encrypted_choose_method() if device_adv.data["isEncrypted"]: return await self.async_step_password() return await self.async_step_confirm() diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index b8cf4e8e1ab..854ab32b657 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -2,6 +2,7 @@ from enum import StrEnum +import switchbot from switchbot import SwitchbotModel DOMAIN = "switchbot" @@ -30,6 +31,9 @@ class SupportedModels(StrEnum): LOCK_PRO = "lock_pro" BLIND_TILT = "blind_tilt" HUB2 = "hub2" + RELAY_SWITCH_1PM = "relay_switch_1pm" + RELAY_SWITCH_1 = "relay_switch_1" + LEAK = "leak" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -44,6 +48,8 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.LOCK_PRO: SupportedModels.LOCK_PRO, SwitchbotModel.BLIND_TILT: SupportedModels.BLIND_TILT, SwitchbotModel.HUB2: SupportedModels.HUB2, + SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM, + SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -53,13 +59,28 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.METER_PRO_C: SupportedModels.HYGROMETER_CO2, SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT, SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, + SwitchbotModel.LEAK: SupportedModels.LEAK, } SUPPORTED_MODEL_TYPES = ( CONNECTABLE_SUPPORTED_MODEL_TYPES | NON_CONNECTABLE_SUPPORTED_MODEL_TYPES ) -SUPPORTED_LOCK_MODELS = {SwitchbotModel.LOCK, SwitchbotModel.LOCK_PRO} +ENCRYPTED_MODELS = { + SwitchbotModel.RELAY_SWITCH_1, + SwitchbotModel.RELAY_SWITCH_1PM, + SwitchbotModel.LOCK, + SwitchbotModel.LOCK_PRO, +} + +ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ + SwitchbotModel, switchbot.SwitchbotEncryptedDevice +] = { + SwitchbotModel.LOCK: switchbot.SwitchbotLock, + SwitchbotModel.LOCK_PRO: switchbot.SwitchbotLock, + SwitchbotModel.RELAY_SWITCH_1PM: switchbot.SwitchbotRelaySwitch, + SwitchbotModel.RELAY_SWITCH_1: switchbot.SwitchbotRelaySwitch, +} HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { str(v): k for k, v in SUPPORTED_MODEL_TYPES.items() diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py index 836ba1bd4f3..927ad5120c7 100644 --- a/homeassistant/components/switchbot/light.py +++ b/homeassistant/components/switchbot/light.py @@ -8,17 +8,13 @@ from switchbot import ColorMode as SwitchBotColorMode, SwitchbotBaseLight from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ColorMode, LightEntity, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, -) from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity @@ -50,8 +46,8 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): """Initialize the Switchbot light.""" super().__init__(coordinator) device = self._device - self._attr_min_mireds = color_temperature_kelvin_to_mired(device.max_temp) - self._attr_max_mireds = color_temperature_kelvin_to_mired(device.min_temp) + self._attr_max_color_temp_kelvin = device.max_temp + self._attr_min_color_temp_kelvin = device.min_temp self._attr_supported_color_modes = { SWITCHBOT_COLOR_MODE_TO_HASS[mode] for mode in device.color_modes } @@ -64,7 +60,7 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): self._attr_is_on = self._device.on self._attr_brightness = max(0, min(255, round(device.brightness * 2.55))) if device.color_mode == SwitchBotColorMode.COLOR_TEMP: - self._attr_color_temp = color_temperature_kelvin_to_mired(device.color_temp) + self._attr_color_temp_kelvin = device.color_temp self._attr_color_mode = ColorMode.COLOR_TEMP return self._attr_rgb_color = device.rgb @@ -77,10 +73,9 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): if ( self.supported_color_modes and ColorMode.COLOR_TEMP in self.supported_color_modes - and ATTR_COLOR_TEMP in kwargs + and ATTR_COLOR_TEMP_KELVIN in kwargs ): - color_temp = kwargs[ATTR_COLOR_TEMP] - kelvin = max(2700, min(6500, color_temperature_mired_to_kelvin(color_temp))) + kelvin = max(2700, min(6500, kwargs[ATTR_COLOR_TEMP_KELVIN])) await self._device.set_color_temp(brightness, kelvin) return if ATTR_RGB_COLOR in kwargs: diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 64a2ec75633..1b80da43e16 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.53.2"] + "requirements": ["PySwitchbot==0.55.4"] } diff --git a/homeassistant/components/switchbot/quality_scale.yaml b/homeassistant/components/switchbot/quality_scale.yaml new file mode 100644 index 00000000000..3b8976aeb8e --- /dev/null +++ b/homeassistant/components/switchbot/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: todo + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: + status: todo + comment: | + set `PARALLEL_UPDATES` in lock.py + reauthentication-flow: todo + test-coverage: + status: todo + comment: | + Consider using snapshots for fixating all the entities a device creates. + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No network discovery. + discovery: + status: done + comment: | + Can be improved: Device type scan filtering is applied to only show devices that are actually supported. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Only one device per config entry. New devices are set up as new entries. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: todo + comment: | + Needs to provide translations for hub2 temperature entity + exception-translations: todo + icon-translations: + status: exempt + comment: | + No custom icons. + reconfiguration-flow: + status: exempt + comment: | + No need for reconfiguration flow. + repair-issues: + status: exempt + comment: | + No repairs/issues. + stale-devices: + status: exempt + comment: | + Device type integration. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index fd3de3e31e9..9787521a5e9 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -14,6 +14,8 @@ from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, UnitOfPower, UnitOfTemperature, ) @@ -82,6 +84,18 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), + "current": SensorEntityDescription( + key="current", + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "voltage": SensorEntityDescription( + key="voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + ), } diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 80ca32d4826..fe44bc39e62 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -4,7 +4,10 @@ "step": { "user": { "data": { - "address": "Device address" + "address": "MAC address" + }, + "data_description": { + "address": "The Bluetooth MAC address of your SwitchBot device" } }, "confirm": { @@ -14,27 +17,38 @@ "description": "The {name} device requires a password", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "The password required for the Bot device access" } }, - "lock_key": { + "encrypted_key": { "description": "The {name} device requires encryption key, details on how to obtain it can be found in the documentation.", "data": { "key_id": "Key ID", "encryption_key": "Encryption key" + }, + "data_description": { + "key_id": "The ID of the encryption key", + "encryption_key": "The encryption key for the device" } }, - "lock_auth": { - "description": "Please provide your SwitchBot app username and password. This data won't be saved and only used to retrieve your locks encryption key. Usernames and passwords are case sensitive.", + "encrypted_auth": { + "description": "Please provide your SwitchBot app username and password. This data won't be saved and only used to retrieve your device's encryption key. Usernames and passwords are case sensitive.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The username of your SwitchBot account", + "password": "The password of your SwitchBot account" } }, - "lock_choose_method": { - "description": "A SwitchBot lock can be set up in Home Assistant in two different ways.\n\nYou can enter the key id and encryption key yourself, or Home Assistant can import them from your SwitchBot account.", + "encrypted_choose_method": { + "description": "An encrypted SwitchBot device can be set up in Home Assistant in two different ways.\n\nYou can enter the key id and encryption key yourself, or Home Assistant can import them from your SwitchBot account.", "menu_options": { - "lock_auth": "SwitchBot account (recommended)", - "lock_key": "Enter lock encryption key manually" + "encrypted_auth": "SwitchBot account (recommended)", + "encrypted_key": "Enter encryption key manually" } } }, diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 625b4698301..d7812158260 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -8,7 +8,7 @@ from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotA from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN @@ -16,6 +16,7 @@ from .coordinator import SwitchBotCoordinator _LOGGER = getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.BUTTON, Platform.CLIMATE, Platform.LOCK, Platform.SENSOR, @@ -28,6 +29,7 @@ PLATFORMS: list[Platform] = [ class SwitchbotDevices: """Switchbot devices data.""" + buttons: list[Device] = field(default_factory=list) climates: list[Remote] = field(default_factory=list) switches: list[Device | Remote] = field(default_factory=list) sensors: list[Device] = field(default_factory=list) @@ -43,72 +45,110 @@ class SwitchbotCloudData: devices: SwitchbotDevices -@callback -def prepare_device( +async def coordinator_for_device( hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote, coordinators_by_id: dict[str, SwitchBotCoordinator], -) -> tuple[Device | Remote, SwitchBotCoordinator]: +) -> SwitchBotCoordinator: """Instantiate coordinator and adds to list for gathering.""" coordinator = coordinators_by_id.setdefault( device.device_id, SwitchBotCoordinator(hass, api, device) ) - return (device, coordinator) + + if coordinator.data is None: + await coordinator.async_config_entry_first_refresh() + + return coordinator -@callback -def make_device_data( +async def make_switchbot_devices( hass: HomeAssistant, api: SwitchBotAPI, devices: list[Device | Remote], coordinators_by_id: dict[str, SwitchBotCoordinator], ) -> SwitchbotDevices: - """Make device data.""" + """Make SwitchBot devices.""" devices_data = SwitchbotDevices() - for device in devices: - if isinstance(device, Remote) and device.device_type.endswith( - "Air Conditioner" - ): - devices_data.climates.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if ( - isinstance(device, Device) - and device.device_type.startswith("Plug") - or isinstance(device, Remote) - ): - devices_data.switches.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if isinstance(device, Device) and device.device_type in [ - "Meter", - "MeterPlus", - "WoIOSensor", - "Hub 2", - "MeterPro", - "MeterPro(CO2)", - ]: - devices_data.sensors.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if isinstance(device, Device) and device.device_type in [ - "K10+", - "K10+ Pro", - "Robot Vacuum Cleaner S1", - "Robot Vacuum Cleaner S1 Plus", - ]: - devices_data.vacuums.append( - prepare_device(hass, api, device, coordinators_by_id) - ) + await gather( + *[ + make_device_data(hass, api, device, devices_data, coordinators_by_id) + for device in devices + ] + ) - if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): - devices_data.locks.append( - prepare_device(hass, api, device, coordinators_by_id) - ) return devices_data +async def make_device_data( + hass: HomeAssistant, + api: SwitchBotAPI, + device: Device | Remote, + devices_data: SwitchbotDevices, + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> None: + """Make device data.""" + if isinstance(device, Remote) and device.device_type.endswith("Air Conditioner"): + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.climates.append((device, coordinator)) + if ( + isinstance(device, Device) + and ( + device.device_type.startswith("Plug") + or device.device_type in ["Relay Switch 1PM", "Relay Switch 1"] + ) + ) or isinstance(device, Remote): + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.switches.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "Meter", + "MeterPlus", + "WoIOSensor", + "Hub 2", + "MeterPro", + "MeterPro(CO2)", + "Relay Switch 1PM", + "Plug Mini (US)", + "Plug Mini (JP)", + ]: + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.sensors.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "K10+", + "K10+ Pro", + "Robot Vacuum Cleaner S1", + "Robot Vacuum Cleaner S1 Plus", + ]: + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.vacuums.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + devices_data.locks.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in ["Bot"]: + coordinator = await coordinator_for_device( + hass, api, device, coordinators_by_id + ) + if coordinator.data is not None: + if coordinator.data.get("deviceMode") == "pressMode": + devices_data.buttons.append((device, coordinator)) + else: + devices_data.switches.append((device, coordinator)) + + async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" token = config.data[CONF_API_TOKEN] @@ -126,14 +166,15 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: raise ConfigEntryNotReady from ex _LOGGER.debug("Devices: %s", devices) coordinators_by_id: dict[str, SwitchBotCoordinator] = {} + + switchbot_devices = await make_switchbot_devices( + hass, api, devices, coordinators_by_id + ) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config.entry_id] = SwitchbotCloudData( - api=api, devices=make_device_data(hass, api, devices, coordinators_by_id) + api=api, devices=switchbot_devices ) await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) - await gather( - *[coordinator.async_refresh() for coordinator in coordinators_by_id.values()] - ) return True diff --git a/homeassistant/components/switchbot_cloud/button.py b/homeassistant/components/switchbot_cloud/button.py new file mode 100644 index 00000000000..a6eb1a134a5 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/button.py @@ -0,0 +1,41 @@ +"""Support for the Switchbot Bot as a Button.""" + +from typing import Any + +from switchbot_api import BotCommands + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SwitchbotCloudData +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + SwitchBotCloudBot(data.api, device, coordinator) + for device, coordinator in data.devices.buttons + ) + + +class SwitchBotCloudBot(SwitchBotCloudEntity, ButtonEntity): + """Representation of a SwitchBot Bot.""" + + _attr_name = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + async def async_press(self, **kwargs: Any) -> None: + """Bot press command.""" + await self.send_api_command(BotCommands.PRESS) diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index cd60313f37a..4e05e9e9a1e 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -79,8 +79,9 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity): _attr_hvac_mode = HVACMode.FAN_ONLY _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature = 21 + _attr_target_temperature_step = 1 + _attr_precision = 1 _attr_name = None - _enable_turn_on_off_backwards_compatibility = False async def _do_send_command( self, @@ -97,7 +98,7 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity): ) await self.send_api_command( AirConditionerCommands.SET_ALL, - parameters=f"{new_temperature},{new_mode},{new_fan_speed},on", + parameters=f"{int(new_temperature)},{new_mode},{new_fan_speed},on", ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py index f77adb7b192..74adcb049c1 100644 --- a/homeassistant/components/switchbot_cloud/entity.py +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -4,6 +4,7 @@ from typing import Any from switchbot_api import Commands, Device, Remote, SwitchBotAPI +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -48,3 +49,17 @@ class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]): command_type, parameters, ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._set_attributes() + super()._handle_coordinator_update() + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + + async def async_added_to_hass(self) -> None: + """Run when entity is about to be added to hass.""" + await super().async_added_to_hass() + self._set_attributes() diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index 2fbd551b919..52f48c66d38 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -6,7 +6,7 @@ from switchbot_api import LockCommands from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SwitchbotCloudData @@ -32,12 +32,10 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity): _attr_name = None - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if coord_data := self.coordinator.data: self._attr_is_locked = coord_data["lockState"] == "locked" - self.async_write_ha_state() async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index eb08d2183b1..99f909e91ab 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "integration_type": "hub", "iot_class": "cloud_polling", - "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==2.2.1"] + "loggers": ["switchbot_api"], + "requirements": ["switchbot-api==2.3.1"] } diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 90135ad96b3..1f755c141a2 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -12,9 +12,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfPower, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SwitchbotCloudData @@ -26,38 +29,112 @@ SENSOR_TYPE_TEMPERATURE = "temperature" SENSOR_TYPE_HUMIDITY = "humidity" SENSOR_TYPE_BATTERY = "battery" SENSOR_TYPE_CO2 = "CO2" +SENSOR_TYPE_POWER = "power" +SENSOR_TYPE_VOLTAGE = "voltage" +SENSOR_TYPE_CURRENT = "electricCurrent" -METER_PLUS_SENSOR_DESCRIPTIONS = ( - SensorEntityDescription( - key=SENSOR_TYPE_TEMPERATURE, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), - SensorEntityDescription( - key=SENSOR_TYPE_HUMIDITY, - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - ), - SensorEntityDescription( - key=SENSOR_TYPE_BATTERY, - device_class=SensorDeviceClass.BATTERY, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - ), +TEMPERATURE_DESCRIPTION = SensorEntityDescription( + key=SENSOR_TYPE_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, ) -METER_PRO_CO2_SENSOR_DESCRIPTIONS = ( - *METER_PLUS_SENSOR_DESCRIPTIONS, - SensorEntityDescription( - key=SENSOR_TYPE_CO2, - native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.CO2, - ), +HUMIDITY_DESCRIPTION = SensorEntityDescription( + key=SENSOR_TYPE_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, ) +BATTERY_DESCRIPTION = SensorEntityDescription( + key=SENSOR_TYPE_BATTERY, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, +) + +POWER_DESCRIPTION = SensorEntityDescription( + key=SENSOR_TYPE_POWER, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, +) + +VOLTAGE_DESCRIPTION = SensorEntityDescription( + key=SENSOR_TYPE_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, +) + +CURRENT_DESCRIPTION_IN_MA = SensorEntityDescription( + key=SENSOR_TYPE_CURRENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, +) + +CURRENT_DESCRIPTION_IN_A = SensorEntityDescription( + key=SENSOR_TYPE_CURRENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, +) + +CO2_DESCRIPTION = SensorEntityDescription( + key=SENSOR_TYPE_CO2, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, +) + +SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { + "Meter": ( + TEMPERATURE_DESCRIPTION, + HUMIDITY_DESCRIPTION, + BATTERY_DESCRIPTION, + ), + "MeterPlus": ( + TEMPERATURE_DESCRIPTION, + HUMIDITY_DESCRIPTION, + BATTERY_DESCRIPTION, + ), + "WoIOSensor": ( + TEMPERATURE_DESCRIPTION, + HUMIDITY_DESCRIPTION, + BATTERY_DESCRIPTION, + ), + "Relay Switch 1PM": ( + POWER_DESCRIPTION, + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_MA, + ), + "Plug Mini (US)": ( + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_A, + ), + "Plug Mini (JP)": ( + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_A, + ), + "Hub 2": ( + TEMPERATURE_DESCRIPTION, + HUMIDITY_DESCRIPTION, + ), + "MeterPro": ( + TEMPERATURE_DESCRIPTION, + HUMIDITY_DESCRIPTION, + BATTERY_DESCRIPTION, + ), + "MeterPro(CO2)": ( + TEMPERATURE_DESCRIPTION, + HUMIDITY_DESCRIPTION, + BATTERY_DESCRIPTION, + CO2_DESCRIPTION, + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -70,11 +147,7 @@ async def async_setup_entry( async_add_entities( SwitchBotCloudSensor(data.api, device, coordinator, description) for device, coordinator in data.devices.sensors - for description in ( - METER_PRO_CO2_SENSOR_DESCRIPTIONS - if device.device_type == "MeterPro(CO2)" - else METER_PLUS_SENSOR_DESCRIPTIONS - ) + for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] ) @@ -93,10 +166,8 @@ class SwitchBotCloudSensor(SwitchBotCloudEntity, SensorEntity): self.entity_description = description self._attr_unique_id = f"{device.device_id}_{description.key}" - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return self._attr_native_value = self.coordinator.data.get(self.entity_description.key) - self.async_write_ha_state() diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index c30e60086fa..22d033625f9 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -46,21 +46,18 @@ class SwitchBotCloudSwitch(SwitchBotCloudEntity, SwitchEntity): self._attr_is_on = False self.async_write_ha_state() - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return self._attr_is_on = self.coordinator.data.get("power") == PowerState.ON.value - self.async_write_ha_state() class SwitchBotCloudRemoteSwitch(SwitchBotCloudSwitch): """Representation of a SwitchBot switch provider by a remote.""" - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch): @@ -69,6 +66,16 @@ class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch): _attr_device_class = SwitchDeviceClass.OUTLET +class SwitchBotCloudRelaySwitchSwitch(SwitchBotCloudSwitch): + """Representation of a SwitchBot relay switch.""" + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if not self.coordinator.data: + return + self._attr_is_on = self.coordinator.data.get("switchStatus") == 1 + + @callback def _async_make_entity( api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator @@ -78,4 +85,11 @@ def _async_make_entity( return SwitchBotCloudRemoteSwitch(api, device, coordinator) if "Plug" in device.device_type: return SwitchBotCloudPlugSwitch(api, device, coordinator) + if device.device_type in [ + "Relay Switch 1PM", + "Relay Switch 1", + ]: + return SwitchBotCloudRelaySwitchSwitch(api, device, coordinator) + if "Bot" in device.device_type: + return SwitchBotCloudSwitch(api, device, coordinator) raise NotImplementedError(f"Unsupported device type: {device.device_type}") diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index f9236507037..84db7cfdbb8 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -5,13 +5,8 @@ from typing import Any from switchbot_api import Device, Remote, SwitchBotAPI, VacuumCommands from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -43,17 +38,17 @@ async def async_setup_entry( ) -VACUUM_SWITCHBOT_STATE_TO_HA_STATE: dict[str, str] = { - "StandBy": STATE_IDLE, - "Clearing": STATE_CLEANING, - "Paused": STATE_PAUSED, - "GotoChargeBase": STATE_RETURNING, - "Charging": STATE_DOCKED, - "ChargeDone": STATE_DOCKED, - "Dormant": STATE_IDLE, - "InTrouble": STATE_ERROR, - "InRemoteControl": STATE_CLEANING, - "InDustCollecting": STATE_DOCKED, +VACUUM_SWITCHBOT_STATE_TO_HA_STATE: dict[str, VacuumActivity] = { + "StandBy": VacuumActivity.IDLE, + "Clearing": VacuumActivity.CLEANING, + "Paused": VacuumActivity.PAUSED, + "GotoChargeBase": VacuumActivity.RETURNING, + "Charging": VacuumActivity.DOCKED, + "ChargeDone": VacuumActivity.DOCKED, + "Dormant": VacuumActivity.IDLE, + "InTrouble": VacuumActivity.ERROR, + "InRemoteControl": VacuumActivity.CLEANING, + "InDustCollecting": VacuumActivity.DOCKED, } VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED: dict[str, str] = { @@ -104,9 +99,8 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): """Start or resume the cleaning task.""" await self.send_api_command(VacuumCommands.START) - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return @@ -114,9 +108,7 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): self._attr_available = self.coordinator.data.get("onlineStatus") == "online" switchbot_state = str(self.coordinator.data.get("workingStatus")) - self._attr_state = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state) - - self.async_write_ha_state() + self._attr_activity = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state) @callback diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index f2d4fb60252..2fc4a331676 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any, cast -from aioswitcher.api import SwitcherApi, SwitcherBaseResponse from aioswitcher.api.remotes import SwitcherBreezeRemote from aioswitcher.device import ( DeviceCategory, @@ -38,6 +37,8 @@ from .coordinator import SwitcherDataUpdateCoordinator from .entity import SwitcherEntity from .utils import get_breeze_remote_manager +API_CONTROL_BREEZE_DEVICE = "control_breeze_device" + DEVICE_MODE_TO_HA = { ThermostatMode.COOL: HVACMode.COOL, ThermostatMode.HEAT: HVACMode.HEAT, @@ -83,7 +84,6 @@ class SwitcherClimateEntity(SwitcherEntity, ClimateEntity): """Representation of a Switcher climate entity.""" _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: SwitcherDataUpdateCoordinator, remote: SwitcherBreezeRemote @@ -156,27 +156,7 @@ class SwitcherClimateEntity(SwitcherEntity, ClimateEntity): async def _async_control_breeze_device(self, **kwargs: Any) -> None: """Call Switcher Control Breeze API.""" - response: SwitcherBaseResponse | None = None - error = None - - try: - async with SwitcherApi( - self.coordinator.data.device_type, - self.coordinator.data.ip_address, - self.coordinator.data.device_id, - self.coordinator.data.device_key, - ) as swapi: - response = await swapi.control_breeze_device(self._remote, **kwargs) - except (TimeoutError, OSError, RuntimeError) as err: - error = repr(err) - - if error or not response or not response.successful: - self.coordinator.last_update_success = False - self.async_write_ha_state() - raise HomeAssistantError( - f"Call Breeze control for {self.name} failed, " - f"response/error: {response or error}" - ) + await self._async_call_api(API_CONTROL_BREEZE_DEVICE, self._remote, **kwargs) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 7d3ec0e4af0..513b786a033 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -2,10 +2,8 @@ from __future__ import annotations -import logging from typing import Any, cast -from aioswitcher.api import SwitcherApi, SwitcherBaseResponse from aioswitcher.device import DeviceCategory, ShutterDirection, SwitcherShutter from homeassistant.components.cover import ( @@ -16,7 +14,6 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,8 +21,6 @@ from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator from .entity import SwitcherEntity -_LOGGER = logging.getLogger(__name__) - API_SET_POSITON = "set_position" API_STOP = "stop_shutter" @@ -92,32 +87,6 @@ class SwitcherBaseCoverEntity(SwitcherEntity, CoverEntity): data.direction[self._cover_id] == ShutterDirection.SHUTTER_UP ) - async def _async_call_api(self, api: str, *args: Any) -> None: - """Call Switcher API.""" - _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) - response: SwitcherBaseResponse | None = None - error = None - - try: - async with SwitcherApi( - self.coordinator.data.device_type, - self.coordinator.data.ip_address, - self.coordinator.data.device_id, - self.coordinator.data.device_key, - self.coordinator.token, - ) as swapi: - response = await getattr(swapi, api)(*args) - except (TimeoutError, OSError, RuntimeError) as err: - error = repr(err) - - if error or not response or not response.successful: - self.coordinator.last_update_success = False - self.async_write_ha_state() - raise HomeAssistantError( - f"Call api for {self.name} failed, api: '{api}', " - f"args: {args}, response/error: {response or error}" - ) - async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self._async_call_api(API_SET_POSITON, 0, self._cover_id) diff --git a/homeassistant/components/switcher_kis/entity.py b/homeassistant/components/switcher_kis/entity.py index 12bde521377..82b892d548d 100644 --- a/homeassistant/components/switcher_kis/entity.py +++ b/homeassistant/components/switcher_kis/entity.py @@ -1,11 +1,20 @@ """Base class for Switcher entities.""" +import logging +from typing import Any + +from aioswitcher.api import SwitcherApi +from aioswitcher.api.messages import SwitcherBaseResponse + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import SwitcherDataUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + class SwitcherEntity(CoordinatorEntity[SwitcherDataUpdateCoordinator]): """Base class for Switcher entities.""" @@ -18,3 +27,29 @@ class SwitcherEntity(CoordinatorEntity[SwitcherDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) + + async def _async_call_api(self, api: str, *args: Any, **kwargs: Any) -> None: + """Call Switcher API.""" + _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) + response: SwitcherBaseResponse | None = None + error = None + + try: + async with SwitcherApi( + self.coordinator.data.device_type, + self.coordinator.data.ip_address, + self.coordinator.data.device_id, + self.coordinator.data.device_key, + self.coordinator.token, + ) as swapi: + response = await getattr(swapi, api)(*args, **kwargs) + except (TimeoutError, OSError, RuntimeError) as err: + error = repr(err) + + if error or not response or not response.successful: + self.coordinator.last_update_success = False + self.async_write_ha_state() + raise HomeAssistantError( + f"Call api for {self.name} failed, api: '{api}', " + f"args: {args}, response/error: {response or error}" + ) diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py index b2ee624dbc5..75156044efa 100644 --- a/homeassistant/components/switcher_kis/light.py +++ b/homeassistant/components/switcher_kis/light.py @@ -2,16 +2,13 @@ from __future__ import annotations -import logging from typing import Any, cast -from aioswitcher.api import SwitcherApi, SwitcherBaseResponse from aioswitcher.device import DeviceCategory, DeviceState, SwitcherLight from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,8 +16,6 @@ from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator from .entity import SwitcherEntity -_LOGGER = logging.getLogger(__name__) - API_SET_LIGHT = "set_light" @@ -79,32 +74,6 @@ class SwitcherBaseLightEntity(SwitcherEntity, LightEntity): data = cast(SwitcherLight, self.coordinator.data) return bool(data.light[self._light_id] == DeviceState.ON) - async def _async_call_api(self, api: str, *args: Any) -> None: - """Call Switcher API.""" - _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) - response: SwitcherBaseResponse | None = None - error = None - - try: - async with SwitcherApi( - self.coordinator.data.device_type, - self.coordinator.data.ip_address, - self.coordinator.data.device_id, - self.coordinator.data.device_key, - self.coordinator.token, - ) as swapi: - response = await getattr(swapi, api)(*args) - except (TimeoutError, OSError, RuntimeError) as err: - error = repr(err) - - if error or not response or not response.successful: - self.coordinator.last_update_success = False - self.async_write_ha_state() - raise HomeAssistantError( - f"Call api for {self.name} failed, api: '{api}', " - f"args: {args}, response/error: {response or error}" - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" await self._async_call_api(API_SET_LIGHT, DeviceState.ON, self._light_id) diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 987dac65077..f96b10b4b6f 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/switcher_kis", "iot_class": "local_push", "loggers": ["aioswitcher"], - "requirements": ["aioswitcher==5.0.0"], + "requirements": ["aioswitcher==6.0.0"], "single_config_entry": true } diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index 844cbb4ca98..e380711303d 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -63,6 +63,14 @@ "temperature": { "name": "Current temperature" } + }, + "switch": { + "child_lock": { + "name": "Child lock" + }, + "multi_child_lock": { + "name": "Child lock {cover_id}" + } } }, "services": { diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 7d14620c1aa..7d3d71a0615 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -4,14 +4,15 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any +from typing import Any, cast -from aioswitcher.api import Command, SwitcherApi, SwitcherBaseResponse -from aioswitcher.device import DeviceCategory, DeviceState +from aioswitcher.api import Command, ShutterChildLock +from aioswitcher.device import DeviceCategory, DeviceState, SwitcherShutter import voluptuous as vol from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -32,6 +33,7 @@ _LOGGER = logging.getLogger(__name__) API_CONTROL_DEVICE = "control_device" API_SET_AUTO_SHUTDOWN = "set_auto_shutdown" +API_SET_CHILD_LOCK = "set_shutter_child_lock" SERVICE_SET_AUTO_OFF_SCHEMA: VolDictType = { vol.Required(CONF_AUTO_OFF): cv.time_period_str, @@ -67,10 +69,28 @@ async def async_setup_entry( @callback def async_add_switch(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add switch from Switcher device.""" + entities: list[SwitchEntity] = [] + if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: - async_add_entities([SwitcherPowerPlugSwitchEntity(coordinator)]) + entities.append(SwitcherPowerPlugSwitchEntity(coordinator)) elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: - async_add_entities([SwitcherWaterHeaterSwitchEntity(coordinator)]) + entities.append(SwitcherWaterHeaterSwitchEntity(coordinator)) + elif coordinator.data.device_type.category in ( + DeviceCategory.SHUTTER, + DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, + DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, + ): + number_of_covers = len(cast(SwitcherShutter, coordinator.data).position) + if number_of_covers == 1: + entities.append( + SwitchereShutterChildLockSingleSwitchEntity(coordinator, 0) + ) + else: + entities.extend( + SwitchereShutterChildLockMultiSwitchEntity(coordinator, i) + for i in range(number_of_covers) + ) + async_add_entities(entities) config_entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_switch) @@ -96,35 +116,6 @@ class SwitcherBaseSwitchEntity(SwitcherEntity, SwitchEntity): self.control_result = None self.async_write_ha_state() - async def _async_call_api(self, api: str, *args: Any) -> None: - """Call Switcher API.""" - _LOGGER.debug( - "Calling api for %s, api: '%s', args: %s", self.coordinator.name, api, args - ) - response: SwitcherBaseResponse | None = None - error = None - - try: - async with SwitcherApi( - self.coordinator.data.device_type, - self.coordinator.data.ip_address, - self.coordinator.data.device_id, - self.coordinator.data.device_key, - ) as swapi: - response = await getattr(swapi, api)(*args) - except (TimeoutError, OSError, RuntimeError) as err: - error = repr(err) - - if error or not response or not response.successful: - _LOGGER.error( - "Call api for %s failed, api: '%s', args: %s, response/error: %s", - self.coordinator.name, - api, - args, - response or error, - ) - self.coordinator.last_update_success = False - @property def is_on(self) -> bool: """Return True if entity is on.""" @@ -183,3 +174,91 @@ class SwitcherWaterHeaterSwitchEntity(SwitcherBaseSwitchEntity): await self._async_call_api(API_CONTROL_DEVICE, Command.ON, timer_minutes) self.control_result = True self.async_write_ha_state() + + +class SwitchereShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): + """Representation of a Switcher shutter base switch entity.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG + _attr_icon = "mdi:lock-open" + _cover_id: int + + def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.control_result: bool | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + super()._handle_coordinator_update() + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if self.control_result is not None: + return self.control_result + + data = cast(SwitcherShutter, self.coordinator.data) + return bool(data.child_lock[self._cover_id] == ShutterChildLock.ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.ON, self._cover_id + ) + self.control_result = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.OFF, self._cover_id + ) + self.control_result = False + self.async_write_ha_state() + + +class SwitchereShutterChildLockSingleSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock single switch entity.""" + + _attr_translation_key = "child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-child_lock" + ) + + +class SwitchereShutterChildLockMultiSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock multiple switch entity.""" + + _attr_translation_key = "multi_child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_translation_placeholders = {"cover_id": str(cover_id + 1)} + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-{cover_id}-child_lock" + ) diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index 1fb155a5648..1407814f838 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -8,10 +8,15 @@ from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import DEFAULT_MODEL, DEFAULT_NAME_TEMPLATE, DOMAIN @@ -33,15 +38,15 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN): return await self._async_check_and_create("user", user_input) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle SSDP initiated flow.""" - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() self.url = url_normalize( discovery_info.upnp.get( - ssdp.ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_PRESENTATION_URL, f"http://{urlparse(discovery_info.ssdp_location or '').hostname}/", ) ) @@ -52,11 +57,11 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN): # Update unique id of entry with the same URL if not existing_entry.unique_id: self.hass.config_entries.async_update_entry( - existing_entry, unique_id=discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + existing_entry, unique_id=discovery_info.upnp[ATTR_UPNP_UDN] ) return self.async_abort(reason="already_configured") - self.name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") + self.name = discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, "") if self.name: # Remove trailing " (ip)" if present for consistency with user driven config self.name = re.sub(r"\s+\([\d.]+\)\s*$", "", self.name) diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index a93e02a51c7..461ce9bfd3a 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/syncthru", "iot_class": "local_polling", "loggers": ["pysyncthru"], - "requirements": ["PySyncThru==0.7.10", "url-normalize==1.4.3"], + "requirements": ["PySyncThru==0.8.0", "url-normalize==1.4.3"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 918a24035f8..03e2eaf8e7b 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -18,7 +18,6 @@ from synology_dsm.exceptions import ( ) import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -41,6 +40,12 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType from homeassistant.util.network import is_ip_address as is_ip @@ -243,7 +248,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_validate_input_create_entry(user_input, step_id=step) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered synology_dsm via zeroconf.""" discovered_macs = [ @@ -258,13 +263,13 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self._async_from_discovery(host, friendly_name, discovered_macs) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered synology_dsm via ssdp.""" parsed_url = urlparse(discovery_info.ssdp_location) - upnp_friendly_name: str = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + upnp_friendly_name: str = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] friendly_name = upnp_friendly_name.split("(", 1)[0].strip() - mac_address = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + mac_address = discovery_info.upnp[ATTR_UPNP_SERIAL] discovered_macs = [format_synology_mac(mac_address)] # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. # The serial of the NAS is actually its MAC address. diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index b85189715ef..ab6fc20b5cb 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.5.3"], + "requirements": ["py-synologydsm-api==2.6.0"], "ssdp": [ { "manufacturer": "Synology", diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 98396e52545..60b57b1e87f 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -16,13 +16,13 @@ from systembridgeconnector.websocket_client import WebSocketClient from systembridgemodels.modules import GetData, Module import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DATA_WAIT_TIMEOUT, DOMAIN @@ -179,7 +179,7 @@ class SystemBridgeConfigFlow( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" properties = discovery_info.properties diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 22950aa9f1e..191a2b5feb8 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -163,16 +163,16 @@ class LogEntry: """Store HA log entries.""" __slots__ = ( + "count", + "exception", "first_occurred", - "timestamp", - "name", + "key", "level", "message", - "exception", + "name", "root_cause", "source", - "count", - "key", + "timestamp", ) def __init__( diff --git a/homeassistant/components/system_log/strings.json b/homeassistant/components/system_log/strings.json index ed1ca79fe07..db71cd6ace4 100644 --- a/homeassistant/components/system_log/strings.json +++ b/homeassistant/components/system_log/strings.json @@ -1,8 +1,8 @@ { "services": { "clear": { - "name": "Clear all", - "description": "Clears all log entries." + "name": "Clear", + "description": "Deletes all log entries." }, "write": { "name": "Write", diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py index 4a794a00432..2776feba272 100644 --- a/homeassistant/components/systemmonitor/__init__.py +++ b/homeassistant/components/systemmonitor/__init__.py @@ -50,7 +50,7 @@ async def async_setup_entry( _LOGGER.debug("disk arguments to be added: %s", disk_arguments) coordinator: SystemMonitorCoordinator = SystemMonitorCoordinator( - hass, psutil_wrapper, disk_arguments + hass, entry, psutil_wrapper, disk_arguments ) await coordinator.async_config_entry_first_refresh() entry.runtime_data = SystemMonitorData(coordinator, psutil_wrapper) @@ -60,17 +60,21 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SystemMonitorConfigEntry +) -> bool: """Unload System Monitor config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: SystemMonitorConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, entry: SystemMonitorConfigEntry +) -> bool: """Migrate old entry.""" if entry.version > 1: diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py index 34b28a1d47a..4be31f6944c 100644 --- a/homeassistant/components/systemmonitor/config_flow.py +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -8,8 +8,6 @@ from typing import Any import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.config_entries import ConfigFlowResult -from homeassistant.core import callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, @@ -100,12 +98,3 @@ class SystemMonitorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return "System Monitor" - - @callback - def async_create_entry( - self, data: Mapping[str, Any], **kwargs: Any - ) -> ConfigFlowResult: - """Finish config flow and create a config entry.""" - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - return super().async_create_entry(data, **kwargs) diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py index 32a171a11ca..03b769ee2e2 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime import logging import os -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple from psutil import Process from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap @@ -17,6 +17,9 @@ from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util import dt as dt_util +if TYPE_CHECKING: + from . import SystemMonitorConfigEntry + _LOGGER = logging.getLogger(__name__) @@ -83,6 +86,7 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]): def __init__( self, hass: HomeAssistant, + config_entry: SystemMonitorConfigEntry, psutil_wrapper: ha_psutil.PsutilWrapper, arguments: list[str], ) -> None: @@ -90,6 +94,7 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name="System Monitor update coordinator", update_interval=DEFAULT_SCAN_INTERVAL, always_update=False, diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 4c6ae0653d3..bd16464b290 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.0"] + "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.1"], + "single_config_entry": true } diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index ef1153f09e8..048d7cefd6c 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -429,16 +429,17 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(slugify(f"{_type}_{argument}")) - entities.append( - SystemMonitorSensor( - coordinator, - sensor_description, - entry.entry_id, - argument, - is_enabled, + if (_add := slugify(f"{_type}_{argument}")) not in loaded_resources: + loaded_resources.add(_add) + entities.append( + SystemMonitorSensor( + coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) ) - ) continue if _type.startswith("ipv"): diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index e595e628853..fb8a318ff45 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "single_instance_allowed": "[%key:common::config_flow::abort::already_configured_service%]" }, "step": { "user": { diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index cc5dee77617..3e42e33489f 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -3,14 +3,15 @@ from datetime import timedelta import logging -import requests.exceptions +import PyTado +import PyTado.exceptions +from PyTado.interface import Tado from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from .const import ( @@ -21,11 +22,9 @@ from .const import ( CONST_OVERLAY_TADO_OPTIONS, DOMAIN, ) +from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator +from .models import TadoData from .services import setup_services -from .tado_connector import TadoConnector - -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [ Platform.BINARY_SENSOR, @@ -41,16 +40,17 @@ SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Tado.""" setup_services(hass) - return True -type TadoConfigEntry = ConfigEntry[TadoConnector] +type TadoConfigEntry = ConfigEntry[TadoData] async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: @@ -58,53 +58,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool _async_import_options_from_data_if_missing(hass, entry) - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT) - - tadoconnector = TadoConnector(hass, username, password, fallback) - + _LOGGER.debug("Setting up Tado connection") try: - await hass.async_add_executor_job(tadoconnector.setup) - except KeyError: - _LOGGER.error("Failed to login to tado") - return False - except RuntimeError as exc: - _LOGGER.error("Failed to setup tado: %s", exc) - return False - except requests.exceptions.Timeout as ex: - raise ConfigEntryNotReady from ex - except requests.exceptions.HTTPError as ex: - if ex.response.status_code > 400 and ex.response.status_code < 500: - _LOGGER.error("Failed to login to tado: %s", ex) - return False - raise ConfigEntryNotReady from ex - - # Do first update - await hass.async_add_executor_job(tadoconnector.update) - - # Poll for updates in the background - entry.async_on_unload( - async_track_time_interval( - hass, - lambda now: tadoconnector.update(), - SCAN_INTERVAL, + tado = await hass.async_add_executor_job( + Tado, + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], ) + except PyTado.exceptions.TadoWrongCredentialsException as err: + raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err + except PyTado.exceptions.TadoException as err: + raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err + _LOGGER.debug( + "Tado connection established for username: %s", entry.data[CONF_USERNAME] ) - entry.async_on_unload( - async_track_time_interval( - hass, - lambda now: tadoconnector.update_mobile_devices(), - SCAN_MOBILE_DEVICE_INTERVAL, - ) - ) + coordinator = TadoDataUpdateCoordinator(hass, entry, tado) + await coordinator.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - - entry.runtime_data = tadoconnector + mobile_coordinator = TadoMobileDeviceUpdateCoordinator(hass, entry, tado) + await mobile_coordinator.async_config_entry_first_refresh() + entry.runtime_data = TadoData(coordinator, mobile_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -126,7 +103,7 @@ def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: Confi hass.config_entries.async_update_entry(entry, options=options) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: TadoConfigEntry): """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 25c1c801155..c969ea34f42 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -13,21 +13,19 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import TadoConfigEntry from .const import ( - SIGNAL_TADO_UPDATE_RECEIVED, TYPE_AIR_CONDITIONING, TYPE_BATTERY, TYPE_HEATING, TYPE_HOT_WATER, TYPE_POWER, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoDeviceEntity, TadoZoneEntity -from .tado_connector import TadoConnector _LOGGER = logging.getLogger(__name__) @@ -121,7 +119,7 @@ async def async_setup_entry( ) -> None: """Set up the Tado sensor platform.""" - tado = entry.runtime_data + tado = entry.runtime_data.coordinator devices = tado.devices zones = tado.zones entities: list[BinarySensorEntity] = [] @@ -164,43 +162,23 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, device_info: dict[str, Any], entity_description: TadoBinarySensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - self._tado = tado - super().__init__(device_info) + super().__init__(device_info, coordinator) self._attr_unique_id = ( - f"{entity_description.key} {self.device_id} {tado.home_id}" + f"{entity_description.key} {self.device_id} {coordinator.home_id}" ) - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "device", self.device_id - ), - self._async_update_callback, - ) - ) - self._async_update_device_data() - @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_device_data() - self.async_write_ha_state() - - @callback - def _async_update_device_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - self._device_info = self._tado.data["device"][self.device_id] + self._device_info = self.coordinator.data["device"][self.device_id] except KeyError: return @@ -209,6 +187,7 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( self._device_info ) + super()._handle_coordinator_update() class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): @@ -218,42 +197,24 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, entity_description: TadoBinarySensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) - self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" - - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_callback, - ) + self._attr_unique_id = ( + f"{entity_description.key} {zone_id} {coordinator.home_id}" ) - self._async_update_zone_data() @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_zone_data() - self.async_write_ha_state() - - @callback - def _async_update_zone_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self.coordinator.data["zone"][self.zone_id] except KeyError: return @@ -262,3 +223,4 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( tado_zone_data ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 21a09086d46..c8eaec76255 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -26,11 +26,10 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType -from . import TadoConfigEntry, TadoConnector +from . import TadoConfigEntry from .const import ( CONST_EXCLUSIVE_OVERLAY_GROUP, CONST_FAN_AUTO, @@ -50,7 +49,6 @@ from .const import ( HA_TO_TADO_HVAC_MODE_MAP, ORDERED_KNOWN_TADO_MODES, PRESET_AUTO, - SIGNAL_TADO_UPDATE_RECEIVED, SUPPORT_PRESET_AUTO, SUPPORT_PRESET_MANUAL, TADO_DEFAULT_MAX_TEMP, @@ -73,6 +71,7 @@ from .const import ( TYPE_AIR_CONDITIONING, TYPE_HEATING, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoZoneEntity from .helper import decide_duration, decide_overlay_mode, generate_supported_fanmodes @@ -105,8 +104,8 @@ async def async_setup_entry( ) -> None: """Set up the Tado climate platform.""" - tado = entry.runtime_data - entities = await hass.async_add_executor_job(_generate_entities, tado) + tado = entry.runtime_data.coordinator + entities = await _generate_entities(tado) platform = entity_platform.async_get_current_platform() @@ -125,12 +124,12 @@ async def async_setup_entry( async_add_entities(entities, True) -def _generate_entities(tado: TadoConnector) -> list[TadoClimate]: +async def _generate_entities(tado: TadoDataUpdateCoordinator) -> list[TadoClimate]: """Create all climate entities.""" entities = [] for zone in tado.zones: if zone["type"] in [TYPE_HEATING, TYPE_AIR_CONDITIONING]: - entity = create_climate_entity( + entity = await create_climate_entity( tado, zone["name"], zone["id"], zone["devices"][0] ) if entity: @@ -138,11 +137,11 @@ def _generate_entities(tado: TadoConnector) -> list[TadoClimate]: return entities -def create_climate_entity( - tado: TadoConnector, name: str, zone_id: int, device_info: dict +async def create_climate_entity( + tado: TadoDataUpdateCoordinator, name: str, zone_id: int, device_info: dict ) -> TadoClimate | None: """Create a Tado climate entity.""" - capabilities = tado.get_capabilities(zone_id) + capabilities = await tado.get_capabilities(zone_id) _LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities) zone_type = capabilities["type"] @@ -243,6 +242,8 @@ def create_climate_entity( cool_max_temp = float(cool_temperatures["celsius"]["max"]) cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS) + auto_geofencing_supported = await tado.get_auto_geofencing_supported() + return TadoClimate( tado, name, @@ -251,6 +252,8 @@ def create_climate_entity( supported_hvac_modes, support_flags, device_info, + capabilities, + auto_geofencing_supported, heat_min_temp, heat_max_temp, heat_step, @@ -269,17 +272,18 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): _attr_name = None _attr_translation_key = DOMAIN _available = False - _enable_turn_on_off_backwards_compatibility = False def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, zone_type: str, supported_hvac_modes: list[HVACMode], support_flags: ClimateEntityFeature, device_info: dict[str, str], + capabilities: dict[str, str], + auto_geofencing_supported: bool, heat_min_temp: float | None = None, heat_max_temp: float | None = None, heat_step: float | None = None, @@ -290,13 +294,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): supported_swing_modes: list[str] | None = None, ) -> None: """Initialize of Tado climate entity.""" - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + self._tado = coordinator + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) self.zone_id = zone_id self.zone_type = zone_type - self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}" + self._attr_unique_id = f"{zone_type} {zone_id} {coordinator.home_id}" self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] @@ -328,36 +332,61 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._current_tado_vertical_swing = TADO_SWING_OFF self._current_tado_horizontal_swing = TADO_SWING_OFF - capabilities = tado.get_capabilities(zone_id) self._current_tado_capabilities = capabilities + self._auto_geofencing_supported = auto_geofencing_supported self._tado_zone_data: PyTado.TadoZone = {} self._tado_geofence_data: dict[str, str] | None = None self._tado_zone_temp_offset: dict[str, Any] = {} - self._async_update_home_data() self._async_update_zone_data() - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), - self._async_update_home_callback, - ) - ) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_zone_data() + super()._handle_coordinator_update() - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_zone_callback, + @callback + def _async_update_zone_data(self) -> None: + """Load tado data into zone.""" + self._tado_geofence_data = self._tado.data["geofence"] + self._tado_zone_data = self._tado.data["zone"][self.zone_id] + + # Assign offset values to mapped attributes + for offset_key, attr in TADO_TO_HA_OFFSET_MAP.items(): + if ( + self._device_id in self._tado.data["device"] + and offset_key + in self._tado.data["device"][self._device_id][TEMP_OFFSET] + ): + self._tado_zone_temp_offset[attr] = self._tado.data["device"][ + self._device_id + ][TEMP_OFFSET][offset_key] + + self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode + self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action + + if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): + self._current_tado_fan_level = self._tado_zone_data.current_fan_level + if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): + self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed + if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING): + self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode + if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING): + self._current_tado_vertical_swing = ( + self._tado_zone_data.current_vertical_swing_mode ) - ) + if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): + self._current_tado_horizontal_swing = ( + self._tado_zone_data.current_horizontal_swing_mode + ) + + @callback + def _async_update_zone_callback(self) -> None: + """Load tado data and update state.""" + self._async_update_zone_data() @property def current_humidity(self) -> int | None: @@ -402,12 +431,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): return FAN_AUTO return None - def set_fan_mode(self, fan_mode: str) -> None: + async def async_set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): - self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode]) + await self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode]) elif self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): - self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) + await self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) + await self.coordinator.async_request_refresh() @property def preset_mode(self) -> str: @@ -426,13 +456,14 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def preset_modes(self) -> list[str]: """Return a list of available preset modes.""" - if self._tado.get_auto_geofencing_supported(): + if self._auto_geofencing_supported: return SUPPORT_PRESET_AUTO return SUPPORT_PRESET_MANUAL - def set_preset_mode(self, preset_mode: str) -> None: + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - self._tado.set_presence(preset_mode) + await self._tado.set_presence(preset_mode) + await self.coordinator.async_request_refresh() @property def target_temperature_step(self) -> float | None: @@ -450,7 +481,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): # the device is switching states return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp - def set_timer( + async def set_timer( self, temperature: float, time_period: int | None = None, @@ -458,14 +489,15 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ): """Set the timer on the entity, and temperature if supported.""" - self._control_hvac( + await self._control_hvac( hvac_mode=CONST_MODE_HEAT, target_temp=temperature, duration=time_period, overlay_mode=requested_overlay, ) + await self.coordinator.async_request_refresh() - def set_temp_offset(self, offset: float) -> None: + async def set_temp_offset(self, offset: float) -> None: """Set offset on the entity.""" _LOGGER.debug( @@ -475,8 +507,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) self._tado.set_temperature_offset(self._device_id, offset) + await self.coordinator.async_request_refresh() - def set_temperature(self, **kwargs: Any) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return @@ -486,15 +519,21 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): CONST_MODE_AUTO, CONST_MODE_SMART_SCHEDULE, ): - self._control_hvac(target_temp=temperature) + await self._control_hvac(target_temp=temperature) + await self.coordinator.async_request_refresh() return new_hvac_mode = CONST_MODE_COOL if self._ac_device else CONST_MODE_HEAT - self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode) + await self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode) + await self.coordinator.async_request_refresh() - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode]) + _LOGGER.debug( + "Setting new hvac mode for device %s to %s", self._device_id, hvac_mode + ) + await self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode]) + await self.coordinator.async_request_refresh() @property def available(self) -> bool: @@ -560,7 +599,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) return state_attr - def set_swing_mode(self, swing_mode: str) -> None: + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set swing modes for the device.""" vertical_swing = None horizontal_swing = None @@ -592,62 +631,12 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): horizontal_swing = TADO_SWING_ON - self._control_hvac( + await self._control_hvac( swing_mode=swing, vertical_swing=vertical_swing, horizontal_swing=horizontal_swing, ) - - @callback - def _async_update_zone_data(self) -> None: - """Load tado data into zone.""" - self._tado_zone_data = self._tado.data["zone"][self.zone_id] - - # Assign offset values to mapped attributes - for offset_key, attr in TADO_TO_HA_OFFSET_MAP.items(): - if ( - self._device_id in self._tado.data["device"] - and offset_key - in self._tado.data["device"][self._device_id][TEMP_OFFSET] - ): - self._tado_zone_temp_offset[attr] = self._tado.data["device"][ - self._device_id - ][TEMP_OFFSET][offset_key] - - self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode - self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action - - if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): - self._current_tado_fan_level = self._tado_zone_data.current_fan_level - if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): - self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed - if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING): - self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode - if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING): - self._current_tado_vertical_swing = ( - self._tado_zone_data.current_vertical_swing_mode - ) - if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): - self._current_tado_horizontal_swing = ( - self._tado_zone_data.current_horizontal_swing_mode - ) - - @callback - def _async_update_zone_callback(self) -> None: - """Load tado data and update state.""" - self._async_update_zone_data() - self.async_write_ha_state() - - @callback - def _async_update_home_data(self) -> None: - """Load tado geofencing data into zone.""" - self._tado_geofence_data = self._tado.data["geofence"] - - @callback - def _async_update_home_callback(self) -> None: - """Load tado data and update state.""" - self._async_update_home_data() - self.async_write_ha_state() + await self.coordinator.async_request_refresh() def _normalize_target_temp_for_hvac_mode(self) -> None: def adjust_temp(min_temp, max_temp) -> float | None: @@ -666,7 +655,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): elif self._current_tado_hvac_mode == CONST_MODE_HEAT: self._target_temp = adjust_temp(self._heat_min_temp, self._heat_max_temp) - def _control_hvac( + async def _control_hvac( self, hvac_mode: str | None = None, target_temp: float | None = None, @@ -713,7 +702,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): _LOGGER.debug( "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id ) - self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type) + await self._tado.set_zone_off( + self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type + ) return if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE: @@ -722,17 +713,17 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self.zone_name, self.zone_id, ) - self._tado.reset_zone_overlay(self.zone_id) + await self._tado.reset_zone_overlay(self.zone_id) return overlay_mode = decide_overlay_mode( - tado=self._tado, + coordinator=self._tado, duration=duration, overlay_mode=overlay_mode, zone_id=self.zone_id, ) duration = decide_duration( - tado=self._tado, + coordinator=self._tado, duration=duration, zone_id=self.zone_id, overlay_mode=overlay_mode, @@ -786,7 +777,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ): swing = self._current_tado_swing_mode - self._tado.set_zone_overlay( + await self._tado.set_zone_overlay( zone_id=self.zone_id, overlay_mode=overlay_mode, # What to do when the period ends temperature=temperature_to_send, @@ -801,18 +792,23 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) def _is_valid_setting_for_hvac_mode(self, setting: str) -> bool: - return ( - self._current_tado_capabilities.get(self._current_tado_hvac_mode, {}).get( - setting - ) - is not None + """Determine if a setting is valid for the current HVAC mode.""" + capabilities: str | dict[str, str] = self._current_tado_capabilities.get( + self._current_tado_hvac_mode, {} ) + if isinstance(capabilities, dict): + return capabilities.get(setting) is not None + return False def _is_current_setting_supported_by_current_hvac_mode( self, setting: str, current_state: str | None ) -> bool: - if self._is_valid_setting_for_hvac_mode(setting): - return current_state in self._current_tado_capabilities[ - self._current_tado_hvac_mode - ].get(setting, []) + """Determine if the current setting is supported by the current HVAC mode.""" + capabilities: str | dict[str, str] = self._current_tado_capabilities.get( + self._current_tado_hvac_mode, {} + ) + if isinstance(capabilities, dict) and self._is_valid_setting_for_hvac_mode( + setting + ): + return current_state in capabilities.get(setting, []) return False diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index c7bb7684901..f251a292800 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -10,7 +10,6 @@ from PyTado.interface import Tado import requests.exceptions import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import ( CONF_FALLBACK, @@ -49,7 +52,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, tado = await hass.async_add_executor_job( Tado, data[CONF_USERNAME], data[CONF_PASSWORD] ) - tado_me = await hass.async_add_executor_job(tado.getMe) + tado_me = await hass.async_add_executor_job(tado.get_me) except KeyError as ex: raise InvalidAuth from ex except RuntimeError as ex: @@ -104,14 +107,14 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self._async_abort_entries_match() properties = { key.lower(): value for (key, value) in discovery_info.properties.items() } - await self.async_set_unique_id(properties[zeroconf.ATTR_PROPERTIES_ID]) + await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured() return await self.async_step_user() diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py new file mode 100644 index 00000000000..ddec9e7f292 --- /dev/null +++ b/homeassistant/components/tado/coordinator.py @@ -0,0 +1,391 @@ +"""Coordinator for the Tado integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +from typing import Any + +from PyTado.interface import Tado +from requests import RequestException + +from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_FALLBACK, + CONST_OVERLAY_TADO_DEFAULT, + DOMAIN, + INSIDE_TEMPERATURE_MEASUREMENT, + PRESET_AUTO, + TEMP_OFFSET, +) + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) +SCAN_INTERVAL = timedelta(minutes=5) +SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) + +type TadoConfigEntry = ConfigEntry[TadoDataUpdateCoordinator] + + +class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Class to manage API calls from and to Tado via PyTado.""" + + tado: Tado + home_id: int + home_name: str + config_entry: TadoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + tado: Tado, + debug: bool = False, + ) -> None: + """Initialize the Tado data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._tado = tado + self._username = entry.data[CONF_USERNAME] + self._password = entry.data[CONF_PASSWORD] + self._fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT) + self._debug = debug + + self.home_id: int + self.home_name: str + self.zones: list[dict[Any, Any]] = [] + self.devices: list[dict[Any, Any]] = [] + self.data: dict[str, dict] = { + "device": {}, + "weather": {}, + "geofence": {}, + "zone": {}, + } + + @property + def fallback(self) -> str: + """Return fallback flag to Smart Schedule.""" + return self._fallback + + async def _async_update_data(self) -> dict[str, dict]: + """Fetch the (initial) latest data from Tado.""" + + try: + _LOGGER.debug("Preloading home data") + tado_home_call = await self.hass.async_add_executor_job(self._tado.get_me) + _LOGGER.debug("Preloading zones and devices") + self.zones = await self.hass.async_add_executor_job(self._tado.get_zones) + self.devices = await self.hass.async_add_executor_job( + self._tado.get_devices + ) + except RequestException as err: + raise UpdateFailed(f"Error during Tado setup: {err}") from err + + tado_home = tado_home_call["homes"][0] + self.home_id = tado_home["id"] + self.home_name = tado_home["name"] + + devices = await self._async_update_devices() + zones = await self._async_update_zones() + home = await self._async_update_home() + + self.data["device"] = devices + self.data["zone"] = zones + self.data["weather"] = home["weather"] + self.data["geofence"] = home["geofence"] + + return self.data + + async def _async_update_devices(self) -> dict[str, dict]: + """Update the device data from Tado.""" + + try: + devices = await self.hass.async_add_executor_job(self._tado.get_devices) + except RequestException as err: + _LOGGER.error("Error updating Tado devices: %s", err) + raise UpdateFailed(f"Error updating Tado devices: {err}") from err + + if not devices: + _LOGGER.error("No linked devices found for home ID %s", self.home_id) + raise UpdateFailed(f"No linked devices found for home ID {self.home_id}") + + return await self.hass.async_add_executor_job(self._update_device_info, devices) + + def _update_device_info(self, devices: list[dict[str, Any]]) -> dict[str, dict]: + """Update the device data from Tado.""" + mapped_devices: dict[str, dict] = {} + for device in devices: + device_short_serial_no = device["shortSerialNo"] + _LOGGER.debug("Updating device %s", device_short_serial_no) + try: + if ( + INSIDE_TEMPERATURE_MEASUREMENT + in device["characteristics"]["capabilities"] + ): + _LOGGER.debug( + "Updating temperature offset for device %s", + device_short_serial_no, + ) + device[TEMP_OFFSET] = self._tado.get_device_info( + device_short_serial_no, TEMP_OFFSET + ) + except RequestException as err: + _LOGGER.error( + "Error updating device %s: %s", device_short_serial_no, err + ) + + _LOGGER.debug( + "Device %s updated, with data: %s", device_short_serial_no, device + ) + mapped_devices[device_short_serial_no] = device + + return mapped_devices + + async def _async_update_zones(self) -> dict[int, dict]: + """Update the zone data from Tado.""" + + try: + zone_states_call = await self.hass.async_add_executor_job( + self._tado.get_zone_states + ) + zone_states = zone_states_call["zoneStates"] + except RequestException as err: + _LOGGER.error("Error updating Tado zones: %s", err) + raise UpdateFailed(f"Error updating Tado zones: {err}") from err + + mapped_zones: dict[int, dict] = {} + for zone in zone_states: + mapped_zones[int(zone)] = await self._update_zone(int(zone)) + + return mapped_zones + + async def _update_zone(self, zone_id: int) -> dict[str, str]: + """Update the internal data of a zone.""" + + _LOGGER.debug("Updating zone %s", zone_id) + try: + data = await self.hass.async_add_executor_job( + self._tado.get_zone_state, zone_id + ) + except RequestException as err: + _LOGGER.error("Error updating Tado zone %s: %s", zone_id, err) + raise UpdateFailed(f"Error updating Tado zone {zone_id}: {err}") from err + + _LOGGER.debug("Zone %s updated, with data: %s", zone_id, data) + return data + + async def _async_update_home(self) -> dict[str, dict]: + """Update the home data from Tado.""" + + try: + weather = await self.hass.async_add_executor_job(self._tado.get_weather) + geofence = await self.hass.async_add_executor_job(self._tado.get_home_state) + except RequestException as err: + _LOGGER.error("Error updating Tado home: %s", err) + raise UpdateFailed(f"Error updating Tado home: {err}") from err + + _LOGGER.debug( + "Home data updated, with weather and geofence data: %s, %s", + weather, + geofence, + ) + + return {"weather": weather, "geofence": geofence} + + async def get_capabilities(self, zone_id: int | str) -> dict: + """Fetch the capabilities from Tado.""" + + try: + return await self.hass.async_add_executor_job( + self._tado.get_capabilities, zone_id + ) + except RequestException as err: + raise UpdateFailed(f"Error updating Tado data: {err}") from err + + async def get_auto_geofencing_supported(self) -> bool: + """Fetch the auto geofencing supported from Tado.""" + + try: + return await self.hass.async_add_executor_job( + self._tado.get_auto_geofencing_supported + ) + except RequestException as err: + raise UpdateFailed(f"Error updating Tado data: {err}") from err + + async def reset_zone_overlay(self, zone_id): + """Reset the zone back to the default operation.""" + + try: + await self.hass.async_add_executor_job( + self._tado.reset_zone_overlay, zone_id + ) + await self._update_zone(zone_id) + except RequestException as err: + raise UpdateFailed(f"Error resetting Tado data: {err}") from err + + async def set_presence( + self, + presence=PRESET_HOME, + ): + """Set the presence to home, away or auto.""" + + if presence == PRESET_AWAY: + await self.hass.async_add_executor_job(self._tado.set_away) + elif presence == PRESET_HOME: + await self.hass.async_add_executor_job(self._tado.set_home) + elif presence == PRESET_AUTO: + await self.hass.async_add_executor_job(self._tado.set_auto) + + async def set_zone_overlay( + self, + zone_id=None, + overlay_mode=None, + temperature=None, + duration=None, + device_type="HEATING", + mode=None, + fan_speed=None, + swing=None, + fan_level=None, + vertical_swing=None, + horizontal_swing=None, + ) -> None: + """Set a zone overlay.""" + + _LOGGER.debug( + "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s, type=%s, mode=%s, fan_speed=%s, swing=%s, fan_level=%s, vertical_swing=%s, horizontal_swing=%s", + zone_id, + overlay_mode, + temperature, + duration, + device_type, + mode, + fan_speed, + swing, + fan_level, + vertical_swing, + horizontal_swing, + ) + + try: + await self.hass.async_add_executor_job( + self._tado.set_zone_overlay, + zone_id, + overlay_mode, + temperature, + duration, + device_type, + "ON", + mode, + fan_speed, + swing, + fan_level, + vertical_swing, + horizontal_swing, + ) + + except RequestException as err: + raise UpdateFailed(f"Error setting Tado overlay: {err}") from err + + await self._update_zone(zone_id) + + async def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): + """Set a zone to off.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_zone_overlay, + zone_id, + overlay_mode, + None, + None, + device_type, + "OFF", + ) + except RequestException as err: + raise UpdateFailed(f"Error setting Tado overlay: {err}") from err + + await self._update_zone(zone_id) + + async def set_temperature_offset(self, device_id, offset): + """Set temperature offset of device.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_temp_offset, device_id, offset + ) + except RequestException as err: + raise UpdateFailed(f"Error setting Tado temperature offset: {err}") from err + + async def set_meter_reading(self, reading: int) -> dict[str, Any]: + """Send meter reading to Tado.""" + dt: str = datetime.now().strftime("%Y-%m-%d") + if self._tado is None: + raise HomeAssistantError("Tado client is not initialized") + + try: + return await self.hass.async_add_executor_job( + self._tado.set_eiq_meter_readings, dt, reading + ) + except RequestException as err: + raise UpdateFailed(f"Error setting Tado meter reading: {err}") from err + + +class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Class to manage the mobile devices from Tado via PyTado.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + tado: Tado, + ) -> None: + """Initialize the Tado data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_MOBILE_DEVICE_INTERVAL, + ) + self._tado = tado + self.data: dict[str, dict] = {} + + async def _async_update_data(self) -> dict[str, dict]: + """Fetch the latest data from Tado.""" + + try: + mobile_devices = await self.hass.async_add_executor_job( + self._tado.get_mobile_devices + ) + except RequestException as err: + _LOGGER.error("Error updating Tado mobile devices: %s", err) + raise UpdateFailed(f"Error updating Tado mobile devices: {err}") from err + + mapped_mobile_devices: dict[str, dict] = {} + for mobile_device in mobile_devices: + mobile_device_id = mobile_device["id"] + _LOGGER.debug("Updating mobile device %s", mobile_device_id) + try: + mapped_mobile_devices[mobile_device_id] = mobile_device + _LOGGER.debug( + "Mobile device %s updated, with data: %s", + mobile_device_id, + mobile_device, + ) + except RequestException: + _LOGGER.error( + "Unable to connect to Tado while updating mobile device %s", + mobile_device_id, + ) + + self.data["mobile_device"] = mapped_mobile_devices + return self.data diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 95e031329c3..a9be560f434 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -11,12 +11,15 @@ from homeassistant.components.device_tracker import ( from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import TadoConfigEntry -from .const import DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED -from .tado_connector import TadoConnector +from .const import DOMAIN +from .coordinator import TadoMobileDeviceUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,7 +31,7 @@ async def async_setup_entry( ) -> None: """Set up the Tado device scannery entity.""" _LOGGER.debug("Setting up Tado device scanner entity") - tado = entry.runtime_data + tado = entry.runtime_data.mobile_coordinator tracked: set = set() # Fix non-string unique_id for device trackers @@ -49,58 +52,56 @@ async def async_setup_entry( update_devices() - entry.async_on_unload( - async_dispatcher_connect( - hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(tado.home_id), - update_devices, - ) - ) - @callback def add_tracked_entities( hass: HomeAssistant, - tado: TadoConnector, + coordinator: TadoMobileDeviceUpdateCoordinator, async_add_entities: AddEntitiesCallback, tracked: set[str], ) -> None: """Add new tracker entities from Tado.""" _LOGGER.debug("Fetching Tado devices from API for (newly) tracked entities") new_tracked = [] - for device_key, device in tado.data["mobile_device"].items(): + for device_key, device in coordinator.data["mobile_device"].items(): if device_key in tracked: continue _LOGGER.debug( "Adding Tado device %s with deviceID %s", device["name"], device_key ) - new_tracked.append(TadoDeviceTrackerEntity(device_key, device["name"], tado)) + new_tracked.append( + TadoDeviceTrackerEntity(device_key, device["name"], coordinator) + ) tracked.add(device_key) async_add_entities(new_tracked) -class TadoDeviceTrackerEntity(TrackerEntity): +class TadoDeviceTrackerEntity(CoordinatorEntity[DataUpdateCoordinator], TrackerEntity): """A Tado Device Tracker entity.""" - _attr_should_poll = False _attr_available = False def __init__( self, device_id: str, device_name: str, - tado: TadoConnector, + coordinator: TadoMobileDeviceUpdateCoordinator, ) -> None: """Initialize a Tado Device Tracker entity.""" - super().__init__() + super().__init__(coordinator) self._attr_unique_id = str(device_id) self._device_id = device_id self._device_name = device_name - self._tado = tado self._active = False + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_state() + super()._handle_coordinator_update() + @callback def update_state(self) -> None: """Update the Tado device.""" @@ -109,7 +110,7 @@ class TadoDeviceTrackerEntity(TrackerEntity): self._device_name, self._device_id, ) - device = self._tado.data["mobile_device"][self._device_id] + device = self.coordinator.data["mobile_device"][self._device_id] self._attr_available = False _LOGGER.debug( @@ -129,25 +130,6 @@ class TadoDeviceTrackerEntity(TrackerEntity): else: _LOGGER.debug("Tado device %s is not at home", device["name"]) - @callback - def on_demand_update(self) -> None: - """Update state on demand.""" - self.update_state() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register state update callback.""" - _LOGGER.debug("Registering Tado device tracker entity") - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self._tado.home_id), - self.on_demand_update, - ) - ) - - self.update_state() - @property def name(self) -> str: """Return the name of the device.""" diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index 6bb90ab849a..971b2863aba 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -1,21 +1,30 @@ """Base class for Tado entity.""" +import logging + from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TadoConnector from .const import DEFAULT_NAME, DOMAIN, TADO_HOME, TADO_ZONE +from .coordinator import TadoDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) -class TadoDeviceEntity(Entity): - """Base implementation for Tado device.""" +class TadoCoordinatorEntity(CoordinatorEntity[TadoDataUpdateCoordinator]): + """Base class for Tado entity.""" - _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, device_info: dict[str, str]) -> None: + +class TadoDeviceEntity(TadoCoordinatorEntity): + """Base implementation for Tado device.""" + + def __init__( + self, device_info: dict[str, str], coordinator: TadoDataUpdateCoordinator + ) -> None: """Initialize a Tado device.""" - super().__init__() + super().__init__(coordinator) self._device_info = device_info self.device_name = device_info["serialNo"] self.device_id = device_info["shortSerialNo"] @@ -30,35 +39,35 @@ class TadoDeviceEntity(Entity): ) -class TadoHomeEntity(Entity): +class TadoHomeEntity(TadoCoordinatorEntity): """Base implementation for Tado home.""" - _attr_should_poll = False - _attr_has_entity_name = True - - def __init__(self, tado: TadoConnector) -> None: + def __init__(self, coordinator: TadoDataUpdateCoordinator) -> None: """Initialize a Tado home.""" - super().__init__() - self.home_name = tado.home_name - self.home_id = tado.home_id + super().__init__(coordinator) + self.home_name = coordinator.home_name + self.home_id = coordinator.home_id self._attr_device_info = DeviceInfo( configuration_url="https://app.tado.com", - identifiers={(DOMAIN, str(tado.home_id))}, + identifiers={(DOMAIN, str(coordinator.home_id))}, manufacturer=DEFAULT_NAME, model=TADO_HOME, - name=tado.home_name, + name=coordinator.home_name, ) -class TadoZoneEntity(Entity): +class TadoZoneEntity(TadoCoordinatorEntity): """Base implementation for Tado zone.""" - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__(self, zone_name: str, home_id: int, zone_id: int) -> None: + def __init__( + self, + zone_name: str, + home_id: int, + zone_id: int, + coordinator: TadoDataUpdateCoordinator, + ) -> None: """Initialize a Tado zone.""" - super().__init__() + super().__init__(coordinator) self.zone_name = zone_name self.zone_id = zone_id self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py index 558aee164d0..571a757a3e8 100644 --- a/homeassistant/components/tado/helper.py +++ b/homeassistant/components/tado/helper.py @@ -5,26 +5,27 @@ from .const import ( CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TIMER, ) -from .tado_connector import TadoConnector +from .coordinator import TadoDataUpdateCoordinator def decide_overlay_mode( - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, duration: int | None, zone_id: int, overlay_mode: str | None = None, ) -> str: """Return correct overlay mode based on the action and defaults.""" + # If user gave duration then overlay mode needs to be timer if duration: return CONST_OVERLAY_TIMER # If no duration or timer set to fallback setting if overlay_mode is None: - overlay_mode = tado.fallback or CONST_OVERLAY_TADO_MODE + overlay_mode = coordinator.fallback or CONST_OVERLAY_TADO_MODE # If default is Tado default then look it up if overlay_mode == CONST_OVERLAY_TADO_DEFAULT: overlay_mode = ( - tado.data["zone"][zone_id].default_overlay_termination_type + coordinator.data["zone"][zone_id].default_overlay_termination_type or CONST_OVERLAY_TADO_MODE ) @@ -32,18 +33,19 @@ def decide_overlay_mode( def decide_duration( - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, duration: int | None, zone_id: int, overlay_mode: str | None = None, ) -> None | int: """Return correct duration based on the selected overlay mode/duration and tado config.""" + # If we ended up with a timer but no duration, set a default duration # If we ended up with a timer but no duration, set a default duration if overlay_mode == CONST_OVERLAY_TIMER and duration is None: duration = ( - int(tado.data["zone"][zone_id].default_overlay_termination_duration) - if tado.data["zone"][zone_id].default_overlay_termination_duration + int(coordinator.data["zone"][zone_id].default_overlay_termination_duration) + if coordinator.data["zone"][zone_id].default_overlay_termination_duration is not None else 3600 ) @@ -53,6 +55,7 @@ def decide_duration( def generate_supported_fanmodes(tado_to_ha_mapping: dict[str, str], options: list[str]): """Return correct list of fan modes or None.""" + supported_fanmodes = [ tado_to_ha_mapping.get(option) for option in options diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 652d51f0261..856a0c5402b 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -1,7 +1,7 @@ { "domain": "tado", "name": "Tado", - "codeowners": ["@chiefdragon", "@erwindouna"], + "codeowners": ["@erwindouna"], "config_flow": true, "dhcp": [ { @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.17.7"] + "requirements": ["python-tado==0.18.5"] } diff --git a/homeassistant/components/tado/models.py b/homeassistant/components/tado/models.py new file mode 100644 index 00000000000..08bdaceaf03 --- /dev/null +++ b/homeassistant/components/tado/models.py @@ -0,0 +1,13 @@ +"""Models for use in Tado integration.""" + +from dataclasses import dataclass + +from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator + + +@dataclass +class TadoData: + """Class to hold Tado data.""" + + coordinator: TadoDataUpdateCoordinator + mobile_coordinator: TadoMobileDeviceUpdateCoordinator diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 8bb13a02cd1..037b33574e7 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -24,13 +23,12 @@ from .const import ( CONDITIONS_MAP, SENSOR_DATA_CATEGORY_GEOFENCE, SENSOR_DATA_CATEGORY_WEATHER, - SIGNAL_TADO_UPDATE_RECEIVED, TYPE_AIR_CONDITIONING, TYPE_HEATING, TYPE_HOT_WATER, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoHomeEntity, TadoZoneEntity -from .tado_connector import TadoConnector _LOGGER = logging.getLogger(__name__) @@ -197,7 +195,7 @@ async def async_setup_entry( ) -> None: """Set up the Tado sensor platform.""" - tado = entry.runtime_data + tado = entry.runtime_data.coordinator zones = tado.zones entities: list[SensorEntity] = [] @@ -232,39 +230,22 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): entity_description: TadoSensorEntityDescription def __init__( - self, tado: TadoConnector, entity_description: TadoSensorEntityDescription + self, + coordinator: TadoDataUpdateCoordinator, + entity_description: TadoSensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - super().__init__(tado) - self._tado = tado + super().__init__(coordinator) - self._attr_unique_id = f"{entity_description.key} {tado.home_id}" - - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), - self._async_update_callback, - ) - ) - self._async_update_home_data() + self._attr_unique_id = f"{entity_description.key} {coordinator.home_id}" @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_home_data() - self.async_write_ha_state() - - @callback - def _async_update_home_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - tado_weather_data = self._tado.data["weather"] - tado_geofence_data = self._tado.data["geofence"] + tado_weather_data = self.coordinator.data["weather"] + tado_geofence_data = self.coordinator.data["geofence"] except KeyError: return @@ -278,6 +259,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( tado_sensor_data ) + super()._handle_coordinator_update() class TadoZoneSensor(TadoZoneEntity, SensorEntity): @@ -287,43 +269,24 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, entity_description: TadoSensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) - self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" - - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_callback, - ) + self._attr_unique_id = ( + f"{entity_description.key} {zone_id} {coordinator.home_id}" ) - self._async_update_zone_data() @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_zone_data() - self.async_write_ha_state() - - @callback - def _async_update_zone_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self.coordinator.data["zone"][self.zone_id] except KeyError: return @@ -332,3 +295,4 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( tado_zone_data ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/tado/services.py b/homeassistant/components/tado/services.py index 89711808066..d931ea303e9 100644 --- a/homeassistant/components/tado/services.py +++ b/homeassistant/components/tado/services.py @@ -43,11 +43,8 @@ def setup_services(hass: HomeAssistant) -> None: if entry is None: raise ServiceValidationError("Config entry not found") - tadoconnector = entry.runtime_data - - response: dict = await hass.async_add_executor_job( - tadoconnector.set_meter_reading, call.data[CONF_READING] - ) + coordinator = entry.runtime_data.coordinator + response: dict = await coordinator.set_meter_reading(call.data[CONF_READING]) if ATTR_MESSAGE in response: raise HomeAssistantError(response[ATTR_MESSAGE]) diff --git a/homeassistant/components/tado/tado_connector.py b/homeassistant/components/tado/tado_connector.py deleted file mode 100644 index 5ed53675153..00000000000 --- a/homeassistant/components/tado/tado_connector.py +++ /dev/null @@ -1,332 +0,0 @@ -"""Tado Connector a class to store the data as an object.""" - -from datetime import datetime, timedelta -import logging -from typing import Any - -from PyTado.interface import Tado -from requests import RequestException - -from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.util import Throttle - -from .const import ( - INSIDE_TEMPERATURE_MEASUREMENT, - PRESET_AUTO, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, - SIGNAL_TADO_UPDATE_RECEIVED, - TEMP_OFFSET, -) - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) -SCAN_INTERVAL = timedelta(minutes=5) -SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) - - -_LOGGER = logging.getLogger(__name__) - - -class TadoConnector: - """An object to store the Tado data.""" - - def __init__( - self, hass: HomeAssistant, username: str, password: str, fallback: str - ) -> None: - """Initialize Tado Connector.""" - self.hass = hass - self._username = username - self._password = password - self._fallback = fallback - - self.home_id: int = 0 - self.home_name = None - self.tado = None - self.zones: list[dict[Any, Any]] = [] - self.devices: list[dict[Any, Any]] = [] - self.data: dict[str, dict] = { - "device": {}, - "mobile_device": {}, - "weather": {}, - "geofence": {}, - "zone": {}, - } - - @property - def fallback(self): - """Return fallback flag to Smart Schedule.""" - return self._fallback - - def setup(self): - """Connect to Tado and fetch the zones.""" - self.tado = Tado(self._username, self._password) - # Load zones and devices - self.zones = self.tado.get_zones() - self.devices = self.tado.get_devices() - tado_home = self.tado.get_me()["homes"][0] - self.home_id = tado_home["id"] - self.home_name = tado_home["name"] - - def get_mobile_devices(self): - """Return the Tado mobile devices.""" - return self.tado.get_mobile_devices() - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update the registered zones.""" - self.update_devices() - self.update_mobile_devices() - self.update_zones() - self.update_home() - - def update_mobile_devices(self) -> None: - """Update the mobile devices.""" - try: - mobile_devices = self.get_mobile_devices() - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating mobile devices") - return - - if not mobile_devices: - _LOGGER.debug("No linked mobile devices found for home ID %s", self.home_id) - return - - # Errors are planned to be converted to exceptions - # in PyTado library, so this can be removed - if isinstance(mobile_devices, dict) and mobile_devices.get("errors"): - _LOGGER.error( - "Error for home ID %s while updating mobile devices: %s", - self.home_id, - mobile_devices["errors"], - ) - return - - for mobile_device in mobile_devices: - self.data["mobile_device"][mobile_device["id"]] = mobile_device - _LOGGER.debug( - "Dispatching update to %s mobile device: %s", - self.home_id, - mobile_device, - ) - - dispatcher_send( - self.hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self.home_id), - ) - - def update_devices(self): - """Update the device data from Tado.""" - try: - devices = self.tado.get_devices() - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating devices") - return - - if not devices: - _LOGGER.debug("No linked devices found for home ID %s", self.home_id) - return - - # Errors are planned to be converted to exceptions - # in PyTado library, so this can be removed - if isinstance(devices, dict) and devices.get("errors"): - _LOGGER.error( - "Error for home ID %s while updating devices: %s", - self.home_id, - devices["errors"], - ) - return - - for device in devices: - device_short_serial_no = device["shortSerialNo"] - _LOGGER.debug("Updating device %s", device_short_serial_no) - try: - if ( - INSIDE_TEMPERATURE_MEASUREMENT - in device["characteristics"]["capabilities"] - ): - device[TEMP_OFFSET] = self.tado.get_device_info( - device_short_serial_no, TEMP_OFFSET - ) - except RuntimeError: - _LOGGER.error( - "Unable to connect to Tado while updating device %s", - device_short_serial_no, - ) - return - - self.data["device"][device_short_serial_no] = device - - _LOGGER.debug( - "Dispatching update to %s device %s: %s", - self.home_id, - device_short_serial_no, - device, - ) - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self.home_id, "device", device_short_serial_no - ), - ) - - def update_zones(self): - """Update the zone data from Tado.""" - try: - zone_states = self.tado.get_zone_states()["zoneStates"] - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating zones") - return - - for zone in zone_states: - self.update_zone(int(zone)) - - def update_zone(self, zone_id): - """Update the internal data from Tado.""" - _LOGGER.debug("Updating zone %s", zone_id) - try: - data = self.tado.get_zone_state(zone_id) - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating zone %s", zone_id) - return - - self.data["zone"][zone_id] = data - - _LOGGER.debug( - "Dispatching update to %s zone %s: %s", - self.home_id, - zone_id, - data, - ) - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone_id), - ) - - def update_home(self): - """Update the home data from Tado.""" - try: - self.data["weather"] = self.tado.get_weather() - self.data["geofence"] = self.tado.get_home_state() - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"), - ) - except RuntimeError: - _LOGGER.error( - "Unable to connect to Tado while updating weather and geofence data" - ) - return - - def get_capabilities(self, zone_id): - """Return the capabilities of the devices.""" - return self.tado.get_capabilities(zone_id) - - def get_auto_geofencing_supported(self): - """Return whether the Tado Home supports auto geofencing.""" - return self.tado.get_auto_geofencing_supported() - - def reset_zone_overlay(self, zone_id): - """Reset the zone back to the default operation.""" - self.tado.reset_zone_overlay(zone_id) - self.update_zone(zone_id) - - def set_presence( - self, - presence=PRESET_HOME, - ): - """Set the presence to home, away or auto.""" - if presence == PRESET_AWAY: - self.tado.set_away() - elif presence == PRESET_HOME: - self.tado.set_home() - elif presence == PRESET_AUTO: - self.tado.set_auto() - - # Update everything when changing modes - self.update_zones() - self.update_home() - - def set_zone_overlay( - self, - zone_id=None, - overlay_mode=None, - temperature=None, - duration=None, - device_type="HEATING", - mode=None, - fan_speed=None, - swing=None, - fan_level=None, - vertical_swing=None, - horizontal_swing=None, - ): - """Set a zone overlay.""" - _LOGGER.debug( - ( - "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s," - " type=%s, mode=%s fan_speed=%s swing=%s fan_level=%s vertical_swing=%s horizontal_swing=%s" - ), - zone_id, - overlay_mode, - temperature, - duration, - device_type, - mode, - fan_speed, - swing, - fan_level, - vertical_swing, - horizontal_swing, - ) - - try: - self.tado.set_zone_overlay( - zone_id, - overlay_mode, - temperature, - duration, - device_type, - "ON", - mode, - fan_speed=fan_speed, - swing=swing, - fan_level=fan_level, - vertical_swing=vertical_swing, - horizontal_swing=horizontal_swing, - ) - - except RequestException as exc: - _LOGGER.error("Could not set zone overlay: %s", exc) - - self.update_zone(zone_id) - - def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): - """Set a zone to off.""" - try: - self.tado.set_zone_overlay( - zone_id, overlay_mode, None, None, device_type, "OFF" - ) - except RequestException as exc: - _LOGGER.error("Could not set zone overlay: %s", exc) - - self.update_zone(zone_id) - - def set_temperature_offset(self, device_id, offset): - """Set temperature offset of device.""" - try: - self.tado.set_temp_offset(device_id, offset) - except RequestException as exc: - _LOGGER.error("Could not set temperature offset: %s", exc) - - def set_meter_reading(self, reading: int) -> dict[str, Any]: - """Send meter reading to Tado.""" - dt: str = datetime.now().strftime("%Y-%m-%d") - if self.tado is None: - raise HomeAssistantError("Tado client is not initialized") - - try: - return self.tado.set_eiq_meter_readings(date=dt, reading=reading) - except RequestException as exc: - raise HomeAssistantError("Could not set meter reading") from exc diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 6c964cfaddd..02fbb3f5e23 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -12,7 +12,6 @@ from homeassistant.components.water_heater import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType @@ -26,13 +25,12 @@ from .const import ( CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TIMER, - SIGNAL_TADO_UPDATE_RECEIVED, TYPE_HOT_WATER, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoZoneEntity from .helper import decide_duration, decide_overlay_mode from .repairs import manage_water_heater_fallback_issue -from .tado_connector import TadoConnector _LOGGER = logging.getLogger(__name__) @@ -67,8 +65,9 @@ async def async_setup_entry( ) -> None: """Set up the Tado water heater platform.""" - tado = entry.runtime_data - entities = await hass.async_add_executor_job(_generate_entities, tado) + data = entry.runtime_data + coordinator = data.coordinator + entities = await _generate_entities(coordinator) platform = entity_platform.async_get_current_platform() @@ -83,27 +82,29 @@ async def async_setup_entry( manage_water_heater_fallback_issue( hass=hass, water_heater_names=[e.zone_name for e in entities], - integration_overlay_fallback=tado.fallback, + integration_overlay_fallback=coordinator.fallback, ) -def _generate_entities(tado: TadoConnector) -> list: +async def _generate_entities(coordinator: TadoDataUpdateCoordinator) -> list: """Create all water heater entities.""" entities = [] - for zone in tado.zones: + for zone in coordinator.zones: if zone["type"] == TYPE_HOT_WATER: - entity = create_water_heater_entity( - tado, zone["name"], zone["id"], str(zone["name"]) + entity = await create_water_heater_entity( + coordinator, zone["name"], zone["id"], str(zone["name"]) ) entities.append(entity) return entities -def create_water_heater_entity(tado: TadoConnector, name: str, zone_id: int, zone: str): +async def create_water_heater_entity( + coordinator: TadoDataUpdateCoordinator, name: str, zone_id: int, zone: str +): """Create a Tado water heater device.""" - capabilities = tado.get_capabilities(zone_id) + capabilities = await coordinator.get_capabilities(zone_id) supports_temperature_control = capabilities["canSetTemperature"] @@ -116,7 +117,7 @@ def create_water_heater_entity(tado: TadoConnector, name: str, zone_id: int, zon max_temp = None return TadoWaterHeater( - tado, + coordinator, name, zone_id, supports_temperature_control, @@ -134,7 +135,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, supports_temperature_control: bool, @@ -142,11 +143,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): max_temp, ) -> None: """Initialize of Tado water heater entity.""" - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) self.zone_id = zone_id - self._attr_unique_id = f"{zone_id} {tado.home_id}" + self._attr_unique_id = f"{zone_id} {coordinator.home_id}" self._device_is_active = False @@ -164,19 +164,14 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self._overlay_mode = CONST_MODE_SMART_SCHEDULE self._tado_zone_data: Any = None - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_callback, - ) - ) self._async_update_data() + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_data() + super()._handle_coordinator_update() + @property def current_operation(self) -> str | None: """Return current readable operation mode.""" @@ -202,7 +197,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): """Return the maximum temperature.""" return self._max_temperature - def set_operation_mode(self, operation_mode: str) -> None: + async def async_set_operation_mode(self, operation_mode: str) -> None: """Set new operation mode.""" mode = None @@ -213,18 +208,20 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): elif operation_mode == MODE_HEAT: mode = CONST_MODE_HEAT - self._control_heater(hvac_mode=mode) + await self._control_heater(hvac_mode=mode) + await self.coordinator.async_request_refresh() - def set_timer(self, time_period: int, temperature: float | None = None): + async def set_timer(self, time_period: int, temperature: float | None = None): """Set the timer on the entity, and temperature if supported.""" if not self._supports_temperature_control and temperature is not None: temperature = None - self._control_heater( + await self._control_heater( hvac_mode=CONST_MODE_HEAT, target_temp=temperature, duration=time_period ) + await self.coordinator.async_request_refresh() - def set_temperature(self, **kwargs: Any) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if not self._supports_temperature_control or temperature is None: @@ -235,10 +232,11 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): CONST_MODE_AUTO, CONST_MODE_SMART_SCHEDULE, ): - self._control_heater(target_temp=temperature) + await self._control_heater(target_temp=temperature) return - self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT) + await self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT) + await self.coordinator.async_request_refresh() @callback def _async_update_callback(self) -> None: @@ -250,10 +248,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): def _async_update_data(self) -> None: """Load tado data.""" _LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id) - self._tado_zone_data = self._tado.data["zone"][self.zone_id] + self._tado_zone_data = self.coordinator.data["zone"][self.zone_id] self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode - def _control_heater( + async def _control_heater( self, hvac_mode: str | None = None, target_temp: float | None = None, @@ -276,23 +274,26 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self.zone_name, self.zone_id, ) - self._tado.reset_zone_overlay(self.zone_id) + await self.coordinator.reset_zone_overlay(self.zone_id) + await self.coordinator.async_request_refresh() return if self._current_tado_hvac_mode == CONST_MODE_OFF: _LOGGER.debug( "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id ) - self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER) + await self.coordinator.set_zone_off( + self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER + ) return overlay_mode = decide_overlay_mode( - tado=self._tado, + coordinator=self.coordinator, duration=duration, zone_id=self.zone_id, ) duration = decide_duration( - tado=self._tado, + coordinator=self.coordinator, duration=duration, zone_id=self.zone_id, overlay_mode=overlay_mode, @@ -304,7 +305,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self.zone_id, self._target_temp, ) - self._tado.set_zone_overlay( + await self.coordinator.set_zone_overlay( zone_id=self.zone_id, overlay_mode=overlay_mode, temperature=self._target_temp, diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 95efae3d386..47c1d14ce60 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -106,7 +106,6 @@ class TagStore(Store[collection.SerializedStorageCollection]): for tag in data["items"]: # Copy name in tag store to the entity registry _create_entry(entity_registry, tag[CONF_ID], tag.get(CONF_NAME)) - tag["migrated"] = True if old_major_version == 1 and old_minor_version < 3: # Version 1.3 removes tag_id from the store for tag in data["items"]: @@ -178,10 +177,7 @@ class TagStorageCollection(collection.DictStorageCollection): We don't store the name, it's stored in the entity registry. """ - # Preserve the name of migrated entries to allow downgrading to 2024.5 - # without losing tag names. This can be removed in HA Core 2025.1. - migrated = item_id in self.data and "migrated" in self.data[item_id] - return {k: v for k, v in item.items() if k != CONF_NAME or migrated} + return {k: v for k, v in item.items() if k != CONF_NAME} class TagDictStorageCollectionWebsocket( diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index 6f1a234e94a..b191d78f2a6 100644 --- a/homeassistant/components/tailwind/__init__.py +++ b/homeassistant/components/tailwind/__init__.py @@ -2,14 +2,12 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .const import DOMAIN -from .coordinator import TailwindDataUpdateCoordinator -from .typing import TailwindConfigEntry +from .coordinator import TailwindConfigEntry, TailwindDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.NUMBER] @@ -39,6 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TailwindConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TailwindConfigEntry) -> bool: """Unload Tailwind config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index 0ce0b4bd964..d2f8e1e2ced 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -16,8 +16,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .coordinator import TailwindConfigEntry from .entity import TailwindDoorEntity -from .typing import TailwindConfigEntry @dataclass(kw_only=True, frozen=True) diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index 2a675bbfdf7..edff3434866 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -19,8 +19,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +from .coordinator import TailwindConfigEntry from .entity import TailwindEntity -from .typing import TailwindConfigEntry @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index 48fe2d23727..daf0fbd32b7 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -15,8 +15,6 @@ from gotailwind import ( ) import voluptuous as vol -from homeassistant.components import zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow @@ -27,6 +25,8 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -83,7 +83,7 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery of a Tailwind device.""" if not (device_id := discovery_info.properties.get("device_id")): diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py index 4d1b4af74c9..770751ccc3b 100644 --- a/homeassistant/components/tailwind/coordinator.py +++ b/homeassistant/components/tailwind/coordinator.py @@ -18,11 +18,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER +type TailwindConfigEntry = ConfigEntry[TailwindDataUpdateCoordinator] + class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]): """Class to manage fetching Tailwind data.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: TailwindConfigEntry) -> None: """Initialize the coordinator.""" self.tailwind = Tailwind( host=entry.data[CONF_HOST], @@ -32,6 +34,7 @@ class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]) super().__init__( hass, LOGGER, + config_entry=entry, name=f"{DOMAIN}_{entry.data[CONF_HOST]}", update_interval=timedelta(seconds=5), ) diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 116fb4a9e6c..8ea1c7d4f6d 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -23,8 +23,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, LOGGER +from .coordinator import TailwindConfigEntry from .entity import TailwindDoorEntity -from .typing import TailwindConfigEntry async def async_setup_entry( diff --git a/homeassistant/components/tailwind/diagnostics.py b/homeassistant/components/tailwind/diagnostics.py index 5d681356647..b7a51b56775 100644 --- a/homeassistant/components/tailwind/diagnostics.py +++ b/homeassistant/components/tailwind/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from .typing import TailwindConfigEntry +from .coordinator import TailwindConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py index ec13dc7bd1f..dafb46e6f63 100644 --- a/homeassistant/components/tailwind/entity.py +++ b/homeassistant/components/tailwind/entity.py @@ -58,7 +58,7 @@ class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")}, via_device=(DOMAIN, coordinator.data.device_id), - name=f"Door {coordinator.data.doors[door_id].index+1}", + name=f"Door {coordinator.data.doors[door_id].index + 1}", manufacturer="Tailwind", model=coordinator.data.product, sw_version=coordinator.data.firmware_version, diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index 705f591785f..7ad43c929a7 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/tailwind", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["gotailwind==0.2.4"], + "requirements": ["gotailwind==0.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index 0ff1f444280..b67df9a6a25 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -15,8 +15,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +from .coordinator import TailwindConfigEntry from .entity import TailwindEntity -from .typing import TailwindConfigEntry @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/tailwind/quality_scale.yaml b/homeassistant/components/tailwind/quality_scale.yaml new file mode 100644 index 00000000000..90c5d0d5837 --- /dev/null +++ b/homeassistant/components/tailwind/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: todo + docs-high-level-description: todo + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration connects to a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: exempt + comment: | + The coordinator needs translation when the update failed. + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not raise any repairable issues. + stale-devices: + status: exempt + comment: | + This integration connects to a single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/tailwind/typing.py b/homeassistant/components/tailwind/typing.py deleted file mode 100644 index 514a94a8e78..00000000000 --- a/homeassistant/components/tailwind/typing.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Typings for the Tailwind integration.""" - -from homeassistant.config_entries import ConfigEntry - -from .coordinator import TailwindDataUpdateCoordinator - -type TailwindConfigEntry = ConfigEntry[TailwindDataUpdateCoordinator] diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py index 9deb846f8e2..5b1adc839ac 100644 --- a/homeassistant/components/tasmota/config_flow.py +++ b/homeassistant/components/tasmota/config_flow.py @@ -66,8 +66,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: bad_prefix = False prefix = user_input[CONF_DISCOVERY_PREFIX] - if prefix.endswith("/#"): - prefix = prefix[:-2] + prefix = prefix.removesuffix("/#") try: valid_subscribe_topic(f"{prefix}/#") except vol.Invalid: diff --git a/homeassistant/components/tasmota/fan.py b/homeassistant/components/tasmota/fan.py index 15664201d99..e927bd6ad72 100644 --- a/homeassistant/components/tasmota/fan.py +++ b/homeassistant/components/tasmota/fan.py @@ -72,7 +72,6 @@ class TasmotaFan( ) _fan_speed = tasmota_const.FAN_SPEED_MEDIUM _tasmota_entity: tasmota_fan.TasmotaFan - _enable_turn_on_off_backwards_compatibility = False def __init__(self, **kwds: Any) -> None: """Initialize the Tasmota fan.""" diff --git a/homeassistant/components/tasmota/light.py b/homeassistant/components/tasmota/light.py index 9b69ee60524..a06e77eceb1 100644 --- a/homeassistant/components/tasmota/light.py +++ b/homeassistant/components/tasmota/light.py @@ -18,7 +18,7 @@ from hatasmota.models import DiscoveryHashType from homeassistant.components import light from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -32,6 +32,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW @@ -199,19 +200,27 @@ class TasmotaLight( return self._color_mode @property - def color_temp(self) -> int | None: - """Return the color temperature in mired.""" - return self._color_temp + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + return ( + color_util.color_temperature_mired_to_kelvin(self._color_temp) + if self._color_temp + else None + ) @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return self._tasmota_entity.min_mireds + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin( + self._tasmota_entity.min_mireds + ) @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return self._tasmota_entity.max_mireds + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin( + self._tasmota_entity.max_mireds + ) @property def effect(self) -> str | None: @@ -255,8 +264,13 @@ class TasmotaLight( if ATTR_BRIGHTNESS in kwargs and brightness_supported(supported_color_modes): attributes["brightness"] = scale_brightness(kwargs[ATTR_BRIGHTNESS]) - if ATTR_COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in supported_color_modes: - attributes["color_temp"] = int(kwargs[ATTR_COLOR_TEMP]) + if ( + ATTR_COLOR_TEMP_KELVIN in kwargs + and ColorMode.COLOR_TEMP in supported_color_modes + ): + attributes["color_temp"] = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) if ATTR_EFFECT in kwargs: attributes["effect"] = kwargs[ATTR_EFFECT] diff --git a/homeassistant/components/technove/binary_sensor.py b/homeassistant/components/technove/binary_sensor.py index 1ecefe6f85c..f231e206c96 100644 --- a/homeassistant/components/technove/binary_sensor.py +++ b/homeassistant/components/technove/binary_sensor.py @@ -4,28 +4,19 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING from technove import Station as TechnoVEStation from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from . import TechnoVEConfigEntry -from .const import DOMAIN from .coordinator import TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity @@ -34,7 +25,6 @@ from .entity import TechnoVEEntity class TechnoVEBinarySensorDescription(BinarySensorEntityDescription): """Describes TechnoVE binary sensor entity.""" - deprecated_version: str | None = None value_fn: Callable[[TechnoVEStation], bool | None] @@ -57,15 +47,6 @@ BINARY_SENSORS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda station: station.info.is_battery_protected, ), - TechnoVEBinarySensorDescription( - key="is_session_active", - entity_category=EntityCategory.DIAGNOSTIC, - device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - value_fn=lambda station: station.info.is_session_active, - deprecated_version="2025.2.0", - # Disabled by default, as this entity is deprecated - entity_registry_enabled_default=False, - ), TechnoVEBinarySensorDescription( key="is_static_ip", translation_key="is_static_ip", @@ -113,34 +94,3 @@ class TechnoVEBinarySensorEntity(TechnoVEEntity, BinarySensorEntity): """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) - - async def async_added_to_hass(self) -> None: - """Raise issue when entity is registered and was not disabled.""" - if TYPE_CHECKING: - assert self.unique_id - if entity_id := er.async_get(self.hass).async_get_entity_id( - BINARY_SENSOR_DOMAIN, DOMAIN, self.unique_id - ): - if self.enabled and self.entity_description.deprecated_version: - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_entity_{self.entity_description.key}", - breaks_in_ha_version=self.entity_description.deprecated_version, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_entity_{self.entity_description.key}", - translation_placeholders={ - "sensor_name": self.name - if isinstance(self.name, str) - else entity_id, - "entity": entity_id, - }, - ) - else: - async_delete_issue( - self.hass, - DOMAIN, - f"deprecated_entity_{self.entity_description.key}", - ) - await super().async_added_to_hass() diff --git a/homeassistant/components/technove/config_flow.py b/homeassistant/components/technove/config_flow.py index 0e4f026ba5c..7ad9829b631 100644 --- a/homeassistant/components/technove/config_flow.py +++ b/homeassistant/components/technove/config_flow.py @@ -5,10 +5,11 @@ from typing import Any from technove import Station as TechnoVEStation, TechnoVE, TechnoVEConnectionError import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -49,7 +50,7 @@ class TechnoVEConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" # Abort quick if the device with provided mac is already configured diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 7175b7c2de5..9976f0b3c59 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -90,11 +90,5 @@ "set_charging_enabled_on_auto_charge": { "message": "Cannot enable or disable charging when auto-charge is enabled. Try disabling auto-charge first." } - }, - "issues": { - "deprecated_entity_is_session_active": { - "title": "The TechnoVE {sensor_name} binary sensor is deprecated", - "description": "`{entity}` is deprecated.\nPlease update your automations and scripts to replace the binary sensor entity with the newly added switch entity.\nWhen you are done migrating you can disable `{entity}`." - } } } diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index 528a5052678..95348053805 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -16,7 +16,6 @@ from homeassistant.components.webhook import ( async_register as webhook_register, async_unregister as webhook_unregister, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -99,7 +98,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TedeeConfigEntry) -> boo return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TedeeConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -131,7 +130,9 @@ def get_webhook_handler( return async_webhook_handler -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: TedeeConfigEntry +) -> bool: """Migrate old entry.""" if config_entry.version > 1: # This means the user has downgraded from a future version diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index b586db7c2a7..4f167619f04 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -18,6 +18,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import TedeeConfigEntry from .entity import TedeeDescriptionEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class TedeeBinarySensorEntityDescription( @@ -66,20 +69,15 @@ async def async_setup_entry( """Set up the Tedee sensor entity.""" coordinator = entry.runtime_data - async_add_entities( - TedeeBinarySensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - for entity_description in ENTITIES - ) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] + def _async_add_new_lock(locks: list[TedeeLock]) -> None: async_add_entities( TedeeBinarySensorEntity(lock, coordinator, entity_description) for entity_description in ENTITIES + for lock in locks ) coordinator.new_lock_callbacks.append(_async_add_new_lock) + _async_add_new_lock(list(coordinator.data.values())) class TedeeBinarySensorEntity(TedeeDescriptionEntity, BinarySensorEntity): diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 4012b6d07c5..f9ebb29dd04 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -60,7 +60,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): self._next_get_locks = time.time() self._locks_last_update: set[int] = set() - self.new_lock_callbacks: list[Callable[[int], None]] = [] + self.new_lock_callbacks: list[Callable[[list[TedeeLock]], None]] = [] self.tedee_webhook_id: int | None = None async def _async_setup(self) -> None: @@ -158,8 +158,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): # add new locks if new_locks := current_locks - self._locks_last_update: _LOGGER.debug("New locks found: %s", ", ".join(map(str, new_locks))) - for lock_id in new_locks: - for callback in self.new_lock_callbacks: - callback(lock_id) + for callback in self.new_lock_callbacks: + callback([self.data[lock_id] for lock_id in new_locks]) self._locks_last_update = current_locks diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 38df85a9cdb..482cd039a98 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -24,23 +24,18 @@ async def async_setup_entry( """Set up the Tedee lock entity.""" coordinator = entry.runtime_data - entities: list[TedeeLockEntity] = [] - for lock in coordinator.data.values(): - if lock.is_enabled_pullspring: - entities.append(TedeeLockWithLatchEntity(lock, coordinator)) - else: - entities.append(TedeeLockEntity(lock, coordinator)) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] - if lock.is_enabled_pullspring: - async_add_entities([TedeeLockWithLatchEntity(lock, coordinator)]) - else: - async_add_entities([TedeeLockEntity(lock, coordinator)]) + def _async_add_new_lock(locks: list[TedeeLock]) -> None: + entities: list[TedeeLockEntity] = [] + for lock in locks: + if lock.is_enabled_pullspring: + entities.append(TedeeLockWithLatchEntity(lock, coordinator)) + else: + entities.append(TedeeLockEntity(lock, coordinator)) + async_add_entities(entities) coordinator.new_lock_callbacks.append(_async_add_new_lock) - async_add_entities(entities) + _async_add_new_lock(list(coordinator.data.values())) class TedeeLockEntity(TedeeEntity, LockEntity): diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index 90f76317fff..828793b4458 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -18,6 +18,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import TedeeConfigEntry from .entity import TedeeDescriptionEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class TedeeSensorEntityDescription(SensorEntityDescription): @@ -55,20 +58,15 @@ async def async_setup_entry( """Set up the Tedee sensor entity.""" coordinator = entry.runtime_data - async_add_entities( - TedeeSensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - for entity_description in ENTITIES - ) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] + def _async_add_new_lock(locks: list[TedeeLock]) -> None: async_add_entities( TedeeSensorEntity(lock, coordinator, entity_description) for entity_description in ENTITIES + for lock in locks ) coordinator.new_lock_callbacks.append(_async_add_new_lock) + _async_add_new_lock(list(coordinator.data.values())) class TedeeSensorEntity(TedeeDescriptionEntity, SensorEntity): diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json index 78cacd706d3..c7204b6d2a9 100644 --- a/homeassistant/components/tedee/strings.json +++ b/homeassistant/components/tedee/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Setup your tedee locks", + "title": "Set up your tedee locks", "data": { "local_access_token": "Local access token", "host": "[%key:common::config_flow::data::host%]" @@ -14,7 +14,7 @@ }, "reauth_confirm": { "title": "Update of access key required", - "description": "Tedee needs an updated access key, because the existing one is invalid, or might have expired.", + "description": "Tedee needs an updated access key because the existing one is invalid or might have expired.", "data": { "local_access_token": "[%key:component::tedee::config::step::user::data::local_access_token%]" }, @@ -23,7 +23,7 @@ } }, "reconfigure": { - "title": "Reconfigure Tedee", + "title": "Reconfigure tedee", "description": "Update the settings of this integration.", "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index b9a032d7f28..f744265e1c2 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -36,7 +36,13 @@ from homeassistant.const import ( HTTP_BEARER_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -from homeassistant.core import Context, HomeAssistant, ServiceCall +from homeassistant.core import ( + Context, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_loaded_integration @@ -398,15 +404,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, bot, p_config.get(CONF_ALLOWED_CHAT_IDS), p_config.get(ATTR_PARSER) ) - async def async_send_telegram_message(service: ServiceCall) -> None: + async def async_send_telegram_message(service: ServiceCall) -> ServiceResponse: """Handle sending Telegram Bot message service calls.""" msgtype = service.service kwargs = dict(service.data) _LOGGER.debug("New telegram message %s: %s", msgtype, kwargs) + messages = None if msgtype == SERVICE_SEND_MESSAGE: - await notify_service.send_message(context=service.context, **kwargs) + messages = await notify_service.send_message( + context=service.context, **kwargs + ) elif msgtype in [ SERVICE_SEND_PHOTO, SERVICE_SEND_ANIMATION, @@ -414,13 +423,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: SERVICE_SEND_VOICE, SERVICE_SEND_DOCUMENT, ]: - await notify_service.send_file(msgtype, context=service.context, **kwargs) + messages = await notify_service.send_file( + msgtype, context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_STICKER: - await notify_service.send_sticker(context=service.context, **kwargs) + messages = await notify_service.send_sticker( + context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_LOCATION: - await notify_service.send_location(context=service.context, **kwargs) + messages = await notify_service.send_location( + context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_POLL: - await notify_service.send_poll(context=service.context, **kwargs) + messages = await notify_service.send_poll(context=service.context, **kwargs) elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY: await notify_service.answer_callback_query( context=service.context, **kwargs @@ -432,10 +447,37 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: msgtype, context=service.context, **kwargs ) + if service.return_response and messages: + return { + "chats": [ + {"chat_id": cid, "message_id": mid} for cid, mid in messages.items() + ] + } + return None + # Register notification services for service_notif, schema in SERVICE_MAP.items(): + supports_response = SupportsResponse.NONE + + if service_notif in [ + SERVICE_SEND_MESSAGE, + SERVICE_SEND_PHOTO, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, + SERVICE_SEND_DOCUMENT, + SERVICE_SEND_STICKER, + SERVICE_SEND_LOCATION, + SERVICE_SEND_POLL, + ]: + supports_response = SupportsResponse.OPTIONAL + hass.services.async_register( - DOMAIN, service_notif, async_send_telegram_message, schema=schema + DOMAIN, + service_notif, + async_send_telegram_message, + schema=schema, + supports_response=supports_response, ) return True @@ -694,9 +736,10 @@ class TelegramNotificationService: title = kwargs.get(ATTR_TITLE) text = f"{title}\n{message}" if title else message params = self._get_msg_kwargs(kwargs) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params) - await self._send_msg( + msg = await self._send_msg( self.bot.send_message, "Error sending message", params[ATTR_MESSAGE_TAG], @@ -711,6 +754,8 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + msg_ids[chat_id] = msg.id + return msg_ids async def delete_message(self, chat_id=None, context=None, **kwargs): """Delete a previously sent message.""" @@ -829,12 +874,13 @@ class TelegramNotificationService: ), ) + msg_ids = {} if file_content: for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Sending file to chat ID %s", chat_id) if file_type == SERVICE_SEND_PHOTO: - await self._send_msg( + msg = await self._send_msg( self.bot.send_photo, "Error sending photo", params[ATTR_MESSAGE_TAG], @@ -851,7 +897,7 @@ class TelegramNotificationService: ) elif file_type == SERVICE_SEND_STICKER: - await self._send_msg( + msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", params[ATTR_MESSAGE_TAG], @@ -866,7 +912,7 @@ class TelegramNotificationService: ) elif file_type == SERVICE_SEND_VIDEO: - await self._send_msg( + msg = await self._send_msg( self.bot.send_video, "Error sending video", params[ATTR_MESSAGE_TAG], @@ -882,7 +928,7 @@ class TelegramNotificationService: context=context, ) elif file_type == SERVICE_SEND_DOCUMENT: - await self._send_msg( + msg = await self._send_msg( self.bot.send_document, "Error sending document", params[ATTR_MESSAGE_TAG], @@ -898,7 +944,7 @@ class TelegramNotificationService: context=context, ) elif file_type == SERVICE_SEND_VOICE: - await self._send_msg( + msg = await self._send_msg( self.bot.send_voice, "Error sending voice", params[ATTR_MESSAGE_TAG], @@ -913,7 +959,7 @@ class TelegramNotificationService: context=context, ) elif file_type == SERVICE_SEND_ANIMATION: - await self._send_msg( + msg = await self._send_msg( self.bot.send_animation, "Error sending animation", params[ATTR_MESSAGE_TAG], @@ -929,17 +975,22 @@ class TelegramNotificationService: context=context, ) + msg_ids[chat_id] = msg.id file_content.seek(0) else: _LOGGER.error("Can't send file with kwargs: %s", kwargs) - async def send_sticker(self, target=None, context=None, **kwargs): + return msg_ids + + async def send_sticker(self, target=None, context=None, **kwargs) -> dict: """Send a sticker from a telegram sticker pack.""" params = self._get_msg_kwargs(kwargs) stickerid = kwargs.get(ATTR_STICKER_ID) + + msg_ids = {} if stickerid: for chat_id in self._get_target_chat_ids(target): - await self._send_msg( + msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", params[ATTR_MESSAGE_TAG], @@ -952,8 +1003,9 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) - else: - await self.send_file(SERVICE_SEND_STICKER, target, **kwargs) + msg_ids[chat_id] = msg.id + return msg_ids + return await self.send_file(SERVICE_SEND_STICKER, target, **kwargs) async def send_location( self, latitude, longitude, target=None, context=None, **kwargs @@ -962,11 +1014,12 @@ class TelegramNotificationService: latitude = float(latitude) longitude = float(longitude) params = self._get_msg_kwargs(kwargs) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug( "Send location %s/%s to chat ID %s", latitude, longitude, chat_id ) - await self._send_msg( + msg = await self._send_msg( self.bot.send_location, "Error sending location", params[ATTR_MESSAGE_TAG], @@ -979,6 +1032,8 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + msg_ids[chat_id] = msg.id + return msg_ids async def send_poll( self, @@ -993,9 +1048,10 @@ class TelegramNotificationService: """Send a poll.""" params = self._get_msg_kwargs(kwargs) openperiod = kwargs.get(ATTR_OPEN_PERIOD) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id) - await self._send_msg( + msg = await self._send_msg( self.bot.send_poll, "Error sending poll", params[ATTR_MESSAGE_TAG], @@ -1011,6 +1067,8 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + msg_ids[chat_id] = msg.id + return msg_ids async def leave_chat(self, chat_id=None, context=None): """Remove bot from chat.""" @@ -1070,6 +1128,7 @@ class BaseTelegramBotEntity: ATTR_MSGID: message.message_id, ATTR_CHAT_ID: message.chat.id, ATTR_DATE: message.date, + ATTR_MESSAGE_THREAD_ID: message.message_thread_id, } if filters.COMMAND.filter(message): # This is a command message - set event type to command and split data into command and args diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 1a02543d4ab..714e7b74db0 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -14,7 +14,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the notification to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the notification to. If not present, first allowed chat ID is the default." }, "parse_mode": { "name": "Parse mode", @@ -30,7 +30,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send message. Will help with timeout errors (poor internet connection, etc)s." + "description": "Timeout for sending the message in seconds. Will help with timeout errors (poor Internet connection, etc)." }, "keyboard": { "name": "Keyboard", @@ -45,11 +45,11 @@ "description": "Tag for sent message." }, "reply_to_message_id": { - "name": "Reply to message id", + "name": "Reply to message ID", "description": "Mark the message as a reply to a previous message." }, "message_thread_id": { - "name": "Message thread id", + "name": "Message thread ID", "description": "Unique identifier for the target message thread (topic) of the forum; for forum supergroups only." } } @@ -84,7 +84,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the document to. If not present, first allowed chat ID is the default." }, "parse_mode": { "name": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::name%]", @@ -100,7 +100,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send photo." + "description": "Timeout for sending the photo in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -166,7 +166,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send sticker." + "description": "Timeout for sending the sticker in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -306,7 +306,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send video." + "description": "Timeout for sending the video in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -372,7 +372,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send voice." + "description": "Timeout for sending the voice in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -442,7 +442,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send document." + "description": "Timeout for sending the document in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -480,7 +480,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the location to. If not present, first allowed chat ID is the default." }, "disable_notification": { "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", @@ -546,7 +546,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send poll." + "description": "Timeout for sending the poll in seconds." }, "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", @@ -568,11 +568,11 @@ "fields": { "message_id": { "name": "Message ID", - "description": "Id of the message to edit." + "description": "ID of the message to edit." }, "chat_id": { "name": "Chat ID", - "description": "The chat_id where to edit the message." + "description": "ID of the chat where to edit the message." }, "message": { "name": "Message", @@ -606,7 +606,7 @@ }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to edit the caption." + "description": "ID of the chat where to edit the caption." }, "caption": { "name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]", @@ -620,7 +620,7 @@ }, "edit_replymarkup": { "name": "Edit reply markup", - "description": "Edit the inline keyboard of a previously sent message.", + "description": "Edits the inline keyboard of a previously sent message.", "fields": { "message_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", @@ -628,7 +628,7 @@ }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to edit the reply_markup." + "description": "ID of the chat where to edit the reply markup." }, "inline_keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", @@ -646,7 +646,7 @@ }, "callback_query_id": { "name": "Callback query ID", - "description": "Unique id of the callback response." + "description": "Unique ID of the callback response." }, "show_alert": { "name": "Show alert", @@ -654,7 +654,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for sending the answer." + "description": "Timeout for sending the answer in seconds." } } }, @@ -664,11 +664,11 @@ "fields": { "message_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", - "description": "Id of the message to delete." + "description": "ID of the message to delete." }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to delete the message." + "description": "ID of the chat where to delete the message." } } } diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index e363aced667..b0750a7785d 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -11,15 +11,15 @@ }, "step": { "auth": { - "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (select **Yes**).\n 4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", - "title": "Authenticate against TelldusLive" + "description": "To link your TelldusLive account:\n1. Open the link below\n2. Log in to Telldus Live\n3. Authorize **{app_name}** (select **Yes**).\n4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", + "title": "Authenticate with TelldusLive" }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for Local API." + "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for local API." } } } diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py index 82d8905a775..0178a6521c4 100644 --- a/homeassistant/components/telnet/switch.py +++ b/homeassistant/components/telnet/switch.py @@ -4,9 +4,9 @@ from __future__ import annotations from datetime import timedelta import logging -import telnetlib # pylint: disable=deprecated-module from typing import Any +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.switch import ( diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 390a4a31bdb..7b7b5eb9b29 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -93,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: continue if isinstance(entry.options[key], str): raise ConfigEntryError( - f"The '{entry.options.get(CONF_NAME) or ""}' number template needs to " + f"The '{entry.options.get(CONF_NAME) or ''}' number template needs to " f"be reconfigured, {key} must be a number, got '{entry.options[key]}'" ) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index c1c023c0ea4..e6cc377bc26 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -157,7 +157,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: type=selector.TextSelectorType.TEXT, multiline=False ) ), - vol.Optional(CONF_SET_VALUE): selector.ActionSelector(), + vol.Required(CONF_SET_VALUE): selector.ActionSelector(), } if domain == Platform.SELECT: @@ -235,8 +235,12 @@ def _validate_unit(options: dict[str, Any]) -> None: and (units := DEVICE_CLASS_UNITS.get(device_class)) is not None and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units ): + # Sort twice to make sure strings with same case-insensitive order of + # letters are sorted consistently still. sorted_units = sorted( - [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units], + sorted( + [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units], + ), key=str.casefold, ) if len(sorted_units) == 1: diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index cedd7d0d725..7720ef7e1b3 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -124,7 +124,6 @@ class TemplateFan(TemplateEntity, FanEntity): """A template fan component.""" _attr_should_poll = False - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index cae6c0cebc1..9391e368e2b 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -9,13 +9,15 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_TRANSITION, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, @@ -39,6 +41,7 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import color as color_util from .const import DOMAIN from .template_entity import ( @@ -77,6 +80,9 @@ CONF_TEMPERATURE_TEMPLATE = "temperature_template" CONF_WHITE_VALUE_ACTION = "set_white_value" CONF_WHITE_VALUE_TEMPLATE = "white_value_template" +DEFAULT_MIN_MIREDS = 153 +DEFAULT_MAX_MIREDS = 500 + LIGHT_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( @@ -262,25 +268,27 @@ class LightTemplate(TemplateEntity, LightEntity): return self._brightness @property - def color_temp(self) -> int | None: - """Return the CT color value in mireds.""" - return self._temperature + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + if self._temperature is None: + return None + return color_util.color_temperature_mired_to_kelvin(self._temperature) @property - def max_mireds(self) -> int: - """Return the max mireds value in mireds.""" + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" if self._max_mireds is not None: - return self._max_mireds + return color_util.color_temperature_mired_to_kelvin(self._max_mireds) - return super().max_mireds + return DEFAULT_MIN_KELVIN @property - def min_mireds(self) -> int: - """Return the min mireds value in mireds.""" + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" if self._min_mireds is not None: - return self._min_mireds + return color_util.color_temperature_mired_to_kelvin(self._min_mireds) - return super().min_mireds + return DEFAULT_MAX_KELVIN @property def hs_color(self) -> tuple[float, float] | None: @@ -447,13 +455,16 @@ class LightTemplate(TemplateEntity, LightEntity): self._brightness = kwargs[ATTR_BRIGHTNESS] optimistic_set = True - if self._temperature_template is None and ATTR_COLOR_TEMP in kwargs: + if self._temperature_template is None and ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) _LOGGER.debug( "Optimistically setting color temperature to %s", - kwargs[ATTR_COLOR_TEMP], + color_temp, ) self._color_mode = ColorMode.COLOR_TEMP - self._temperature = kwargs[ATTR_COLOR_TEMP] + self._temperature = color_temp if self._hs_template is None and self._color_template is None: self._hs_color = None if self._rgb_template is None: @@ -544,8 +555,10 @@ class LightTemplate(TemplateEntity, LightEntity): if ATTR_TRANSITION in kwargs and self._supports_transition is True: common_params["transition"] = kwargs[ATTR_TRANSITION] - if ATTR_COLOR_TEMP in kwargs and self._temperature_script: - common_params["color_temp"] = kwargs[ATTR_COLOR_TEMP] + if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temperature_script: + common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) await self.async_run_script( self._temperature_script, @@ -756,7 +769,9 @@ class LightTemplate(TemplateEntity, LightEntity): self._temperature = None return temperature = int(render) - if self.min_mireds <= temperature <= self.max_mireds: + min_mireds = self._min_mireds or DEFAULT_MIN_MIREDS + max_mireds = self._max_mireds or DEFAULT_MAX_MIREDS + if min_mireds <= temperature <= max_mireds: self._temperature = temperature else: _LOGGER.error( @@ -766,8 +781,8 @@ class LightTemplate(TemplateEntity, LightEntity): ), temperature, self.entity_id, - self.min_mireds, - self.max_mireds, + min_mireds, + max_mireds, ) self._temperature = None except ValueError: diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 6ea8aff4c1a..f194154a50c 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -2,13 +2,14 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.lock import ( PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, + LockEntityFeature, LockState, ) from homeassistant.const import ( @@ -17,7 +18,6 @@ from homeassistant.const import ( CONF_OPTIMISTIC, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, - STATE_ON, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError @@ -36,6 +36,7 @@ from .template_entity import ( CONF_CODE_FORMAT_TEMPLATE = "code_format_template" CONF_LOCK = "lock" CONF_UNLOCK = "unlock" +CONF_OPEN = "open" DEFAULT_NAME = "Template Lock" DEFAULT_OPTIMISTIC = False @@ -45,6 +46,7 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_CODE_FORMAT_TEMPLATE): cv.template, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, @@ -53,7 +55,9 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) -async def _async_create_entities(hass, config): +async def _async_create_entities( + hass: HomeAssistant, config: dict[str, Any] +) -> list[TemplateLock]: """Create the Template lock.""" config = rewrite_common_legacy_to_modern_conf(hass, config) return [TemplateLock(hass, config, config.get(CONF_UNIQUE_ID))] @@ -76,29 +80,33 @@ class TemplateLock(TemplateEntity, LockEntity): def __init__( self, - hass, - config, - unique_id, - ): + hass: HomeAssistant, + config: dict[str, Any], + unique_id: str | None, + ) -> None: """Initialize the lock.""" super().__init__( hass, config=config, fallback_name=DEFAULT_NAME, unique_id=unique_id ) - self._state = None + self._state: LockState | None = None name = self._attr_name + assert name self._state_template = config.get(CONF_VALUE_TEMPLATE) self._command_lock = Script(hass, config[CONF_LOCK], name, DOMAIN) self._command_unlock = Script(hass, config[CONF_UNLOCK], name, DOMAIN) + if CONF_OPEN in config: + self._command_open = Script(hass, config[CONF_OPEN], name, DOMAIN) + self._attr_supported_features |= LockEntityFeature.OPEN self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE) - self._code_format = None - self._code_format_template_error = None + self._code_format: str | None = None + self._code_format_template_error: TemplateError | None = None self._optimistic = config.get(CONF_OPTIMISTIC) self._attr_assumed_state = bool(self._optimistic) @property def is_locked(self) -> bool: """Return true if lock is locked.""" - return self._state in ("true", STATE_ON, LockState.LOCKED) + return self._state == LockState.LOCKED @property def is_jammed(self) -> bool: @@ -115,8 +123,13 @@ class TemplateLock(TemplateEntity, LockEntity): """Return true if lock is locking.""" return self._state == LockState.LOCKING + @property + def is_open(self) -> bool: + """Return true if lock is open.""" + return self._state == LockState.OPEN + @callback - def _update_state(self, result): + def _update_state(self, result: str | TemplateError) -> None: """Update the state from the template.""" super()._update_state(result) if isinstance(result, TemplateError): @@ -128,7 +141,23 @@ class TemplateLock(TemplateEntity, LockEntity): return if isinstance(result, str): - self._state = result.lower() + if result.lower() in ( + "true", + "on", + "locked", + ): + self._state = LockState.LOCKED + elif result.lower() in ( + "false", + "off", + "unlocked", + ): + self._state = LockState.UNLOCKED + else: + try: + self._state = LockState(result.lower()) + except ValueError: + self._state = None return self._state = None @@ -141,6 +170,8 @@ class TemplateLock(TemplateEntity, LockEntity): @callback def _async_setup_templates(self) -> None: """Set up templates.""" + if TYPE_CHECKING: + assert self._state_template is not None self.add_template_attribute( "_state", self._state_template, None, self._update_state ) @@ -168,10 +199,12 @@ class TemplateLock(TemplateEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" + # Check if we need to raise for incorrect code format + # template before processing the action. self._raise_template_error_if_available() if self._optimistic: - self._state = True + self._state = LockState.LOCKED self.async_write_ha_state() tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} @@ -182,10 +215,12 @@ class TemplateLock(TemplateEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" + # Check if we need to raise for incorrect code format + # template before processing the action. self._raise_template_error_if_available() if self._optimistic: - self._state = False + self._state = LockState.UNLOCKED self.async_write_ha_state() tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} @@ -194,7 +229,24 @@ class TemplateLock(TemplateEntity, LockEntity): self._command_unlock, run_variables=tpl_vars, context=self._context ) + async def async_open(self, **kwargs: Any) -> None: + """Open the device.""" + # Check if we need to raise for incorrect code format + # template before processing the action. + self._raise_template_error_if_available() + + if self._optimistic: + self._state = LockState.OPEN + self.async_write_ha_state() + + tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} + + await self.async_run_script( + self._command_open, run_variables=tpl_vars, context=self._context + ) + def _raise_template_error_if_available(self): + """Raise an error if the rendered code format is not valid.""" if self._code_format_template_error is not None: raise ServiceValidationError( translation_domain=DOMAIN, diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index f5b84b1ad7a..d025f052732 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -8,7 +8,7 @@ import itertools import logging from typing import Any, cast -from propcache import under_cached_property +from propcache.api import under_cached_property import voluptuous as vol from homeassistant.components.blueprint import CONF_USE_BLUEPRINT diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1d021bcb571..19029cc708b 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -17,13 +17,8 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.const import ( @@ -58,12 +53,12 @@ CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" ENTITY_ID_FORMAT = VACUUM_DOMAIN + ".{}" _VALID_STATES = [ - STATE_CLEANING, - STATE_DOCKED, - STATE_PAUSED, - STATE_IDLE, - STATE_RETURNING, - STATE_ERROR, + VacuumActivity.CLEANING, + VacuumActivity.DOCKED, + VacuumActivity.PAUSED, + VacuumActivity.IDLE, + VacuumActivity.RETURNING, + VacuumActivity.ERROR, ] VACUUM_SCHEMA = vol.All( @@ -202,7 +197,7 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Return the status of the vacuum cleaner.""" return self._state diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 1ddfa188c0a..81705e326f7 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,7 +10,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==2.1.3", - "Pillow==11.0.0" + "numpy==2.2.2", + "Pillow==11.1.0" ] } diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index e7030b568b3..945c6351cfc 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -34,15 +34,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo -from .config_flow import OAuth2FlowHandler from .const import DOMAIN, LOGGER, MODELS from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, ) from .models import TeslaFleetData, TeslaFleetEnergyData, TeslaFleetVehicleData -from .oauth import TeslaSystemImplementation PLATFORMS: Final = [ Platform.BINARY_SENSOR, @@ -66,6 +65,15 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -> bool: """Set up TeslaFleet config.""" + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ValueError as e: + # Remove invalid implementation from config entry then raise AuthFailed + hass.config_entries.async_update_entry( + entry, data={"auth_implementation": None} + ) + raise ConfigEntryAuthFailed from e + access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) @@ -73,12 +81,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - scopes: list[Scope] = [Scope(s) for s in token["scp"]] region: str = token["ou_code"].lower() - OAuth2FlowHandler.async_register_implementation( - hass, - TeslaSystemImplementation(hass), - ) - - implementation = await async_get_config_entry_implementation(hass, entry) oauth_session = OAuth2Session(hass, entry, implementation) refresh_lock = asyncio.Lock() @@ -175,9 +177,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - api = EnergySpecific(tesla.energy, site_id) live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, api) + history_coordinator = TeslaFleetEnergySiteHistoryCoordinator(hass, api) info_coordinator = TeslaFleetEnergySiteInfoCoordinator(hass, api, product) await live_coordinator.async_config_entry_first_refresh() + await history_coordinator.async_config_entry_first_refresh() await info_coordinator.async_config_entry_first_refresh() # Create energy site model @@ -210,6 +214,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - TeslaFleetEnergyData( api=api, live_coordinator=live_coordinator, + history_coordinator=history_coordinator, info_coordinator=info_coordinator, id=site_id, device=device, diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py index 9a1533a688f..06e9c9d7c64 100644 --- a/homeassistant/components/tesla_fleet/climate.py +++ b/homeassistant/components/tesla_fleet/climate.py @@ -74,7 +74,6 @@ class TeslaFleetClimateEntity(TeslaFleetVehicleEntity, ClimateEntity): | ClimateEntityFeature.PRESET_MODE ) _attr_preset_modes = ["off", "keep", "dog", "camp"] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -220,7 +219,7 @@ class TeslaFleetCabinOverheatProtectionEntity(TeslaFleetVehicleEntity, ClimateEn _attr_max_temp = COP_LEVELS["High"] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(COP_MODES.values()) - _enable_turn_on_off_backwards_compatibility = False + _attr_entity_registry_enabled_default = False def __init__( diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py index ca36c6f511b..feeb5e74ca6 100644 --- a/homeassistant/components/tesla_fleet/config_flow.py +++ b/homeassistant/components/tesla_fleet/config_flow.py @@ -12,7 +12,6 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, LOGGER -from .oauth import TeslaSystemImplementation class OAuth2FlowHandler( @@ -31,11 +30,6 @@ class OAuth2FlowHandler( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow start.""" - self.async_register_implementation( - self.hass, - TeslaSystemImplementation(self.hass), - ) - return await super().async_step_user() async def async_oauth_create_entry( diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index 53e34092326..5d2dc84c49e 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -21,6 +21,7 @@ SCOPES = [ Scope.OPENID, Scope.OFFLINE_ACCESS, Scope.VEHICLE_DEVICE_DATA, + Scope.VEHICLE_LOCATION, Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS, Scope.ENERGY_DEVICE_DATA, @@ -32,8 +33,34 @@ MODELS = { "3": "Model 3", "X": "Model X", "Y": "Model Y", + "C": "Cybertruck", + "T": "Tesla Semi", } +ENERGY_HISTORY_FIELDS = [ + "solar_energy_exported", + "generator_energy_exported", + "grid_energy_imported", + "grid_services_energy_imported", + "grid_services_energy_exported", + "grid_energy_exported_from_solar", + "grid_energy_exported_from_generator", + "grid_energy_exported_from_battery", + "battery_energy_exported", + "battery_energy_imported_from_grid", + "battery_energy_imported_from_solar", + "battery_energy_imported_from_generator", + "consumer_energy_imported_from_grid", + "consumer_energy_imported_from_solar", + "consumer_energy_imported_from_battery", + "consumer_energy_imported_from_generator", + "total_home_usage", + "total_battery_charge", + "total_battery_discharge", + "total_solar_generation", + "total_grid_energy_exported", +] + class TeslaFleetState(StrEnum): """Teslemetry Vehicle States.""" diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 42b93352a6f..4d99319d49f 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -1,10 +1,12 @@ """Tesla Fleet Data Coordinator.""" from datetime import datetime, timedelta +from random import randint +from time import time from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific -from tesla_fleet_api.const import VehicleDataEndpoint +from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( InvalidToken, LoginRequired, @@ -19,14 +21,15 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import LOGGER, TeslaFleetState +from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState -VEHICLE_INTERVAL_SECONDS = 90 +VEHICLE_INTERVAL_SECONDS = 300 VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS) VEHICLE_WAIT = timedelta(minutes=15) ENERGY_INTERVAL_SECONDS = 60 ENERGY_INTERVAL = timedelta(seconds=ENERGY_INTERVAL_SECONDS) +ENERGY_HISTORY_INTERVAL = timedelta(minutes=5) ENDPOINTS = [ VehicleDataEndpoint.CHARGE_STATE, @@ -73,7 +76,7 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.data = flatten(product) self.updated_once = False self.last_active = datetime.now() - self.rate = RateCalculator(200, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5) + self.rate = RateCalculator(100, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5) async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using TeslaFleet API.""" @@ -182,6 +185,61 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) return data +class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching energy site history import and export from the Tesla Fleet API.""" + + def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + """Initialize Tesla Fleet Energy Site History coordinator.""" + super().__init__( + hass, + LOGGER, + name=f"Tesla Fleet Energy History {api.energy_site_id}", + update_interval=timedelta(seconds=300), + ) + self.api = api + self.data = {} + self.updated_once = False + + async def async_config_entry_first_refresh(self) -> None: + """Set up the data coordinator.""" + await super().async_config_entry_first_refresh() + + # Calculate seconds until next 5 minute period plus a random delay + delta = randint(310, 330) - (int(time()) % 300) + self.logger.debug("Scheduling next %s refresh in %s seconds", self.name, delta) + self.update_interval = timedelta(seconds=delta) + self._schedule_refresh() + self.update_interval = ENERGY_HISTORY_INTERVAL + + async def _async_update_data(self) -> dict[str, Any]: + """Update energy site history data using Tesla Fleet API.""" + + try: + data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] + except RateLimited as e: + LOGGER.warning( + "%s rate limited, will retry in %s seconds", + self.name, + e.data.get("after"), + ) + if "after" in e.data: + self.update_interval = timedelta(seconds=int(e.data["after"])) + return self.data + except (InvalidToken, OAuthExpired, LoginRequired) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise UpdateFailed(e.message) from e + self.updated_once = True + + # Add all time periods together + output = {key: 0 for key in ENERGY_HISTORY_FIELDS} + for period in data.get("time_series", []): + for key in ENERGY_HISTORY_FIELDS: + output[key] += period.get(key, 0) + + return output + + class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the TeslaFleet API.""" diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index 0ee41b5e322..0260acf368e 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -12,6 +12,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, @@ -24,6 +25,7 @@ class TeslaFleetEntity( CoordinatorEntity[ TeslaFleetVehicleDataCoordinator | TeslaFleetEnergySiteLiveCoordinator + | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator ] ): @@ -37,6 +39,7 @@ class TeslaFleetEntity( self, coordinator: TeslaFleetVehicleDataCoordinator | TeslaFleetEnergySiteLiveCoordinator + | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator, api: VehicleSpecific | EnergySpecific, key: str, @@ -139,6 +142,21 @@ class TeslaFleetEnergyLiveEntity(TeslaFleetEntity): super().__init__(data.live_coordinator, data.api, key) +class TeslaFleetEnergyHistoryEntity(TeslaFleetEntity): + """Parent class for TeslaFleet Energy Site History entities.""" + + def __init__( + self, + data: TeslaFleetEnergyData, + key: str, + ) -> None: + """Initialize common aspects of a Tesla Fleet Energy Site History entity.""" + self._attr_unique_id = f"{data.id}-{key}" + self._attr_device_info = data.device + + super().__init__(data.history_coordinator, data.api, key) + + class TeslaFleetEnergyInfoEntity(TeslaFleetEntity): """Parent class for TeslaFleet Energy Site Info entities.""" diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index 449dda93c62..c806138c219 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -232,6 +232,69 @@ "island_status_unknown": "mdi:help-circle", "off_grid_intentional": "mdi:account-cancel" } + }, + "total_home_usage": { + "default": "mdi:home-lightning-bolt" + }, + "total_battery_charge": { + "default": "mdi:battery-arrow-up" + }, + "total_battery_discharge": { + "default": "mdi:battery-arrow-down" + }, + "total_solar_production": { + "default": "mdi:solar-power-variant" + }, + "grid_energy_imported": { + "default": "mdi:transmission-tower-import" + }, + "total_grid_energy_exported": { + "default": "mdi:transmission-tower-export" + }, + "solar_energy_exported": { + "default": "mdi:solar-power-variant" + }, + "generator_energy_exported": { + "default": "mdi:generator-stationary" + }, + "grid_services_energy_imported": { + "default": "mdi:transmission-tower-import" + }, + "grid_services_energy_exported": { + "default": "mdi:transmission-tower-export" + }, + "grid_energy_exported_from_solar": { + "default": "mdi:solar-power" + }, + "grid_energy_exported_from_generator": { + "default": "mdi:generator-stationary" + }, + "grid_energy_exported_from_battery": { + "default": "mdi:battery-arrow-down" + }, + "battery_energy_exported": { + "default": "mdi:battery-arrow-down" + }, + "battery_energy_imported_from_grid": { + "default": "mdi:transmission-tower-import" + }, + "battery_energy_imported_from_solar": { + "default": "mdi:solar-power" + }, + "battery_energy_imported_from_generator": { + "default": "mdi:generator-stationary" + }, + "consumer_energy_imported_from_grid": { + "default": "mdi:transmission-tower-import" + }, + "consumer_energy_imported_from_solar": { + "default": "mdi:solar-power" + }, + "consumer_energy_imported_from_battery": { + "default": "mdi:home-battery" + }, + "consumer_energy_imported_from_generator": { + "default": "mdi:generator-stationary" } }, "switch": { diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index f27929032d7..aecc6a04af3 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.8.4"] + "requirements": ["tesla-fleet-api==0.9.2"] } diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py index ae945dd96bf..469ebdca914 100644 --- a/homeassistant/components/tesla_fleet/models.py +++ b/homeassistant/components/tesla_fleet/models.py @@ -11,6 +11,7 @@ from tesla_fleet_api.const import Scope from homeassistant.helpers.device_registry import DeviceInfo from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, @@ -44,6 +45,7 @@ class TeslaFleetEnergyData: api: EnergySpecific live_coordinator: TeslaFleetEnergySiteLiveCoordinator + history_coordinator: TeslaFleetEnergySiteHistoryCoordinator info_coordinator: TeslaFleetEnergySiteInfoCoordinator id: int device: DeviceInfo diff --git a/homeassistant/components/tesla_fleet/oauth.py b/homeassistant/components/tesla_fleet/oauth.py index 8b43460436b..b25c5216009 100644 --- a/homeassistant/components/tesla_fleet/oauth.py +++ b/homeassistant/components/tesla_fleet/oauth.py @@ -1,8 +1,5 @@ """Provide oauth implementations for the Tesla Fleet integration.""" -import base64 -import hashlib -import secrets from typing import Any from homeassistant.components.application_credentials import ( @@ -11,59 +8,8 @@ from homeassistant.components.application_credentials import ( ClientCredential, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow -from .const import AUTHORIZE_URL, CLIENT_ID, DOMAIN, SCOPES, TOKEN_URL - - -class TeslaSystemImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation): - """Tesla Fleet API open source Oauth2 implementation.""" - - code_verifier: str - code_challenge: str - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize open source Oauth2 implementation.""" - - # Setup PKCE - self.code_verifier = secrets.token_urlsafe(32) - hashed_verifier = hashlib.sha256(self.code_verifier.encode()).digest() - self.code_challenge = ( - base64.urlsafe_b64encode(hashed_verifier).decode().replace("=", "") - ) - super().__init__( - hass, - DOMAIN, - CLIENT_ID, - "", - AUTHORIZE_URL, - TOKEN_URL, - ) - - @property - def name(self) -> str: - """Name of the implementation.""" - return "Built-in open source client ID" - - @property - def extra_authorize_data(self) -> dict[str, Any]: - """Extra data that needs to be appended to the authorize url.""" - return { - "prompt": "login", - "scope": " ".join(SCOPES), - "code_challenge": self.code_challenge, # PKCE - } - - async def async_resolve_external_data(self, external_data: Any) -> dict: - """Resolve the authorization code to tokens.""" - return await self._token_request( - { - "grant_type": "authorization_code", - "code": external_data["code"], - "redirect_uri": external_data["state"]["redirect_uri"], - "code_verifier": self.code_verifier, # PKCE - } - ) +from .const import AUTHORIZE_URL, SCOPES, TOKEN_URL class TeslaUserImplementation(AuthImplementation): diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index b4e7b51faba..3e05e7e723b 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -35,8 +35,9 @@ from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance from . import TeslaFleetConfigEntry -from .const import TeslaFleetState +from .const import ENERGY_HISTORY_FIELDS, TeslaFleetState from .entity import ( + TeslaFleetEnergyHistoryEntity, TeslaFleetEnergyInfoEntity, TeslaFleetEnergyLiveEntity, TeslaFleetVehicleEntity, @@ -415,6 +416,21 @@ WALL_CONNECTOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( ), ) +ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple( + SensorEntityDescription( + key=key, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=( + key.startswith("total") or key == "grid_energy_imported" + ), + ) + for key in ENERGY_HISTORY_FIELDS +) + ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="vpp_backup_reserve_percent", @@ -450,6 +466,13 @@ async def async_setup_entry( for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data ), + ( # Add energy site history + TeslaFleetEnergyHistorySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_HISTORY_DESCRIPTIONS + if energysite.info_coordinator.data.get("components_battery") + or energysite.info_coordinator.data.get("components_solar") + ), ( # Add wall connectors TeslaFleetWallConnectorSensorEntity(energysite, wc["din"], description) for energysite in entry.runtime_data.energysites @@ -540,7 +563,25 @@ class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_available = not self.is_none + self._attr_native_value = self._value + + +class TeslaFleetEnergyHistorySensorEntity(TeslaFleetEnergyHistoryEntity, SensorEntity): + """Base class for Tesla Fleet energy site metric sensors.""" + + entity_description: SensorEntityDescription + + def __init__( + self, + data: TeslaFleetEnergyData, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" self._attr_native_value = self._value diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index fe5cd06c1ef..c438bfff50f 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -424,6 +424,9 @@ "off_grid_intentional": "Disconnected intentionally" } }, + "storm_mode_active": { + "name": "Storm Watch active" + }, "vehicle_state_tpms_pressure_fl": { "name": "Tire pressure front left" }, @@ -453,6 +456,69 @@ }, "wall_connector_state": { "name": "State code" + }, + "solar_energy_exported": { + "name": "Solar exported" + }, + "generator_energy_exported": { + "name": "Generator exported" + }, + "grid_energy_imported": { + "name": "Grid imported" + }, + "grid_services_energy_imported": { + "name": "Grid services imported" + }, + "grid_services_energy_exported": { + "name": "Grid services exported" + }, + "grid_energy_exported_from_solar": { + "name": "Grid exported from solar" + }, + "grid_energy_exported_from_generator": { + "name": "Grid exported from generator" + }, + "grid_energy_exported_from_battery": { + "name": "Grid exported from battery" + }, + "battery_energy_exported": { + "name": "Battery exported" + }, + "battery_energy_imported_from_grid": { + "name": "Battery imported from grid" + }, + "battery_energy_imported_from_solar": { + "name": "Battery imported from solar" + }, + "battery_energy_imported_from_generator": { + "name": "Battery imported from generator" + }, + "consumer_energy_imported_from_grid": { + "name": "Consumer imported from grid" + }, + "consumer_energy_imported_from_solar": { + "name": "Consumer imported from solar" + }, + "consumer_energy_imported_from_battery": { + "name": "Consumer imported from battery" + }, + "consumer_energy_imported_from_generator": { + "name": "Consumer imported from generator" + }, + "total_home_usage": { + "name": "Home usage" + }, + "total_battery_charge": { + "name": "Battery charged" + }, + "total_battery_discharge": { + "name": "Battery discharged" + }, + "total_solar_generation": { + "name": "Solar generated" + }, + "total_grid_energy_exported": { + "name": "Grid exported" } }, "switch": { diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py index 3296539f701..d100b1e5549 100644 --- a/homeassistant/components/tesla_wall_connector/config_flow.py +++ b/homeassistant/components/tesla_wall_connector/config_flow.py @@ -9,11 +9,11 @@ from tesla_wall_connector import WallConnector from tesla_wall_connector.exceptions import WallConnectorError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME, WALLCONNECTOR_SERIAL_NUMBER @@ -48,7 +48,7 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN): self.ip_address: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self.ip_address = discovery_info.ip diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index aa1d2b42660..b9cbc64dcd9 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -7,6 +7,7 @@ from typing import Final from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( + Forbidden, InvalidToken, SubscriptionRequired, TeslaFleetError, @@ -85,6 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - scopes = calls[0]["scopes"] region = calls[0]["region"] + vehicle_metadata = calls[0]["vehicles"] products = calls[1]["response"] device_registry = dr.async_get(hass) @@ -102,7 +104,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) for product in products: - if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes: + if ( + "vin" in product + and vehicle_metadata.get(product["vin"], {}).get("access") + and Scope.VEHICLE_DEVICE_DATA in scopes + ): # Remove the protobuff 'cached_data' that we do not use to save memory product.pop("cached_data", None) vin = product["vin"] @@ -121,13 +127,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - create_handle_vehicle_stream(vin, coordinator), {"vin": vin}, ) + firmware = vehicle_metadata[vin].get("firmware", "Unknown") + stream_vehicle = stream.get_vehicle(vin) vehicles.append( TeslemetryVehicleData( api=api, + config_entry=entry, coordinator=coordinator, stream=stream, + stream_vehicle=stream_vehicle, vin=vin, + firmware=firmware, device=device, remove_listener=remove_listener, ) @@ -155,10 +166,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - serial_number=str(site_id), ) + # Check live status endpoint works before creating its coordinator + try: + live_status = (await api.live_status())["response"] + except (InvalidToken, Forbidden, SubscriptionRequired) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise ConfigEntryNotReady(e.message) from e + energysites.append( TeslemetryEnergyData( api=api, - live_coordinator=TeslemetryEnergySiteLiveCoordinator(hass, api), + live_coordinator=( + TeslemetryEnergySiteLiveCoordinator(hass, api, live_status) + if isinstance(live_status, dict) + else None + ), info_coordinator=TeslemetryEnergySiteInfoCoordinator( hass, api, product ), @@ -174,14 +197,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - # Run all first refreshes await asyncio.gather( + *(async_setup_stream(hass, entry, vehicle) for vehicle in vehicles), *( vehicle.coordinator.async_config_entry_first_refresh() for vehicle in vehicles ), - *( - energysite.live_coordinator.async_config_entry_first_refresh() - for energysite in energysites - ), *( energysite.info_coordinator.async_config_entry_first_refresh() for energysite in energysites @@ -253,7 +273,6 @@ def create_handle_vehicle_stream(vin: str, coordinator) -> Callable[[dict], None """Handle vehicle data from the stream.""" if "vehicle_data" in data: LOGGER.debug("Streaming received vehicle data from %s", vin) - coordinator.updated_once = True coordinator.async_set_updated_data(flatten(data["vehicle_data"])) elif "state" in data: LOGGER.debug("Streaming received state from %s", vin) @@ -261,3 +280,16 @@ def create_handle_vehicle_stream(vin: str, coordinator) -> Callable[[dict], None coordinator.async_set_updated_data(coordinator.data) return handle_vehicle_stream + + +async def async_setup_stream( + hass: HomeAssistant, entry: ConfigEntry, vehicle: TeslemetryVehicleData +): + """Set up the stream for a vehicle.""" + + await vehicle.stream_vehicle.get_config() + entry.async_create_background_task( + hass, + vehicle.stream_vehicle.prefer_typed(True), + f"Prefer typed for {vehicle.vin}", + ) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index b51a67a0b4e..0b6823f8b61 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -4,17 +4,20 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from itertools import chain from typing import cast +from teslemetry_stream import Signal +from teslemetry_stream.const import WindowState + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry @@ -23,6 +26,7 @@ from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, ) from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -33,133 +37,327 @@ PARALLEL_UPDATES = 0 class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Teslemetry binary sensor entity.""" - is_on: Callable[[StateType], bool] = bool + polling_value_fn: Callable[[StateType], bool | None] = bool + polling: bool = False + streaming_key: Signal | None = None + streaming_firmware: str = "2024.26" + streaming_value_fn: Callable[[StateType], bool | None] = ( + lambda x: x is True or x == "true" + ) VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="state", + polling=True, + polling_value_fn=lambda x: x == TeslemetryState.ONLINE, device_class=BinarySensorDeviceClass.CONNECTIVITY, - is_on=lambda x: x == TeslemetryState.ONLINE, ), TeslemetryBinarySensorEntityDescription( key="charge_state_battery_heater_on", + polling=True, + streaming_key=Signal.BATTERY_HEATER_ON, device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_charger_phases", - is_on=lambda x: cast(int, x) > 1, + polling=True, + streaming_key=Signal.CHARGER_PHASES, + polling_value_fn=lambda x: cast(int, x) > 1, + streaming_value_fn=lambda x: cast(int, x) > 1, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_preconditioning_enabled", + polling=True, + streaming_key=Signal.PRECONDITIONING_ENABLED, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="climate_state_is_preconditioning", + polling=True, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_scheduled_charging_pending", + polling=True, + streaming_key=Signal.SCHEDULED_CHARGING_PENDING, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_trip_charging", + polling=True, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_conn_charge_cable", - is_on=lambda x: x != "", + polling=True, + polling_value_fn=lambda x: x != "", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), TeslemetryBinarySensorEntityDescription( key="climate_state_cabin_overheat_protection_actively_cooling", + polling=True, device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dashcam_state", + polling=True, device_class=BinarySensorDeviceClass.RUNNING, - is_on=lambda x: x == "Recording", + polling_value_fn=lambda x: x == "Recording", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_is_user_present", + polling=True, device_class=BinarySensorDeviceClass.PRESENCE, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_fl", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_fr", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_rl", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_rr", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_fd_window", + polling=True, + streaming_key=Signal.FD_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_fp_window", + polling=True, + streaming_key=Signal.FP_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_rd_window", + polling=True, + streaming_key=Signal.RD_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_rp_window", + polling=True, + streaming_key=Signal.RP_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_df", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("DriverFront"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dr", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("DriverRear"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pf", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("PassengerFront"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pr", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("PassengerRear"), entity_category=EntityCategory.DIAGNOSTIC, ), + TeslemetryBinarySensorEntityDescription( + key="automatic_blind_spot_camera", + streaming_key=Signal.AUTOMATIC_BLIND_SPOT_CAMERA, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="automatic_emergency_braking_off", + streaming_key=Signal.AUTOMATIC_EMERGENCY_BRAKING_OFF, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="blind_spot_collision_warning_chime", + streaming_key=Signal.BLIND_SPOT_COLLISION_WARNING_CHIME, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="bms_full_charge_complete", + streaming_key=Signal.BMS_FULL_CHARGE_COMPLETE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="brake_pedal", + streaming_key=Signal.BRAKE_PEDAL, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="charge_port_cold_weather_mode", + streaming_key=Signal.CHARGE_PORT_COLD_WEATHER_MODE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="service_mode", + streaming_key=Signal.SERVICE_MODE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="pin_to_drive_enabled", + streaming_key=Signal.PIN_TO_DRIVE_ENABLED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="drive_rail", + streaming_key=Signal.DRIVE_RAIL, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="driver_seat_belt", + streaming_key=Signal.DRIVER_SEAT_BELT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="driver_seat_occupied", + streaming_key=Signal.DRIVER_SEAT_OCCUPIED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="passenger_seat_belt", + streaming_key=Signal.PASSENGER_SEAT_BELT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="fast_charger_present", + streaming_key=Signal.FAST_CHARGER_PRESENT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="gps_state", + streaming_key=Signal.GPS_STATE, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + TeslemetryBinarySensorEntityDescription( + key="guest_mode_enabled", + streaming_key=Signal.GUEST_MODE_ENABLED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="dc_dc_enable", + streaming_key=Signal.DC_DC_ENABLE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="emergency_lane_departure_avoidance", + streaming_key=Signal.EMERGENCY_LANE_DEPARTURE_AVOIDANCE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="supercharger_session_trip_planner", + streaming_key=Signal.SUPERCHARGER_SESSION_TRIP_PLANNER, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="wiper_heat_enabled", + streaming_key=Signal.WIPER_HEAT_ENABLED, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="rear_display_hvac_enabled", + streaming_key=Signal.REAR_DISPLAY_HVAC_ENABLED, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="offroad_lightbar_present", + streaming_key=Signal.OFFROAD_LIGHTBAR_PRESENT, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="homelink_nearby", + streaming_key=Signal.HOMELINK_NEARBY, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="europe_vehicle", + streaming_key=Signal.EUROPE_VEHICLE, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="right_hand_drive", + streaming_key=Signal.RIGHT_HAND_DRIVE, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_home", + streaming_key=Signal.LOCATED_AT_HOME, + streaming_firmware="2024.44.32", + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_work", + streaming_key=Signal.LOCATED_AT_WORK, + streaming_firmware="2024.44.32", + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_favorite", + streaming_key=Signal.LOCATED_AT_FAVORITE, + streaming_firmware="2024.44.32", + entity_registry_enabled_default=False, + ), ) ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( @@ -183,30 +381,42 @@ async def async_setup_entry( ) -> None: """Set up the Teslemetry binary sensor platform from a config entry.""" - async_add_entities( - chain( - ( # Vehicles - TeslemetryVehicleBinarySensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_DESCRIPTIONS - ), - ( # Energy Site Live - TeslemetryEnergyLiveBinarySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_LIVE_DESCRIPTIONS - if energysite.info_coordinator.data.get("components_battery") - ), - ( # Energy Site Info - TeslemetryEnergyInfoBinarySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_INFO_DESCRIPTIONS - if energysite.info_coordinator.data.get("components_battery") - ), - ) + entities: list[BinarySensorEntity] = [] + for vehicle in entry.runtime_data.vehicles: + for description in VEHICLE_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and description.streaming_key + and vehicle.firmware >= description.streaming_firmware + ): + entities.append( + TeslemetryVehicleStreamingBinarySensorEntity(vehicle, description) + ) + elif description.polling: + entities.append( + TeslemetryVehiclePollingBinarySensorEntity(vehicle, description) + ) + + entities.extend( + TeslemetryEnergyLiveBinarySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + if energysite.live_coordinator + for description in ENERGY_LIVE_DESCRIPTIONS + if description.key in energysite.live_coordinator.data + ) + entities.extend( + TeslemetryEnergyInfoBinarySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.key in energysite.info_coordinator.data ) + async_add_entities(entities) -class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorEntity): + +class TeslemetryVehiclePollingBinarySensorEntity( + TeslemetryVehicleEntity, BinarySensorEntity +): """Base class for Teslemetry vehicle binary sensors.""" entity_description: TeslemetryBinarySensorEntityDescription @@ -223,15 +433,40 @@ class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorE def _async_update_attrs(self) -> None: """Update the attributes of the binary sensor.""" - if self.coordinator.updated_once: - if self._value is None: - self._attr_available = False - self._attr_is_on = None - else: - self._attr_available = True - self._attr_is_on = self.entity_description.is_on(self._value) - else: - self._attr_is_on = None + self._attr_available = self._value is not None + if self._attr_available: + assert self._value is not None + self._attr_is_on = self.entity_description.polling_value_fn(self._value) + + +class TeslemetryVehicleStreamingBinarySensorEntity( + TeslemetryVehicleStreamEntity, BinarySensorEntity, RestoreEntity +): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryBinarySensorEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryBinarySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_is_on = state.state == STATE_ON + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + self._attr_available = value is not None + if self._attr_available: + self._attr_is_on = self.entity_description.streaming_value_fn(value) class TeslemetryEnergyLiveBinarySensorEntity( diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index a9bf3eddd6a..ceeda265795 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TeslemetryConfigEntry from .entity import TeslemetryVehicleEntity -from .helpers import handle_vehicle_command +from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryVehicleData PARALLEL_UPDATES = 0 @@ -24,28 +24,35 @@ PARALLEL_UPDATES = 0 class TeslemetryButtonEntityDescription(ButtonEntityDescription): """Describes a Teslemetry Button entity.""" - func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] | None = None + func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( - TeslemetryButtonEntityDescription(key="wake"), # Every button runs wakeup TeslemetryButtonEntityDescription( - key="flash_lights", func=lambda self: self.api.flash_lights() + key="wake", func=lambda self: handle_command(self.api.wake_up()) ), TeslemetryButtonEntityDescription( - key="honk", func=lambda self: self.api.honk_horn() + key="flash_lights", + func=lambda self: handle_vehicle_command(self.api.flash_lights()), ), TeslemetryButtonEntityDescription( - key="enable_keyless_driving", func=lambda self: self.api.remote_start_drive() + key="honk", func=lambda self: handle_vehicle_command(self.api.honk_horn()) ), TeslemetryButtonEntityDescription( - key="boombox", func=lambda self: self.api.remote_boombox(0) + key="enable_keyless_driving", + func=lambda self: handle_vehicle_command(self.api.remote_start_drive()), + ), + TeslemetryButtonEntityDescription( + key="boombox", + func=lambda self: handle_vehicle_command(self.api.remote_boombox(0)), ), TeslemetryButtonEntityDescription( key="homelink", - func=lambda self: self.api.trigger_homelink( - lat=self.coordinator.data["drive_state_latitude"], - lon=self.coordinator.data["drive_state_longitude"], + func=lambda self: handle_vehicle_command( + self.api.trigger_homelink( + lat=self.coordinator.data["drive_state_latitude"], + lon=self.coordinator.data["drive_state_longitude"], + ) ), ), ) @@ -85,6 +92,4 @@ class TeslemetryButtonEntity(TeslemetryVehicleEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - await self.wake_up_if_asleep() - if self.entity_description.func: - await handle_vehicle_command(self.entity_description.func(self)) + await self.entity_description.func(self) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 5e933d1dbce..95b769a1c2d 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -74,7 +74,6 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): | ClimateEntityFeature.PRESET_MODE ) _attr_preset_modes = ["off", "keep", "dog", "camp"] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -97,9 +96,7 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" value = self.get("climate_state_is_climate_on") - if value is None: - self._attr_hvac_mode = None - elif value: + if value: self._attr_hvac_mode = HVACMode.HEAT_COOL else: self._attr_hvac_mode = HVACMode.OFF @@ -209,7 +206,7 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn _attr_max_temp = COP_LEVELS["High"] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(COP_MODES.values()) - _enable_turn_on_off_backwards_compatibility = False + _attr_entity_registry_enabled_default = False def __init__( diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index f37d0613de9..d39402c622c 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -6,21 +6,19 @@ from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( - Forbidden, InvalidToken, SubscriptionRequired, TeslaFleetError, - VehicleOffline, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslemetryState +from .const import ENERGY_HISTORY_FIELDS, LOGGER from .helpers import flatten -VEHICLE_INTERVAL = timedelta(seconds=30) +VEHICLE_INTERVAL = timedelta(seconds=60) VEHICLE_WAIT = timedelta(minutes=15) ENERGY_LIVE_INTERVAL = timedelta(seconds=30) ENERGY_INFO_INTERVAL = timedelta(seconds=30) @@ -39,7 +37,6 @@ ENDPOINTS = [ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Teslemetry API.""" - updated_once: bool last_active: datetime def __init__( @@ -54,55 +51,18 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.api = api self.data = flatten(product) - self.updated_once = False self.last_active = datetime.now() async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Teslemetry API.""" - self.update_interval = VEHICLE_INTERVAL - try: - if self.data["state"] != TeslemetryState.ONLINE: - response = await self.api.vehicle() - self.data["state"] = response["response"]["state"] - - if self.data["state"] != TeslemetryState.ONLINE: - return self.data - - response = await self.api.vehicle_data(endpoints=ENDPOINTS) - data = response["response"] - - except VehicleOffline: - self.data["state"] = TeslemetryState.OFFLINE - return self.data - except InvalidToken as e: - raise ConfigEntryAuthFailed from e - except SubscriptionRequired as e: + data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"] + except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e - self.updated_once = True - - if self.api.pre2021 and data["state"] == TeslemetryState.ONLINE: - # Handle pre-2021 vehicles which cannot sleep by themselves - if ( - data["charge_state"].get("charging_state") == "Charging" - or data["vehicle_state"].get("is_user_present") - or data["vehicle_state"].get("sentry_mode") - ): - # Vehicle is active, reset timer - self.last_active = datetime.now() - else: - elapsed = datetime.now() - self.last_active - if elapsed > timedelta(minutes=20): - # Vehicle didn't sleep, try again in 15 minutes - self.last_active = datetime.now() - elif elapsed > timedelta(minutes=15): - # Let vehicle go to sleep now - self.update_interval = VEHICLE_WAIT - return flatten(data) @@ -111,7 +71,7 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) updated_once: bool - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + def __init__(self, hass: HomeAssistant, api: EnergySpecific, data: dict) -> None: """Initialize Teslemetry Energy Site Live coordinator.""" super().__init__( hass, @@ -121,12 +81,18 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) ) self.api = api + # Convert Wall Connectors from array to dict + data["wall_connectors"] = { + wc["din"]: wc for wc in (data.get("wall_connectors") or []) + } + self.data = data + async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" try: data = (await self.api.live_status())["response"] - except (InvalidToken, Forbidden, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e @@ -142,8 +108,6 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the Teslemetry API.""" - updated_once: bool - def __init__(self, hass: HomeAssistant, api: EnergySpecific, product: dict) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( @@ -160,7 +124,7 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) try: data = (await self.api.site_info())["response"] - except (InvalidToken, Forbidden, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e @@ -171,8 +135,6 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the Teslemetry API.""" - updated_once: bool - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( @@ -188,13 +150,11 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] - except (InvalidToken, Forbidden, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e - self.updated_once = True - # Add all time periods together output = {key: 0 for key in ENERGY_HISTORY_FIELDS} for period in data.get("time_series", []): diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 8775da931d5..4cc15b6feb8 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -2,9 +2,12 @@ from __future__ import annotations +from itertools import chain from typing import Any from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand +from teslemetry_stream import Signal +from teslemetry_stream.const import WindowState from homeassistant.components.cover import ( CoverDeviceClass, @@ -13,9 +16,14 @@ from homeassistant.components.cover import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -33,30 +41,95 @@ async def async_setup_entry( """Set up the Teslemetry cover platform from a config entry.""" async_add_entities( - klass(vehicle, entry.runtime_data.scopes) - for (klass) in ( - TeslemetryWindowEntity, - TeslemetryChargePortEntity, - TeslemetryFrontTrunkEntity, - TeslemetryRearTrunkEntity, - TeslemetrySunroofEntity, + chain( + ( + TeslemetryPollingWindowEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingChargePortEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingChargePortEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingFrontTrunkEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingFrontTrunkEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingRearTrunkEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingRearTrunkEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetrySunroofEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + if vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") + ), ) - for vehicle in entry.runtime_data.vehicles ) -class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the windows.""" +class CoverRestoreEntity(RestoreEntity, CoverEntity): + """Restore class for cover entities.""" + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + if state.state == "open": + self._attr_is_closed = False + elif state.state == "closed": + self._attr_is_closed = True + + +class TeslemetryWindowEntity(TeslemetryRootEntity, CoverEntity): + """Base class for window cover entities.""" _attr_device_class = CoverDeviceClass.WINDOW + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + async def async_open_cover(self, **kwargs: Any) -> None: + """Vent windows.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command( + self.api.window_control(command=WindowCommand.VENT) + ) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command( + self.api.window_control(command=WindowCommand.CLOSE) + ) + self._attr_is_closed = True + self.async_write_ha_state() + + +class TeslemetryPollingWindowEntity( + TeslemetryVehicleEntity, TeslemetryWindowEntity, CoverEntity +): + """Polling cover entity for windows.""" def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" super().__init__(data, "windows") self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) @@ -67,41 +140,108 @@ class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity): rd = self.get("vehicle_state_rd_window") rp = self.get("vehicle_state_rp_window") - # Any open set to open if OPEN in (fd, fp, rd, rp): self._attr_is_closed = False - # All closed set to closed - elif CLOSED == fd == fp == rd == rp: - self._attr_is_closed = True - # Otherwise, set to unknown - else: + elif None in (fd, fp, rd, rp): self._attr_is_closed = None + else: + self._attr_is_closed = True + + +class TeslemetryStreamingWindowEntity( + TeslemetryVehicleStreamEntity, TeslemetryWindowEntity, CoverRestoreEntity +): + """Streaming cover entity for windows.""" + + fd: bool | None = None + fp: bool | None = None + rd: bool | None = None + rp: bool | None = None + + def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__( + data, + "windows", + ) + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.stream.async_add_listener( + self._handle_stream_update, + {"vin": self.vin, "data": {self.streaming_key: None}}, + ) + ) + for signal in ( + Signal.FD_WINDOW, + Signal.FP_WINDOW, + Signal.RD_WINDOW, + Signal.RP_WINDOW, + ): + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(signal), + f"Adding field {signal} to {self.vehicle.vin}", + ) + + def _handle_stream_update(self, data) -> None: + """Update the entity attributes.""" + + if value := data.get(Signal.FD_WINDOW): + self.fd = WindowState.get(value) == "closed" + if value := data.get(Signal.FP_WINDOW): + self.fp = WindowState.get(value) == "closed" + if value := data.get(Signal.RD_WINDOW): + self.rd = WindowState.get(value) == "closed" + if value := data.get(Signal.RP_WINDOW): + self.rp = WindowState.get(value) == "closed" + + if False in (self.fd, self.fp, self.rd, self.rp): + self._attr_is_closed = False + elif None in (self.fd, self.fp, self.rd, self.rp): + self._attr_is_closed = None + else: + self._attr_is_closed = True + + self.async_write_ha_state() + + +class TeslemetryChargePortEntity( + TeslemetryRootEntity, + CoverEntity, +): + """Base class for for charge port cover entities.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE async def async_open_cover(self, **kwargs: Any) -> None: - """Vent windows.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command( - self.api.window_control(command=WindowCommand.VENT) - ) + """Open charge port.""" + self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) + + await handle_vehicle_command(self.api.charge_port_door_open()) self._attr_is_closed = False self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: - """Close windows.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command( - self.api.window_control(command=WindowCommand.CLOSE) - ) + """Close charge port.""" + self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) + + await handle_vehicle_command(self.api.charge_port_door_close()) self._attr_is_closed = True self.async_write_ha_state() -class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the charge port.""" - - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryPollingChargePortEntity( + TeslemetryVehicleEntity, TeslemetryChargePortEntity +): + """Polling cover entity for the charge port.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" @@ -120,75 +260,113 @@ class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverEntity): """Update the entity attributes.""" self._attr_is_closed = not self._value - async def async_open_cover(self, **kwargs: Any) -> None: - """Open charge port.""" - self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.charge_port_door_open()) - self._attr_is_closed = False - self.async_write_ha_state() - async def async_close_cover(self, **kwargs: Any) -> None: - """Close charge port.""" - self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.charge_port_door_close()) - self._attr_is_closed = True - self.async_write_ha_state() - - -class TeslemetryFrontTrunkEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the front trunk.""" - - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryStreamingChargePortEntity( + TeslemetryVehicleStreamEntity, TeslemetryChargePortEntity, CoverRestoreEntity +): + """Streaming cover entity for the charge port.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: - """Initialize the cover.""" - super().__init__(vehicle, "vehicle_state_ft") - - self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = CoverEntityFeature.OPEN + """Initialize the sensor.""" + super().__init__( + vehicle, + "charge_state_charge_port_door_open", + ) + self.scoped = any( + scope in scopes + for scope in (Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS) + ) if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None - def _async_update_attrs(self) -> None: - """Update the entity attributes.""" - self._attr_is_closed = self._value == CLOSED + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_ChargePortDoorOpen( + self._async_value_from_stream + ) + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the value of the entity.""" + self._attr_is_closed = None if value is None else not value + self.async_write_ha_state() + + +class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity): + """Base class for the front trunk cover entities.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN async def async_open_cover(self, **kwargs: Any) -> None: """Open front trunk.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.FRONT)) self._attr_is_closed = False self.async_write_ha_state() + # In the future this could be extended to add aftermarket close support through a option flow -class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the rear trunk.""" - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryPollingFrontTrunkEntity( + TeslemetryVehicleEntity, TeslemetryFrontTrunkEntity +): + """Polling cover entity for the front trunk.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" - super().__init__(vehicle, "vehicle_state_rt") - self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) + super().__init__(vehicle, "vehicle_state_ft") def _async_update_attrs(self) -> None: """Update the entity attributes.""" self._attr_is_closed = self._value == CLOSED + +class TeslemetryStreamingFrontTrunkEntity( + TeslemetryVehicleStreamEntity, TeslemetryFrontTrunkEntity, CoverRestoreEntity +): + """Streaming cover entity for the front trunk.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + super().__init__(vehicle, "vehicle_state_ft") + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_TrunkFront(self._async_value_from_stream) + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the entity attributes.""" + + self._attr_is_closed = None if value is None else not value + self.async_write_ha_state() + + +class TeslemetryRearTrunkEntity(TeslemetryRootEntity, CoverEntity): + """Cover entity for the rear trunk.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + async def async_open_cover(self, **kwargs: Any) -> None: """Open rear trunk.""" if self.is_closed is not False: self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) self._attr_is_closed = False self.async_write_ha_state() @@ -197,12 +375,55 @@ class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity): """Close rear trunk.""" if self.is_closed is not True: self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) self._attr_is_closed = True self.async_write_ha_state() +class TeslemetryPollingRearTrunkEntity( + TeslemetryVehicleEntity, TeslemetryRearTrunkEntity +): + """Base class for the rear trunk cover entities.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + super().__init__(vehicle, "vehicle_state_rt") + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_is_closed = self._value == CLOSED + + +class TeslemetryStreamingRearTrunkEntity( + TeslemetryVehicleStreamEntity, TeslemetryRearTrunkEntity, CoverRestoreEntity +): + """Polling cover entity for the rear trunk.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(vehicle, "vehicle_state_rt") + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_TrunkRear(self._async_value_from_stream) + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the entity attributes.""" + + self._attr_is_closed = None if value is None else not value + + class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): """Cover entity for the sunroof.""" @@ -213,7 +434,7 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): _attr_entity_registry_enabled_default = False def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: - """Initialize the sensor.""" + """Initialize the cover.""" super().__init__(vehicle, "vehicle_state_sun_roof_state") self.scoped = Scope.VEHICLE_CMDS in scopes @@ -235,7 +456,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.VENT)) self._attr_is_closed = False self.async_write_ha_state() @@ -243,7 +463,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.CLOSE)) self._attr_is_closed = True self.async_write_ha_state() @@ -251,7 +470,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Close sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.STOP)) self._attr_is_closed = False self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 2b0ffd88cc6..42c8fea8d09 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -2,18 +2,69 @@ from __future__ import annotations -from homeassistant.components.device_tracker.config_entry import TrackerEntity +from collections.abc import Callable +from dataclasses import dataclass + +from teslemetry_stream import TeslemetryStreamVehicle +from teslemetry_stream.const import TeslaLocation + +from homeassistant.components.device_tracker.config_entry import ( + TrackerEntity, + TrackerEntityDescription, +) from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import TeslemetryVehicleEntity, TeslemetryVehicleStreamEntity from .models import TeslemetryVehicleData PARALLEL_UPDATES = 0 +@dataclass(frozen=True, kw_only=True) +class TeslemetryDeviceTrackerEntityDescription(TrackerEntityDescription): + """Describe a Teslemetry device tracker entity.""" + + value_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[TeslaLocation | None], None]], + Callable[[], None], + ] + name_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[str | None], None]], Callable[[], None] + ] + | None + ) = None + streaming_firmware: str + polling_prefix: str | None = None + + +DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = ( + TeslemetryDeviceTrackerEntityDescription( + key="location", + polling_prefix="drive_state", + value_listener=lambda x, y: x.listen_Location(y), + streaming_firmware="2024.26", + ), + TeslemetryDeviceTrackerEntityDescription( + key="route", + polling_prefix="drive_state_active_route", + value_listener=lambda x, y: x.listen_DestinationLocation(y), + name_listener=lambda x, y: x.listen_DestinationName(y), + streaming_firmware="2024.26", + ), + TeslemetryDeviceTrackerEntityDescription( + key="origin", + value_listener=lambda x, y: x.listen_OriginLocation(y), + streaming_firmware="2024.26", + entity_registry_enabled_default=False, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, @@ -21,67 +72,105 @@ async def async_setup_entry( ) -> None: """Set up the Teslemetry device tracker platform from a config entry.""" - async_add_entities( - klass(vehicle) - for klass in ( - TeslemetryDeviceTrackerLocationEntity, - TeslemetryDeviceTrackerRouteEntity, - ) - for vehicle in entry.runtime_data.vehicles - ) + entities: list[ + TeslemetryPollingDeviceTrackerEntity | TeslemetryStreamingDeviceTrackerEntity + ] = [] + for vehicle in entry.runtime_data.vehicles: + for description in DESCRIPTIONS: + if vehicle.api.pre2021 or vehicle.firmware < description.streaming_firmware: + if description.polling_prefix: + entities.append( + TeslemetryPollingDeviceTrackerEntity(vehicle, description) + ) + else: + entities.append( + TeslemetryStreamingDeviceTrackerEntity(vehicle, description) + ) + + async_add_entities(entities) -class TeslemetryDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): - """Base class for Teslemetry tracker entities.""" +class TeslemetryPollingDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): + """Base class for Teslemetry Tracker Entities.""" - lat_key: str - lon_key: str + entity_description: TeslemetryDeviceTrackerEntityDescription def __init__( self, vehicle: TeslemetryVehicleData, + description: TeslemetryDeviceTrackerEntityDescription, ) -> None: """Initialize the device tracker.""" - super().__init__(vehicle, self.key) + self.entity_description = description + super().__init__(vehicle, description.key) def _async_update_attrs(self) -> None: - """Update the attributes of the device tracker.""" - + """Update the attributes of the entity.""" + self._attr_latitude = self.get( + f"{self.entity_description.polling_prefix}_latitude" + ) + self._attr_longitude = self.get( + f"{self.entity_description.polling_prefix}_longitude" + ) + self._attr_location_name = self.get( + f"{self.entity_description.polling_prefix}_destination" + ) + if self._attr_location_name == "Home": + self._attr_location_name = STATE_HOME self._attr_available = ( - self.get(self.lat_key, False) is not None - and self.get(self.lon_key, False) is not None + self._attr_latitude is not None and self._attr_longitude is not None ) - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - return self.get(self.lat_key) - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - return self.get(self.lon_key) +class TeslemetryStreamingDeviceTrackerEntity( + TeslemetryVehicleStreamEntity, TrackerEntity, RestoreEntity +): + """Base class for Teslemetry Tracker Entities.""" + entity_description: TeslemetryDeviceTrackerEntityDescription -class TeslemetryDeviceTrackerLocationEntity(TeslemetryDeviceTrackerEntity): - """Vehicle location device tracker class.""" + def __init__( + self, + vehicle: TeslemetryVehicleData, + description: TeslemetryDeviceTrackerEntityDescription, + ) -> None: + """Initialize the device tracker.""" + self.entity_description = description + super().__init__(vehicle, description.key) - key = "location" - lat_key = "drive_state_latitude" - lon_key = "drive_state_longitude" + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_state = state.state + self._attr_latitude = state.attributes.get("latitude") + self._attr_longitude = state.attributes.get("longitude") + self._attr_location_name = state.attributes.get("location_name") + self.async_on_remove( + self.entity_description.value_listener( + self.vehicle.stream_vehicle, self._location_callback + ) + ) + if self.entity_description.name_listener: + self.async_on_remove( + self.entity_description.name_listener( + self.vehicle.stream_vehicle, self._name_callback + ) + ) + def _location_callback(self, location: TeslaLocation | None) -> None: + """Update the value of the entity.""" + if location is None: + self._attr_available = False + else: + self._attr_available = True + self._attr_latitude = location.latitude + self._attr_longitude = location.longitude + self.async_write_ha_state() -class TeslemetryDeviceTrackerRouteEntity(TeslemetryDeviceTrackerEntity): - """Vehicle navigation device tracker class.""" - - key = "route" - lat_key = "drive_state_active_route_latitude" - lon_key = "drive_state_active_route_longitude" - - @property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - location = self.get("drive_state_active_route_destination") - if location == "Home": - return STATE_HOME - return location + def _name_callback(self, name: str | None) -> None: + """Update the value of the entity.""" + self._attr_location_name = name + if self._attr_location_name == "Home": + self._attr_location_name = STATE_HOME + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/diagnostics.py b/homeassistant/components/teslemetry/diagnostics.py index 7e9c8a9a5b0..fc601a58ae6 100644 --- a/homeassistant/components/teslemetry/diagnostics.py +++ b/homeassistant/components/teslemetry/diagnostics.py @@ -41,7 +41,9 @@ async def async_get_config_entry_diagnostics( ] energysites = [ { - "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT), + "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT) + if x.live_coordinator + else None, "info": async_redact_data(x.info_coordinator.data, ENERGY_INFO_REDACT), } for x in entry.runtime_data.energysites diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index d14f3a42734..82d3db123c3 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -3,11 +3,14 @@ from abc import abstractmethod from typing import Any +from propcache.api import cached_property from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from teslemetry_stream import Signal from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -21,18 +24,33 @@ from .helpers import wake_up_vehicle from .models import TeslemetryEnergyData, TeslemetryVehicleData +class TeslemetryRootEntity(Entity): + """Parent class for all Teslemetry entities.""" + + _attr_has_entity_name = True + scoped: bool + api: VehicleSpecific | EnergySpecific + + def raise_for_scope(self, scope: Scope): + """Raise an error if a scope is not available.""" + if not self.scoped: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_scope", + translation_placeholders={"scope": scope}, + ) + + class TeslemetryEntity( + TeslemetryRootEntity, CoordinatorEntity[ TeslemetryVehicleDataCoordinator | TeslemetryEnergyHistoryCoordinator | TeslemetryEnergySiteLiveCoordinator | TeslemetryEnergySiteInfoCoordinator - ] + ], ): - """Parent class for all Teslemetry entities.""" - - _attr_has_entity_name = True - scoped: bool + """Parent class for all Teslemetry Coordinator entities.""" def __init__( self, @@ -73,11 +91,6 @@ class TeslemetryEntity( """Return if the value is a literal None.""" return self.get(self.key, False) is None - @property - def has(self) -> bool: - """Return True if a specific value is in coordinator data.""" - return self.key in self.coordinator.data - def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._async_update_attrs() @@ -87,15 +100,6 @@ class TeslemetryEntity( def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" - def raise_for_scope(self, scope: Scope): - """Raise an error if a scope is not available.""" - if not self.scoped: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="missing_scope", - translation_placeholders={"scope": scope}, - ) - class TeslemetryVehicleEntity(TeslemetryEntity): """Parent class for Teslemetry Vehicle entities.""" @@ -139,6 +143,8 @@ class TeslemetryEnergyLiveEntity(TeslemetryEntity): ) -> None: """Initialize common aspects of a Teslemetry Energy Site Live entity.""" + assert data.live_coordinator + self.api = data.api self._attr_unique_id = f"{data.id}-{key}" self._attr_device_info = data.device @@ -198,6 +204,8 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): ) -> None: """Initialize common aspects of a Teslemetry entity.""" + assert data.live_coordinator + self.api = data.api self.din = din self._attr_unique_id = f"{data.id}-{din}-{key}" @@ -236,3 +244,53 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): return self.key in self.coordinator.data.get("wall_connectors", {}).get( self.din, {} ) + + +class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): + """Parent class for Teslemetry Vehicle Stream entities.""" + + def __init__( + self, data: TeslemetryVehicleData, key: str, streaming_key: Signal | None = None + ) -> None: + """Initialize common aspects of a Teslemetry entity.""" + self.streaming_key = streaming_key + self.vehicle = data + + self.api = data.api + self.stream = data.stream + self.vin = data.vin + self.add_field = data.stream.get_vehicle(self.vin).add_field + + self._attr_translation_key = key + self._attr_unique_id = f"{data.vin}-{key}" + self._attr_device_info = data.device + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + if self.streaming_key: + self.async_on_remove( + self.stream.async_add_listener( + self._handle_stream_update, + {"vin": self.vin, "data": {self.streaming_key: None}}, + ) + ) + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(self.streaming_key), + f"Adding field {self.streaming_key.value} to {self.vehicle.vin}", + ) + + def _handle_stream_update(self, data: dict[str, Any]) -> None: + """Handle updated data from the stream.""" + self._async_value_from_stream(data["data"][self.streaming_key]) + self.async_write_ha_state() + + def _async_value_from_stream(self, value: Any) -> None: + """Update the entity with the latest value from the stream.""" + raise NotImplementedError + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index 0a7a557ed88..4600391145b 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -82,8 +82,6 @@ class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity): def _async_update_attrs(self) -> None: """Update entity attributes.""" - if self._value is None: - self._attr_is_locked = None self._attr_is_locked = self._value == ENGAGED async def async_lock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index fc82dea6445..7a3d0905ea1 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.8.4", "teslemetry-stream==0.4.2"] + "requirements": ["tesla-fleet-api==0.9.2", "teslemetry-stream==0.6.6"] } diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index d3969b30a7c..5b78386c68a 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -8,8 +8,9 @@ from dataclasses import dataclass from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope -from teslemetry_stream import TeslemetryStream +from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo from .coordinator import ( @@ -34,12 +35,15 @@ class TeslemetryVehicleData: """Data for a vehicle in the Teslemetry integration.""" api: VehicleSpecific + config_entry: ConfigEntry coordinator: TeslemetryVehicleDataCoordinator stream: TeslemetryStream + stream_vehicle: TeslemetryStreamVehicle vin: str - wakelock = asyncio.Lock() + firmware: str device: DeviceInfo remove_listener: Callable + wakelock = asyncio.Lock() @dataclass @@ -47,7 +51,7 @@ class TeslemetryEnergyData: """Data for a vehicle in the Teslemetry integration.""" api: EnergySpecific - live_coordinator: TeslemetryEnergySiteLiveCoordinator + live_coordinator: TeslemetryEnergySiteLiveCoordinator | None info_coordinator: TeslemetryEnergySiteInfoCoordinator history_coordinator: TeslemetryEnergyHistoryCoordinator | None id: int diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 192e2b194a8..baf1d80ac6c 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -90,10 +90,12 @@ async def async_setup_entry( ) for description in SEAT_HEATER_DESCRIPTIONS for vehicle in entry.runtime_data.vehicles + if description.key in vehicle.coordinator.data ), ( TeslemetryWheelHeaterSelectEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles + if vehicle.coordinator.data.get("climate_state_steering_wheel_heater") ), ( TeslemetryOperationSelectEntity(energysite, entry.runtime_data.scopes) @@ -137,7 +139,7 @@ class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): """Handle updated data from the coordinator.""" self._attr_available = self.entity_description.available_fn(self) value = self._value - if value is None: + if not isinstance(value, int): self._attr_current_option = None else: self._attr_current_option = self._attr_options[value] @@ -182,7 +184,7 @@ class TeslemetryWheelHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): """Handle updated data from the coordinator.""" value = self._value - if value is None: + if not isinstance(value, int): self._attr_current_option = None else: self._attr_current_option = self._attr_options[value] diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 95876cc2cf9..0fb0a6ee0e0 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -5,10 +5,12 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -from itertools import chain -from typing import cast + +from propcache.api import cached_property +from teslemetry_stream import Signal from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -40,6 +42,7 @@ from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, TeslemetryWallConnectorEntity, ) from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -59,125 +62,165 @@ SHIFT_STATES = {"P": "p", "D": "d", "R": "r", "N": "n"} @dataclass(frozen=True, kw_only=True) -class TeslemetrySensorEntityDescription(SensorEntityDescription): +class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): """Describes Teslemetry Sensor entity.""" - value_fn: Callable[[StateType], StateType] = lambda x: x + polling: bool = False + polling_value_fn: Callable[[StateType], StateType] = lambda x: x + polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None + streaming_key: Signal | None = None + streaming_value_fn: Callable[[StateType], StateType] = lambda x: x + streaming_firmware: str = "2024.26" -VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( - TeslemetrySensorEntityDescription( +VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charging_state", + polling=True, + streaming_key=Signal.DETAILED_CHARGE_STATE, + polling_value_fn=lambda value: CHARGE_STATES.get(str(value)), + streaming_value_fn=lambda value: CHARGE_STATES.get( + str(value).replace("DetailedChargeState", "") + ), options=list(CHARGE_STATES.values()), device_class=SensorDeviceClass.ENUM, - value_fn=lambda value: CHARGE_STATES.get(cast(str, value)), ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_battery_level", + polling=True, + streaming_key=Signal.BATTERY_LEVEL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_usable_battery_level", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charge_energy_added", + polling=True, + streaming_key=Signal.AC_CHARGING_ENERGY_IN, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_power", + polling=True, + streaming_key=Signal.AC_CHARGING_POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_voltage", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_actual_current", + polling=True, + streaming_key=Signal.CHARGE_AMPS, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charge_rate", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_conn_charge_cable", + polling=True, + streaming_key=Signal.CHARGING_CABLE_TYPE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_fast_charger_type", + polling=True, + streaming_key=Signal.FAST_CHARGER_TYPE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_battery_range", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_est_battery_range", + polling=True, + streaming_key=Signal.EST_BATTERY_RANGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_ideal_battery_range", + polling=True, + streaming_key=Signal.IDEAL_BATTERY_RANGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_speed", + polling=True, + polling_value_fn=lambda value: value or 0, + streaming_key=Signal.VEHICLE_SPEED, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, entity_registry_enabled_default=False, - value_fn=lambda value: value or 0, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_power", + polling=True, + polling_value_fn=lambda value: value or 0, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda value: value or 0, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_shift_state", + polling=True, + polling_available_fn=lambda x: True, + polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), + streaming_key=Signal.GEAR, + streaming_value_fn=lambda x: SHIFT_STATES.get(str(x)), options=list(SHIFT_STATES.values()), device_class=SensorDeviceClass.ENUM, - value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_odometer", + polling=True, + streaming_key=Signal.ODOMETER, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -185,8 +228,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fl", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_FL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -195,8 +240,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fr", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_FR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -205,8 +252,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rl", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_RL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -215,8 +264,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rr", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_RR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -225,22 +276,27 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_inside_temp", + polling=True, + streaming_key=Signal.INSIDE_TEMP, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_outside_temp", + polling=True, + streaming_key=Signal.OUTSIDE_TEMP, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_driver_temp_setting", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -248,8 +304,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_passenger_temp_setting", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -257,23 +314,29 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_traffic_minutes_delay", + polling=True, + streaming_key=Signal.ROUTE_TRAFFIC_MINUTES_DELAY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MINUTES, device_class=SensorDeviceClass.DURATION, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_energy_at_arrival", + polling=True, + streaming_key=Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_miles_to_arrival", + polling=True, + streaming_key=Signal.MILES_TO_ARRIVAL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -286,17 +349,21 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription): """Describes Teslemetry Sensor entity.""" variance: int + streaming_key: Signal + streaming_firmware: str = "2024.26" VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( TeslemetryTimeEntityDescription( key="charge_state_minutes_to_full_charge", + streaming_key=Signal.TIME_TO_FULL_CHARGE, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, variance=4, ), TeslemetryTimeEntityDescription( key="drive_state_active_route_minutes_to_arrival", + streaming_key=Signal.MINUTES_TO_ARRIVAL, device_class=SensorDeviceClass.TIMESTAMP, variance=1, ), @@ -391,6 +458,14 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( ), ) + +@dataclass(frozen=True, kw_only=True) +class TeslemetrySensorEntityDescription(SensorEntityDescription): + """Describes Teslemetry Sensor entity.""" + + value_fn: Callable[[StateType], StateType] = lambda x: x + + WALL_CONNECTOR_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( TeslemetrySensorEntityDescription( key="wall_connector_state", @@ -448,55 +523,109 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Teslemetry sensor platform from a config entry.""" - async_add_entities( - chain( - ( # Add vehicles - TeslemetryVehicleSensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_DESCRIPTIONS - ), - ( # Add vehicles time sensors - TeslemetryVehicleTimeSensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_TIME_DESCRIPTIONS - ), - ( # Add energy site live - TeslemetryEnergyLiveSensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_LIVE_DESCRIPTIONS - if description.key in energysite.live_coordinator.data - ), - ( # Add wall connectors - TeslemetryWallConnectorSensorEntity(energysite, din, description) - for energysite in entry.runtime_data.energysites - for din in energysite.live_coordinator.data.get("wall_connectors", {}) - for description in WALL_CONNECTOR_DESCRIPTIONS - ), - ( # Add energy site info - TeslemetryEnergyInfoSensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_INFO_DESCRIPTIONS - if description.key in energysite.info_coordinator.data - ), - ( # Add energy history sensor - TeslemetryEnergyHistorySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_HISTORY_DESCRIPTIONS - if energysite.history_coordinator - ), - ) + + entities: list[SensorEntity] = [] + for vehicle in entry.runtime_data.vehicles: + for description in VEHICLE_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and description.streaming_key + and vehicle.firmware >= description.streaming_firmware + ): + entities.append(TeslemetryStreamSensorEntity(vehicle, description)) + elif description.polling: + entities.append(TeslemetryVehicleSensorEntity(vehicle, description)) + + for time_description in VEHICLE_TIME_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and vehicle.firmware >= time_description.streaming_firmware + ): + entities.append( + TeslemetryStreamTimeSensorEntity(vehicle, time_description) + ) + else: + entities.append( + TeslemetryVehicleTimeSensorEntity(vehicle, time_description) + ) + + entities.extend( + TeslemetryEnergyLiveSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + if energysite.live_coordinator + for description in ENERGY_LIVE_DESCRIPTIONS + if description.key in energysite.live_coordinator.data ) + entities.extend( + TeslemetryWallConnectorSensorEntity(energysite, din, description) + for energysite in entry.runtime_data.energysites + if energysite.live_coordinator + for din in energysite.live_coordinator.data.get("wall_connectors", {}) + for description in WALL_CONNECTOR_DESCRIPTIONS + ) + + entities.extend( + TeslemetryEnergyInfoSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.key in energysite.info_coordinator.data + ) + + entities.extend( + TeslemetryEnergyHistorySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_HISTORY_DESCRIPTIONS + if energysite.history_coordinator is not None + ) + + async_add_entities(entities) + + +class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryVehicleSensorEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryVehicleSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + if (sensor_data := await self.async_get_last_sensor_data()) is not None: + self._attr_native_value = sensor_data.native_value + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + if value is None: + self._attr_native_value = None + else: + self._attr_native_value = self.entity_description.streaming_value_fn(value) + class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): """Base class for Teslemetry vehicle metric sensors.""" - entity_description: TeslemetrySensorEntityDescription + entity_description: TeslemetryVehicleSensorEntityDescription def __init__( self, data: TeslemetryVehicleData, - description: TeslemetrySensorEntityDescription, + description: TeslemetryVehicleSensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -504,12 +633,48 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - if self.has: - self._attr_native_value = self.entity_description.value_fn(self._value) + if self.entity_description.polling_available_fn(self._value): + self._attr_available = True + self._attr_native_value = self.entity_description.polling_value_fn( + self._value + ) else: + self._attr_available = False self._attr_native_value = None +class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEntity): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryTimeEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryTimeEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + self._get_timestamp = ignore_variance( + func=lambda value: dt_util.now() + timedelta(minutes=value), + ignored_variance=timedelta(minutes=description.variance), + ) + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + if value is None: + self._attr_native_value = None + else: + self._attr_native_value = self._get_timestamp(value) + + class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): """Base class for Teslemetry vehicle time sensors.""" diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 97cfffa1699..8215adb5711 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -98,7 +98,7 @@ def async_get_energy_site_for_entry( return energy_data -def async_register_services(hass: HomeAssistant) -> None: # noqa: C901 +def async_register_services(hass: HomeAssistant) -> None: """Set up the Teslemetry services.""" async def navigate_gps_request(call: ServiceCall) -> None: diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 4f4bc2ae60c..8dc8b053712 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -51,7 +51,7 @@ "name": "Trip charging" }, "climate_state_cabin_overheat_protection_actively_cooling": { - "name": "Cabin overheat protection actively cooling" + "name": "Cabin overheat protection active" }, "climate_state_is_preconditioning": { "name": "Preconditioning" @@ -68,6 +68,27 @@ "storm_mode_active": { "name": "Storm watch active" }, + "automatic_blind_spot_camera": { + "name": "Automatic blind spot camera" + }, + "automatic_emergency_braking_off": { + "name": "Automatic emergency braking off" + }, + "blind_spot_collision_warning_chime": { + "name": "Blind spot collision warning chime" + }, + "bms_full_charge_complete": { + "name": "BMS full charge" + }, + "brake_pedal": { + "name": "Brake pedal" + }, + "charge_port_cold_weather_mode": { + "name": "Charge port cold weather mode" + }, + "service_mode": { + "name": "Service mode" + }, "vehicle_state_dashcam_state": { "name": "Dashcam" }, @@ -109,6 +130,66 @@ }, "vehicle_state_tpms_soft_warning_rr": { "name": "Tire pressure warning rear right" + }, + "pin_to_drive_enabled": { + "name": "Pin to drive enabled" + }, + "drive_rail": { + "name": "Drive rail" + }, + "driver_seat_belt": { + "name": "Driver seat belt" + }, + "driver_seat_occupied": { + "name": "Driver seat occupied" + }, + "passenger_seat_belt": { + "name": "Passenger seat belt" + }, + "fast_charger_present": { + "name": "Fast charger present" + }, + "gps_state": { + "name": "GPS state" + }, + "guest_mode_enabled": { + "name": "Guest mode enabled" + }, + "dc_dc_enable": { + "name": "DC to DC converter" + }, + "emergency_lane_departure_avoidance": { + "name": "Emergency lane departure avoidance" + }, + "supercharger_session_trip_planner": { + "name": "Supercharger session trip planner" + }, + "wiper_heat_enabled": { + "name": "Wiper heat" + }, + "rear_display_hvac_enabled": { + "name": "Rear display HVAC" + }, + "offroad_lightbar_present": { + "name": "Offroad lightbar" + }, + "homelink_nearby": { + "name": "Homelink nearby" + }, + "europe_vehicle": { + "name": "European vehicle" + }, + "right_hand_drive": { + "name": "Right hand drive" + }, + "located_at_home": { + "name": "Located at home" + }, + "located_at_work": { + "name": "Located at work" + }, + "located_at_favorite": { + "name": "Located at favorite" } }, "button": { @@ -155,6 +236,9 @@ }, "route": { "name": "Route" + }, + "origin": { + "name": "Origin" } }, "lock": { @@ -610,7 +694,7 @@ }, "services": { "navigation_gps_request": { - "description": "Set vehicle navigation to the provided latitude/longitude coordinates.", + "description": "Sets vehicle navigation to the provided latitude/longitude coordinates.", "fields": { "device_id": { "description": "Vehicle to share to.", @@ -646,7 +730,7 @@ "name": "Set scheduled charging" }, "set_scheduled_departure": { - "description": "Sets a time at which departure should be completed.", + "description": "Sets the departure time for a vehicle to schedule charging and preconditioning.", "fields": { "departure_time": { "description": "Time to be preconditioned by.", @@ -684,7 +768,7 @@ "name": "Set scheduled departure" }, "speed_limit": { - "description": "Activate the speed limit of the vehicle.", + "description": "Activates the speed limit of a vehicle.", "fields": { "device_id": { "description": "Vehicle to limit.", @@ -702,7 +786,7 @@ "name": "Set speed limit" }, "time_of_use": { - "description": "Update the time of use settings for the energy site.", + "description": "Updates the time of use settings for an energy site.", "fields": { "device_id": { "description": "Energy Site to configure.", @@ -716,7 +800,7 @@ "name": "Time of use settings" }, "valet_mode": { - "description": "Activate the valet mode of the vehicle.", + "description": "Activates the valet mode of a vehicle.", "fields": { "device_id": { "description": "Vehicle to limit.", diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 91ef3074bae..6a1cff4c5da 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -102,6 +102,7 @@ async def async_setup_entry( ) for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS + if description.key in vehicle.coordinator.data ), ( TeslemetryChargeSwitchEntity( @@ -150,10 +151,7 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - if self._value is None: - self._attr_is_on = None - else: - self._attr_is_on = bool(self._value) + self._attr_is_on = bool(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py index e0649432e05..1d26926aeaa 100644 --- a/homeassistant/components/tessie/climate.py +++ b/homeassistant/components/tessie/climate.py @@ -60,7 +60,6 @@ class TessieClimateEntity(TessieEntity, ClimateEntity): TessieClimateKeeper.DOG, TessieClimateKeeper.CAMP, ] - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index cab9f4c706d..8f7c9890664 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.8.4"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.2"] } diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 5b677594b42..4ac645a0270 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -521,7 +521,7 @@ "message": "{name} is already inactive." }, "incorrect_pin": { - "message": "Incorrect pin for {name}." + "message": "Incorrect PIN for {name}." }, "no_cable": { "message": "Insert cable to lock" diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index d0f5ac7d3b7..27af7e3fe59 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -9,7 +9,7 @@ import logging import re from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index 81517a6f1f5..e3aa9060787 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -88,7 +88,6 @@ class TfiacClimate(ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - _enable_turn_on_off_backwards_compatibility = False def __init__(self, hass, client): """Init class.""" diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 51348afb0a4..2c066d785ca 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.10.0"] + "requirements": ["thermopro-ble==0.10.1"] } diff --git a/homeassistant/components/thethingsnetwork/strings.json b/homeassistant/components/thethingsnetwork/strings.json index f5a4fcef8fd..8b3eb7b53c4 100644 --- a/homeassistant/components/thethingsnetwork/strings.json +++ b/homeassistant/components/thethingsnetwork/strings.json @@ -2,12 +2,12 @@ "config": { "step": { "user": { - "title": "Connect to The Things Network v3 App", - "description": "Enter the API hostname, app id and API key for your TTN application.\n\nYou can find your API key in the [The Things Network console](https://console.thethingsnetwork.org) -> Applications -> application_id -> API keys.", + "title": "Connect to The Things Network v3", + "description": "Enter the API hostname, application ID and API key to use with Home Assistant.\n\n[Read the instructions](https://www.thethingsindustries.com/docs/integrations/adding-applications/) on how to register your application and create an API key.", "data": { - "hostname": "[%key:common::config_flow::data::host%]", + "host": "[%key:common::config_flow::data::host%]", "app_id": "Application ID", - "access_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]" } }, "reauth_confirm": { diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index abf3e604472..4e44b2b1ffd 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -4,8 +4,8 @@ from __future__ import annotations import logging import re -import telnetlib # pylint: disable=deprecated-module +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.device_tracker import ( diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py index 568b76d4999..bf202a50c34 100644 --- a/homeassistant/components/thread/config_flow.py +++ b/homeassistant/components/thread/config_flow.py @@ -4,8 +4,9 @@ from __future__ import annotations from typing import Any -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -28,7 +29,7 @@ class ThreadConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title="Thread", data={}) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Set up because the user has border routers.""" await self._async_handle_discovery_without_unique_id() diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index fc95e524181..1b4ae7ba01f 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -8,7 +8,7 @@ from datetime import datetime import logging from typing import Any, cast -from propcache import cached_property +from propcache.api import cached_property from python_otbr_api import tlv_parser from python_otbr_api.tlv_parser import MeshcopTLVType diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 65d4c9d044c..868ced022b8 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.6.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.7.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 125dc8eae6f..c1ec7bf2a9e 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -397,7 +397,7 @@ class TibberSensorElPrice(TibberSensor): if ( not self._tibber_home.last_data_timestamp or (self._tibber_home.last_data_timestamp - now).total_seconds() - < 11 * 3600 + self._spread_load_constant + < 10 * 3600 - self._spread_load_constant or not self.available ): _LOGGER.debug("Asking for new data") diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index 5033cda11d0..938e96b9917 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -4,7 +4,6 @@ from __future__ import annotations import datetime as dt from datetime import datetime -from functools import partial from typing import Any, Final import voluptuous as vol @@ -33,8 +32,8 @@ SERVICE_SCHEMA: Final = vol.Schema( ) -async def __get_prices(call: ServiceCall, *, hass: HomeAssistant) -> ServiceResponse: - tibber_connection = hass.data[DOMAIN] +async def __get_prices(call: ServiceCall) -> ServiceResponse: + tibber_connection = call.hass.data[DOMAIN] start = __get_date(call.data.get(ATTR_START), "start") end = __get_date(call.data.get(ATTR_END), "end") @@ -94,7 +93,7 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, PRICE_SERVICE_NAME, - partial(__get_prices, hass=hass), + __get_prices, schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/tile/__init__.py b/homeassistant/components/tile/__init__.py index 594c4e7bdcb..4b7dc9ca3b5 100644 --- a/homeassistant/components/tile/__init__.py +++ b/homeassistant/components/tile/__init__.py @@ -2,70 +2,28 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import timedelta -from functools import partial - from pytile import async_login -from pytile.errors import InvalidAuthError, SessionExpiredError, TileError -from pytile.tile import Tile +from pytile.errors import InvalidAuthError, TileError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.async_ import gather_with_limited_concurrency -from .const import DOMAIN, LOGGER +from .coordinator import TileConfigEntry, TileCoordinator -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER] DEVICE_TYPES = ["PHONE", "TILE"] DEFAULT_INIT_TASK_LIMIT = 2 -DEFAULT_UPDATE_INTERVAL = timedelta(minutes=2) CONF_SHOW_INACTIVE = "show_inactive" -@dataclass -class TileData: - """Define an object to be stored in `hass.data`.""" - - coordinators: dict[str, DataUpdateCoordinator[None]] - tiles: dict[str, Tile] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TileConfigEntry) -> bool: """Set up Tile as config entry.""" - @callback - def async_migrate_callback(entity_entry: RegistryEntry) -> dict | None: - """Define a callback to migrate appropriate Tile entities to new unique IDs. - - Old: tile_{uuid} - New: {username}_{uuid} - """ - if entity_entry.unique_id.startswith(entry.data[CONF_USERNAME]): - return None - - new_unique_id = f"{entry.data[CONF_USERNAME]}_".join( - entity_entry.unique_id.split(f"{DOMAIN}_") - ) - - LOGGER.debug( - "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", - entity_entry.entity_id, - entity_entry.unique_id, - new_unique_id, - ) - - return {"new_unique_id": new_unique_id} - - await async_migrate_entries(hass, entry.entry_id, async_migrate_callback) - # Tile's API uses cookies to identify a consumer; in order to allow for multiple # instances of this config entry, we use a new session each time: websession = aiohttp_client.async_create_clientsession(hass) @@ -82,47 +40,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except TileError as err: raise ConfigEntryNotReady("Error during integration setup") from err - async def async_update_tile(tile: Tile) -> None: - """Update the Tile.""" - try: - await tile.async_update() - except InvalidAuthError as err: - raise ConfigEntryAuthFailed("Invalid credentials") from err - except SessionExpiredError: - LOGGER.debug("Tile session expired; creating a new one") - await client.async_init() - except TileError as err: - raise UpdateFailed(f"Error while retrieving data: {err}") from err - - coordinators: dict[str, DataUpdateCoordinator[None]] = {} + coordinators: dict[str, TileCoordinator] = {} coordinator_init_tasks = [] for tile_uuid, tile in tiles.items(): - coordinator = coordinators[tile_uuid] = DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name=tile.name, - update_interval=DEFAULT_UPDATE_INTERVAL, - update_method=partial(async_update_tile, tile), + coordinator = coordinators[tile_uuid] = TileCoordinator( + hass, entry, client, tile ) coordinator_init_tasks.append(coordinator.async_refresh()) await gather_with_limited_concurrency( DEFAULT_INIT_TASK_LIMIT, *coordinator_init_tasks ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = TileData(coordinators=coordinators, tiles=tiles) + entry.runtime_data = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TileConfigEntry) -> bool: """Unload a Tile config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tile/binary_sensor.py b/homeassistant/components/tile/binary_sensor.py new file mode 100644 index 00000000000..1719c793c0e --- /dev/null +++ b/homeassistant/components/tile/binary_sensor.py @@ -0,0 +1,69 @@ +"""Support for Tile binary sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pytile.tile import Tile + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import TileConfigEntry, TileCoordinator +from .entity import TileEntity + + +@dataclass(frozen=True, kw_only=True) +class TileBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Tile binary sensor entity.""" + + is_on_fn: Callable[[Tile], bool] + + +ENTITIES: tuple[TileBinarySensorEntityDescription, ...] = ( + TileBinarySensorEntityDescription( + key="lost", + translation_key="lost", + is_on_fn=lambda tile: tile.lost, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: TileConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tile binary sensors.""" + + async_add_entities( + TileBinarySensor(coordinator, entity_description) + for entity_description in ENTITIES + for coordinator in entry.runtime_data.values() + ) + + +class TileBinarySensor(TileEntity, BinarySensorEntity): + """Representation of a Tile binary sensor.""" + + entity_description: TileBinarySensorEntityDescription + + def __init__( + self, + coordinator: TileCoordinator, + description: TileBinarySensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.username}_{self._tile.uuid}_{description.key}" + ) + + @property + def is_on(self) -> bool: + """Return True if the binary sensor is on.""" + return self.entity_description.is_on_fn(self._tile) diff --git a/homeassistant/components/tile/config_flow.py b/homeassistant/components/tile/config_flow.py index 53425958341..2ff7c0ca9ed 100644 --- a/homeassistant/components/tile/config_flow.py +++ b/homeassistant/components/tile/config_flow.py @@ -71,10 +71,6 @@ class TileFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=self._username, data=data) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_data) - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/tile/coordinator.py b/homeassistant/components/tile/coordinator.py new file mode 100644 index 00000000000..9a554c1e3ae --- /dev/null +++ b/homeassistant/components/tile/coordinator.py @@ -0,0 +1,50 @@ +"""Update coordinator for Tile.""" + +from datetime import timedelta + +from pytile.api import API +from pytile.errors import InvalidAuthError, SessionExpiredError, TileError +from pytile.tile import Tile + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +type TileConfigEntry = ConfigEntry[dict[str, TileCoordinator]] + + +class TileCoordinator(DataUpdateCoordinator[None]): + """Define an object to coordinate Tile data retrieval.""" + + config_entry: TileConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: TileConfigEntry, client: API, tile: Tile + ) -> None: + """Initialize.""" + super().__init__( + hass, + LOGGER, + name=tile.name, + config_entry=entry, + update_interval=timedelta(minutes=2), + ) + self.tile = tile + self.client = client + self.username = entry.data[CONF_USERNAME] + + async def _async_update_data(self) -> None: + """Update data via library.""" + try: + await self.tile.async_update() + except InvalidAuthError as err: + raise ConfigEntryAuthFailed("Invalid credentials") from err + except SessionExpiredError: + LOGGER.debug("Tile session expired; creating a new one") + await self.client.async_init() + except TileError as err: + raise UpdateFailed(f"Error while retrieving data: {err}") from err diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 71abbbef2c7..6a0aae1bdf9 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -4,23 +4,13 @@ from __future__ import annotations import logging -from pytile.tile import Tile - -from homeassistant.components.device_tracker import AsyncSeeCallback, TrackerEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from homeassistant.util.dt import as_utc -from . import TileData -from .const import DOMAIN +from .coordinator import TileConfigEntry, TileCoordinator +from .entity import TileEntity _LOGGER = logging.getLogger(__name__) @@ -36,72 +26,27 @@ ATTR_VOIP_STATE = "voip_state" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TileConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tile device trackers.""" - data: TileData = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - TileDeviceTracker(entry, data.coordinators[tile_uuid], tile) - for tile_uuid, tile in data.tiles.items() - ] + TileDeviceTracker(coordinator) for coordinator in entry.runtime_data.values() ) -async def async_setup_scanner( - hass: HomeAssistant, - config: ConfigType, - async_see: AsyncSeeCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> bool: - """Detect a legacy configuration and import it.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_USERNAME: config[CONF_USERNAME], - CONF_PASSWORD: config[CONF_PASSWORD], - }, - ) - ) - - _LOGGER.debug( - "Your Tile configuration has been imported into the UI; " - "please remove it from configuration.yaml" - ) - - return True - - -class TileDeviceTracker(CoordinatorEntity[DataUpdateCoordinator[None]], TrackerEntity): +class TileDeviceTracker(TileEntity, TrackerEntity): """Representation of a network infrastructure device.""" - _attr_has_entity_name = True _attr_name = None _attr_translation_key = "tile" - def __init__( - self, entry: ConfigEntry, coordinator: DataUpdateCoordinator[None], tile: Tile - ) -> None: + def __init__(self, coordinator: TileCoordinator) -> None: """Initialize.""" super().__init__(coordinator) self._attr_extra_state_attributes = {} - self._attr_unique_id = f"{entry.data[CONF_USERNAME]}_{tile.uuid}" - self._entry = entry - self._tile = tile - - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and not self._tile.dead - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo(identifiers={(DOMAIN, self._tile.uuid)}, name=self._tile.name) + self._attr_unique_id = f"{coordinator.username}_{self._tile.uuid}" @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/tile/diagnostics.py b/homeassistant/components/tile/diagnostics.py index 22991ef24c1..9db33b737c0 100644 --- a/homeassistant/components/tile/diagnostics.py +++ b/homeassistant/components/tile/diagnostics.py @@ -5,12 +5,10 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_UUID from homeassistant.core import HomeAssistant -from . import TileData -from .const import DOMAIN +from .coordinator import TileConfigEntry CONF_ALTITUDE = "altitude" @@ -23,11 +21,12 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: TileConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: TileData = hass.data[DOMAIN][entry.entry_id] + coordinators = entry.runtime_data.values() return async_redact_data( - {"tiles": [tile.as_dict() for tile in data.tiles.values()]}, TO_REDACT + {"tiles": [coordinator.tile.as_dict() for coordinator in coordinators]}, + TO_REDACT, ) diff --git a/homeassistant/components/tile/entity.py b/homeassistant/components/tile/entity.py new file mode 100644 index 00000000000..21dd6df9cf8 --- /dev/null +++ b/homeassistant/components/tile/entity.py @@ -0,0 +1,30 @@ +"""Define a base Tile entity.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TileCoordinator + + +class TileEntity(CoordinatorEntity[TileCoordinator]): + """Define a base Tile entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: TileCoordinator) -> None: + """Initialize.""" + super().__init__(coordinator) + self._tile = coordinator.tile + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._tile.uuid)}, + name=self._tile.name, + manufacturer="Tile Inc.", + hw_version=self._tile.hardware_version, + sw_version=self._tile.firmware_version, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and not self._tile.dead diff --git a/homeassistant/components/tile/icons.json b/homeassistant/components/tile/icons.json index f6f38fe8cef..bac7cfcdcb0 100644 --- a/homeassistant/components/tile/icons.json +++ b/homeassistant/components/tile/icons.json @@ -1,5 +1,10 @@ { "entity": { + "binary_sensor": { + "lost": { + "default": "mdi:map-marker-remove" + } + }, "device_tracker": { "tile": { "default": "mdi:view-grid" diff --git a/homeassistant/components/tile/manifest.json b/homeassistant/components/tile/manifest.json index 8dceddcb77f..f8acbc0bf1a 100644 --- a/homeassistant/components/tile/manifest.json +++ b/homeassistant/components/tile/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pytile"], - "requirements": ["pytile==2023.12.0"] + "requirements": ["pytile==2024.12.0"] } diff --git a/homeassistant/components/tile/strings.json b/homeassistant/components/tile/strings.json index 2d34d13c436..5146a5e9aff 100644 --- a/homeassistant/components/tile/strings.json +++ b/homeassistant/components/tile/strings.json @@ -33,5 +33,12 @@ } } } + }, + "entity": { + "binary_sensor": { + "lost": { + "name": "Lost" + } + } } } diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 473472356d4..60e55c214fe 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -6,7 +6,7 @@ from datetime import time, timedelta import logging from typing import final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 064ec81df1d..4fd80f565a2 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -34,33 +34,33 @@ "services": { "start": { "name": "[%key:common::action::start%]", - "description": "Starts a timer.", + "description": "Starts a timer or restarts it with a provided duration.", "fields": { "duration": { "name": "Duration", - "description": "Duration the timer requires to finish. [optional]." + "description": "Custom duration to restart the timer with." } } }, "pause": { "name": "[%key:common::action::pause%]", - "description": "Pauses a timer." + "description": "Pauses a running timer, retaining the remaining duration for later continuation." }, "cancel": { "name": "Cancel", - "description": "Cancels a timer." + "description": "Resets a timer's duration to the last known initial value without firing the timer finished event." }, "finish": { "name": "Finish", - "description": "Finishes a timer." + "description": "Finishes a running timer earlier than scheduled." }, "change": { "name": "Change", - "description": "Changes a timer.", + "description": "Changes a timer by adding or subtracting a given duration.", "fields": { "duration": { "name": "Duration", - "description": "Duration to add or subtract to the running timer." + "description": "Duration to add to or subtract from the running timer." } } }, diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index e4bc549a16b..937187c1c6f 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -8,7 +8,7 @@ import datetime import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import frontend, websocket_api diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 717aa310ecd..cffb22e89f0 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -7,8 +7,8 @@ }, "services": { "get_items": { - "name": "Get to-do list items", - "description": "Get items on a to-do list.", + "name": "Get items", + "description": "Gets items on a to-do list.", "fields": { "status": { "name": "Status", @@ -17,8 +17,8 @@ } }, "add_item": { - "name": "Add to-do list item", - "description": "Add a new to-do list item.", + "name": "Add item", + "description": "Adds a new to-do list item.", "fields": { "item": { "name": "Item name", @@ -39,16 +39,16 @@ } }, "update_item": { - "name": "Update to-do list item", - "description": "Update an existing to-do list item based on its name.", + "name": "Update item", + "description": "Updates an existing to-do list item based on its name.", "fields": { "item": { "name": "Item name", - "description": "The name for the to-do list item." + "description": "The current name of the to-do item." }, "rename": { "name": "Rename item", - "description": "The new name of the to-do item" + "description": "The new name for the to-do item" }, "status": { "name": "Set status", @@ -69,16 +69,16 @@ } }, "remove_completed_items": { - "name": "Remove all completed to-do list items", - "description": "Remove all to-do list items that have been completed." + "name": "Remove completed items", + "description": "Removes all to-do list items that have been completed." }, "remove_item": { - "name": "Remove a to-do list item", - "description": "Remove an existing to-do list item by its name.", + "name": "Remove item", + "description": "Removes an existing to-do list item by its name.", "fields": { "item": { "name": "Item name", - "description": "The name for the to-do list items." + "description": "The name for the to-do list item." } } } diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py index 8c5176b3e4e..5e6428525c1 100644 --- a/homeassistant/components/tolo/climate.py +++ b/homeassistant/components/tolo/climate.py @@ -60,7 +60,6 @@ class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity): ) _attr_target_temperature_step = 1 _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py index d5d7e33a5e0..fed4ff332fc 100644 --- a/homeassistant/components/tolo/config_flow.py +++ b/homeassistant/components/tolo/config_flow.py @@ -8,10 +8,10 @@ from typing import Any from tololib import ToloClient, ToloCommunicationError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -61,7 +61,7 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py index 9b62346a83b..9e48778b507 100644 --- a/homeassistant/components/tolo/fan.py +++ b/homeassistant/components/tolo/fan.py @@ -29,7 +29,6 @@ class ToloFan(ToloSaunaCoordinatorEntity, FanEntity): _attr_translation_key = "fan" _attr_supported_features = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index 365706ba4fd..0c2e5b9b232 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -52,7 +52,6 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index 0d8b915770a..9f291ea15a6 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -8,13 +8,17 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import AUTO_BYPASS, CONF_USERCODES, DOMAIN +from .const import AUTO_BYPASS, CONF_USERCODES from .coordinator import TotalConnectDataUpdateCoordinator PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON] +type TotalConnectConfigEntry = ConfigEntry[TotalConnectDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: TotalConnectConfigEntry +) -> bool: """Set up upon config entry in user interface.""" conf = entry.data username = conf[CONF_USERNAME] @@ -40,8 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = TotalConnectDataUpdateCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -49,18 +52,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: TotalConnectConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: TotalConnectConfigEntry) -> None: """Update listener.""" bypass = entry.options.get(AUTO_BYPASS, False) - client = hass.data[DOMAIN][entry.entry_id].client + client = entry.runtime_data.client for location_id in client.locations: client.locations[location_id].auto_bypass_low_battery = bypass diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index bc33129a741..48ba78acc92 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -30,7 +30,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up TotalConnect alarm panels based on a config entry.""" - coordinator: TotalConnectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data code_required = entry.options.get(CODE_REQUIRED, False) async_add_entities( diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index 3126efff88a..9a3c2558999 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN from .coordinator import TotalConnectDataUpdateCoordinator from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity @@ -125,7 +124,7 @@ async def async_setup_entry( """Set up TotalConnect device sensors based on a config entry.""" sensors: list = [] - coordinator: TotalConnectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data client_locations = coordinator.client.locations diff --git a/homeassistant/components/totalconnect/button.py b/homeassistant/components/totalconnect/button.py index fc5b5e89587..e228f03ec6b 100644 --- a/homeassistant/components/totalconnect/button.py +++ b/homeassistant/components/totalconnect/button.py @@ -12,7 +12,6 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN from .coordinator import TotalConnectDataUpdateCoordinator from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity @@ -43,7 +42,7 @@ async def async_setup_entry( ) -> None: """Set up TotalConnect buttons based on a config entry.""" buttons: list = [] - coordinator: TotalConnectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data for location_id, location in coordinator.client.locations.items(): buttons.extend( diff --git a/homeassistant/components/totalconnect/diagnostics.py b/homeassistant/components/totalconnect/diagnostics.py index b590c54e2ba..85f52ccc670 100644 --- a/homeassistant/components/totalconnect/diagnostics.py +++ b/homeassistant/components/totalconnect/diagnostics.py @@ -8,8 +8,6 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN - TO_REDACT = [ "username", "Password", @@ -27,7 +25,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - client = hass.data[DOMAIN][config_entry.entry_id].client + client = config_entry.runtime_data.client data: dict[str, Any] = {} data["client"] = { diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 87ec14621d9..33306a7adba 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2024.5"] + "requirements": ["total-connect-client==2024.12"] } diff --git a/homeassistant/components/totalconnect/quality_scale.yaml b/homeassistant/components/totalconnect/quality_scale.yaml new file mode 100644 index 00000000000..2ec54250b72 --- /dev/null +++ b/homeassistant/components/totalconnect/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: todo + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: todo + dependency-transparency: done + action-setup: todo + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: todo + action-exceptions: todo + reauthentication-flow: done + parallel-updates: todo + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: done + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: todo + stale-devices: todo + diagnostics: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: todo + discovery-update-info: todo + repair-issues: todo + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index 004056ef9ac..daf720084a5 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -2,21 +2,36 @@ "config": { "step": { "user": { + "title": "Total Connect 2.0 Account Credentials", + "description": "It is highly recommended to use a 'standard' Total Connect user account with Home Assistant. The account should not have full administrative privileges.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The Total Connect username", + "password": "The Total Connect password" } }, "locations": { "title": "Location Usercodes", "description": "Enter the usercode for this user at location {location_id}", "data": { - "usercode": "Usercode" + "usercodes": "Usercode" + }, + "data_description": { + "usercodes": "The usercode is usually a 4 digit number" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Total Connect needs to re-authenticate your account" + "description": "Total Connect needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::totalconnect::config::step::user::data_description::password%]" + } } }, "error": { @@ -36,6 +51,10 @@ "data": { "auto_bypass_low_battery": "Auto bypass low battery", "code_required": "Require user to enter code for alarm actions" + }, + "data_description": { + "auto_bypass_low_battery": "If enabled, Total Connect zones will immediately be bypassed when they report low battery. This option helps because zones tend to report low battery in the middle of the night. The downside of this option is that when the alarm system is armed, the bypassed zone will not be monitored.", + "code_required": "If enabled, you must enter the user code to arm or disarm the alarm" } } } diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index 7b14404ee34..e9d27341cb7 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -70,7 +70,6 @@ class Touchline(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, touchline_thermostat): """Initialize the Touchline device.""" diff --git a/homeassistant/components/touchline_sl/climate.py b/homeassistant/components/touchline_sl/climate.py index 0035bd07c34..8a0ffc4cd86 100644 --- a/homeassistant/components/touchline_sl/climate.py +++ b/homeassistant/components/touchline_sl/climate.py @@ -2,8 +2,6 @@ from typing import Any -from pytouchlinesl import Zone - from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -12,13 +10,11 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TouchlineSLConfigEntry -from .const import DOMAIN from .coordinator import TouchlineSLModuleCoordinator +from .entity import TouchlineSLZoneEntity async def async_setup_entry( @@ -38,10 +34,9 @@ async def async_setup_entry( CONSTANT_TEMPERATURE = "constant_temperature" -class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEntity): +class TouchlineSLZone(TouchlineSLZoneEntity, ClimateEntity): """Roth Touchline SL Zone.""" - _attr_has_entity_name = True _attr_hvac_action = HVACAction.IDLE _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] @@ -54,22 +49,12 @@ class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEn def __init__(self, coordinator: TouchlineSLModuleCoordinator, zone_id: int) -> None: """Construct a Touchline SL climate zone.""" - super().__init__(coordinator) - self.zone_id: int = zone_id + super().__init__(coordinator, zone_id) self._attr_unique_id = ( f"module-{self.coordinator.data.module.id}-zone-{self.zone_id}" ) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(zone_id))}, - name=self.zone.name, - manufacturer="Roth", - via_device=(DOMAIN, coordinator.data.module.id), - model="zone", - suggested_area=self.zone.name, - ) - # Call this in __init__ so data is populated right away, since it's # already available in the coordinator data. self.set_attr() @@ -80,16 +65,6 @@ class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEn self.set_attr() super()._handle_coordinator_update() - @property - def zone(self) -> Zone: - """Return the device object from the coordinator data.""" - return self.coordinator.data.zones[self.zone_id] - - @property - def available(self) -> bool: - """Return if the device is available.""" - return super().available and self.zone_id in self.coordinator.data.zones - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: diff --git a/homeassistant/components/touchline_sl/entity.py b/homeassistant/components/touchline_sl/entity.py new file mode 100644 index 00000000000..637ad8955eb --- /dev/null +++ b/homeassistant/components/touchline_sl/entity.py @@ -0,0 +1,38 @@ +"""Base class for Touchline SL zone entities.""" + +from pytouchlinesl import Zone + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TouchlineSLModuleCoordinator + + +class TouchlineSLZoneEntity(CoordinatorEntity[TouchlineSLModuleCoordinator]): + """Defines a base Touchline SL zone entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: TouchlineSLModuleCoordinator, zone_id: int) -> None: + """Initialize touchline entity.""" + super().__init__(coordinator) + self.zone_id = zone_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(zone_id))}, + name=self.zone.name, + manufacturer="Roth", + via_device=(DOMAIN, coordinator.data.module.id), + model="zone", + suggested_area=self.zone.name, + ) + + @property + def zone(self) -> Zone: + """Return the device object from the coordinator data.""" + return self.coordinator.data.zones[self.zone_id] + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self.zone_id in self.coordinator.data.zones diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json index ca3136f55c0..ab07ae770fd 100644 --- a/homeassistant/components/touchline_sl/manifest.json +++ b/homeassistant/components/touchline_sl/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/touchline_sl", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pytouchlinesl==0.2.0"] + "requirements": ["pytouchlinesl==0.3.0"] } diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index ee1d90e70b4..31bdcc5481c 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Iterable from datetime import timedelta import logging -from typing import Any +from typing import Any, cast from aiohttp import ClientSession from kasa import ( @@ -18,11 +18,9 @@ from kasa import ( KasaException, ) from kasa.httpclient import get_cookie_jar -from kasa.iot import IotStrip from homeassistant import config_entries from homeassistant.components import network -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ALIAS, CONF_AUTHENTICATION, @@ -47,20 +45,19 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_AES_KEYS, + CONF_CAMERA_CREDENTIALS, CONF_CONFIG_ENTRY_MINOR_VERSION, CONF_CONNECTION_PARAMETERS, CONF_CREDENTIALS_HASH, CONF_DEVICE_CONFIG, + CONF_LIVE_VIEW, CONF_USES_HTTP, CONNECT_TIMEOUT, DISCOVERY_TIMEOUT, DOMAIN, PLATFORMS, ) -from .coordinator import TPLinkDataUpdateCoordinator -from .models import TPLinkData - -type TPLinkConfigEntry = ConfigEntry[TPLinkData] +from .coordinator import TPLinkConfigEntry, TPLinkData, TPLinkDataUpdateCoordinator DISCOVERY_INTERVAL = timedelta(minutes=15) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -148,7 +145,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo if conn_params_dict := entry.data.get(CONF_CONNECTION_PARAMETERS): try: conn_params = Device.ConnectionParameters.from_dict(conn_params_dict) - except KasaException: + except (KasaException, TypeError, ValueError, LookupError): _LOGGER.warning( "Invalid connection parameters dict for %s: %s", host, conn_params_dict ) @@ -176,9 +173,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo if not credentials and entry_credentials_hash: data = {k: v for k, v in entry.data.items() if k != CONF_CREDENTIALS_HASH} hass.config_entries.async_update_entry(entry, data=data) - raise ConfigEntryAuthFailed from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "func": "connect", + "exc": str(ex), + }, + ) from ex except KasaException as ex: - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_error", + translation_placeholders={ + "func": "connect", + "exc": str(ex), + }, + ) from ex device_credentials_hash = device.credentials_hash @@ -210,23 +221,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo # wait for the next discovery to find the device at its new address # and update the config entry so we do not mix up devices. raise ConfigEntryNotReady( - f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" + translation_domain=DOMAIN, + translation_key="unexpected_device", + translation_placeholders={ + "host": host, + # all entries have a unique id + "expected": cast(str, entry.unique_id), + "found": found_mac, + }, ) - parent_coordinator = TPLinkDataUpdateCoordinator(hass, device, timedelta(seconds=5)) - child_coordinators: list[TPLinkDataUpdateCoordinator] = [] + parent_coordinator = TPLinkDataUpdateCoordinator( + hass, device, timedelta(seconds=5), entry + ) - # The iot HS300 allows a limited number of concurrent requests and fetching the - # emeter information requires separate ones so create child coordinators here. - if isinstance(device, IotStrip): - child_coordinators = [ - # The child coordinators only update energy data so we can - # set a longer update interval to avoid flooding the device - TPLinkDataUpdateCoordinator(hass, child, timedelta(seconds=60)) - for child in device.children - ] + camera_creds: Credentials | None = None + if camera_creds_dict := entry.data.get(CONF_CAMERA_CREDENTIALS): + camera_creds = Credentials( + camera_creds_dict[CONF_USERNAME], camera_creds_dict[CONF_PASSWORD] + ) + live_view = entry.data.get(CONF_LIVE_VIEW) - entry.runtime_data = TPLinkData(parent_coordinator, child_coordinators) + entry.runtime_data = TPLinkData(parent_coordinator, camera_creds, live_view) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -252,7 +268,7 @@ def legacy_device_id(device: Device) -> str: return device_id.split("_")[1] -def get_device_name(device: Device, parent: Device | None = None) -> str: +def get_device_name(device: Device, parent: Device | None = None) -> str | None: """Get a name for the device. alias can be none on some devices.""" if device.alias: return device.alias @@ -267,7 +283,7 @@ def get_device_name(device: Device, parent: Device | None = None) -> str: ] suffix = f" {devices.index(device.device_id) + 1}" if len(devices) > 1 else "" return f"{device.device_type.value.capitalize()}{suffix}" - return f"Unnamed {device.model}" + return None async def get_credentials(hass: HomeAssistant) -> Credentials | None: @@ -314,7 +330,9 @@ def _device_id_is_mac_or_none(mac: str, device_ids: Iterable[str]) -> str | None ) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: TPLinkConfigEntry +) -> bool: """Migrate old entry.""" entry_version = config_entry.version entry_minor_version = config_entry.minor_version diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index 34375bccf4f..e08495f5c88 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -3,11 +3,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final +from typing import Final, cast from kasa import Feature from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, @@ -23,9 +24,12 @@ from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescripti class TPLinkBinarySensorEntityDescription( BinarySensorEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based binary sensor entity description.""" +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + BINARY_SENSOR_DESCRIPTIONS: Final = ( TPLinkBinarySensorEntityDescription( key="overheated", @@ -39,11 +43,6 @@ BINARY_SENSOR_DESCRIPTIONS: Final = ( key="cloud_connection", device_class=BinarySensorDeviceClass.CONNECTIVITY, ), - # To be replaced & disabled per default by the upcoming update platform. - TPLinkBinarySensorEntityDescription( - key="update_available", - device_class=BinarySensorDeviceClass.UPDATE, - ), TPLinkBinarySensorEntityDescription( key="temperature_warning", ), @@ -75,19 +74,28 @@ async def async_setup_entry( """Set up sensors.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.BinarySensor, - entity_class=TPLinkBinarySensorEntity, - descriptions=BINARYSENSOR_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_add_entities(entities) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.BinarySensor, + entity_class=TPLinkBinarySensorEntity, + descriptions=BINARYSENSOR_DESCRIPTIONS_MAP, + platform_domain=BINARY_SENSOR_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntity): @@ -96,6 +104,7 @@ class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntit entity_description: TPLinkBinarySensorEntityDescription @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" - self._attr_is_on = self._feature.value + self._attr_is_on = cast(bool | None, self._feature.value) + return True diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 131325e489d..0a4517b967d 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry -from .deprecate import DeprecatedInfo, async_cleanup_deprecated +from .deprecate import DeprecatedInfo from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -29,6 +29,10 @@ class TPLinkButtonEntityDescription( """Base class for a TPLink feature based button entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + BUTTON_DESCRIPTIONS: Final = [ TPLinkButtonEntityDescription( key="test_alarm", @@ -50,6 +54,22 @@ BUTTON_DESCRIPTIONS: Final = [ key="reboot", device_class=ButtonDeviceClass.RESTART, ), + TPLinkButtonEntityDescription( + key="pan_left", + available_fn=lambda dev: dev.is_on, + ), + TPLinkButtonEntityDescription( + key="pan_right", + available_fn=lambda dev: dev.is_on, + ), + TPLinkButtonEntityDescription( + key="tilt_up", + available_fn=lambda dev: dev.is_on, + ), + TPLinkButtonEntityDescription( + key="tilt_down", + available_fn=lambda dev: dev.is_on, + ), ] BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS} @@ -63,20 +83,27 @@ async def async_setup_entry( """Set up buttons.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Action, - entity_class=TPLinkButtonEntity, - descriptions=BUTTON_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities) - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Action, + entity_class=TPLinkButtonEntity, + descriptions=BUTTON_DESCRIPTIONS_MAP, + platform_domain=BUTTON_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkButtonEntity(CoordinatedTPLinkFeatureEntity, ButtonEntity): @@ -88,5 +115,6 @@ class TPLinkButtonEntity(CoordinatedTPLinkFeatureEntity, ButtonEntity): """Execute action.""" await self._feature.set_value(True) - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """No need to update anything.""" + return self.entity_description.available_fn(self._device) diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py new file mode 100644 index 00000000000..b0f1f1a62c1 --- /dev/null +++ b/homeassistant/components/tplink/camera.py @@ -0,0 +1,242 @@ +"""Support for TPLink camera entities.""" + +import asyncio +from dataclasses import dataclass +import logging +import time + +from aiohttp import web +from haffmpeg.camera import CameraMjpeg +from kasa import Device, Module, StreamResolution + +from homeassistant.components import ffmpeg, stream +from homeassistant.components.camera import ( + DOMAIN as CAMERA_DOMAIN, + Camera, + CameraEntityDescription, + CameraEntityFeature, +) +from homeassistant.config_entries import ConfigFlowContext +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TPLinkConfigEntry +from .const import CONF_CAMERA_CREDENTIALS +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import CoordinatedTPLinkModuleEntity, TPLinkModuleEntityDescription + +_LOGGER = logging.getLogger(__name__) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TPLinkCameraEntityDescription( + CameraEntityDescription, TPLinkModuleEntityDescription +): + """Base class for camera entity description.""" + + +CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = ( + TPLinkCameraEntityDescription( + key="live_view", + translation_key="live_view", + available_fn=lambda dev: dev.is_on, + exists_fn=lambda dev, entry: ( + (rtd := entry.runtime_data) is not None + and rtd.live_view is True + and (cam_creds := rtd.camera_credentials) is not None + and (cm := dev.modules.get(Module.Camera)) is not None + and cm.stream_rtsp_url(cam_creds) is not None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up camera entities.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + device = parent_coordinator.device + + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkCameraEntity, + descriptions=CAMERA_DESCRIPTIONS, + platform_domain=CAMERA_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) + + +class TPLinkCameraEntity(CoordinatedTPLinkModuleEntity, Camera): + """Representation of a TPLink camera.""" + + IMAGE_INTERVAL = 5 * 60 + + _attr_supported_features = CameraEntityFeature.STREAM | CameraEntityFeature.ON_OFF + + entity_description: TPLinkCameraEntityDescription + + _ffmpeg_manager: ffmpeg.FFmpegManager + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkCameraEntityDescription, + *, + parent: Device | None = None, + ) -> None: + """Initialize a TPlink camera.""" + super().__init__(device, coordinator, description=description, parent=parent) + Camera.__init__(self) + + self._camera_module = device.modules[Module.Camera] + self._camera_credentials = ( + coordinator.config_entry.runtime_data.camera_credentials + ) + self._video_url = self._camera_module.stream_rtsp_url( + self._camera_credentials, stream_resolution=StreamResolution.SD + ) + self._image: bytes | None = None + self._image_lock = asyncio.Lock() + self._last_update: float = 0 + self._can_stream = True + self._http_mpeg_stream_running = False + + async def async_added_to_hass(self) -> None: + """Call update attributes after the device is added to the platform.""" + await super().async_added_to_hass() + + self._ffmpeg_manager = ffmpeg.get_ffmpeg_manager(self.hass) + + @callback + def _async_update_attrs(self) -> bool: + """Update the entity's attributes.""" + self._attr_is_on = self._camera_module.is_on + return self.entity_description.available_fn(self._device) + + async def stream_source(self) -> str | None: + """Return the source of the stream.""" + return self._camera_module.stream_rtsp_url( + self._camera_credentials, stream_resolution=StreamResolution.HD + ) + + async def _async_check_stream_auth(self, video_url: str) -> None: + """Check for an auth error and start reauth flow.""" + try: + await stream.async_check_stream_client_error(self.hass, video_url) + except stream.StreamOpenClientError as ex: + if ex.error_code is stream.StreamClientError.Unauthorized: + _LOGGER.debug( + "Camera stream failed authentication for %s", + self._device.host, + ) + self._can_stream = False + self.coordinator.config_entry.async_start_reauth( + self.hass, + ConfigFlowContext( + reauth_source=CONF_CAMERA_CREDENTIALS, # type: ignore[typeddict-unknown-key] + ), + {"device": self._device}, + ) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return a still image response from the camera.""" + now = time.monotonic() + + if self._image and now - self._last_update < self.IMAGE_INTERVAL: + return self._image + + # Don't try to capture a new image if a stream is running + if self._http_mpeg_stream_running: + return self._image + + if self._can_stream and (video_url := self._video_url): + # Sometimes the front end makes multiple image requests + async with self._image_lock: + if self._image and (now - self._last_update) < self.IMAGE_INTERVAL: + return self._image + + _LOGGER.debug("Updating camera image for %s", self._device.host) + image = await ffmpeg.async_get_image( + self.hass, + video_url, + width=width, + height=height, + ) + if image: + self._image = image + self._last_update = now + _LOGGER.debug("Updated camera image for %s", self._device.host) + # This coroutine is called by camera with an asyncio.timeout + # so image could be None whereas an auth issue returns b'' + elif image == b"": + _LOGGER.debug( + "Empty camera image returned for %s", self._device.host + ) + # image could be empty if a stream is running so check for explicit auth error + await self._async_check_stream_auth(video_url) + else: + _LOGGER.debug( + "None camera image returned for %s", self._device.host + ) + + return self._image + + async def handle_async_mjpeg_stream( + self, request: web.Request + ) -> web.StreamResponse | None: + """Generate an HTTP MJPEG stream from the camera. + + The frontend falls back to calling this method if the HLS + stream fails. + """ + _LOGGER.debug("Starting http mjpeg stream for %s", self._device.host) + if self._video_url is None or self._can_stream is False: + return None + + mjpeg_stream = CameraMjpeg(self._ffmpeg_manager.binary) + await mjpeg_stream.open_camera(self._video_url) + self._http_mpeg_stream_running = True + try: + stream_reader = await mjpeg_stream.get_reader() + return await async_aiohttp_proxy_stream( + self.hass, + request, + stream_reader, + self._ffmpeg_manager.ffmpeg_stream_content_type, + ) + finally: + self._http_mpeg_stream_running = False + await mjpeg_stream.close() + _LOGGER.debug("Stopped http mjpeg stream for %s", self._device.host) + + async def async_turn_on(self) -> None: + """Turn on camera.""" + await self._camera_module.set_state(True) + + async def async_turn_off(self) -> None: + """Turn off camera.""" + await self._camera_module.set_state(False) diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index f86992ea0cf..7204c2a7665 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -2,40 +2,72 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging from typing import Any, cast -from kasa import Device, DeviceType +from kasa import Device, Module from kasa.smart.modules.temperaturecontrol import ThermostatState from homeassistant.components.climate import ( ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, ClimateEntity, + ClimateEntityDescription, ClimateEntityFeature, HVACAction, HVACMode, ) -from homeassistant.const import PRECISION_TENTHS +from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TPLinkConfigEntry -from .const import UNIT_MAPPING +from . import TPLinkConfigEntry, legacy_device_id +from .const import DOMAIN, UNIT_MAPPING from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 # Upstream state to HVACAction STATE_TO_ACTION = { ThermostatState.Idle: HVACAction.IDLE, ThermostatState.Heating: HVACAction.HEATING, ThermostatState.Off: HVACAction.OFF, + ThermostatState.Calibrating: HVACAction.IDLE, } _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class TPLinkClimateEntityDescription( + ClimateEntityDescription, TPLinkModuleEntityDescription +): + """Base class for climate entity description.""" + + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: f"{legacy_device_id(device)}_{desc.key}" + ) + + +CLIMATE_DESCRIPTIONS: tuple[TPLinkClimateEntityDescription, ...] = ( + TPLinkClimateEntityDescription( + key="climate", + exists_fn=lambda dev, _: Module.Thermostat in dev.modules, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, @@ -46,15 +78,28 @@ async def async_setup_entry( parent_coordinator = data.parent_coordinator device = parent_coordinator.device - # As there are no standalone thermostats, we just iterate over the children. - async_add_entities( - TPLinkClimateEntity(child, parent_coordinator, parent=device) - for child in device.children - if child.device_type is DeviceType.Thermostat - ) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkClimateEntity, + descriptions=CLIMATE_DESCRIPTIONS, + platform_domain=CLIMATE_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) -class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): +class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity): """Representation of a TPLink thermostat.""" _attr_name = None @@ -66,76 +111,95 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_precision = PRECISION_TENTHS + entity_description: TPLinkClimateEntityDescription + # This disables the warning for async_turn_{on,off}, can be removed later. - _enable_turn_on_off_backwards_compatibility = False def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkClimateEntityDescription, *, parent: Device, ) -> None: """Initialize the climate entity.""" - self._state_feature = device.features["state"] - self._mode_feature = device.features["thermostat_mode"] - self._temp_feature = device.features["temperature"] - self._target_feature = device.features["target_temperature"] + super().__init__(device, coordinator, description, parent=parent) + self._thermostat_module = device.modules[Module.Thermostat] - self._attr_min_temp = self._target_feature.minimum_value - self._attr_max_temp = self._target_feature.maximum_value - self._attr_temperature_unit = UNIT_MAPPING[cast(str, self._temp_feature.unit)] + if target_feature := self._thermostat_module.get_feature("target_temperature"): + self._attr_min_temp = target_feature.minimum_value + self._attr_max_temp = target_feature.maximum_value + else: + _LOGGER.error( + "Unable to get min/max target temperature for %s, using defaults", + device.host, + ) - super().__init__(device, coordinator, parent=parent) + if temperature_feature := self._thermostat_module.get_feature("temperature"): + self._attr_temperature_unit = UNIT_MAPPING[ + cast(str, temperature_feature.unit) + ] + else: + _LOGGER.error( + "Unable to get correct temperature unit for %s, defaulting to celsius", + device.host, + ) + self._attr_temperature_unit = UnitOfTemperature.CELSIUS @async_refresh_after async def async_set_temperature(self, **kwargs: Any) -> None: """Set target temperature.""" - await self._target_feature.set_value(int(kwargs[ATTR_TEMPERATURE])) + await self._thermostat_module.set_target_temperature( + float(kwargs[ATTR_TEMPERATURE]) + ) @async_refresh_after async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode (heat/off).""" if hvac_mode is HVACMode.HEAT: - await self._state_feature.set_value(True) + await self._thermostat_module.set_state(True) elif hvac_mode is HVACMode.OFF: - await self._state_feature.set_value(False) + await self._thermostat_module.set_state(False) else: - raise ServiceValidationError(f"Tried to set unsupported mode: {hvac_mode}") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_mode", + translation_placeholders={ + "mode": hvac_mode, + }, + ) @async_refresh_after async def async_turn_on(self) -> None: """Turn heating on.""" - await self._state_feature.set_value(True) + await self._thermostat_module.set_state(True) @async_refresh_after async def async_turn_off(self) -> None: """Turn heating off.""" - await self._state_feature.set_value(False) + await self._thermostat_module.set_state(False) @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" - self._attr_current_temperature = self._temp_feature.value - self._attr_target_temperature = self._target_feature.value + self._attr_current_temperature = self._thermostat_module.temperature + self._attr_target_temperature = self._thermostat_module.target_temperature self._attr_hvac_mode = ( - HVACMode.HEAT if self._state_feature.value else HVACMode.OFF + HVACMode.HEAT if self._thermostat_module.state else HVACMode.OFF ) if ( - self._mode_feature.value not in STATE_TO_ACTION + self._thermostat_module.mode not in STATE_TO_ACTION and self._attr_hvac_action is not HVACAction.OFF ): _LOGGER.warning( "Unknown thermostat state, defaulting to OFF: %s", - self._mode_feature.value, + self._thermostat_module.mode, ) self._attr_hvac_action = HVACAction.OFF - return + return True - self._attr_hvac_action = STATE_TO_ACTION[self._mode_feature.value] - - def _get_unique_id(self) -> str: - """Return unique id.""" - return f"{self._device.device_id}_climate" + self._attr_hvac_action = STATE_TO_ACTION[self._thermostat_module.mode] + return True diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 63f1b4e125b..9ca2fe80cf9 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Mapping import logging -from typing import TYPE_CHECKING, Any, Self +from typing import TYPE_CHECKING, Any, Self, cast from kasa import ( AuthenticationError, @@ -13,13 +13,15 @@ from kasa import ( DeviceConfig, Discover, KasaException, + Module, TimeoutError, ) import voluptuous as vol -from homeassistant.components import dhcp +from homeassistant.components import ffmpeg, stream from homeassistant.config_entries import ( SOURCE_REAUTH, + SOURCE_RECONFIGURE, ConfigEntry, ConfigEntryState, ConfigFlow, @@ -31,12 +33,14 @@ from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_MODEL, + CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, ) from homeassistant.core import callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from . import ( @@ -48,9 +52,11 @@ from . import ( ) from .const import ( CONF_AES_KEYS, + CONF_CAMERA_CREDENTIALS, CONF_CONFIG_ENTRY_MINOR_VERSION, CONF_CONNECTION_PARAMETERS, CONF_CREDENTIALS_HASH, + CONF_LIVE_VIEW, CONF_USES_HTTP, CONNECT_TIMEOUT, DOMAIN, @@ -62,6 +68,16 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} ) +STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + +STEP_CAMERA_AUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LIVE_VIEW): bool, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } +) + class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for tplink.""" @@ -78,7 +94,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device: Device | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" return await self._async_handle_discovery( @@ -227,7 +243,12 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): self.hass.async_create_task( self._async_reload_requires_auth_entries(), eager_start=False ) - return self._async_create_entry_from_device(self._discovered_device) + if self._async_supports_camera_credentials(device): + return await self.async_step_camera_auth_confirm() + + return self._async_create_or_update_entry_from_device( + self._discovered_device + ) self.context["title_placeholders"] = placeholders return self.async_show_form( @@ -253,7 +274,12 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): """Confirm discovery.""" assert self._discovered_device is not None if user_input is not None: - return self._async_create_entry_from_device(self._discovered_device) + if self._async_supports_camera_credentials(self._discovered_device): + return await self.async_step_camera_auth_confirm() + + return self._async_create_or_update_entry_from_device( + self._discovered_device + ) self._set_confirm_only() placeholders = self._async_make_placeholders_from_discovery() @@ -282,6 +308,13 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): return host, port + def _async_supports_camera_credentials(self, device: Device) -> bool: + """Return True if device could have separate camera credentials.""" + if camera_module := device.modules.get(Module.Camera): + self._discovered_device = device + return bool(camera_module.stream_rtsp_url()) + return False + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -324,7 +357,11 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): else: if not device: return await self.async_step_user_auth_confirm() - return self._async_create_entry_from_device(device) + + if self._async_supports_camera_credentials(device): + return await self.async_step_camera_auth_confirm() + + return self._async_create_or_update_entry_from_device(device) return self.async_show_form( step_id="user", @@ -375,7 +412,10 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): self.hass.async_create_task( self._async_reload_requires_auth_entries(), eager_start=False ) - return self._async_create_entry_from_device(device) + if self._async_supports_camera_credentials(device): + return await self.async_step_camera_auth_confirm() + + return self._async_create_or_update_entry_from_device(device) return self.async_show_form( step_id="user_auth_confirm", @@ -384,6 +424,104 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders=placeholders, ) + def _create_camera_entry( + self, device: Device, un: str, pw: str + ) -> ConfigFlowResult: + entry_data: dict[str, bool | dict[str, str]] = {CONF_LIVE_VIEW: True} + entry_data[CONF_CAMERA_CREDENTIALS] = { + CONF_USERNAME: un, + CONF_PASSWORD: pw, + } + _LOGGER.debug("Creating camera account entry for device %s", device.host) + return self._async_create_or_update_entry_from_device( + device, camera_data=entry_data + ) + + async def async_step_camera_auth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that gives the user option to set camera credentials.""" + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + device = self._discovered_device + assert device + + if user_input: + live_view = user_input[CONF_LIVE_VIEW] + if not live_view: + return self._async_create_or_update_entry_from_device( + device, camera_data={CONF_LIVE_VIEW: False} + ) + + un = user_input.get(CONF_USERNAME) + pw = user_input.get(CONF_PASSWORD) + + if user_input and un and pw: + camera_creds = Credentials(un, cast(str, pw)) + + camera_module = device.modules[Module.Camera] + rtsp_url = camera_module.stream_rtsp_url(camera_creds) + assert rtsp_url + + # If camera fails to create HLS stream via 'stream' then try + # ffmpeg.async_get_image as some cameras do not work with HLS + # and the frontend will fallback to mpeg on error + try: + await stream.async_check_stream_client_error(self.hass, rtsp_url) + except stream.StreamOpenClientError as ex: + if ex.error_code is stream.StreamClientError.Unauthorized: + errors["base"] = "invalid_camera_auth" + else: + _LOGGER.debug( + "Device %s client error checking stream: %s", device.host, ex + ) + if await ffmpeg.async_get_image(self.hass, rtsp_url): + return self._create_camera_entry(device, un, pw) + + errors["base"] = "cannot_connect_camera" + placeholders["error"] = str(ex) + except Exception as ex: # noqa: BLE001 + _LOGGER.debug("Device %s error checking stream: %s", device.host, ex) + if await ffmpeg.async_get_image(self.hass, rtsp_url): + return self._create_camera_entry(device, un, pw) + + errors["base"] = "cannot_connect_camera" + placeholders["error"] = str(ex) + else: + return self._create_camera_entry(device, un, pw) + + elif user_input: + errors["base"] = "camera_creds" + + entry = None + if self.source == SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + elif self.source == SOURCE_REAUTH: + entry = self._get_reauth_entry() + + if entry: + placeholders[CONF_NAME] = entry.data[CONF_ALIAS] + placeholders[CONF_MODEL] = entry.data[CONF_MODEL] + placeholders[CONF_HOST] = entry.data[CONF_HOST] + + if user_input: + form_data = {**user_input} + elif entry: + form_data = {**entry.data.get(CONF_CAMERA_CREDENTIALS, {})} + form_data[CONF_LIVE_VIEW] = entry.data.get(CONF_LIVE_VIEW, False) + else: + form_data = {} + + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="camera_auth_confirm", + data_schema=self.add_suggested_values_to_schema( + STEP_CAMERA_AUTH_DATA_SCHEMA, form_data + ), + errors=errors, + description_placeholders=placeholders, + ) + async def async_step_pick_device( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -403,7 +541,11 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user_auth_confirm() except KasaException: return self.async_abort(reason="cannot_connect") - return self._async_create_entry_from_device(device) + + if self._async_supports_camera_credentials(device): + return await self.async_step_camera_auth_confirm() + + return self._async_create_or_update_entry_from_device(device) configured_devices = { entry.unique_id for entry in self._async_current_entries() @@ -444,11 +586,19 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): _config_entries.flow.async_abort(flow["flow_id"]) @callback - def _async_create_entry_from_device(self, device: Device) -> ConfigFlowResult: + def _async_create_or_update_entry_from_device( + self, device: Device, *, camera_data: dict | None = None + ) -> ConfigFlowResult: """Create a config entry from a smart device.""" - # This is only ever called after a successful device update so we know that - # the credential_hash is correct and should be saved. - self._abort_if_unique_id_configured(updates={CONF_HOST: device.host}) + entry = None + if self.source == SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + elif self.source == SOURCE_REAUTH: + entry = self._get_reauth_entry() + + if not entry: + self._abort_if_unique_id_configured(updates={CONF_HOST: device.host}) + data: dict[str, Any] = { CONF_HOST: device.host, CONF_ALIAS: device.alias, @@ -456,16 +606,28 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): CONF_CONNECTION_PARAMETERS: device.config.connection_type.to_dict(), CONF_USES_HTTP: device.config.uses_http, } + if camera_data is not None: + data[CONF_LIVE_VIEW] = camera_data[CONF_LIVE_VIEW] + if camera_creds := camera_data.get(CONF_CAMERA_CREDENTIALS): + data[CONF_CAMERA_CREDENTIALS] = camera_creds + if device.config.aes_keys: data[CONF_AES_KEYS] = device.config.aes_keys + + # This is only ever called after a successful device update so we know that + # the credential_hash is correct and should be saved. if device.credentials_hash: data[CONF_CREDENTIALS_HASH] = device.credentials_hash if port := device.config.port_override: data[CONF_PORT] = port - return self.async_create_entry( - title=f"{device.alias} {device.model}", - data=data, - ) + + if not entry: + return self.async_create_entry( + title=f"{device.alias} {device.model}", + data=data, + ) + + return self.async_update_reload_and_abort(entry, data=data) async def _async_try_connect_all( self, @@ -546,7 +708,8 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): credentials: Credentials | None, ) -> Device: """Try to connect.""" - self._async_abort_entries_match({CONF_HOST: discovered_device.host}) + if self.source not in {SOURCE_RECONFIGURE, SOURCE_REAUTH}: + self._async_abort_entries_match({CONF_HOST: discovered_device.host}) config = discovered_device.config if credentials: @@ -566,6 +729,10 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Start the reauthentication flow if the device needs updated credentials.""" + if self.context.get("reauth_source") == CONF_CAMERA_CREDENTIALS: + self._discovered_device = entry_data["device"] + return await self.async_step_camera_auth_confirm() + return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -634,3 +801,62 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders=placeholders, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Trigger a reconfiguration flow.""" + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + + reconfigure_entry = self._get_reconfigure_entry() + assert reconfigure_entry.unique_id + await self.async_set_unique_id(reconfigure_entry.unique_id) + + host = reconfigure_entry.data[CONF_HOST] + port = reconfigure_entry.data.get(CONF_PORT) + + if user_input is not None: + host, port = self._async_get_host_port(host) + + self.host = host + credentials = await get_credentials(self.hass) + try: + device = await self._async_try_discover_and_update( + host, + credentials, + raise_on_progress=False, + raise_on_timeout=False, + port=port, + ) or await self._async_try_connect_all( + host, + credentials=credentials, + raise_on_progress=False, + port=port, + ) + except AuthenticationError: # Error from the update() + return await self.async_step_user_auth_confirm() + except KasaException as ex: + errors["base"] = "cannot_connect" + placeholders["error"] = str(ex) + else: + if not device: + return await self.async_step_user_auth_confirm() + + if self._async_supports_camera_credentials(device): + return await self.async_step_camera_auth_confirm() + + return self._async_create_or_update_entry_from_device(device) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_RECONFIGURE_DATA_SCHEMA, + {CONF_HOST: f"{host}:{port}" if port else host}, + ), + errors=errors, + description_placeholders={ + **placeholders, + CONF_MAC: reconfigure_entry.unique_id, + }, + ) diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 28e4b04bcf9..61c1bf1cb9b 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -24,12 +24,15 @@ CONF_CREDENTIALS_HASH: Final = "credentials_hash" CONF_CONNECTION_PARAMETERS: Final = "connection_parameters" CONF_USES_HTTP: Final = "uses_http" CONF_AES_KEYS: Final = "aes_keys" +CONF_CAMERA_CREDENTIALS = "camera_credentials" +CONF_LIVE_VIEW = "live_view" CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 5 PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CAMERA, Platform.CLIMATE, Platform.FAN, Platform.LIGHT, diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 1c362d33746..186840e8faf 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -2,38 +2,57 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging -from kasa import AuthenticationError, Device, KasaException +from kasa import AuthenticationError, Credentials, Device, KasaException +from kasa.iot import IotStrip -from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) + +@dataclass(slots=True) +class TPLinkData: + """Data for the tplink integration.""" + + parent_coordinator: TPLinkDataUpdateCoordinator + camera_credentials: Credentials | None + live_view: bool | None + + +type TPLinkConfigEntry = ConfigEntry[TPLinkData] + REQUEST_REFRESH_DELAY = 0.35 class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific TPLink device.""" - config_entry: config_entries.ConfigEntry + config_entry: TPLinkConfigEntry def __init__( self, hass: HomeAssistant, device: Device, update_interval: timedelta, + config_entry: TPLinkConfigEntry, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device super().__init__( hass, _LOGGER, + config_entry=config_entry, name=device.host, update_interval=update_interval, # We don't want an immediate refresh since the device @@ -42,12 +61,74 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), ) + self._previous_child_device_ids = {child.device_id for child in device.children} + self.removed_child_device_ids: set[str] = set() + self._child_coordinators: dict[str, TPLinkDataUpdateCoordinator] = {} async def _async_update_data(self) -> None: """Fetch all device and sensor data from api.""" try: await self.device.update(update_children=False) except AuthenticationError as ex: - raise ConfigEntryAuthFailed from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "func": "update", + "exc": str(ex), + }, + ) from ex except KasaException as ex: - raise UpdateFailed(ex) from ex + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="device_error", + translation_placeholders={ + "func": "update", + "exc": str(ex), + }, + ) from ex + + await self._process_child_devices() + + async def _process_child_devices(self) -> None: + """Process child devices and remove stale devices.""" + current_child_device_ids = {child.device_id for child in self.device.children} + if ( + stale_device_ids := self._previous_child_device_ids + - current_child_device_ids + ): + device_registry = dr.async_get(self.hass) + for device_id in stale_device_ids: + device = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + child_coordinator = self._child_coordinators.pop(device_id, None) + if child_coordinator: + await child_coordinator.async_shutdown() + + self._previous_child_device_ids = current_child_device_ids + self.removed_child_device_ids = stale_device_ids + + def get_child_coordinator( + self, + child: Device, + ) -> TPLinkDataUpdateCoordinator: + """Get separate child coordinator for a device or self if not needed.""" + # The iot HS300 allows a limited number of concurrent requests and fetching the + # emeter information requires separate ones so create child coordinators here. + if isinstance(self.device, IotStrip): + if not (child_coordinator := self._child_coordinators.get(child.device_id)): + # The child coordinators only update energy data so we can + # set a longer update interval to avoid flooding the device + child_coordinator = TPLinkDataUpdateCoordinator( + self.hass, child, timedelta(seconds=60), self.config_entry + ) + self._child_coordinators[child.device_id] = child_coordinator + return child_coordinator + + return self diff --git a/homeassistant/components/tplink/deprecate.py b/homeassistant/components/tplink/deprecate.py index 738f3d24c38..86d4f66cdc0 100644 --- a/homeassistant/components/tplink/deprecate.py +++ b/homeassistant/components/tplink/deprecate.py @@ -6,16 +6,20 @@ from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING +from kasa import Device + from homeassistant.components.automation import automations_with_entity +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.script import scripts_with_entity from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from . import legacy_device_id from .const import DOMAIN if TYPE_CHECKING: - from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription + from .entity import CoordinatedTPLinkEntity, TPLinkEntityDescription @dataclass(slots=True) @@ -30,7 +34,7 @@ class DeprecatedInfo: def async_check_create_deprecated( hass: HomeAssistant, unique_id: str, - entity_description: TPLinkFeatureEntityDescription, + entity_description: TPLinkEntityDescription, ) -> bool: """Return true if the entity should be created based on the deprecated_info. @@ -58,13 +62,21 @@ def async_check_create_deprecated( return not entity_entry.disabled -def async_cleanup_deprecated( +def async_process_deprecated( hass: HomeAssistant, - platform: str, + platform_domain: str, entry_id: str, - entities: Sequence[CoordinatedTPLinkFeatureEntity], + entities: Sequence[CoordinatedTPLinkEntity], + device: Device, ) -> None: - """Remove disabled deprecated entities or create issues if necessary.""" + """Process deprecated entities for a device. + + Create issues for deprececated entities that appear in automations. + Delete entities that are no longer provided by the integration either + because they have been removed at the end of the deprecation period, or + they are disabled by the user so the async_check_create_deprecated + returned false. + """ ent_reg = er.async_get(hass) for entity in entities: if not (deprecated_info := entity.entity_description.deprecated_info): @@ -72,7 +84,7 @@ def async_cleanup_deprecated( assert entity.unique_id entity_id = ent_reg.async_get_entity_id( - platform, + platform_domain, DOMAIN, entity.unique_id, ) @@ -94,17 +106,27 @@ def async_cleanup_deprecated( translation_placeholders={ "entity": entity_id, "info": item, - "platform": platform, + "platform": platform_domain, "new_platform": deprecated_info.new_platform, }, ) + # The light platform does not currently support cleaning up disabled + # deprecated entities because it uses two entity classes so a completeness + # check is not possible. It also uses the mac address as device id in some + # instances instead of device_id. + if platform_domain == LIGHT_DOMAIN: + return + # Remove entities that are no longer provided and have been disabled. + device_id = legacy_device_id(device) + unique_ids = {entity.unique_id for entity in entities} for entity_entry in er.async_entries_for_config_entry(ent_reg, entry_id): if ( - entity_entry.domain == platform + entity_entry.domain == platform_domain and entity_entry.disabled + and entity_entry.unique_id.startswith(device_id) and entity_entry.unique_id not in unique_ids ): ent_reg.async_remove(entity_entry.entity_id) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index ef9e2ad5eee..edef8bd83a0 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable, Coroutine, Mapping +from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping from dataclasses import dataclass, replace import logging from typing import Any, Concatenate @@ -35,8 +35,12 @@ from .const import ( DOMAIN, PRIMARY_STATE_ID, ) -from .coordinator import TPLinkDataUpdateCoordinator -from .deprecate import DeprecatedInfo, async_check_create_deprecated +from .coordinator import TPLinkConfigEntry, TPLinkDataUpdateCoordinator +from .deprecate import ( + DeprecatedInfo, + async_check_create_deprecated, + async_process_deprecated, +) _LOGGER = logging.getLogger(__name__) @@ -85,10 +89,26 @@ LEGACY_KEY_MAPPING = { @dataclass(frozen=True, kw_only=True) -class TPLinkFeatureEntityDescription(EntityDescription): +class TPLinkEntityDescription(EntityDescription): """Base class for a TPLink feature based entity description.""" deprecated_info: DeprecatedInfo | None = None + available_fn: Callable[[Device], bool] = lambda _: True + + +@dataclass(frozen=True, kw_only=True) +class TPLinkFeatureEntityDescription(TPLinkEntityDescription): + """Base class for a TPLink feature based entity description.""" + + +@dataclass(frozen=True, kw_only=True) +class TPLinkModuleEntityDescription(TPLinkEntityDescription): + """Base class for a TPLink module based entity description.""" + + exists_fn: Callable[[Device, TPLinkConfigEntry], bool] + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: f"{legacy_device_id(device)}-{desc.key}" + ) def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( @@ -138,21 +158,29 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB _attr_has_entity_name = True _device: Device + entity_description: TPLinkEntityDescription + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkEntityDescription, *, feature: Feature | None = None, parent: Device | None = None, ) -> None: """Initialize the entity.""" super().__init__(coordinator) + self.entity_description = description self._device: Device = device + self._parent = parent self._feature = feature registry_device = device device_name = get_device_name(device, parent=parent) + translation_key: str | None = None + translation_placeholders: Mapping[str, str] | None = None + if parent and parent.device_type is not Device.Type.Hub: if not feature or feature.id == PRIMARY_STATE_ID: # Entity will be added to parent if not a hub and no feature @@ -160,6 +188,9 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB # is the primary state registry_device = parent device_name = get_device_name(registry_device) + if not device_name: + translation_key = "unnamed_device" + translation_placeholders = {"model": parent.model} else: # Prefix the device name with the parent name unless it is a # hub attached device. Sensible default for child devices like @@ -168,17 +199,36 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB # Bedroom Ceiling Fan; Child device aliases will be Ceiling Fan # and Dimmer Switch for both so should be distinguished by the # parent name. - device_name = f"{get_device_name(parent)} {get_device_name(device, parent=parent)}" + parent_device_name = get_device_name(parent) + child_device_name = get_device_name(device, parent=parent) + if parent_device_name: + device_name = f"{parent_device_name} {child_device_name}" + else: + device_name = None + translation_key = "unnamed_device" + translation_placeholders = { + "model": f"{parent.model} {child_device_name}" + } + + if device_name is None and not translation_key: + translation_key = "unnamed_device" + translation_placeholders = {"model": device.model} self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(registry_device.device_id))}, manufacturer="TP-Link", model=registry_device.model, name=device_name, + translation_key=translation_key, + translation_placeholders=translation_placeholders, sw_version=registry_device.hw_info["sw_ver"], hw_version=registry_device.hw_info["hw_ver"], ) + # child device entities will link via_device unless they were created + # above on the parent. Otherwise the mac connections is set which or + # for wall switches like the ks240 will mean the child and parent devices + # are treated as one device. if ( parent is not None and parent != registry_device @@ -192,23 +242,30 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB self._attr_unique_id = self._get_unique_id() - self._async_call_update_attrs() - def _get_unique_id(self) -> str: """Return unique ID for the entity.""" - return legacy_device_id(self._device) + raise NotImplementedError + + async def async_added_to_hass(self) -> None: + """Call update attributes after the device is added to the platform.""" + await super().async_added_to_hass() + + self._async_call_update_attrs() @abstractmethod @callback - def _async_update_attrs(self) -> None: - """Platforms implement this to update the entity internals.""" + def _async_update_attrs(self) -> bool: + """Platforms implement this to update the entity internals. + + The return value is used to the set the entity available attribute. + """ raise NotImplementedError @callback def _async_call_update_attrs(self) -> None: """Call update_attrs and make entity unavailable on errors.""" try: - self._async_update_attrs() + available = self._async_update_attrs() except Exception as ex: # noqa: BLE001 if self._attr_available: _LOGGER.warning( @@ -219,7 +276,7 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB ) self._attr_available = False else: - self._attr_available = True + self._attr_available = available @callback def _handle_coordinator_update(self) -> None: @@ -243,14 +300,19 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkFeatureEntityDescription, *, feature: Feature, - description: TPLinkFeatureEntityDescription, parent: Device | None = None, ) -> None: """Initialize the entity.""" - self.entity_description = description - super().__init__(device, coordinator, parent=parent, feature=feature) + super().__init__( + device, coordinator, description, parent=parent, feature=feature + ) + + # Update the feature attributes so the registered entity contains + # values like unit_of_measurement and suggested_display_precision + self._async_call_update_attrs() def _get_unique_id(self) -> str: """Return unique ID for the entity.""" @@ -308,6 +370,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): if descriptions and (desc := descriptions.get(feature.id)): translation_key: str | None = feature.id + # HA logic is to name entities based on the following logic: # _attr_name > translation.name > description.name # > device_class (if base platform supports). @@ -351,6 +414,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): feature_type: Feature.Type, entity_class: type[_E], descriptions: Mapping[str, _D], + platform_domain: str, parent: Device | None = None, ) -> list[_E]: """Return a list of entities to add. @@ -385,6 +449,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): desc, ) ] + async_process_deprecated( + hass, platform_domain, coordinator.config_entry.entry_id, entities, device + ) return entities @classmethod @@ -400,7 +467,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): feature_type: Feature.Type, entity_class: type[_E], descriptions: Mapping[str, _D], - child_coordinators: list[TPLinkDataUpdateCoordinator] | None = None, + platform_domain: str, + known_child_device_ids: set[str], + first_check: bool, ) -> list[_E]: """Create entities for device and its children. @@ -408,36 +477,236 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): """ entities: list[_E] = [] # Add parent entities before children so via_device id works. - entities.extend( - cls._entities_for_device( + # Only add the parent entities the first time + if first_check: + entities.extend( + cls._entities_for_device( + hass, + device, + coordinator=coordinator, + feature_type=feature_type, + entity_class=entity_class, + descriptions=descriptions, + platform_domain=platform_domain, + ) + ) + + children = _get_new_children( + device, coordinator, known_child_device_ids, entity_class.__name__ + ) + + if children: + _LOGGER.debug( + "Getting %s entities for %s child devices on device %s", + entity_class.__name__, + len(children), + device.host, + ) + + for child in children: + child_coordinator = coordinator.get_child_coordinator(child) + + child_entities = cls._entities_for_device( hass, - device, - coordinator=coordinator, + child, + coordinator=child_coordinator, feature_type=feature_type, entity_class=entity_class, descriptions=descriptions, + platform_domain=platform_domain, + parent=device, ) - ) - if device.children: - _LOGGER.debug("Initializing device with %s children", len(device.children)) - for idx, child in enumerate(device.children): - # HS300 does not like too many concurrent requests and its - # emeter data requires a request for each socket, so we receive - # separate coordinators. - if child_coordinators: - child_coordinator = child_coordinators[idx] - else: - child_coordinator = coordinator - entities.extend( - cls._entities_for_device( - hass, - child, - coordinator=child_coordinator, - feature_type=feature_type, - entity_class=entity_class, - descriptions=descriptions, - parent=device, - ) - ) + _LOGGER.debug( + "Device %s, found %s child %s entities for child id %s", + device.host, + len(entities), + entity_class.__name__, + child.device_id, + ) + entities.extend(child_entities) return entities + + +class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): + """Common base class for all coordinated tplink module based entities.""" + + entity_description: TPLinkModuleEntityDescription + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkModuleEntityDescription, + *, + parent: Device | None = None, + ) -> None: + """Initialize the entity.""" + super().__init__(device, coordinator, description, parent=parent) + + # Module based entities will usually be 1 per device so they will use + # the device name. If there are multiple module entities based entities + # the description should have a translation key. + # HA logic is to name entities based on the following logic: + # _attr_name > translation.name > description.name + if not description.translation_key: + if parent is None or parent.device_type is Device.Type.Hub: + self._attr_name = None + else: + self._attr_name = get_device_name(device) + + def _get_unique_id(self) -> str: + """Return unique ID for the entity.""" + desc = self.entity_description + return desc.unique_id_fn(self._device, desc) + + @classmethod + def _entities_for_device[ + _E: CoordinatedTPLinkModuleEntity, + _D: TPLinkModuleEntityDescription, + ]( + cls, + hass: HomeAssistant, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + entity_class: type[_E], + descriptions: Iterable[_D], + platform_domain: str, + parent: Device | None = None, + ) -> list[_E]: + """Return a list of entities to add.""" + entities: list[_E] = [ + entity_class( + device, + coordinator, + description=description, + parent=parent, + ) + for description in descriptions + if description.exists_fn(device, coordinator.config_entry) + and async_check_create_deprecated( + hass, + description.unique_id_fn(device, description), + description, + ) + ] + async_process_deprecated( + hass, platform_domain, coordinator.config_entry.entry_id, entities, device + ) + return entities + + @classmethod + def entities_for_device_and_its_children[ + _E: CoordinatedTPLinkModuleEntity, + _D: TPLinkModuleEntityDescription, + ]( + cls, + hass: HomeAssistant, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + entity_class: type[_E], + descriptions: Iterable[_D], + platform_domain: str, + known_child_device_ids: set[str], + first_check: bool, + ) -> list[_E]: + """Create entities for device and its children. + + This is a helper that calls *_entities_for_device* for the device and its children. + """ + entities: list[_E] = [] + + # Add parent entities before children so via_device id works. + # Only add the parent entities the first time + if first_check: + entities.extend( + cls._entities_for_device( + hass, + device, + coordinator=coordinator, + entity_class=entity_class, + descriptions=descriptions, + platform_domain=platform_domain, + ) + ) + has_parent_entities = bool(entities) + + children = _get_new_children( + device, coordinator, known_child_device_ids, entity_class.__name__ + ) + + if children: + _LOGGER.debug( + "Getting %s entities for %s child devices on device %s", + entity_class.__name__, + len(children), + device.host, + ) + for child in children: + child_coordinator = coordinator.get_child_coordinator(child) + + child_entities: list[_E] = cls._entities_for_device( + hass, + child, + coordinator=child_coordinator, + entity_class=entity_class, + descriptions=descriptions, + platform_domain=platform_domain, + parent=device, + ) + _LOGGER.debug( + "Device %s, found %s child %s entities for child id %s", + device.host, + len(entities), + entity_class.__name__, + child.device_id, + ) + entities.extend(child_entities) + + if first_check and entities and not has_parent_entities: + # Get or create the parent device for via_device. + # This is a timing factor in case this platform is loaded before + # other platforms that will have entities on the parent. Eventually + # those other platforms will update the parent with full DeviceInfo + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=coordinator.config_entry.entry_id, + identifiers={(DOMAIN, device.device_id)}, + ) + return entities + + +def _get_new_children( + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + known_child_device_ids: set[str], + entity_class_name: str, +) -> list[Device]: + """Get a list of children to check for entity creation.""" + # Remove any device ids removed via the coordinator so they can be re-added + for removed_child_id in coordinator.removed_child_device_ids: + _LOGGER.debug( + "Removing %s from known %s child ids for device %s" + "as it has been removed by the coordinator", + removed_child_id, + entity_class_name, + device.host, + ) + known_child_device_ids.discard(removed_child_id) + + current_child_devices = {child.device_id: child for child in device.children} + current_child_device_ids = set(current_child_devices.keys()) + new_child_device_ids = current_child_device_ids - known_child_device_ids + children = [] + + if new_child_device_ids: + children = [ + child + for child_id, child in current_child_devices.items() + if child_id in new_child_device_ids + ] + known_child_device_ids.update(new_child_device_ids) + return children + return [] diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py index f90eadbc531..1c31d84b778 100644 --- a/homeassistant/components/tplink/fan.py +++ b/homeassistant/components/tplink/fan.py @@ -1,13 +1,19 @@ """Support for TPLink Fan devices.""" +from collections.abc import Callable +from dataclasses import dataclass import logging import math from typing import Any from kasa import Device, Module -from kasa.interfaces import Fan as FanInterface -from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.components.fan import ( + DOMAIN as FAN_DOMAIN, + FanEntity, + FanEntityDescription, + FanEntityFeature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -16,13 +22,40 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from . import TPLinkConfigEntry +from . import TPLinkConfigEntry, legacy_device_id from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class TPLinkFanEntityDescription(FanEntityDescription, TPLinkModuleEntityDescription): + """Base class for fan entity description.""" + + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: legacy_device_id(device) + if desc.key == "fan" + else f"{legacy_device_id(device)}-{desc.key}" + ) + + +FAN_DESCRIPTIONS: tuple[TPLinkFanEntityDescription, ...] = ( + TPLinkFanEntityDescription( + key="fan", + exists_fn=lambda dev, _: Module.Fan in dev.modules, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, @@ -32,30 +65,32 @@ async def async_setup_entry( data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - entities: list[CoordinatedTPLinkEntity] = [] - if Module.Fan in device.modules: - entities.append( - TPLinkFanEntity( - device, parent_coordinator, fan_module=device.modules[Module.Fan] - ) + + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkFanEntity, + descriptions=FAN_DESCRIPTIONS, + platform_domain=FAN_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, ) - entities.extend( - TPLinkFanEntity( - child, - parent_coordinator, - fan_module=child.modules[Module.Fan], - parent=device, - ) - for child in device.children - if Module.Fan in child.modules - ) - async_add_entities(entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) SPEED_RANGE = (1, 4) # off is not included -class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): +class TPLinkFanEntity(CoordinatedTPLinkModuleEntity, FanEntity): """Representation of a fan for a TPLink Fan device.""" _attr_speed_count = int_states_in_range(SPEED_RANGE) @@ -64,21 +99,20 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False + + entity_description: TPLinkFanEntityDescription def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, - fan_module: FanInterface, + description: TPLinkFanEntityDescription, + *, parent: Device | None = None, ) -> None: """Initialize the fan.""" - self.fan_module = fan_module - # If _attr_name is None the entity name will be the device name - self._attr_name = None if parent is None else device.alias - - super().__init__(device, coordinator, parent=parent) + super().__init__(device, coordinator, description, parent=parent) + self.fan_module = device.modules[Module.Fan] @async_refresh_after async def async_turn_on( @@ -107,7 +141,7 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): await self.fan_module.set_fan_speed_level(value_in_range) @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" fan_speed = self.fan_module.fan_speed_level self._attr_is_on = fan_speed != 0 @@ -115,3 +149,4 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): self._attr_percentage = ranged_value_to_percentage(SPEED_RANGE, fan_speed) else: self._attr_percentage = None + return True diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 0abd68543c5..e00e8f69467 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -20,6 +20,18 @@ }, "stop_alarm": { "default": "mdi:bell-cancel" + }, + "pan_left": { + "default": "mdi:chevron-left" + }, + "pan_right": { + "default": "mdi:chevron-right" + }, + "tilt_up": { + "default": "mdi:chevron-up" + }, + "tilt_down": { + "default": "mdi:chevron-down" } }, "select": { @@ -77,6 +89,30 @@ "state": { "on": "mdi:motion-sensor" } + }, + "motion_detection": { + "default": "mdi:motion-sensor-off", + "state": { + "on": "mdi:motion-sensor" + } + }, + "person_detection": { + "default": "mdi:account-off", + "state": { + "on": "mdi:account" + } + }, + "tamper_detection": { + "default": "mdi:shield-off", + "state": { + "on": "mdi:shield" + } + }, + "baby_cry_detection": { + "default": "mdi:baby-face-outline", + "state": { + "on": "mdi:baby-face" + } } }, "sensor": { @@ -89,12 +125,6 @@ "signal_level": { "default": "mdi:signal" }, - "current_firmware_version": { - "default": "mdi:information" - }, - "available_firmware_version": { - "default": "mdi:information-outline" - }, "alarm_source": { "default": "mdi:bell" }, @@ -115,8 +145,11 @@ "temperature_offset": { "default": "mdi:contrast" }, - "target_temperature": { - "default": "mdi:thermometer" + "pan_step": { + "default": "mdi:unfold-more-vertical" + }, + "tilt_step": { + "default": "mdi:unfold-more-horizontal" } } }, diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 8d6ec27f81c..c1311c256df 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -3,11 +3,12 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import dataclass import logging from typing import Any -from kasa import Device, DeviceType, LightState, Module -from kasa.interfaces import Light, LightEffect +from kasa import Device, DeviceType, KasaException, LightState, Module +from kasa.interfaces import LightEffect from kasa.iot import IotDevice import voluptuous as vol @@ -17,21 +18,32 @@ from homeassistant.components.light import ( ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, + DOMAIN as LIGHT_DOMAIN, EFFECT_OFF, ColorMode, LightEntity, + LightEntityDescription, LightEntityFeature, filter_supported_color_modes, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import TPLinkConfigEntry, legacy_device_id +from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) @@ -130,115 +142,136 @@ def _async_build_base_effect( } +def _get_backwards_compatible_light_unique_id( + device: Device, entity_description: TPLinkModuleEntityDescription +) -> str: + """Return unique ID for the entity.""" + # For historical reasons the light platform uses the mac address as + # the unique id whereas all other platforms use device_id. + + # For backwards compat with pyHS100 + if device.device_type is DeviceType.Dimmer and isinstance(device, IotDevice): + # Dimmers used to use the switch format since + # pyHS100 treated them as SmartPlug but the old code + # created them as lights + # https://github.com/home-assistant/core/blob/2021.9.7/ \ + # homeassistant/components/tplink/common.py#L86 + return legacy_device_id(device) + + # Newer devices can have child lights. While there isn't currently + # an example of a device with more than one light we use the device_id + # for consistency and future proofing + if device.parent or device.children: + return legacy_device_id(device) + + return device.mac.replace(":", "").upper() + + +@dataclass(frozen=True, kw_only=True) +class TPLinkLightEntityDescription( + LightEntityDescription, TPLinkModuleEntityDescription +): + """Base class for tplink light entity description.""" + + unique_id_fn = _get_backwards_compatible_light_unique_id + + +LIGHT_DESCRIPTIONS: tuple[TPLinkLightEntityDescription, ...] = ( + TPLinkLightEntityDescription( + key="light", + exists_fn=lambda dev, _: Module.Light in dev.modules + and Module.LightEffect not in dev.modules, + ), +) + +LIGHT_EFFECT_DESCRIPTIONS: tuple[TPLinkLightEntityDescription, ...] = ( + TPLinkLightEntityDescription( + key="light_effect", + exists_fn=lambda dev, _: Module.Light in dev.modules + and Module.LightEffect in dev.modules, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up switches.""" + """Set up lights.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - entities: list[TPLinkLightEntity | TPLinkLightEffectEntity] = [] - if effect_module := device.modules.get(Module.LightEffect): - entities.append( - TPLinkLightEffectEntity( - device, - parent_coordinator, - light_module=device.modules[Module.Light], - effect_module=effect_module, + + known_child_device_ids_light: set[str] = set() + known_child_device_ids_light_effect: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkLightEntity, + descriptions=LIGHT_DESCRIPTIONS, + platform_domain=LIGHT_DOMAIN, + known_child_device_ids=known_child_device_ids_light, + first_check=first_check, + ) + entities.extend( + CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkLightEffectEntity, + descriptions=LIGHT_EFFECT_DESCRIPTIONS, + platform_domain=LIGHT_DOMAIN, + known_child_device_ids=known_child_device_ids_light_effect, + first_check=first_check, ) ) - if effect_module.has_custom_effects: - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_RANDOM_EFFECT, - RANDOM_EFFECT_DICT, - "async_set_random_effect", - ) - platform.async_register_entity_service( - SERVICE_SEQUENCE_EFFECT, - SEQUENCE_EFFECT_DICT, - "async_set_sequence_effect", - ) - elif Module.Light in device.modules: - entities.append( - TPLinkLightEntity( - device, parent_coordinator, light_module=device.modules[Module.Light] - ) - ) - entities.extend( - TPLinkLightEntity( - child, - parent_coordinator, - light_module=child.modules[Module.Light], - parent=device, - ) - for child in device.children - if Module.Light in child.modules - ) - async_add_entities(entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) -class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): +class TPLinkLightEntity(CoordinatedTPLinkModuleEntity, LightEntity): """Representation of a TPLink Smart Bulb.""" _attr_supported_features = LightEntityFeature.TRANSITION _fixed_color_mode: ColorMode | None = None + entity_description: TPLinkLightEntityDescription + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkLightEntityDescription, *, - light_module: Light, parent: Device | None = None, ) -> None: """Initialize the light.""" - self._parent = parent + super().__init__(device, coordinator, description, parent=parent) + + light_module = device.modules[Module.Light] self._light_module = light_module - # If _attr_name is None the entity name will be the device name - self._attr_name = None if parent is None else device.alias modes: set[ColorMode] = {ColorMode.ONOFF} - if light_module.is_variable_color_temp: + if color_temp_feat := light_module.get_feature("color_temp"): modes.add(ColorMode.COLOR_TEMP) - temp_range = light_module.valid_temperature_range - self._attr_min_color_temp_kelvin = temp_range.min - self._attr_max_color_temp_kelvin = temp_range.max - if light_module.is_color: + self._attr_min_color_temp_kelvin = color_temp_feat.minimum_value + self._attr_max_color_temp_kelvin = color_temp_feat.maximum_value + if light_module.has_feature("hsv"): modes.add(ColorMode.HS) - if light_module.is_dimmable: + if light_module.has_feature("brightness"): modes.add(ColorMode.BRIGHTNESS) self._attr_supported_color_modes = filter_supported_color_modes(modes) if len(self._attr_supported_color_modes) == 1: # If the light supports only a single color mode, set it now self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) - super().__init__(device, coordinator, parent=parent) - - def _get_unique_id(self) -> str: - """Return unique ID for the entity.""" - # For historical reasons the light platform uses the mac address as - # the unique id whereas all other platforms use device_id. - device = self._device - - # For backwards compat with pyHS100 - if device.device_type is DeviceType.Dimmer and isinstance(device, IotDevice): - # Dimmers used to use the switch format since - # pyHS100 treated them as SmartPlug but the old code - # created them as lights - # https://github.com/home-assistant/core/blob/2021.9.7/ \ - # homeassistant/components/tplink/common.py#L86 - return legacy_device_id(device) - - # Newer devices can have child lights. While there isn't currently - # an example of a device with more than one light we use the device_id - # for consistency and future proofing - if self._parent or device.children: - return legacy_device_id(device) - - return device.mac.replace(":", "").upper() - @callback def _async_extract_brightness_transition( self, **kwargs: Any @@ -270,15 +303,17 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): self, color_temp: float, brightness: int | None, transition: int | None ) -> None: light_module = self._light_module - valid_temperature_range = light_module.valid_temperature_range + color_temp_feat = light_module.get_feature("color_temp") + assert color_temp_feat + requested_color_temp = round(color_temp) # Clamp color temp to valid range # since if the light in a group we will # get requests for color temps for the range # of the group and not the light clamped_color_temp = min( - valid_temperature_range.max, - max(valid_temperature_range.min, requested_color_temp), + color_temp_feat.maximum_value, + max(color_temp_feat.minimum_value, requested_color_temp), ) await light_module.set_color_temp( clamped_color_temp, @@ -325,17 +360,20 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): # The light supports only a single color mode, return it return self._fixed_color_mode - # The light supports both color temp and color, determine which on is active - if self._light_module.is_variable_color_temp and self._light_module.color_temp: + # The light supports both color temp and color, determine which one is active + if ( + self._light_module.has_feature("color_temp") + and self._light_module.color_temp + ): return ColorMode.COLOR_TEMP return ColorMode.HS @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" light_module = self._light_module self._attr_is_on = light_module.state.light_on is True - if light_module.is_dimmable: + if light_module.has_feature("brightness"): self._attr_brightness = round((light_module.brightness * 255.0) / 100.0) color_mode = self._determine_color_mode() self._attr_color_mode = color_mode @@ -345,26 +383,48 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): hue, saturation, _ = light_module.hsv self._attr_hs_color = hue, saturation + return True + class TPLinkLightEffectEntity(TPLinkLightEntity): """Representation of a TPLink Smart Light Strip.""" + _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkLightEntityDescription, *, - light_module: Light, - effect_module: LightEffect, + parent: Device | None = None, ) -> None: """Initialize the light strip.""" - self._effect_module = effect_module - super().__init__(device, coordinator, light_module=light_module) + super().__init__(device, coordinator, description, parent=parent) - _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + self._effect_module = device.modules[Module.LightEffect] + + async def async_added_to_hass(self) -> None: + """Call update attributes after the device is added to the platform.""" + await super().async_added_to_hass() + + self._register_effects_services() + + def _register_effects_services(self) -> None: + if self._effect_module.has_custom_effects: + self.platform.async_register_entity_service( + SERVICE_RANDOM_EFFECT, + RANDOM_EFFECT_DICT, + "async_set_random_effect", + ) + self.platform.async_register_entity_service( + SERVICE_SEQUENCE_EFFECT, + SEQUENCE_EFFECT_DICT, + "async_set_sequence_effect", + ) @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" super()._async_update_attrs() effect_module = self._effect_module @@ -377,6 +437,7 @@ class TPLinkLightEffectEntity(TPLinkLightEntity): self._attr_effect_list = effect_list else: self._attr_effect_list = None + return True @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: @@ -451,7 +512,17 @@ class TPLinkLightEffectEntity(TPLinkLightEntity): if transition_range: effect["transition_range"] = transition_range effect["transition"] = 0 - await self._effect_module.set_custom_effect(effect) + try: + await self._effect_module.set_custom_effect(effect) + except KasaException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_custom_effect", + translation_placeholders={ + "effect": str(effect), + "exc": str(ex), + }, + ) from ex async def async_set_sequence_effect( self, @@ -473,4 +544,14 @@ class TPLinkLightEffectEntity(TPLinkLightEntity): "spread": spread, "direction": direction, } - await self._effect_module.set_custom_effect(effect) + try: + await self._effect_module.set_custom_effect(effect) + except KasaException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_custom_effect", + translation_placeholders={ + "effect": str(effect), + "exc": str(ex), + }, + ) from ex diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 67ae1af9003..a975e675ceb 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -3,7 +3,7 @@ "name": "TP-Link Smart Home", "codeowners": ["@rytilahti", "@bdraco", "@sdb9696"], "config_flow": true, - "dependencies": ["network"], + "dependencies": ["network", "ffmpeg", "stream"], "dhcp": [ { "registered_devices": true @@ -300,5 +300,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], - "requirements": ["python-kasa[speedups]==0.7.7"] + "requirements": ["python-kasa[speedups]==0.9.1"] } diff --git a/homeassistant/components/tplink/models.py b/homeassistant/components/tplink/models.py deleted file mode 100644 index ced58d3d21f..00000000000 --- a/homeassistant/components/tplink/models.py +++ /dev/null @@ -1,15 +0,0 @@ -"""The tplink integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from .coordinator import TPLinkDataUpdateCoordinator - - -@dataclass(slots=True) -class TPLinkData: - """Data for the tplink integration.""" - - parent_coordinator: TPLinkDataUpdateCoordinator - children_coordinators: list[TPLinkDataUpdateCoordinator] diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 5f80d5479d2..0af2b7403e8 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -4,11 +4,12 @@ from __future__ import annotations from dataclasses import dataclass import logging -from typing import Final +from typing import Final, cast from kasa import Device, Feature from homeassistant.components.number import ( + DOMAIN as NUMBER_DOMAIN, NumberEntity, NumberEntityDescription, NumberMode, @@ -31,7 +32,12 @@ _LOGGER = logging.getLogger(__name__) class TPLinkNumberEntityDescription( NumberEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based number entity description.""" + + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 NUMBER_DESCRIPTIONS: Final = ( @@ -51,6 +57,14 @@ NUMBER_DESCRIPTIONS: Final = ( key="temperature_offset", mode=NumberMode.BOX, ), + TPLinkNumberEntityDescription( + key="pan_step", + mode=NumberMode.BOX, + ), + TPLinkNumberEntityDescription( + key="tilt_step", + mode=NumberMode.BOX, + ), ) NUMBER_DESCRIPTIONS_MAP = {desc.key: desc for desc in NUMBER_DESCRIPTIONS} @@ -61,26 +75,34 @@ async def async_setup_entry( config_entry: TPLinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up sensors.""" + """Set up number entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Number, - entity_class=TPLinkNumberEntity, - descriptions=NUMBER_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) + known_child_device_ids: set[str] = set() + first_check = True - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Number, + entity_class=TPLinkNumberEntity, + descriptions=NUMBER_DESCRIPTIONS_MAP, + platform_domain=NUMBER_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): - """Representation of a feature-based TPLink sensor.""" + """Representation of a feature-based TPLink number entity.""" entity_description: TPLinkNumberEntityDescription @@ -93,7 +115,7 @@ class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): description: TPLinkFeatureEntityDescription, parent: Device | None = None, ) -> None: - """Initialize the a switch.""" + """Initialize the number entity.""" super().__init__( device, coordinator, feature=feature, description=description, parent=parent ) @@ -106,6 +128,7 @@ class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): await self._feature.set_value(int(value)) @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" - self._attr_native_value = self._feature.value + self._attr_native_value = cast(float | None, self._feature.value) + return True diff --git a/homeassistant/components/tplink/quality_scale.yaml b/homeassistant/components/tplink/quality_scale.yaml new file mode 100644 index 00000000000..ced9cbcc831 --- /dev/null +++ b/homeassistant/components/tplink/quality_scale.yaml @@ -0,0 +1,66 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: The integration does not use events. + dependency-transparency: done + action-setup: + status: exempt + comment: The integration only uses platform services. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: The integration does not have any options configuration parameters. + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: todo + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: todo + discovery-update-info: done + repair-issues: done + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index 41e3224215b..8e9dee7b964 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -7,7 +7,11 @@ from typing import Final, cast from kasa import Device, Feature -from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,9 +28,13 @@ from .entity import ( class TPLinkSelectEntityDescription( SelectEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based select entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + SELECT_DESCRIPTIONS: Final = [ TPLinkSelectEntityDescription( key="light_preset", @@ -47,22 +55,30 @@ async def async_setup_entry( config_entry: TPLinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up sensors.""" + """Set up select entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Choice, - entity_class=TPLinkSelectEntity, - descriptions=SELECT_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Choice, + entity_class=TPLinkSelectEntity, + descriptions=SELECT_DESCRIPTIONS_MAP, + platform_domain=SELECT_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity): @@ -91,6 +107,7 @@ class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity): await self._feature.set_value(option) @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" - self._attr_current_option = self._feature.value + self._attr_current_option = cast(str | None, self._feature.value) + return True diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 809d9002768..aaba6b2674d 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import cast +from typing import TYPE_CHECKING, cast from kasa import Feature @@ -19,7 +19,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry from .const import UNIT_MAPPING -from .deprecate import async_cleanup_deprecated from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -30,6 +29,9 @@ class TPLinkSensorEntityDescription( """Base class for a TPLink feature based sensor entity description.""" +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="current_consumption", @@ -113,11 +115,6 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="alarm_source", ), - TPLinkSensorEntityDescription( - key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), ) SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS} @@ -131,20 +128,27 @@ async def async_setup_entry( """Set up sensors.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Sensor, - entity_class=TPLinkSensorEntity, - descriptions=SENSOR_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities) - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Sensor, + entity_class=TPLinkSensorEntity, + descriptions=SENSOR_DESCRIPTIONS_MAP, + platform_domain=SENSOR_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): @@ -153,7 +157,7 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): entity_description: TPLinkSensorEntityDescription @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" value = self._feature.value if value is not None and self._feature.precision_hint is not None: @@ -161,7 +165,14 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): # We probably do not need this, when we are rounding already? self._attr_suggested_display_precision = self._feature.precision_hint + if TYPE_CHECKING: + # pylint: disable-next=import-outside-toplevel + from datetime import date, datetime + + assert isinstance(value, str | int | float | date | datetime | None) + self._attr_native_value = value # Map to homeassistant units and fallback to upstream one if none found if (unit := self._feature.unit) is not None: self._attr_native_unit_of_measurement = UNIT_MAPPING.get(unit, unit) + return True diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index c4ece56f0f6..5931a508d6c 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -1,19 +1,55 @@ -"""Support for TPLink hub alarm.""" +"""Support for TPLink siren entity.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any from kasa import Device, Module from kasa.smart.modules.alarm import Alarm -from homeassistant.components.siren import SirenEntity, SirenEntityFeature +from homeassistant.components.siren import ( + DOMAIN as SIREN_DOMAIN, + SirenEntity, + SirenEntityDescription, + SirenEntityFeature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TPLinkConfigEntry +from . import TPLinkConfigEntry, legacy_device_id from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TPLinkSirenEntityDescription( + SirenEntityDescription, TPLinkModuleEntityDescription +): + """Base class for siren entity description.""" + + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: legacy_device_id(device) + if desc.key == "siren" + else f"{legacy_device_id(device)}-{desc.key}" + ) + + +SIREN_DESCRIPTIONS: tuple[TPLinkSirenEntityDescription, ...] = ( + TPLinkSirenEntityDescription( + key="siren", + exists_fn=lambda dev, _: Module.Alarm in dev.modules, + ), +) async def async_setup_entry( @@ -26,24 +62,46 @@ async def async_setup_entry( parent_coordinator = data.parent_coordinator device = parent_coordinator.device - if Module.Alarm in device.modules: - async_add_entities([TPLinkSirenEntity(device, parent_coordinator)]) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkSirenEntity, + descriptions=SIREN_DESCRIPTIONS, + platform_domain=SIREN_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) -class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity): - """Representation of a tplink hub alarm.""" +class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity): + """Representation of a tplink siren entity.""" _attr_name = None _attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON + entity_description: TPLinkSirenEntityDescription + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkSirenEntityDescription, + *, + parent: Device | None = None, ) -> None: """Initialize the siren entity.""" + super().__init__(device, coordinator, description, parent=parent) self._alarm_module: Alarm = device.modules[Module.Alarm] - super().__init__(device, coordinator) @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: @@ -56,6 +114,7 @@ class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity): await self._alarm_module.stop() @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_is_on = self._alarm_module.active + return True diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 8e5118c2720..9c32dd5bbf4 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -14,6 +14,9 @@ "pick_device": { "data": { "device": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "device": "Pick the TP-Link device to add." } }, "discovery_confirm": { @@ -21,10 +24,14 @@ }, "user_auth_confirm": { "title": "Authenticate", - "description": "The device requires authentication, please input your TP-Link credentials below.", + "description": "The device requires authentication, please input your TP-Link credentials below. Note, that both e-mail and password are case-sensitive.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your TP-Link cloud username which is the full email and is case sensitive.", + "password": "Your TP-Link cloud password which is case sensitive." } }, "discovery_auth_confirm": { @@ -33,6 +40,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::tplink::config::step::user_auth_confirm::data_description::username%]", + "password": "[%key:component::tplink::config::step::user_auth_confirm::data_description::password%]" } }, "reauth_confirm": { @@ -41,17 +52,49 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::tplink::config::step::user_auth_confirm::data_description::username%]", + "password": "[%key:component::tplink::config::step::user_auth_confirm::data_description::password%]" + } + }, + "reconfigure": { + "title": "Reconfigure TPLink entry", + "description": "Update your configuration for device {mac}", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::tplink::config::step::user::data_description::host%]" + } + }, + "camera_auth_confirm": { + "title": "Set camera account credentials", + "description": "Input device camera account credentials.", + "data": { + "live_view": "Enable camera live view", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "live_view": "Enabling live view will create the live view camera entity and requires your camera account credentials.", + "username": "Your camera account username configured for the device in the Tapo app.", + "password": "Your camera account password configured for the device in the Tapo app." } } }, "error": { "cannot_connect": "Connection error: {error}", - "invalid_auth": "Invalid authentication: {error}" + "invalid_auth": "Unable to authenticate: {error}", + "invalid_camera_auth": "Camera stream authentication failed", + "cannot_connect_camera": "Unable to access the camera stream, verify that you have set up the camera account: {error}", + "camera_creds": "You have to set both username and password" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, @@ -66,26 +109,9 @@ "overheated": { "name": "Overheated" }, - "battery_low": { - "name": "Battery low" - }, "cloud_connection": { "name": "Cloud connection" }, - "update_available": { - "name": "[%key:component::binary_sensor::entity_component::update::name%]", - "state": { - "off": "[%key:component::binary_sensor::entity_component::update::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::update::state::on%]" - } - }, - "is_open": { - "name": "[%key:component::binary_sensor::entity_component::door::name%]", - "state": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" - } - }, "water_alert": { "name": "[%key:component::binary_sensor::entity_component::moisture::name%]", "state": { @@ -100,6 +126,23 @@ }, "stop_alarm": { "name": "Stop alarm" + }, + "pan_left": { + "name": "Pan left" + }, + "pan_right": { + "name": "Pan right" + }, + "tilt_up": { + "name": "Tilt up" + }, + "tilt_down": { + "name": "Tilt down" + } + }, + "camera": { + "live_view": { + "name": "Live view" } }, "select": { @@ -135,27 +178,6 @@ "signal_level": { "name": "Signal level" }, - "current_firmware_version": { - "name": "Current firmware version" - }, - "available_firmware_version": { - "name": "Available firmware version" - }, - "battery_level": { - "name": "Battery level" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "voltage": { - "name": "[%key:component::sensor::entity_component::voltage::name%]" - }, - "current": { - "name": "[%key:component::sensor::entity_component::current::name%]" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, "device_time": { "name": "Device time" }, @@ -170,9 +192,6 @@ }, "alarm_source": { "name": "Alarm source" - }, - "rssi": { - "name": "[%key:component::sensor::entity_component::signal_strength::name%]" } }, "switch": { @@ -196,6 +215,18 @@ }, "pir_enabled": { "name": "Motion sensor" + }, + "motion_detection": { + "name": "Motion detection" + }, + "person_detection": { + "name": "Person detection" + }, + "tamper_detection": { + "name": "Tamper detection" + }, + "baby_cry_detection": { + "name": "Baby cry detection" } }, "number": { @@ -210,9 +241,20 @@ }, "temperature_offset": { "name": "Temperature offset" + }, + "pan_step": { + "name": "Pan degrees" + }, + "tilt_step": { + "name": "Tilt degrees" } } }, + "device": { + "unnamed_device": { + "name": "Unnamed {model}" + } + }, "services": { "sequence_effect": { "name": "Sequence effect", @@ -316,6 +358,15 @@ }, "device_authentication": { "message": "Device authentication error {func}: {exc}" + }, + "set_custom_effect": { + "message": "Error trying to set custom effect {effect}: {exc}" + }, + "unexpected_device": { + "message": "Unexpected device found at {host}; expected {expected}, found {found}" + }, + "unsupported_mode": { + "message": "Tried to set unsupported mode: {mode}" } }, "issues": { diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index c9285d86ba6..04ca95273af 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -4,11 +4,15 @@ from __future__ import annotations from dataclasses import dataclass import logging -from typing import Any +from typing import Any, cast from kasa import Feature -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -26,9 +30,13 @@ _LOGGER = logging.getLogger(__name__) class TPLinkSwitchEntityDescription( SwitchEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based switch entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( TPLinkSwitchEntityDescription( key="state", @@ -54,6 +62,18 @@ SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( TPLinkSwitchEntityDescription( key="pir_enabled", ), + TPLinkSwitchEntityDescription( + key="motion_detection", + ), + TPLinkSwitchEntityDescription( + key="person_detection", + ), + TPLinkSwitchEntityDescription( + key="tamper_detection", + ), + TPLinkSwitchEntityDescription( + key="baby_cry_detection", + ), ) SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} @@ -68,17 +88,26 @@ async def async_setup_entry( data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Switch, - entity_class=TPLinkSwitch, - descriptions=SWITCH_DESCRIPTIONS_MAP, - ) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Switch, + entity_class=TPLinkSwitch, + descriptions=SWITCH_DESCRIPTIONS_MAP, + platform_domain=SWITCH_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) - async_add_entities(entities) + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity): @@ -97,6 +126,7 @@ class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity): await self._feature.set_value(False) @callback - def _async_update_attrs(self) -> None: + def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" - self._attr_is_on = self._feature.value + self._attr_is_on = cast(bool | None, self._feature.value) + return True diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index a92efa660b6..a3c1893267c 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ATTR_BATTERY_LEVEL, PERCENTAGE, EntityCategory, + UnitOfEnergy, UnitOfTime, ) from homeassistant.core import HomeAssistant, callback @@ -127,7 +128,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_CALORIES, translation_key="calories", - native_unit_of_measurement="kcal", + native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE, signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index d9911472a67..29d876346a7 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -10,10 +10,13 @@ from pytradfri import Gateway, RequestError from pytradfri.api.aiocoap_api import APIFactory import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import CONF_GATEWAY_ID, CONF_IDENTITY, CONF_KEY, DOMAIN @@ -78,12 +81,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle homekit discovery.""" - await self.async_set_unique_id( - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] - ) + await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured({CONF_HOST: discovery_info.host}) host = discovery_info.host @@ -96,7 +97,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if not entry.unique_id: self.hass.config_entries.async_update_entry( entry, - unique_id=discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID], + unique_id=discovery_info.properties[ATTR_PROPERTIES_ID], ) return self.async_abort(reason="already_configured") diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index 75616607ee8..3f45ee3e1eb 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -69,7 +69,6 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): # ... with step size 1 # 50 = Max _attr_speed_count = ATTR_MAX_FAN_STEPS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index b0bf6d24019..a71691e6e90 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -9,7 +9,7 @@ from pytradfri.command import Command from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, ColorMode, @@ -87,8 +87,16 @@ class TradfriLight(TradfriBaseEntity, LightEntity): self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) if self._device_control: - self._attr_min_mireds = self._device_control.min_mireds - self._attr_max_mireds = self._device_control.max_mireds + self._attr_max_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin( + self._device_control.min_mireds + ) + ) + self._attr_min_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin( + self._device_control.max_mireds + ) + ) def _refresh(self) -> None: """Refresh the device.""" @@ -118,11 +126,11 @@ class TradfriLight(TradfriBaseEntity, LightEntity): return cast(int, self._device_data.dimmer) @property - def color_temp(self) -> int | None: - """Return the color temp value in mireds.""" - if not self._device_data: + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + if not self._device_data or not (color_temp := self._device_data.color_temp): return None - return cast(int, self._device_data.color_temp) + return color_util.color_temperature_mired_to_kelvin(color_temp) @property def hs_color(self) -> tuple[float, float] | None: @@ -191,18 +199,19 @@ class TradfriLight(TradfriBaseEntity, LightEntity): transition_time = None temp_command = None - if ATTR_COLOR_TEMP in kwargs and ( + if ATTR_COLOR_TEMP_KELVIN in kwargs and ( self._device_control.can_set_temp or self._device_control.can_set_color ): - temp = kwargs[ATTR_COLOR_TEMP] + temp_k = kwargs[ATTR_COLOR_TEMP_KELVIN] # White Spectrum bulb if self._device_control.can_set_temp: - if temp > self.max_mireds: - temp = self.max_mireds - elif temp < self.min_mireds: - temp = self.min_mireds + temp = color_util.color_temperature_kelvin_to_mired(temp_k) + if temp < (min_mireds := self._device_control.min_mireds): + temp = min_mireds + elif temp > (max_mireds := self._device_control.max_mireds): + temp = max_mireds temp_data = { - ATTR_COLOR_TEMP: temp, + "color_temp": temp, "transition_time": transition_time, } temp_command = self._device_control.set_color_temp(**temp_data) @@ -210,7 +219,6 @@ class TradfriLight(TradfriBaseEntity, LightEntity): # Color bulb (CWS) # color_temp needs to be set with hue/saturation elif self._device_control.can_set_color: - temp_k = color_util.color_temperature_mired_to_kelvin(temp) hs_color = color_util.color_temperature_to_hs(temp_k) hue = int(hs_color[0] * (self._device_control.max_hue / 360)) sat = int(hs_color[1] * (self._device_control.max_saturation / 100)) diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index 614072cc706..fc5588f40ac 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -34,12 +34,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVCameraConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TVCameraConfigEntry) -> bool: """Unload Trafikverket Camera config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: TVCameraConfigEntry) -> bool: """Migrate old entry.""" api_key = entry.data[CONF_API_KEY] web_session = async_get_clientsession(hass) diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py index 1ae48732c88..ece02cacf70 100644 --- a/homeassistant/components/trafikverket_camera/camera.py +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -15,6 +15,8 @@ from .const import ATTR_DESCRIPTION, ATTR_TYPE from .coordinator import TVDataUpdateCoordinator from .entity import TrafikverketCameraEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/trafikverket_ferry/__init__.py b/homeassistant/components/trafikverket_ferry/__init__.py index dbcbc1a4aba..ac9b1bd95ae 100644 --- a/homeassistant/components/trafikverket_ferry/__init__.py +++ b/homeassistant/components/trafikverket_ferry/__init__.py @@ -14,7 +14,7 @@ TVFerryConfigEntry = ConfigEntry[TVDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: TVFerryConfigEntry) -> bool: """Set up Trafikverket Ferry from a config entry.""" - coordinator = TVDataUpdateCoordinator(hass) + coordinator = TVDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -22,6 +22,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVFerryConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TVFerryConfigEntry) -> bool: """Unload Trafikverket Ferry config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py index fdde6766185..59b6bb4aaa3 100644 --- a/homeassistant/components/trafikverket_ferry/coordinator.py +++ b/homeassistant/components/trafikverket_ferry/coordinator.py @@ -52,21 +52,22 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator): config_entry: TVFerryConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: TVFerryConfigEntry) -> None: """Initialize the Trafikverket coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=TIME_BETWEEN_UPDATES, ) self._ferry_api = TrafikverketFerry( - async_get_clientsession(hass), self.config_entry.data[CONF_API_KEY] + async_get_clientsession(hass), config_entry.data[CONF_API_KEY] ) - self._from: str = self.config_entry.data[CONF_FROM] - self._to: str = self.config_entry.data[CONF_TO] - self._time: time | None = dt_util.parse_time(self.config_entry.data[CONF_TIME]) - self._weekdays: list[str] = self.config_entry.data[CONF_WEEKDAY] + self._from: str = config_entry.data[CONF_FROM] + self._to: str = config_entry.data[CONF_TO] + self._time: time | None = dt_util.parse_time(config_entry.data[CONF_TIME]) + self._weekdays: list[str] = config_entry.data[CONF_WEEKDAY] async def _async_update_data(self) -> dict[str, Any]: """Fetch data from Trafikverket.""" diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index 5a13159ecfd..44176ab82b7 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -31,6 +31,8 @@ ATTR_OTHER_INFO = "other_info" SCAN_INTERVAL = timedelta(minutes=5) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class TrafikverketSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 23aee50d816..19f88817e71 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -4,11 +4,21 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from pytrafikverket import ( + InvalidAuthentication, + NoTrainStationFound, + TrafikverketTrain, + UnknownError, +) -from .const import PLATFORMS +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_FROM, CONF_TO, PLATFORMS from .coordinator import TVDataUpdateCoordinator TVTrainConfigEntry = ConfigEntry[TVDataUpdateCoordinator] @@ -19,7 +29,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool: """Set up Trafikverket Train from a config entry.""" - coordinator = TVDataUpdateCoordinator(hass) + coordinator = TVDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -37,13 +47,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool: """Unload Trafikverket Weatherstation config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: TVTrainConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) @@ -52,13 +62,55 @@ async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version > 1: + if entry.version > 2: # This means the user has downgraded from a future version return False - if entry.version == 1 and entry.minor_version == 1: - # Remove unique id - hass.config_entries.async_update_entry(entry, unique_id=None, minor_version=2) + if entry.version == 1: + if entry.minor_version == 1: + # Remove unique id + hass.config_entries.async_update_entry( + entry, unique_id=None, minor_version=2 + ) + + # Change from station names to station signatures + try: + web_session = async_get_clientsession(hass) + train_api = TrafikverketTrain(web_session, entry.data[CONF_API_KEY]) + from_stations = await train_api.async_search_train_stations( + entry.data[CONF_FROM] + ) + to_stations = await train_api.async_search_train_stations( + entry.data[CONF_TO] + ) + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except NoTrainStationFound as error: + _LOGGER.error( + "Migration failed as no train station found with provided name %s", + str(error), + ) + return False + except UnknownError as error: + _LOGGER.error("Unknown error occurred during validation %s", str(error)) + return False + except Exception as error: # noqa: BLE001 + _LOGGER.error("Unknown exception occurred during validation %s", str(error)) + return False + + if len(from_stations) > 1 or len(to_stations) > 1: + _LOGGER.error( + "Migration failed as more than one station found with provided name" + ) + return False + + new_data = entry.data.copy() + new_data[CONF_FROM] = from_stations[0].signature + new_data[CONF_TO] = to_stations[0].signature + + hass.config_entries.async_update_entry( + entry, data=new_data, version=2, minor_version=1 + ) _LOGGER.debug( "Migration to version %s.%s successful", diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 363b9bb2542..da1fb0f7622 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -3,21 +3,20 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import datetime import logging from typing import Any -from pytrafikverket import TrafikverketTrain -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, - MultipleTrainStationsFound, - NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrafikverketTrain, UnknownError, ) import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -28,16 +27,15 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, TextSelector, TimeSelector, ) -import homeassistant.util.dt as dt_util from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TIME, CONF_TO, DOMAIN -from .util import next_departuredate _LOGGER = logging.getLogger(__name__) @@ -68,49 +66,23 @@ DATA_SCHEMA_REAUTH = vol.Schema( ) -async def validate_input( +async def validate_station( hass: HomeAssistant, api_key: str, - train_from: str, - train_to: str, - train_time: str | None, - weekdays: list[str], - product_filter: str | None, -) -> dict[str, str]: + train_station: str, + field: str, +) -> tuple[list[StationInfoModel], dict[str, str]]: """Validate input from user input.""" errors: dict[str, str] = {} - - when = dt_util.now() - if train_time: - departure_day = next_departuredate(weekdays) - if _time := dt_util.parse_time(train_time): - when = datetime.combine( - departure_day, - _time, - dt_util.get_default_time_zone(), - ) - + stations = [] try: web_session = async_get_clientsession(hass) train_api = TrafikverketTrain(web_session, api_key) - from_station = await train_api.async_search_train_station(train_from) - to_station = await train_api.async_search_train_station(train_to) - if train_time: - await train_api.async_get_train_stop( - from_station, to_station, when, product_filter - ) - else: - await train_api.async_get_next_train_stop( - from_station, to_station, when, product_filter - ) + stations = await train_api.async_search_train_stations(train_station) except InvalidAuthentication: errors["base"] = "invalid_auth" except NoTrainStationFound: - errors["base"] = "invalid_station" - except MultipleTrainStationsFound: - errors["base"] = "more_stations" - except NoTrainAnnouncementFound: - errors["base"] = "no_trains" + errors[field] = "invalid_station" except UnknownError as error: _LOGGER.error("Unknown error occurred during validation %s", str(error)) errors["base"] = "cannot_connect" @@ -118,14 +90,18 @@ async def validate_input( _LOGGER.error("Unknown exception occurred during validation %s", str(error)) errors["base"] = "cannot_connect" - return errors + return (stations, errors) class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Train integration.""" - VERSION = 1 - MINOR_VERSION = 2 + VERSION = 2 + MINOR_VERSION = 1 + + _from_stations: list[StationInfoModel] + _to_stations: list[StationInfoModel] + _data: dict[str, Any] @staticmethod @callback @@ -151,14 +127,11 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] reauth_entry = self._get_reauth_entry() - errors = await validate_input( + _, errors = await validate_station( self.hass, api_key, reauth_entry.data[CONF_FROM], - reauth_entry.data[CONF_TO], - reauth_entry.data.get(CONF_TIME), - reauth_entry.data[CONF_WEEKDAY], - reauth_entry.options.get(CONF_FILTER_PRODUCT), + CONF_FROM, ) if not errors: return self.async_update_reload_and_abort( @@ -174,6 +147,18 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + return await self.async_step_initial(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + return await self.async_step_initial(user_input) + + async def async_step_initial( + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the user step.""" errors: dict[str, str] = {} @@ -193,27 +178,99 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): if train_time: name = f"{train_from} to {train_to} at {train_time}" - errors = await validate_input( - self.hass, - api_key, - train_from, - train_to, - train_time, - train_days, - filter_product, + self._from_stations, from_errors = await validate_station( + self.hass, api_key, train_from, CONF_FROM ) + self._to_stations, to_errors = await validate_station( + self.hass, api_key, train_to, CONF_TO + ) + errors = {**from_errors, **to_errors} + if not errors: - self._async_abort_entries_match( - { - CONF_API_KEY: api_key, - CONF_FROM: train_from, - CONF_TO: train_to, - CONF_TIME: train_time, - CONF_WEEKDAY: train_days, - CONF_FILTER_PRODUCT: filter_product, - } + if len(self._from_stations) == 1 and len(self._to_stations) == 1: + self._async_abort_entries_match( + { + CONF_API_KEY: api_key, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + CONF_FILTER_PRODUCT: filter_product, + } + ) + + if self.source == SOURCE_RECONFIGURE: + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) + return self.async_create_entry( + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) + self._data = user_input + return await self.async_step_select_stations() + + return self.async_show_form( + step_id="initial", + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, user_input or {} + ), + errors=errors, + ) + + async def async_step_select_stations( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the select station step.""" + if user_input is not None: + api_key: str = self._data[CONF_API_KEY] + train_from: str = user_input[CONF_FROM] + train_to: str = user_input[CONF_TO] + train_time: str | None = self._data.get(CONF_TIME) + train_days: list = self._data[CONF_WEEKDAY] + filter_product: str | None = self._data[CONF_FILTER_PRODUCT] + + if filter_product == "": + filter_product = None + + name = f"{self._data[CONF_FROM]} to {self._data[CONF_TO]}" + if train_time: + name = ( + f"{self._data[CONF_FROM]} to {self._data[CONF_TO]} at {train_time}" ) - return self.async_create_entry( + self._async_abort_entries_match( + { + CONF_API_KEY: api_key, + CONF_FROM: train_from, + CONF_TO: user_input[CONF_TO], + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + CONF_FILTER_PRODUCT: filter_product, + } + ) + if self.source == SOURCE_RECONFIGURE: + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, title=name, data={ CONF_API_KEY: api_key, @@ -225,13 +282,45 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): }, options={CONF_FILTER_PRODUCT: filter_product}, ) + return self.async_create_entry( + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: train_from, + CONF_TO: train_to, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) + from_options = [ + SelectOptionDict(value=station.signature, label=station.station_name) + for station in self._from_stations + ] + to_options = [ + SelectOptionDict(value=station.signature, label=station.station_name) + for station in self._to_stations + ] + schema = {} + if len(from_options) > 1: + schema[vol.Required(CONF_FROM)] = SelectSelector( + SelectSelectorConfig( + options=from_options, mode=SelectSelectorMode.DROPDOWN, sort=True + ) + ) + if len(to_options) > 1: + schema[vol.Required(CONF_TO)] = SelectSelector( + SelectSelectorConfig( + options=to_options, mode=SelectSelectorMode.DROPDOWN, sort=True + ) + ) return self.async_show_form( - step_id="user", + step_id="select_stations", data_schema=self.add_suggested_values_to_schema( - DATA_SCHEMA, user_input or {} + vol.Schema(schema), user_input or {} ), - errors=errors, ) diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index 49d4e1ded74..28c9ab6fe8e 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -7,15 +7,16 @@ from datetime import datetime, time, timedelta import logging from typing import TYPE_CHECKING -from pytrafikverket import TrafikverketTrain -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, MultipleTrainStationsFound, NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrafikverketTrain, + TrainStopModel, UnknownError, ) -from pytrafikverket.models import StationInfoModel, TrainStopModel from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY from homeassistant.core import HomeAssistant @@ -74,31 +75,34 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): from_station: StationInfoModel to_station: StationInfoModel - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: TVTrainConfigEntry) -> None: """Initialize the Trafikverket coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=TIME_BETWEEN_UPDATES, ) self._train_api = TrafikverketTrain( - async_get_clientsession(hass), self.config_entry.data[CONF_API_KEY] - ) - self._time: time | None = dt_util.parse_time(self.config_entry.data[CONF_TIME]) - self._weekdays: list[str] = self.config_entry.data[CONF_WEEKDAY] - self._filter_product: str | None = self.config_entry.options.get( - CONF_FILTER_PRODUCT + async_get_clientsession(hass), config_entry.data[CONF_API_KEY] ) + self._time: time | None = dt_util.parse_time(config_entry.data[CONF_TIME]) + self._weekdays: list[str] = config_entry.data[CONF_WEEKDAY] + self._filter_product: str | None = config_entry.options.get(CONF_FILTER_PRODUCT) async def _async_setup(self) -> None: """Initiate stations.""" try: - self.to_station = await self._train_api.async_search_train_station( - self.config_entry.data[CONF_TO] + self.to_station = ( + await self._train_api.async_get_train_station_from_signature( + self.config_entry.data[CONF_TO] + ) ) - self.from_station = await self._train_api.async_search_train_station( - self.config_entry.data[CONF_FROM] + self.from_station = ( + await self._train_api.async_get_train_station_from_signature( + self.config_entry.data[CONF_FROM] + ) ) except InvalidAuthentication as error: raise ConfigEntryAuthFailed from error diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index e5331a47d16..a4de8c1ef26 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -25,6 +25,8 @@ from .coordinator import TrainData, TVDataUpdateCoordinator ATTR_PRODUCT_FILTER = "product_filter" +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class TrafikverketSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 89542211a92..02155e46c2f 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -13,7 +14,7 @@ "incorrect_api_key": "Invalid API key for selected account" }, "step": { - "user": { + "initial": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "to": "To station", @@ -27,6 +28,13 @@ "filter_product": "To filter by product description add the phrase here to match" } }, + "select_stations": { + "description": "More than one station was found with the provided name, select the correct ones from the provided lists", + "data": { + "to": "To station", + "from": "From station" + } + }, "reauth_confirm": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" @@ -38,10 +46,10 @@ "step": { "init": { "data": { - "filter_product": "[%key:component::trafikverket_train::config::step::user::data::filter_product%]" + "filter_product": "[%key:component::trafikverket_train::config::step::initial::data::filter_product%]" }, "data_description": { - "filter_product": "[%key:component::trafikverket_train::config::step::user::data_description::filter_product%]" + "filter_product": "[%key:component::trafikverket_train::config::step::initial::data_description::filter_product%]" } } } diff --git a/homeassistant/components/trafikverket_weatherstation/__init__.py b/homeassistant/components/trafikverket_weatherstation/__init__.py index 1bd7fc69ae4..8fe67b5c66a 100644 --- a/homeassistant/components/trafikverket_weatherstation/__init__.py +++ b/homeassistant/components/trafikverket_weatherstation/__init__.py @@ -14,7 +14,7 @@ TVWeatherConfigEntry = ConfigEntry[TVDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: TVWeatherConfigEntry) -> bool: """Set up Trafikverket Weatherstation from a config entry.""" - coordinator = TVDataUpdateCoordinator(hass) + coordinator = TVDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -22,6 +22,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVWeatherConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TVWeatherConfigEntry) -> bool: """Unload Trafikverket Weatherstation config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/trafikverket_weatherstation/coordinator.py b/homeassistant/components/trafikverket_weatherstation/coordinator.py index 22ecf6fc1b5..33f09c0ffe2 100644 --- a/homeassistant/components/trafikverket_weatherstation/coordinator.py +++ b/homeassistant/components/trafikverket_weatherstation/coordinator.py @@ -34,18 +34,19 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[WeatherStationInfoModel]): config_entry: TVWeatherConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: TVWeatherConfigEntry) -> None: """Initialize the Sensibo coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=TIME_BETWEEN_UPDATES, ) self._weather_api = TrafikverketWeather( - async_get_clientsession(hass), self.config_entry.data[CONF_API_KEY] + async_get_clientsession(hass), config_entry.data[CONF_API_KEY] ) - self._station = self.config_entry.data[CONF_STATION] + self._station = config_entry.data[CONF_STATION] async def _async_update_data(self) -> WeatherStationInfoModel: """Fetch data from Trafikverket.""" diff --git a/homeassistant/components/trafikverket_weatherstation/diagnostics.py b/homeassistant/components/trafikverket_weatherstation/diagnostics.py new file mode 100644 index 00000000000..e70d60493f6 --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/diagnostics.py @@ -0,0 +1,17 @@ +"""Diagnostics support for Trafikverket Weatherstation.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import TVWeatherConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: TVWeatherConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Trafikverket Weatherstation config entry.""" + return asdict(entry.runtime_data.data) diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 22661426f00..bc17c82748a 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -42,6 +42,8 @@ PRECIPITATION_TYPE = [ "yes", ] +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class TrafikverketSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 1c108831acf..1a8ffdea0c2 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -42,6 +42,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_DELETE_DATA, + ATTR_DOWNLOAD_PATH, ATTR_TORRENT, CONF_ENTRY_ID, DEFAULT_DELETE_DATA, @@ -82,7 +83,12 @@ SERVICE_BASE_SCHEMA = vol.Schema( ) SERVICE_ADD_TORRENT_SCHEMA = vol.All( - SERVICE_BASE_SCHEMA.extend({vol.Required(ATTR_TORRENT): cv.string}), + SERVICE_BASE_SCHEMA.extend( + { + vol.Required(ATTR_TORRENT): cv.string, + vol.Optional(ATTR_DOWNLOAD_PATH, default=None): cv.string, + } + ), ) @@ -213,10 +219,18 @@ def setup_hass_services(hass: HomeAssistant) -> None: entry_id: str = service.data[CONF_ENTRY_ID] coordinator = _get_coordinator_from_service_data(hass, entry_id) torrent: str = service.data[ATTR_TORRENT] + download_path: str | None = service.data.get(ATTR_DOWNLOAD_PATH) if torrent.startswith( ("http", "ftp:", "magnet:") ) or hass.config.is_allowed_path(torrent): - await hass.async_add_executor_job(coordinator.api.add_torrent, torrent) + if download_path: + await hass.async_add_executor_job( + partial( + coordinator.api.add_torrent, torrent, download_dir=download_path + ) + ) + else: + await hass.async_add_executor_job(coordinator.api.add_torrent, torrent) await coordinator.async_request_refresh() else: _LOGGER.warning("Could not add torrent: unsupported type or no permission") diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 120918b24a2..c232f26cefd 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -40,6 +40,7 @@ STATE_ATTR_TORRENT_INFO = "torrent_info" ATTR_DELETE_DATA = "delete_data" ATTR_TORRENT = "torrent" +ATTR_DOWNLOAD_PATH = "download_path" SERVICE_ADD_TORRENT = "add_torrent" SERVICE_REMOVE_TORRENT = "remove_torrent" diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index e0930bd9e9e..b998ab6fbdd 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -102,7 +102,12 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): for torrent in current_completed_torrents: if torrent.id not in old_completed_torrents: self.hass.bus.fire( - EVENT_DOWNLOADED_TORRENT, {"name": torrent.name, "id": torrent.id} + EVENT_DOWNLOADED_TORRENT, + { + "name": torrent.name, + "id": torrent.id, + "download_path": torrent.download_dir, + }, ) self._completed_torrents = current_completed_torrents @@ -118,7 +123,12 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): for torrent in current_started_torrents: if torrent.id not in old_started_torrents: self.hass.bus.fire( - EVENT_STARTED_TORRENT, {"name": torrent.name, "id": torrent.id} + EVENT_STARTED_TORRENT, + { + "name": torrent.name, + "id": torrent.id, + "download_path": torrent.download_dir, + }, ) self._started_torrents = current_started_torrents @@ -130,7 +140,12 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): for torrent in self._all_torrents: if torrent.id not in current_torrents: self.hass.bus.fire( - EVENT_REMOVED_TORRENT, {"name": torrent.name, "id": torrent.id} + EVENT_REMOVED_TORRENT, + { + "name": torrent.name, + "id": torrent.id, + "download_path": torrent.download_dir, + }, ) self._all_torrents = self.torrents.copy() diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 737520adb5f..652f5d51fbb 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -83,7 +83,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( TransmissionSensorEntityDescription( key="active_torrents", translation_key="active_torrents", - native_unit_of_measurement="torrents", val_func=lambda coordinator: coordinator.data.active_torrent_count, extra_state_attr_func=lambda coordinator: _torrents_info_attr( coordinator=coordinator, key="active_torrents" @@ -92,7 +91,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( TransmissionSensorEntityDescription( key="paused_torrents", translation_key="paused_torrents", - native_unit_of_measurement="torrents", val_func=lambda coordinator: coordinator.data.paused_torrent_count, extra_state_attr_func=lambda coordinator: _torrents_info_attr( coordinator=coordinator, key="paused_torrents" @@ -101,7 +99,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( TransmissionSensorEntityDescription( key="total_torrents", translation_key="total_torrents", - native_unit_of_measurement="torrents", val_func=lambda coordinator: coordinator.data.torrent_count, extra_state_attr_func=lambda coordinator: _torrents_info_attr( coordinator=coordinator, key="total_torrents" @@ -110,7 +107,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( TransmissionSensorEntityDescription( key="completed_torrents", translation_key="completed_torrents", - native_unit_of_measurement="torrents", val_func=lambda coordinator: len( _filter_torrents(coordinator.torrents, MODES["completed_torrents"]) ), @@ -121,7 +117,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( TransmissionSensorEntityDescription( key="started_torrents", translation_key="started_torrents", - native_unit_of_measurement="torrents", val_func=lambda coordinator: len( _filter_torrents(coordinator.torrents, MODES["started_torrents"]) ), diff --git a/homeassistant/components/transmission/services.yaml b/homeassistant/components/transmission/services.yaml index 2d61bda442f..8f9aadd5009 100644 --- a/homeassistant/components/transmission/services.yaml +++ b/homeassistant/components/transmission/services.yaml @@ -9,6 +9,11 @@ add_torrent: example: http://releases.ubuntu.com/19.04/ubuntu-19.04-desktop-amd64.iso.torrent selector: text: + download_path: + required: false + example: "/path/to/download/directory" + selector: + text: remove_torrent: fields: diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 20ae6ca723d..0fe1953d31e 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up Transmission Client", + "title": "Set up Transmission client", "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", @@ -60,19 +60,24 @@ } }, "active_torrents": { - "name": "Active torrents" + "name": "Active torrents", + "unit_of_measurement": "torrents" }, "paused_torrents": { - "name": "Paused torrents" + "name": "Paused torrents", + "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" }, "total_torrents": { - "name": "Total torrents" + "name": "Total torrents", + "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" }, "completed_torrents": { - "name": "Completed torrents" + "name": "Completed torrents", + "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" }, "started_torrents": { - "name": "Started torrents" + "name": "Started torrents", + "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" } }, "switch": { @@ -91,11 +96,15 @@ "fields": { "entry_id": { "name": "Transmission entry", - "description": "Config entry id." + "description": "ID of the config entry to use." }, "torrent": { "name": "Torrent", "description": "URL, magnet link or Base64 encoded file." + }, + "download_path": { + "name": "Download path", + "description": "Optional path to specify where the torrent should be downloaded. If not specified, the default download directory is used." } } }, diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 681680f180f..9691ecf0744 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -227,10 +227,15 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): state = new_state.attributes.get(self._attribute) else: state = new_state.state - if state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + + if state in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self._attr_available = False + else: + self._attr_available = True sample = (new_state.last_updated.timestamp(), float(state)) # type: ignore[arg-type] self.samples.append(sample) - self.async_schedule_update_ha_state(True) + + self.async_schedule_update_ha_state(True) except (ValueError, TypeError) as ex: _LOGGER.error(ex) diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index d7981105fd2..16c7067c7ce 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.1.3"] + "requirements": ["numpy==2.2.2"] } diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index e7d1091719b..bbe4d334def 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -21,7 +21,7 @@ from typing import Any, Final, TypedDict, final from aiohttp import web import mutagen from mutagen.id3 import ID3, TextFrame as ID3Text -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import ffmpeg, websocket_api @@ -73,23 +73,23 @@ from .media_source import generate_media_source_id, media_source_id_to_kwargs from .models import Voice __all__ = [ + "ATTR_AUDIO_OUTPUT", + "ATTR_PREFERRED_FORMAT", + "ATTR_PREFERRED_SAMPLE_BYTES", + "ATTR_PREFERRED_SAMPLE_CHANNELS", + "ATTR_PREFERRED_SAMPLE_RATE", + "CONF_LANG", + "DEFAULT_CACHE_DIR", + "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", + "Provider", + "SampleFormat", + "TtsAudioType", + "Voice", "async_default_engine", "async_get_media_source_audio", "async_support_options", - "ATTR_AUDIO_OUTPUT", - "ATTR_PREFERRED_FORMAT", - "ATTR_PREFERRED_SAMPLE_RATE", - "ATTR_PREFERRED_SAMPLE_CHANNELS", - "ATTR_PREFERRED_SAMPLE_BYTES", - "CONF_LANG", - "DEFAULT_CACHE_DIR", "generate_media_source_id", - "PLATFORM_SCHEMA_BASE", - "PLATFORM_SCHEMA", - "SampleFormat", - "Provider", - "TtsAudioType", - "Voice", ] _LOGGER = logging.getLogger(__name__) @@ -1052,10 +1052,8 @@ class TextToSpeechUrlView(HomeAssistantView): data = await request.json() except ValueError: return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST) - if ( - not data.get("engine_id") - and not data.get(ATTR_PLATFORM) - or not data.get(ATTR_MESSAGE) + if (not data.get("engine_id") and not data.get(ATTR_PLATFORM)) or not data.get( + ATTR_MESSAGE ): return self.json_message( "Must specify platform and message", HTTPStatus.BAD_REQUEST diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 93aaaa40c26..1780256a740 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -77,6 +77,9 @@ CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { key="wkf", switch_only_hvac_mode=HVACMode.HEAT, ), + # Electric Fireplace + # https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop + "dbl": TuyaClimateEntityDescription(key="dbl", switch_only_hvac_mode=HVACMode.HEAT), } @@ -120,7 +123,6 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): _set_temperature: IntegerTypeData | None = None entity_description: TuyaClimateEntityDescription _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 4a6de1cae09..ffab9efdde8 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -66,7 +66,6 @@ class TuyaFanEntity(TuyaEntity, FanEntity): _speeds: EnumTypeData | None = None _switch: DPCode | None = None _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 060b1f4b7ef..d7dffc16b58 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -10,7 +10,7 @@ from tuya_sharing import CustomerDevice, Manager from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -21,6 +21,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode @@ -49,6 +50,9 @@ DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData( v_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1), ) +MAX_MIREDS = 500 # 2000 K +MIN_MIREDS = 153 # 6500 K + @dataclass(frozen=True) class TuyaLightEntityDescription(LightEntityDescription): @@ -457,6 +461,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): _color_mode: DPCode | None = None _color_temp: IntegerTypeData | None = None _fixed_color_mode: ColorMode | None = None + _attr_min_color_temp_kelvin = 2000 # 500 Mireds + _attr_max_color_temp_kelvin = 6500 # 153 Mireds def __init__( self, @@ -532,7 +538,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): """Turn on or control the light.""" commands = [{"code": self.entity_description.key, "value": True}] - if self._color_temp and ATTR_COLOR_TEMP in kwargs: + if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs: if self._color_mode_dpcode: commands += [ { @@ -546,9 +552,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity): "code": self._color_temp.dpcode, "value": round( self._color_temp.remap_value_from( - kwargs[ATTR_COLOR_TEMP], - self.min_mireds, - self.max_mireds, + color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ), + MIN_MIREDS, + MAX_MIREDS, reverse=True, ) ), @@ -560,7 +568,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): or ( ATTR_BRIGHTNESS in kwargs and self.color_mode == ColorMode.HS - and ATTR_COLOR_TEMP not in kwargs + and ATTR_COLOR_TEMP_KELVIN not in kwargs ) ): if self._color_mode_dpcode: @@ -688,8 +696,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): return round(brightness) @property - def color_temp(self) -> int | None: - """Return the color_temp of the light.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" if not self._color_temp: return None @@ -697,9 +705,9 @@ class TuyaLightEntity(TuyaEntity, LightEntity): if temperature is None: return None - return round( + return color_util.color_temperature_mired_to_kelvin( self._color_temp.remap_value_to( - temperature, self.min_mireds, self.max_mireds, reverse=True + temperature, MIN_MIREDS, MAX_MIREDS, reverse=True ) ) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index b53e6fa27d8..96ee50a38c9 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -1,7 +1,7 @@ { "domain": "tuya", "name": "Tuya", - "codeowners": ["@Tuya", "@zlinoliver", "@frenck"], + "codeowners": ["@Tuya", "@zlinoliver"], "config_flow": true, "dependencies": ["ffmpeg"], "dhcp": [ diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index d2e381d9982..8d5b5dbfa19 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -292,6 +292,17 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { device_class=NumberDeviceClass.TEMPERATURE, ), ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + NumberEntityDescription( + key=DPCode.ALARM_TIME, + translation_key="alarm_duration", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), } diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index abc5e4c496b..831d3cb3e0c 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -307,6 +307,15 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + SelectEntityDescription( + key=DPCode.ALARM_VOLUME, + translation_key="volume", + entity_category=EntityCategory.CONFIG, + ), + ), } # Socket (duplicate of `kg`) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index b9677037b7e..f766c744998 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -214,6 +214,12 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.PM25_VALUE, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), *BATTERY_SENSORS, ), # Two-way temperature and humidity switch @@ -254,6 +260,31 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { entity_registry_enabled_default=False, ), ), + # Single Phase power meter + # Note: Undocumented + "aqcz": ( + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + ), # CO Detector # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v "cobj": ( diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 334dced134d..6f7dfe4c96c 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -11,6 +11,7 @@ from homeassistant.components.siren import ( SirenEntityDescription, SirenEntityFeature, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -43,6 +44,14 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { key=DPCode.SIREN_SWITCH, ), ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + SirenEntityDescription( + key=DPCode.ALARM_SWITCH, + entity_category=EntityCategory.CONFIG, + ), + ), } diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 0f005821cbb..8ec61cc8aa5 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -119,6 +119,9 @@ } }, "number": { + "alarm_duration": { + "name": "Alarm duration" + }, "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 2e0a154e670..bab9ac309ec 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -7,13 +7,10 @@ from typing import Any from tuya_sharing import CustomerDevice, Manager from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) -from homeassistant.const import STATE_IDLE, STATE_PAUSED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,29 +21,29 @@ from .entity import EnumTypeData, IntegerTypeData, TuyaEntity TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { - "charge_done": STATE_DOCKED, - "chargecompleted": STATE_DOCKED, - "chargego": STATE_DOCKED, - "charging": STATE_DOCKED, - "cleaning": STATE_CLEANING, - "docking": STATE_RETURNING, - "goto_charge": STATE_RETURNING, - "goto_pos": STATE_CLEANING, - "mop_clean": STATE_CLEANING, - "part_clean": STATE_CLEANING, - "paused": STATE_PAUSED, - "pick_zone_clean": STATE_CLEANING, - "pos_arrived": STATE_CLEANING, - "pos_unarrive": STATE_CLEANING, - "random": STATE_CLEANING, - "sleep": STATE_IDLE, - "smart_clean": STATE_CLEANING, - "smart": STATE_CLEANING, - "spot_clean": STATE_CLEANING, - "standby": STATE_IDLE, - "wall_clean": STATE_CLEANING, - "wall_follow": STATE_CLEANING, - "zone_clean": STATE_CLEANING, + "charge_done": VacuumActivity.DOCKED, + "chargecompleted": VacuumActivity.DOCKED, + "chargego": VacuumActivity.DOCKED, + "charging": VacuumActivity.DOCKED, + "cleaning": VacuumActivity.CLEANING, + "docking": VacuumActivity.RETURNING, + "goto_charge": VacuumActivity.RETURNING, + "goto_pos": VacuumActivity.CLEANING, + "mop_clean": VacuumActivity.CLEANING, + "part_clean": VacuumActivity.CLEANING, + "paused": VacuumActivity.PAUSED, + "pick_zone_clean": VacuumActivity.CLEANING, + "pos_arrived": VacuumActivity.CLEANING, + "pos_unarrive": VacuumActivity.CLEANING, + "random": VacuumActivity.CLEANING, + "sleep": VacuumActivity.IDLE, + "smart_clean": VacuumActivity.CLEANING, + "smart": VacuumActivity.CLEANING, + "spot_clean": VacuumActivity.CLEANING, + "standby": VacuumActivity.IDLE, + "wall_clean": VacuumActivity.CLEANING, + "wall_follow": VacuumActivity.CLEANING, + "zone_clean": VacuumActivity.CLEANING, } @@ -92,9 +89,8 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): if self.find_dpcode(DPCode.PAUSE, prefer_function=True): self._attr_supported_features |= VacuumEntityFeature.PAUSE - if ( - self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) - or ( + if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) or ( + ( enum_type := self.find_dpcode( DPCode.MODE, dptype=DPType.ENUM, prefer_function=True ) @@ -137,12 +133,12 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): return self.device.status.get(DPCode.SUCTION) @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Return Tuya vacuum device state.""" if self.device.status.get(DPCode.PAUSE) and not ( self.device.status.get(DPCode.STATUS) ): - return STATE_PAUSED + return VacuumActivity.PAUSED if not (status := self.device.status.get(DPCode.STATUS)): return None return TUYA_STATUS_TO_HA.get(status) diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py index 0a2fb50c7c4..1359e707601 100644 --- a/homeassistant/components/twentemilieu/__init__.py +++ b/homeassistant/components/twentemilieu/__init__.py @@ -2,51 +2,25 @@ from __future__ import annotations -from datetime import date, timedelta - -from twentemilieu import TwenteMilieu, WasteType import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_HOUSE_LETTER, CONF_HOUSE_NUMBER, CONF_POST_CODE, DOMAIN, LOGGER - -SCAN_INTERVAL = timedelta(seconds=3600) +from .coordinator import TwenteMilieuConfigEntry, TwenteMilieuDataUpdateCoordinator SERVICE_UPDATE = "update" SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_ID): cv.string}) PLATFORMS = [Platform.CALENDAR, Platform.SENSOR] -type TwenteMilieuDataUpdateCoordinator = DataUpdateCoordinator[ - dict[WasteType, list[date]] -] -type TwenteMilieuConfigEntry = ConfigEntry[TwenteMilieuDataUpdateCoordinator] - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: TwenteMilieuConfigEntry +) -> bool: """Set up Twente Milieu from a config entry.""" - session = async_get_clientsession(hass) - twentemilieu = TwenteMilieu( - post_code=entry.data[CONF_POST_CODE], - house_number=entry.data[CONF_HOUSE_NUMBER], - house_letter=entry.data[CONF_HOUSE_LETTER], - session=session, - ) - - coordinator: TwenteMilieuDataUpdateCoordinator = DataUpdateCoordinator( - hass, - LOGGER, - config_entry=entry, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - update_method=twentemilieu.update, - ) + coordinator = TwenteMilieuDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -55,6 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: TwenteMilieuConfigEntry +) -> bool: """Unload Twente Milieu config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index 8e7452823b7..69c509b9edf 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import TwenteMilieuConfigEntry from .const import WASTE_TYPE_TO_DESCRIPTION +from .coordinator import TwenteMilieuConfigEntry from .entity import TwenteMilieuEntity @@ -70,8 +70,7 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): waste_dates and ( next_waste_pickup_date is None - or waste_dates[0] # type: ignore[unreachable] - < next_waste_pickup_date + or waste_dates[0] < next_waste_pickup_date ) and waste_dates[0] >= dt_util.now().date() ): diff --git a/homeassistant/components/twentemilieu/coordinator.py b/homeassistant/components/twentemilieu/coordinator.py new file mode 100644 index 00000000000..d2cf5a887ef --- /dev/null +++ b/homeassistant/components/twentemilieu/coordinator.py @@ -0,0 +1,49 @@ +"""Data update coordinator for Twente Milieu.""" + +from __future__ import annotations + +from datetime import date + +from twentemilieu import TwenteMilieu, WasteType + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONF_HOUSE_LETTER, + CONF_HOUSE_NUMBER, + CONF_POST_CODE, + DOMAIN, + LOGGER, + SCAN_INTERVAL, +) + +type TwenteMilieuConfigEntry = ConfigEntry[TwenteMilieuDataUpdateCoordinator] + + +class TwenteMilieuDataUpdateCoordinator( + DataUpdateCoordinator[dict[WasteType, list[date]]] +): + """Class to manage fetching Twente Milieu data.""" + + def __init__(self, hass: HomeAssistant, entry: TwenteMilieuConfigEntry) -> None: + """Initialize Twente Milieu data update coordinator.""" + self.twentemilieu = TwenteMilieu( + post_code=entry.data[CONF_POST_CODE], + house_number=entry.data[CONF_HOUSE_NUMBER], + house_letter=entry.data[CONF_HOUSE_LETTER], + session=async_get_clientsession(hass), + ) + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + + async def _async_update_data(self) -> dict[WasteType, list[date]]: + """Fetch Twente Milieu data.""" + return await self.twentemilieu.update() diff --git a/homeassistant/components/twentemilieu/diagnostics.py b/homeassistant/components/twentemilieu/diagnostics.py index 9de3f9bfaff..cb3b411c530 100644 --- a/homeassistant/components/twentemilieu/diagnostics.py +++ b/homeassistant/components/twentemilieu/diagnostics.py @@ -4,12 +4,13 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from .coordinator import TwenteMilieuConfigEntry + async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: TwenteMilieuConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return { diff --git a/homeassistant/components/twentemilieu/entity.py b/homeassistant/components/twentemilieu/entity.py index 896a8e32de9..660dd16288c 100644 --- a/homeassistant/components/twentemilieu/entity.py +++ b/homeassistant/components/twentemilieu/entity.py @@ -2,14 +2,13 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TwenteMilieuDataUpdateCoordinator from .const import DOMAIN +from .coordinator import TwenteMilieuConfigEntry, TwenteMilieuDataUpdateCoordinator class TwenteMilieuEntity(CoordinatorEntity[TwenteMilieuDataUpdateCoordinator], Entity): @@ -17,7 +16,7 @@ class TwenteMilieuEntity(CoordinatorEntity[TwenteMilieuDataUpdateCoordinator], E _attr_has_entity_name = True - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: TwenteMilieuConfigEntry) -> None: """Initialize the Twente Milieu entity.""" super().__init__(coordinator=entry.runtime_data) self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index a89091948c2..b1cb98dbca6 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["twentemilieu"], - "requirements": ["twentemilieu==2.1.0"] + "quality_scale": "silver", + "requirements": ["twentemilieu==2.2.1"] } diff --git a/homeassistant/components/twentemilieu/quality_scale.yaml b/homeassistant/components/twentemilieu/quality_scale.yaml index f8fd813b03d..42ff152cb4d 100644 --- a/homeassistant/components/twentemilieu/quality_scale.yaml +++ b/homeassistant/components/twentemilieu/quality_scale.yaml @@ -6,26 +6,17 @@ rules: This integration does not provide additional actions. appropriate-polling: done brands: done - common-modules: - status: todo - comment: | - The coordinator isn't in the common module yet. + common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - data_description's are missing. + config-flow: done dependency-transparency: done docs-actions: status: exempt comment: | This integration does not provide additional actions. - docs-high-level-description: - status: todo - comment: | - The introduction can be improved and is missing links to the provider. + docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -57,7 +48,7 @@ rules: data), there is no need to implement parallel updates. test-coverage: done integration-owner: done - docs-installation-parameters: todo + docs-installation-parameters: done docs-configuration-parameters: status: exempt comment: | @@ -101,16 +92,16 @@ rules: status: exempt comment: | This integration doesn't have any cases where raising an issue is needed. - docs-use-cases: todo + docs-use-cases: done docs-supported-devices: status: exempt comment: | This is an service, which doesn't integrate with any devices. docs-supported-functions: done - docs-data-update: todo - docs-known-limitations: todo - docs-troubleshooting: todo - docs-examples: todo + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done # Platinum async-dependency: done diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index 2d2e3de0f0e..4605ede1f87 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -12,12 +12,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +from .coordinator import TwenteMilieuConfigEntry from .entity import TwenteMilieuEntity @@ -64,7 +64,7 @@ SENSORS: tuple[TwenteMilieuSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TwenteMilieuConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Twente Milieu sensor based on a config entry.""" @@ -80,7 +80,7 @@ class TwenteMilieuSensor(TwenteMilieuEntity, SensorEntity): def __init__( self, - entry: ConfigEntry, + entry: TwenteMilieuConfigEntry, description: TwenteMilieuSensorDescription, ) -> None: """Initialize the Twente Milieu entity.""" diff --git a/homeassistant/components/twentemilieu/strings.json b/homeassistant/components/twentemilieu/strings.json index 7797167ea0b..5c40df1b0c2 100644 --- a/homeassistant/components/twentemilieu/strings.json +++ b/homeassistant/components/twentemilieu/strings.json @@ -7,6 +7,11 @@ "post_code": "Postal code", "house_number": "House number", "house_letter": "House letter/additional" + }, + "data_description": { + "post_code": "The postal code of the address, for example 7500AA", + "house_number": "The house number of the address", + "house_letter": "The house letter or additional information of the address" } } }, diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index b09e58ff12f..cd29ffaf423 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -1,52 +1,83 @@ """The twinkly component.""" +import logging + from aiohttp import ClientError from ttls.client import Twinkly from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SW_VERSION, CONF_HOST, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ATTR_VERSION, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN +from .const import DOMAIN +from .coordinator import TwinklyCoordinator -PLATFORMS = [Platform.LIGHT] +PLATFORMS = [Platform.LIGHT, Platform.SELECT] + +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type TwinklyConfigEntry = ConfigEntry[TwinklyCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> bool: """Set up entries from config flow.""" - hass.data.setdefault(DOMAIN, {}) - # We setup the client here so if at some point we add any other entity for this device, # we will be able to properly share the connection. host = entry.data[CONF_HOST] - hass.data[DOMAIN].setdefault(entry.entry_id, {}) - client = Twinkly(host, async_get_clientsession(hass)) - try: - device_info = await client.get_details() - software_version = await client.get_firmware_version() - except (TimeoutError, ClientError) as exception: - raise ConfigEntryNotReady from exception + coordinator = TwinklyCoordinator(hass, client) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator - hass.data[DOMAIN][entry.entry_id] = { - DATA_CLIENT: client, - DATA_DEVICE_INFO: device_info, - ATTR_SW_VERSION: software_version.get(ATTR_VERSION), - } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> bool: """Remove a twinkly entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unload_ok + +async def async_migrate_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> bool: + """Migrate old entry.""" + if entry.minor_version == 1: + client = Twinkly(entry.data[CONF_HOST], async_get_clientsession(hass)) + try: + device_info = await client.get_details() + except (TimeoutError, ClientError) as exception: + _LOGGER.error("Error while migrating: %s", exception) + return False + identifier = entry.unique_id + assert identifier is not None + entity_registry = er.async_get(hass) + entity_id = entity_registry.async_get_entity_id("light", DOMAIN, identifier) + if entity_id: + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + entity_registry.async_update_entity( + entity_entry.entity_id, new_unique_id=device_info["mac"] + ) + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, identifier)} + ) + if device_entry: + device_registry.async_update_device( + device_entry.id, new_identifiers={(DOMAIN, device_info["mac"])} + ) + hass.config_entries.async_update_entry( + entry, + unique_id=device_info["mac"], + minor_version=2, + ) + + return True diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index 68c455dc619..0f2f87302af 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -9,10 +9,10 @@ from aiohttp import ClientError from ttls.client import Twinkly from voluptuous import Required, Schema -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN @@ -23,6 +23,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle twinkly config flow.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" @@ -45,7 +46,9 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN): except (TimeoutError, ClientError): errors[CONF_HOST] = "cannot_connect" else: - await self.async_set_unique_id(device_info[DEV_ID]) + await self.async_set_unique_id( + device_info["mac"], raise_on_progress=False + ) self._abort_if_unique_id_configured() return self._create_entry_from_device(device_info, host) @@ -55,14 +58,14 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery for twinkly.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) device_info = await Twinkly( discovery_info.ip, async_get_clientsession(self.hass) ).get_details() - await self.async_set_unique_id(device_info[DEV_ID]) + await self.async_set_unique_id(device_info["mac"]) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) self._discovered_device = (device_info, discovery_info.ip) @@ -77,6 +80,9 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN): return self._create_entry_from_device(device_info, host) self._set_confirm_only() + self.context["title_placeholders"] = { + "name": device_info[DEV_NAME], + } placeholders = { "model": device_info[DEV_MODEL], "name": device_info[DEV_NAME], diff --git a/homeassistant/components/twinkly/const.py b/homeassistant/components/twinkly/const.py index f33024ed156..488b213b895 100644 --- a/homeassistant/components/twinkly/const.py +++ b/homeassistant/components/twinkly/const.py @@ -15,8 +15,5 @@ DEV_LED_PROFILE = "led_profile" DEV_PROFILE_RGB = "RGB" DEV_PROFILE_RGBW = "RGBW" -DATA_CLIENT = "client" -DATA_DEVICE_INFO = "device_info" - # Minimum version required to support effects MIN_EFFECT_VERSION = "2.7.1" diff --git a/homeassistant/components/twinkly/coordinator.py b/homeassistant/components/twinkly/coordinator.py new file mode 100644 index 00000000000..627fb0b39ba --- /dev/null +++ b/homeassistant/components/twinkly/coordinator.py @@ -0,0 +1,106 @@ +"""Coordinator for Twinkly.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from aiohttp import ClientError +from awesomeversion import AwesomeVersion +from ttls.client import Twinkly, TwinklyError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEV_NAME, DOMAIN, MIN_EFFECT_VERSION + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class TwinklyData: + """Class for Twinkly data.""" + + device_info: dict[str, Any] + brightness: int + is_on: bool + movies: dict[int, str] + current_movie: int | None + current_mode: str | None + + +class TwinklyCoordinator(DataUpdateCoordinator[TwinklyData]): + """Class to manage fetching Twinkly data from API.""" + + software_version: str + supports_effects: bool + device_name: str + + def __init__(self, hass: HomeAssistant, client: Twinkly) -> None: + """Initialize global Twinkly data updater.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.client = client + + async def _async_setup(self) -> None: + """Set up the Twinkly data.""" + try: + software_version = await self.client.get_firmware_version() + self.device_name = (await self.client.get_details())[DEV_NAME] + except (TimeoutError, ClientError) as exception: + raise UpdateFailed from exception + self.software_version = software_version["version"] + self.supports_effects = AwesomeVersion(self.software_version) >= AwesomeVersion( + MIN_EFFECT_VERSION + ) + + async def _async_update_data(self) -> TwinklyData: + """Fetch data from Twinkly.""" + movies: list[dict[str, Any]] = [] + current_movie: dict[str, Any] = {} + try: + device_info = await self.client.get_details() + brightness = await self.client.get_brightness() + is_on = await self.client.is_on() + mode_data = await self.client.get_mode() + current_mode = mode_data.get("mode") + if self.supports_effects: + movies = (await self.client.get_saved_movies())["movies"] + except (TimeoutError, ClientError) as exception: + raise UpdateFailed from exception + if self.supports_effects: + try: + current_movie = await self.client.get_current_movie() + except (TwinklyError, TimeoutError, ClientError) as exception: + _LOGGER.debug("Error fetching current movie: %s", exception) + brightness = ( + int(brightness["value"]) if brightness["mode"] == "enabled" else 100 + ) + brightness = int(round(brightness * 2.55)) if is_on else 0 + if self.device_name != device_info[DEV_NAME]: + self._async_update_device_info(device_info[DEV_NAME]) + return TwinklyData( + device_info, + brightness, + is_on, + {movie["id"]: movie["name"] for movie in movies}, + current_movie.get("id"), + current_mode, + ) + + def _async_update_device_info(self, name: str) -> None: + """Update the device info.""" + device_registry = dr.async_get(self.hass) + device = device_registry.async_get_device( + identifiers={(DOMAIN, self.data.device_info["mac"])}, + ) + if device: + device_registry.async_update_device( + device.id, + name=name, + ) diff --git a/homeassistant/components/twinkly/diagnostics.py b/homeassistant/components/twinkly/diagnostics.py index e188e92ecd5..d732ce14929 100644 --- a/homeassistant/components/twinkly/diagnostics.py +++ b/homeassistant/components/twinkly/diagnostics.py @@ -6,18 +6,18 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SW_VERSION, CONF_HOST, CONF_IP_ADDRESS, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import DATA_DEVICE_INFO, DOMAIN +from . import TwinklyConfigEntry +from .const import DOMAIN TO_REDACT = [CONF_HOST, CONF_IP_ADDRESS, CONF_MAC] async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: TwinklyConfigEntry ) -> dict[str, Any]: """Return diagnostics for a Twinkly config entry.""" attributes = None @@ -34,8 +34,8 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { "entry": entry.as_dict(), - "device_info": hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_INFO], - ATTR_SW_VERSION: hass.data[DOMAIN][entry.entry_id][ATTR_SW_VERSION], + "device_info": entry.runtime_data.data.device_info, + ATTR_SW_VERSION: entry.runtime_data.software_version, "attributes": attributes, }, TO_REDACT, diff --git a/homeassistant/components/twinkly/entity.py b/homeassistant/components/twinkly/entity.py new file mode 100644 index 00000000000..0a0f321bb17 --- /dev/null +++ b/homeassistant/components/twinkly/entity.py @@ -0,0 +1,27 @@ +"""Base entity for Twinkly.""" + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEV_MODEL, DEV_NAME, DOMAIN +from .coordinator import TwinklyCoordinator + + +class TwinklyEntity(CoordinatorEntity[TwinklyCoordinator]): + """Defines a base Twinkly entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: TwinklyCoordinator) -> None: + """Initialize.""" + super().__init__(coordinator) + device_info = coordinator.data.device_info + mac = device_info["mac"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mac)}, + connections={(CONNECTION_NETWORK_MAC, mac)}, + manufacturer="LEDWORKS", + model=device_info[DEV_MODEL], + name=device_info[DEV_NAME], + sw_version=coordinator.software_version, + ) diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 6f6dffe63d2..de55aa5f217 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -5,10 +5,6 @@ from __future__ import annotations import logging from typing import Any -from aiohttp import ClientError -from awesomeversion import AwesomeVersion -from ttls.client import Twinkly - from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, @@ -18,67 +14,38 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_SW_VERSION, - CONF_HOST, - CONF_ID, - CONF_MODEL, - CONF_NAME, -) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - DATA_CLIENT, - DATA_DEVICE_INFO, - DEV_LED_PROFILE, - DEV_MODEL, - DEV_NAME, - DEV_PROFILE_RGB, - DEV_PROFILE_RGBW, - DOMAIN, - MIN_EFFECT_VERSION, -) +from . import TwinklyConfigEntry, TwinklyCoordinator +from .const import DEV_LED_PROFILE, DEV_PROFILE_RGB, DEV_PROFILE_RGBW +from .entity import TwinklyEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TwinklyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Setups an entity from a config entry (UI config flow).""" - - client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] - device_info = hass.data[DOMAIN][config_entry.entry_id][DATA_DEVICE_INFO] - software_version = hass.data[DOMAIN][config_entry.entry_id][ATTR_SW_VERSION] - - entity = TwinklyLight(config_entry, client, device_info, software_version) + entity = TwinklyLight(config_entry.runtime_data) async_add_entities([entity], update_before_add=True) -class TwinklyLight(LightEntity): +class TwinklyLight(TwinklyEntity, LightEntity): """Implementation of the light for the Twinkly service.""" - _attr_has_entity_name = True _attr_name = None _attr_translation_key = "light" - def __init__( - self, - conf: ConfigEntry, - client: Twinkly, - device_info, - software_version: str | None = None, - ) -> None: + def __init__(self, coordinator: TwinklyCoordinator) -> None: """Initialize a TwinklyLight entity.""" - self._attr_unique_id: str = conf.data[CONF_ID] - self._conf = conf + super().__init__(coordinator) + device_info = coordinator.data.device_info + self._attr_unique_id = device_info["mac"] if device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGBW: self._attr_supported_color_modes = {ColorMode.RGBW} @@ -91,64 +58,27 @@ class TwinklyLight(LightEntity): else: self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._attr_color_mode = ColorMode.BRIGHTNESS - - # Those are saved in the config entry in order to have meaningful values even - # if the device is currently offline. - # They are expected to be updated using the device_info. - self._name = conf.data[CONF_NAME] or "Twinkly light" - self._model = conf.data[CONF_MODEL] - - self._client = client - - # Set default state before any update - self._attr_is_on = False - self._attr_available = False - self._current_movie: dict[Any, Any] = {} - self._movies: list[Any] = [] - self._software_version = software_version - # We guess that most devices are "new" and support effects - self._attr_supported_features = LightEntityFeature.EFFECT - - @property - def device_info(self) -> DeviceInfo | None: - """Get device specific attributes.""" - return DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer="LEDWORKS", - model=self._model, - name=self._name, - sw_version=self._software_version, - ) + self.client = coordinator.client + if coordinator.supports_effects: + self._attr_supported_features = LightEntityFeature.EFFECT + self._update_attr() @property def effect(self) -> str | None: """Return the current effect.""" - if "name" in self._current_movie: - return f"{self._current_movie['id']} {self._current_movie['name']}" + if (current_movie_id := self.coordinator.data.current_movie) is not None: + return ( + f"{current_movie_id} {self.coordinator.data.movies[current_movie_id]}" + ) return None @property def effect_list(self) -> list[str]: """Return the list of saved effects.""" - return [f"{movie['id']} {movie['name']}" for movie in self._movies] - - async def async_added_to_hass(self) -> None: - """Device is added to hass.""" - if self._software_version: - if AwesomeVersion(self._software_version) < AwesomeVersion( - MIN_EFFECT_VERSION - ): - self._attr_supported_features = ( - self.supported_features & ~LightEntityFeature.EFFECT - ) - device_registry = dr.async_get(self.hass) - device_entry = device_registry.async_get_device( - {(DOMAIN, self._attr_unique_id)}, set() - ) - if device_entry: - device_registry.async_update_device( - device_entry.id, sw_version=self._software_version - ) + return [ + f"{identifier} {name}" + for identifier, name in self.coordinator.data.movies.items() + ] async def async_turn_on(self, **kwargs: Any) -> None: """Turn device on.""" @@ -158,29 +88,29 @@ class TwinklyLight(LightEntity): # If brightness is 0, the twinkly will only "disable" the brightness, # which means that it will be 100%. if brightness == 0: - await self._client.turn_off() + await self.client.turn_off() return - await self._client.set_brightness(brightness) + await self.client.set_brightness(brightness) if ( ATTR_RGBW_COLOR in kwargs and kwargs[ATTR_RGBW_COLOR] != self._attr_rgbw_color ): - await self._client.interview() + await self.client.interview() if LightEntityFeature.EFFECT & self.supported_features: # Static color only supports rgb - await self._client.set_static_colour( + await self.client.set_static_colour( ( kwargs[ATTR_RGBW_COLOR][0], kwargs[ATTR_RGBW_COLOR][1], kwargs[ATTR_RGBW_COLOR][2], ) ) - await self._client.set_mode("color") - self._client.default_mode = "color" + await self.client.set_mode("color") + self.client.default_mode = "color" else: - await self._client.set_cycle_colours( + await self.client.set_cycle_colours( ( kwargs[ATTR_RGBW_COLOR][3], kwargs[ATTR_RGBW_COLOR][0], @@ -188,20 +118,20 @@ class TwinklyLight(LightEntity): kwargs[ATTR_RGBW_COLOR][2], ) ) - await self._client.set_mode("movie") - self._client.default_mode = "movie" + await self.client.set_mode("movie") + self.client.default_mode = "movie" self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR] if ATTR_RGB_COLOR in kwargs and kwargs[ATTR_RGB_COLOR] != self._attr_rgb_color: - await self._client.interview() + await self.client.interview() if LightEntityFeature.EFFECT & self.supported_features: - await self._client.set_static_colour(kwargs[ATTR_RGB_COLOR]) - await self._client.set_mode("color") - self._client.default_mode = "color" + await self.client.set_static_colour(kwargs[ATTR_RGB_COLOR]) + await self.client.set_mode("color") + self.client.default_mode = "color" else: - await self._client.set_cycle_colours(kwargs[ATTR_RGB_COLOR]) - await self._client.set_mode("movie") - self._client.default_mode = "movie" + await self.client.set_cycle_colours(kwargs[ATTR_RGB_COLOR]) + await self.client.set_mode("movie") + self.client.default_mode = "movie" self._attr_rgb_color = kwargs[ATTR_RGB_COLOR] @@ -210,100 +140,29 @@ class TwinklyLight(LightEntity): and LightEntityFeature.EFFECT & self.supported_features ): movie_id = kwargs[ATTR_EFFECT].split(" ")[0] - if "id" not in self._current_movie or int(movie_id) != int( - self._current_movie["id"] + if ( + self.coordinator.data.current_movie is None + or int(movie_id) != self.coordinator.data.current_movie ): - await self._client.interview() - await self._client.set_current_movie(int(movie_id)) - await self._client.set_mode("movie") - self._client.default_mode = "movie" + await self.client.interview() + await self.client.set_current_movie(int(movie_id)) + await self.client.set_mode("movie") + self.client.default_mode = "movie" if not self._attr_is_on: - await self._client.turn_on() + await self.client.turn_on() + await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn device off.""" - await self._client.turn_off() + await self.client.turn_off() + await self.coordinator.async_refresh() - async def async_update(self) -> None: - """Asynchronously updates the device properties.""" - _LOGGER.debug("Updating '%s'", self._client.host) + def _update_attr(self) -> None: + """Update the entity attributes.""" + self._attr_is_on = self.coordinator.data.is_on + self._attr_brightness = self.coordinator.data.brightness - try: - self._attr_is_on = await self._client.is_on() - - brightness = await self._client.get_brightness() - brightness_value = ( - int(brightness["value"]) if brightness["mode"] == "enabled" else 100 - ) - - self._attr_brightness = ( - int(round(brightness_value * 2.55)) if self._attr_is_on else 0 - ) - - device_info = await self._client.get_details() - - if ( - DEV_NAME in device_info - and DEV_MODEL in device_info - and ( - device_info[DEV_NAME] != self._name - or device_info[DEV_MODEL] != self._model - ) - ): - self._name = device_info[DEV_NAME] - self._model = device_info[DEV_MODEL] - - # If the name has changed, persist it in conf entry, - # so we will be able to restore this new name if hass - # is started while the LED string is offline. - self.hass.config_entries.async_update_entry( - self._conf, - data={ - CONF_HOST: self._client.host, # this cannot change - CONF_ID: self._attr_unique_id, # this cannot change - CONF_NAME: self._name, - CONF_MODEL: self._model, - }, - ) - - device_registry = dr.async_get(self.hass) - device_entry = device_registry.async_get_device( - {(DOMAIN, self._attr_unique_id)} - ) - if device_entry: - device_registry.async_update_device( - device_entry.id, name=self._name, model=self._model - ) - - if LightEntityFeature.EFFECT & self.supported_features: - await self.async_update_movies() - await self.async_update_current_movie() - - if not self._attr_available: - _LOGGER.warning("Twinkly '%s' is now available", self._client.host) - - # We don't use the echo API to track the availability since - # we already have to pull the device to get its state. - self._attr_available = True - except (TimeoutError, ClientError): - # We log this as "info" as it's pretty common that the Christmas - # light are not reachable in July - if self._attr_available: - _LOGGER.warning( - "Twinkly '%s' is not reachable (client error)", self._client.host - ) - self._attr_available = False - - async def async_update_movies(self) -> None: - """Update the list of movies (effects).""" - movies = await self._client.get_saved_movies() - _LOGGER.debug("Movies: %s", movies) - if movies and "movies" in movies: - self._movies = movies["movies"] - - async def async_update_current_movie(self) -> None: - """Update the current active movie.""" - current_movie = await self._client.get_current_movie() - _LOGGER.debug("Current movie: %s", current_movie) - if current_movie and "id" in current_movie: - self._current_movie = current_movie + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr() + super()._handle_coordinator_update() diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py new file mode 100644 index 00000000000..38e5c9a6fc7 --- /dev/null +++ b/homeassistant/components/twinkly/select.py @@ -0,0 +1,49 @@ +"""The Twinkly select component.""" + +from __future__ import annotations + +import logging + +from ttls.client import TWINKLY_MODES + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TwinklyConfigEntry, TwinklyCoordinator +from .entity import TwinklyEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TwinklyConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a mode select from a config entry.""" + entity = TwinklyModeSelect(config_entry.runtime_data) + async_add_entities([entity], update_before_add=True) + + +class TwinklyModeSelect(TwinklyEntity, SelectEntity): + """Twinkly Mode Selection.""" + + _attr_name = "Mode" + _attr_options = TWINKLY_MODES + + def __init__(self, coordinator: TwinklyCoordinator) -> None: + """Initialize TwinklyModeSelect.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data.device_info['mac']}_mode" + self.client = coordinator.client + + @property + def current_option(self) -> str | None: + """Return current mode.""" + return self.coordinator.data.current_mode + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.client.set_mode(option) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index 88bc67abbbd..bbc3d67373d 100644 --- a/homeassistant/components/twinkly/strings.json +++ b/homeassistant/components/twinkly/strings.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{name}", "step": { "user": { "data": { @@ -17,7 +18,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "device_exists": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } } diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py index 6979a016447..22a1782f594 100644 --- a/homeassistant/components/twitch/__init__.py +++ b/homeassistant/components/twitch/__init__.py @@ -7,7 +7,6 @@ from typing import cast from aiohttp.client_exceptions import ClientError, ClientResponseError from twitchAPI.twitch import Twitch -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -17,11 +16,11 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( async_get_config_entry_implementation, ) -from .const import DOMAIN, OAUTH_SCOPES, PLATFORMS -from .coordinator import TwitchCoordinator +from .const import OAUTH_SCOPES, PLATFORMS +from .coordinator import TwitchConfigEntry, TwitchCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TwitchConfigEntry) -> bool: """Set up Twitch from a config entry.""" implementation = cast( LocalOAuth2Implementation, @@ -47,18 +46,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client.auto_refresh_auth = False await client.set_user_authentication(access_token, scope=OAUTH_SCOPES) - coordinator = TwitchCoordinator(hass, client, session) - + coordinator = TwitchCoordinator(hass, client, session, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TwitchConfigEntry) -> bool: """Unload Twitch config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/twitch/coordinator.py b/homeassistant/components/twitch/coordinator.py index c34eeaa5325..010a9e90ccc 100644 --- a/homeassistant/components/twitch/coordinator.py +++ b/homeassistant/components/twitch/coordinator.py @@ -15,6 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_CHANNELS, DOMAIN, LOGGER, OAUTH_SCOPES +type TwitchConfigEntry = ConfigEntry[TwitchCoordinator] + def chunk_list(lst: list, chunk_size: int) -> list[list]: """Split a list into chunks of chunk_size.""" @@ -44,12 +46,16 @@ class TwitchUpdate: class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]): """Class to manage fetching Twitch data.""" - config_entry: ConfigEntry + config_entry: TwitchConfigEntry users: list[TwitchUser] current_user: TwitchUser def __init__( - self, hass: HomeAssistant, twitch: Twitch, session: OAuth2Session + self, + hass: HomeAssistant, + twitch: Twitch, + session: OAuth2Session, + entry: TwitchConfigEntry, ) -> None: """Initialize the coordinator.""" self.twitch = twitch @@ -58,6 +64,7 @@ class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]): LOGGER, name=DOMAIN, update_interval=timedelta(minutes=5), + config_entry=entry, ) self.session = session @@ -115,7 +122,7 @@ class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]): stream.game_name if stream else None, stream.title if stream else None, stream.started_at if stream else None, - stream.thumbnail_url if stream else None, + stream.thumbnail_url.format(width="", height="") if stream else None, channel.profile_image_url, bool(sub), sub.is_gift if sub else None, diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index bd5fc509989..b407eae0319 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -4,16 +4,13 @@ from __future__ import annotations from typing import Any -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TwitchCoordinator -from .const import DOMAIN -from .coordinator import TwitchUpdate +from .coordinator import TwitchConfigEntry, TwitchCoordinator, TwitchUpdate ATTR_GAME = "game" ATTR_TITLE = "title" @@ -34,11 +31,11 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TwitchConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Initialize entries.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( TwitchSensor(coordinator, channel_id) for channel_id in coordinator.data @@ -49,6 +46,8 @@ class TwitchSensor(CoordinatorEntity[TwitchCoordinator], SensorEntity): """Representation of a Twitch channel.""" _attr_translation_key = "channel" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = [STATE_OFFLINE, STATE_STREAMING] def __init__(self, coordinator: TwitchCoordinator, channel_id: str) -> None: """Initialize the sensor.""" @@ -82,8 +81,8 @@ class TwitchSensor(CoordinatorEntity[TwitchCoordinator], SensorEntity): ATTR_TITLE: channel.title, ATTR_STARTED_AT: channel.started_at, ATTR_VIEWERS: channel.viewers, + ATTR_SUBSCRIPTION: False, } - resp[ATTR_SUBSCRIPTION] = False if channel.subscribed is not None: resp[ATTR_SUBSCRIPTION] = channel.subscribed resp[ATTR_SUBSCRIPTION_GIFTED] = channel.subscription_gifted diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json index bbe46526c36..7271b81e924 100644 --- a/homeassistant/components/twitch/strings.json +++ b/homeassistant/components/twitch/strings.json @@ -16,5 +16,47 @@ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" } + }, + "entity": { + "sensor": { + "channel": { + "state": { + "streaming": "Streaming", + "offline": "Offline" + }, + "state_attributes": { + "followers": { + "name": "Followers" + }, + "game": { + "name": "Game" + }, + "title": { + "name": "Title" + }, + "started_at": { + "name": "Started at" + }, + "viewers": { + "name": "Viewers" + }, + "subscribed": { + "name": "Subscribed" + }, + "subscription_is_gifted": { + "name": "Subscription is gifted" + }, + "subscription_tier": { + "name": "Subscription tier" + }, + "following": { + "name": "Following" + }, + "following_since": { + "name": "Following since" + } + } + } + } } } diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 63c8533aa2e..479055b84eb 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -18,7 +18,6 @@ from urllib.parse import urlparse from aiounifi.interfaces.sites import Sites import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntryState, @@ -36,6 +35,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from . import UnifiConfigEntry from .const import ( @@ -212,12 +216,12 @@ class UnifiFlowHandler(ConfigFlow, domain=UNIFI_DOMAIN): return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UniFi device.""" parsed_url = urlparse(discovery_info.ssdp_location) - model_description = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_DESCRIPTION] - mac_address = format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + model_description = discovery_info.upnp[ATTR_UPNP_MODEL_DESCRIPTION] + mac_address = format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]) self.config = { CONF_HOST: parsed_url.hostname, diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index 2b16895a9a8..bbd03b070a4 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -50,17 +50,16 @@ DPI_SWITCH = "dpi" OUTLET_SWITCH = "outlet" DEVICE_STATES = { - DeviceState.DISCONNECTED: "Disconnected", - DeviceState.CONNECTED: "Connected", - DeviceState.PENDING: "Pending", - DeviceState.FIRMWARE_MISMATCH: "Firmware Mismatch", - DeviceState.UPGRADING: "Upgrading", - DeviceState.PROVISIONING: "Provisioning", - DeviceState.HEARTBEAT_MISSED: "Heartbeat Missed", - DeviceState.ADOPTING: "Adopting", - DeviceState.DELETING: "Deleting", - DeviceState.INFORM_ERROR: "Inform Error", - DeviceState.ADOPTION_FALIED: "Adoption Failed", - DeviceState.ISOLATED: "Isolated", - DeviceState.UNKNOWN: "Unknown", + DeviceState.DISCONNECTED: "disconnected", + DeviceState.CONNECTED: "connected", + DeviceState.PENDING: "pending", + DeviceState.FIRMWARE_MISMATCH: "firmware_mismatch", + DeviceState.UPGRADING: "upgrading", + DeviceState.PROVISIONING: "provisioning", + DeviceState.HEARTBEAT_MISSED: "heartbeat_missed", + DeviceState.ADOPTING: "adopting", + DeviceState.DELETING: "deleting", + DeviceState.INFORM_ERROR: "inform_error", + DeviceState.ADOPTION_FALIED: "adoption_failed", + DeviceState.ISOLATED: "isolated", } diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 735f76a73bf..2ac47e67913 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -16,7 +16,7 @@ from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index f11ddefec98..64403152b0c 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -47,9 +47,13 @@ class UnifiEntityLoader: hub.api.sites.update, hub.api.system_information.update, hub.api.traffic_rules.update, + hub.api.traffic_routes.update, hub.api.wlans.update, ) - self.polling_api_updaters = (hub.api.traffic_rules.update,) + self.polling_api_updaters = ( + hub.api.traffic_rules.update, + hub.api.traffic_routes.update, + ) self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS] self._dataUpdateCoordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json index 76990c1c4a1..6874bb5ae03 100644 --- a/homeassistant/components/unifi/icons.json +++ b/homeassistant/components/unifi/icons.json @@ -61,6 +61,9 @@ "traffic_rule_control": { "default": "mdi:security-network" }, + "traffic_route_control": { + "default": "mdi:routes" + }, "poe_port_control": { "default": "mdi:ethernet", "state": { diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 66d0a53284b..ce573592153 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==80"], + "requirements": ["aiounifi==81"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 74d49db6e4e..194a8575174 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -205,9 +205,9 @@ def async_client_is_connected_fn(hub: UnifiHub, obj_id: str) -> bool: @callback -def async_device_state_value_fn(hub: UnifiHub, device: Device) -> str: +def async_device_state_value_fn(hub: UnifiHub, device: Device) -> str | None: """Retrieve the state of the device.""" - return DEVICE_STATES[device.state] + return DEVICE_STATES.get(device.state) @callback diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index ce726a0f5d0..fc63c092d56 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -69,8 +69,7 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): if config_entry.state is not ConfigEntryState.LOADED or ( - (hub := config_entry.runtime_data) - and not hub.available + ((hub := config_entry.runtime_data) and not hub.available) or (client := hub.api.clients.get(mac)) is None or client.is_wired ): @@ -87,10 +86,8 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> - Neither IP, hostname nor name is configured. """ for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): - if ( - config_entry.state is not ConfigEntryState.LOADED - or (hub := config_entry.runtime_data) - and not hub.available + if config_entry.state is not ConfigEntryState.LOADED or ( + (hub := config_entry.runtime_data) and not hub.available ): continue diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 1c7317c4267..8f4f2b420a5 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -33,6 +33,26 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "entity": { + "sensor": { + "device_state": { + "state": { + "disconnected": "[%key:common::state::disconnected%]", + "connected": "[%key:common::state::connected%]", + "pending": "Pending", + "firmware_mismatch": "Firmware mismatch", + "upgrading": "Upgrading", + "provisioning": "Provisioning", + "heartbeat_missed": "Heartbeat missed", + "adopting": "Adopting", + "deleting": "Deleting", + "inform_error": "Inform error", + "adoption_failed": "Adoption failed", + "isolated": "Isolated" + } + } + } + }, "options": { "abort": { "integration_not_setup": "UniFi integration is not set up" @@ -91,7 +111,7 @@ "fields": { "device_id": { "name": "[%key:common::config_flow::data::device%]", - "description": "Try reconnect client to wireless network." + "description": "The device that should be forced to reconnect to the wireless network." } } }, diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 01843a8a95b..7741e57c82c 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -20,6 +20,7 @@ from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.port_forwarding import PortForwarding from aiounifi.interfaces.ports import Ports +from aiounifi.interfaces.traffic_routes import TrafficRoutes from aiounifi.interfaces.traffic_rules import TrafficRules from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT @@ -31,6 +32,7 @@ from aiounifi.models.event import Event, EventKey from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port from aiounifi.models.port_forward import PortForward, PortForwardEnableRequest +from aiounifi.models.traffic_route import TrafficRoute, TrafficRouteSaveRequest from aiounifi.models.traffic_rule import TrafficRule, TrafficRuleEnableRequest from aiounifi.models.wlan import Wlan, WlanEnableRequest @@ -170,6 +172,16 @@ async def async_traffic_rule_control_fn( await hub.api.traffic_rules.update() +async def async_traffic_route_control_fn( + hub: UnifiHub, obj_id: str, target: bool +) -> None: + """Control traffic route state.""" + traffic_route = hub.api.traffic_routes[obj_id].raw + await hub.api.request(TrafficRouteSaveRequest.create(traffic_route, target)) + # Update the traffic routes so the UI is updated appropriately + await hub.api.traffic_routes.update() + + async def async_wlan_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: """Control outlet relay.""" await hub.api.request(WlanEnableRequest.create(obj_id, target)) @@ -263,6 +275,19 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( object_fn=lambda api, obj_id: api.traffic_rules[obj_id], unique_id_fn=lambda hub, obj_id: f"traffic_rule-{obj_id}", ), + UnifiSwitchEntityDescription[TrafficRoutes, TrafficRoute]( + key="Traffic route control", + translation_key="traffic_route_control", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + api_handler_fn=lambda api: api.traffic_routes, + control_fn=async_traffic_route_control_fn, + device_info_fn=async_unifi_network_device_info_fn, + is_on_fn=lambda hub, traffic_route: traffic_route.enabled, + name_fn=lambda traffic_route: traffic_route.description, + object_fn=lambda api, obj_id: api.traffic_routes[obj_id], + unique_id_fn=lambda hub, obj_id: f"traffic_route-{obj_id}", + ), UnifiSwitchEntityDescription[Ports, Port]( key="PoE port control", translation_key="poe_port_control", diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index 144cbd4dec7..d5e2e926114 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -67,11 +67,11 @@ class UnifiDeviceScanner(DeviceScanner): """Update the client info from AP.""" try: self.clients = self.ap.get_clients() - except UniFiAPConnectionException: - _LOGGER.error("Failed to connect to accesspoint") + except UniFiAPConnectionException as e: + _LOGGER.error("Failed to connect to accesspoint: %s", str(e)) return False - except UniFiAPDataException: - _LOGGER.error("Failed to get proper response from accesspoint") + except UniFiAPDataException as e: + _LOGGER.error("Failed to get proper response from accesspoint: %s", str(e)) return False return True diff --git a/homeassistant/components/unifi_direct/manifest.json b/homeassistant/components/unifi_direct/manifest.json index 775279c64e2..aa696985dbe 100644 --- a/homeassistant/components/unifi_direct/manifest.json +++ b/homeassistant/components/unifi_direct/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["unifi_ap"], "quality_scale": "legacy", - "requirements": ["unifi_ap==0.0.1"] + "requirements": ["unifi_ap==0.0.2"] } diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 394a7f43329..ba255bb7f7c 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -45,7 +45,12 @@ from .utils import ( async_create_api_client, async_get_devices, ) -from .views import ThumbnailProxyView, VideoProxyView +from .views import ( + SnapshotProxyView, + ThumbnailProxyView, + VideoEventProxyView, + VideoProxyView, +) _LOGGER = logging.getLogger(__name__) @@ -173,7 +178,9 @@ async def _async_setup_entry( data_service.async_setup() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) hass.http.register_view(ThumbnailProxyView(hass)) + hass.http.register_view(SnapshotProxyView(hass)) hass.http.register_view(VideoProxyView(hass)) + hass.http.register_view(VideoEventProxyView(hass)) async def _async_options_updated(hass: HomeAssistant, entry: UFPConfigEntry) -> None: diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index a40939be917..0b1c03b8dd6 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -90,7 +90,7 @@ def _get_camera_channels( is_default = False # no RTSP enabled use first channel with no stream - if is_default: + if is_default and not camera.is_third_party_camera: _create_rtsp_repair(hass, entry, data, camera) yield camera, camera.channels[0], True else: diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 31950f8f7e4..22af2fb135d 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -14,7 +14,6 @@ from uiprotect.exceptions import ClientError, NotAuthorized from unifi_discovery import async_console_is_alive import voluptuous as vol -from homeassistant.components import dhcp, ssdp from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -36,6 +35,8 @@ from homeassistant.helpers.aiohttp_client import ( async_create_clientsession, async_get_clientsession, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.loader import async_get_integration @@ -107,14 +108,14 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): self._discovered_device: dict[str, str] = {} async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" _LOGGER.debug("Starting discovery via: %s", discovery_info) return await self._async_discovery_handoff() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UniFi device.""" _LOGGER.debug("Starting discovery via: %s", discovery_info) diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 7d1e5b55d3f..d041b713125 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -41,6 +41,7 @@ DEFAULT_VERIFY_SSL = False DEFAULT_MAX_MEDIA = 1000 DEVICES_THAT_ADOPT = { + ModelType.AIPORT, ModelType.CAMERA, ModelType.LIGHT, ModelType.VIEWPORT, @@ -82,3 +83,10 @@ EVENT_TYPE_FINGERPRINT_IDENTIFIED: Final = "identified" EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED: Final = "not_identified" EVENT_TYPE_NFC_SCANNED: Final = "scanned" EVENT_TYPE_DOORBELL_RING: Final = "ring" + +KEYRINGS_ULP_ID: Final = "ulp_id" +KEYRINGS_USER_STATUS: Final = "user_status" +KEYRINGS_USER_FULL_NAME: Final = "full_name" +KEYRINGS_KEY_TYPE: Final = "key_type" +KEYRINGS_KEY_TYPE_ID_FINGERPRINT: Final = "fingerprint_id" +KEYRINGS_KEY_TYPE_ID_NFC: Final = "nfc_id" diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 4ad8892ca01..baecc7f8323 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -349,6 +349,7 @@ def async_ufp_instance_for_config_entry_ids( entry.runtime_data.api for entry_id in config_entry_ids if (entry := hass.config_entries.async_get_entry(entry_id)) + and entry.domain == DOMAIN and hasattr(entry, "runtime_data") ), None, diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 1d68b18f1de..335bc1e933d 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -119,6 +119,7 @@ def _async_device_entities( _ALL_MODEL_TYPES = ( + ModelType.AIPORT, ModelType.CAMERA, ModelType.LIGHT, ModelType.SENSOR, diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index f126920fb18..78fdf7746de 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -4,8 +4,6 @@ from __future__ import annotations import dataclasses -from uiprotect.data import Camera, EventType, ProtectAdoptableDeviceModel - from homeassistant.components.event import ( EventDeviceClass, EventEntity, @@ -14,17 +12,43 @@ from homeassistant.components.event import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import Bootstrap from .const import ( ATTR_EVENT_ID, EVENT_TYPE_DOORBELL_RING, EVENT_TYPE_FINGERPRINT_IDENTIFIED, EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED, EVENT_TYPE_NFC_SCANNED, + KEYRINGS_KEY_TYPE_ID_NFC, + KEYRINGS_ULP_ID, + KEYRINGS_USER_FULL_NAME, + KEYRINGS_USER_STATUS, +) +from .data import ( + Camera, + EventType, + ProtectAdoptableDeviceModel, + ProtectData, + ProtectDeviceType, + UFPConfigEntry, ) -from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import EventEntityMixin, ProtectDeviceEntity, ProtectEventMixin +def _add_ulp_user_infos( + bootstrap: Bootstrap, event_data: dict[str, str], ulp_id: str +) -> None: + """Add ULP user information to the event data.""" + if ulp_usr := bootstrap.ulp_users.by_ulp_id(ulp_id): + event_data.update( + { + KEYRINGS_ULP_ID: ulp_usr.ulp_id, + KEYRINGS_USER_FULL_NAME: ulp_usr.full_name, + KEYRINGS_USER_STATUS: ulp_usr.status, + } + ) + + @dataclasses.dataclass(frozen=True, kw_only=True) class ProtectEventEntityDescription(ProtectEventMixin, EventEntityDescription): """Describes UniFi Protect event entity.""" @@ -78,9 +102,22 @@ class ProtectDeviceNFCEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEn and not self._event_already_ended(prev_event, prev_event_end) and event.type is EventType.NFC_CARD_SCANNED ): - event_data = {ATTR_EVENT_ID: event.id} + event_data = { + ATTR_EVENT_ID: event.id, + KEYRINGS_USER_FULL_NAME: "", + KEYRINGS_ULP_ID: "", + KEYRINGS_USER_STATUS: "", + KEYRINGS_KEY_TYPE_ID_NFC: "", + } + if event.metadata and event.metadata.nfc and event.metadata.nfc.nfc_id: - event_data["nfc_id"] = event.metadata.nfc.nfc_id + nfc_id = event.metadata.nfc.nfc_id + event_data[KEYRINGS_KEY_TYPE_ID_NFC] = nfc_id + keyring = self.data.api.bootstrap.keyrings.by_registry_id(nfc_id) + if keyring and keyring.ulp_user: + _add_ulp_user_infos( + self.data.api.bootstrap, event_data, keyring.ulp_user + ) self._trigger_event(EVENT_TYPE_NFC_SCANNED, event_data) self.async_write_ha_state() @@ -109,17 +146,22 @@ class ProtectDeviceFingerprintEventEntity( and not self._event_already_ended(prev_event, prev_event_end) and event.type is EventType.FINGERPRINT_IDENTIFIED ): - event_data = {ATTR_EVENT_ID: event.id} + event_data = { + ATTR_EVENT_ID: event.id, + KEYRINGS_USER_FULL_NAME: "", + KEYRINGS_ULP_ID: "", + } + event_identified = EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED if ( event.metadata and event.metadata.fingerprint and event.metadata.fingerprint.ulp_id ): - event_data["ulp_id"] = event.metadata.fingerprint.ulp_id event_identified = EVENT_TYPE_FINGERPRINT_IDENTIFIED - else: - event_data["ulp_id"] = "" - event_identified = EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED + ulp_id = event.metadata.fingerprint.ulp_id + if ulp_id: + event_data[KEYRINGS_ULP_ID] = ulp_id + _add_ulp_user_infos(self.data.api.bootstrap, event_data, ulp_id) self._trigger_event(event_identified, event_data) self.async_write_ha_state() @@ -139,7 +181,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( ProtectEventEntityDescription( key="nfc", translation_key="nfc", - device_class=EventDeviceClass.DOORBELL, icon="mdi:nfc", ufp_required_field="feature_flags.support_nfc", ufp_event_obj="last_nfc_card_scanned_event", @@ -149,7 +190,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( ProtectEventEntityDescription( key="fingerprint", translation_key="fingerprint", - device_class=EventDeviceClass.DOORBELL, icon="mdi:fingerprint", ufp_required_field="feature_flags.has_fingerprint_sensor", ufp_event_obj="last_fingerprint_identified_event", diff --git a/homeassistant/components/unifiprotect/icons.json b/homeassistant/components/unifiprotect/icons.json index 5e80e3095b3..b5e8277d82a 100644 --- a/homeassistant/components/unifiprotect/icons.json +++ b/homeassistant/components/unifiprotect/icons.json @@ -11,6 +11,9 @@ }, "remove_privacy_zone": { "service": "mdi:eye-minus" + }, + "get_user_keyring_info": { + "service": "mdi:key-chain" } } } diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 486a8956e0c..fcdfe5e85b8 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -7,7 +7,7 @@ from typing import Any from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -71,13 +71,10 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - hass_brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) - unifi_brightness = hass_to_unifi_brightness(hass_brightness) - - _LOGGER.debug("Turning on light with brightness %s", unifi_brightness) - await self.device.set_light(True, unifi_brightness) + _LOGGER.debug("Turning on light") + await self.device.api.set_light_is_led_force_on(self.device.id, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" _LOGGER.debug("Turning off light") - await self.device.set_light(False) + await self.device.api.set_light_is_led_force_on(self.device.id, False) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a8ad956a667..69c7f8b205b 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.6.2", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.5.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 119fe52756c..402aae2eeba 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import functools from typing import Any, cast from pydantic import ValidationError @@ -14,7 +13,13 @@ import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_validation as cv, @@ -22,9 +27,19 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.service import async_extract_referenced_entity_ids +from homeassistant.util.json import JsonValueType from homeassistant.util.read_only_dict import ReadOnlyDict -from .const import ATTR_MESSAGE, DOMAIN +from .const import ( + ATTR_MESSAGE, + DOMAIN, + KEYRINGS_KEY_TYPE, + KEYRINGS_KEY_TYPE_ID_FINGERPRINT, + KEYRINGS_KEY_TYPE_ID_NFC, + KEYRINGS_ULP_ID, + KEYRINGS_USER_FULL_NAME, + KEYRINGS_USER_STATUS, +) from .data import async_ufp_instance_for_config_entry_ids SERVICE_ADD_DOORBELL_TEXT = "add_doorbell_text" @@ -32,12 +47,14 @@ SERVICE_REMOVE_DOORBELL_TEXT = "remove_doorbell_text" SERVICE_SET_PRIVACY_ZONE = "set_privacy_zone" SERVICE_REMOVE_PRIVACY_ZONE = "remove_privacy_zone" SERVICE_SET_CHIME_PAIRED = "set_chime_paired_doorbells" +SERVICE_GET_USER_KEYRING_INFO = "get_user_keyring_info" ALL_GLOBAL_SERIVCES = [ SERVICE_ADD_DOORBELL_TEXT, SERVICE_REMOVE_DOORBELL_TEXT, SERVICE_SET_CHIME_PAIRED, SERVICE_REMOVE_PRIVACY_ZONE, + SERVICE_GET_USER_KEYRING_INFO, ] DOORBELL_TEXT_SCHEMA = vol.All( @@ -70,6 +87,15 @@ REMOVE_PRIVACY_ZONE_SCHEMA = vol.All( cv.has_at_least_one_key(ATTR_DEVICE_ID), ) +GET_USER_KEYRING_INFO_SCHEMA = vol.All( + vol.Schema( + { + **cv.ENTITY_SERVICE_FIELDS, + }, + ), + cv.has_at_least_one_key(ATTR_DEVICE_ID), +) + @callback def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiClient: @@ -88,9 +114,9 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl @callback -def _async_get_ufp_camera(hass: HomeAssistant, call: ServiceCall) -> Camera: - ref = async_extract_referenced_entity_ids(hass, call) - entity_registry = er.async_get(hass) +def _async_get_ufp_camera(call: ServiceCall) -> Camera: + ref = async_extract_referenced_entity_ids(call.hass, call) + entity_registry = er.async_get(call.hass) entity_id = ref.indirectly_referenced.pop() camera_entity = entity_registry.async_get(entity_id) @@ -98,30 +124,27 @@ def _async_get_ufp_camera(hass: HomeAssistant, call: ServiceCall) -> Camera: assert camera_entity.device_id is not None camera_mac = _async_unique_id_to_mac(camera_entity.unique_id) - instance = _async_get_ufp_instance(hass, camera_entity.device_id) + instance = _async_get_ufp_instance(call.hass, camera_entity.device_id) return cast(Camera, instance.bootstrap.get_device_from_mac(camera_mac)) @callback -def _async_get_protect_from_call( - hass: HomeAssistant, call: ServiceCall -) -> set[ProtectApiClient]: +def _async_get_protect_from_call(call: ServiceCall) -> set[ProtectApiClient]: return { - _async_get_ufp_instance(hass, device_id) + _async_get_ufp_instance(call.hass, device_id) for device_id in async_extract_referenced_entity_ids( - hass, call + call.hass, call ).referenced_devices } async def _async_service_call_nvr( - hass: HomeAssistant, call: ServiceCall, method: str, *args: Any, **kwargs: Any, ) -> None: - instances = _async_get_protect_from_call(hass, call) + instances = _async_get_protect_from_call(call) try: await asyncio.gather( *(getattr(i.bootstrap.nvr, method)(*args, **kwargs) for i in instances) @@ -130,23 +153,23 @@ async def _async_service_call_nvr( raise HomeAssistantError(str(err)) from err -async def add_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None: +async def add_doorbell_text(call: ServiceCall) -> None: """Add a custom doorbell text message.""" message: str = call.data[ATTR_MESSAGE] - await _async_service_call_nvr(hass, call, "add_custom_doorbell_message", message) + await _async_service_call_nvr(call, "add_custom_doorbell_message", message) -async def remove_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None: +async def remove_doorbell_text(call: ServiceCall) -> None: """Remove a custom doorbell text message.""" message: str = call.data[ATTR_MESSAGE] - await _async_service_call_nvr(hass, call, "remove_custom_doorbell_message", message) + await _async_service_call_nvr(call, "remove_custom_doorbell_message", message) -async def remove_privacy_zone(hass: HomeAssistant, call: ServiceCall) -> None: +async def remove_privacy_zone(call: ServiceCall) -> None: """Remove privacy zone from camera.""" name: str = call.data[ATTR_NAME] - camera = _async_get_ufp_camera(hass, call) + camera = _async_get_ufp_camera(call) remove_index: int | None = None for index, zone in enumerate(camera.privacy_zones): @@ -171,10 +194,10 @@ def _async_unique_id_to_mac(unique_id: str) -> str: return unique_id.split("_")[0] -async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> None: +async def set_chime_paired_doorbells(call: ServiceCall) -> None: """Set paired doorbells on chime.""" - ref = async_extract_referenced_entity_ids(hass, call) - entity_registry = er.async_get(hass) + ref = async_extract_referenced_entity_ids(call.hass, call) + entity_registry = er.async_get(call.hass) entity_id = ref.indirectly_referenced.pop() chime_button = entity_registry.async_get(entity_id) @@ -182,13 +205,13 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> assert chime_button.device_id is not None chime_mac = _async_unique_id_to_mac(chime_button.unique_id) - instance = _async_get_ufp_instance(hass, chime_button.device_id) + instance = _async_get_ufp_instance(call.hass, chime_button.device_id) chime = instance.bootstrap.get_device_from_mac(chime_mac) chime = cast(Chime, chime) assert chime is not None call.data = ReadOnlyDict(call.data.get("doorbells") or {}) - doorbell_refs = async_extract_referenced_entity_ids(hass, call) + doorbell_refs = async_extract_referenced_entity_ids(call.hass, call) doorbell_ids: set[str] = set() for camera_id in doorbell_refs.referenced | doorbell_refs.indirectly_referenced: doorbell_sensor = entity_registry.async_get(camera_id) @@ -209,31 +232,81 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> await chime.save_device(data_before_changed) +async def get_user_keyring_info(call: ServiceCall) -> ServiceResponse: + """Get the user keyring info.""" + camera = _async_get_ufp_camera(call) + ulp_users = camera.api.bootstrap.ulp_users.as_list() + if not ulp_users: + raise HomeAssistantError("No users found, please check Protect permissions.") + + user_keyrings: list[JsonValueType] = [ + { + KEYRINGS_USER_FULL_NAME: user.full_name, + KEYRINGS_USER_STATUS: user.status, + KEYRINGS_ULP_ID: user.ulp_id, + "keys": [ + { + KEYRINGS_KEY_TYPE: key.registry_type, + **( + {KEYRINGS_KEY_TYPE_ID_FINGERPRINT: key.registry_id} + if key.registry_type == "fingerprint" + else {} + ), + **( + {KEYRINGS_KEY_TYPE_ID_NFC: key.registry_id} + if key.registry_type == "nfc" + else {} + ), + } + for key in camera.api.bootstrap.keyrings.as_list() + if key.ulp_user == user.ulp_id + ], + } + for user in ulp_users + ] + + response: ServiceResponse = {"users": user_keyrings} + return response + + +SERVICES = [ + ( + SERVICE_ADD_DOORBELL_TEXT, + add_doorbell_text, + DOORBELL_TEXT_SCHEMA, + SupportsResponse.NONE, + ), + ( + SERVICE_REMOVE_DOORBELL_TEXT, + remove_doorbell_text, + DOORBELL_TEXT_SCHEMA, + SupportsResponse.NONE, + ), + ( + SERVICE_SET_CHIME_PAIRED, + set_chime_paired_doorbells, + CHIME_PAIRED_SCHEMA, + SupportsResponse.NONE, + ), + ( + SERVICE_REMOVE_PRIVACY_ZONE, + remove_privacy_zone, + REMOVE_PRIVACY_ZONE_SCHEMA, + SupportsResponse.NONE, + ), + ( + SERVICE_GET_USER_KEYRING_INFO, + get_user_keyring_info, + GET_USER_KEYRING_INFO_SCHEMA, + SupportsResponse.ONLY, + ), +] + + def async_setup_services(hass: HomeAssistant) -> None: """Set up the global UniFi Protect services.""" - services = [ - ( - SERVICE_ADD_DOORBELL_TEXT, - functools.partial(add_doorbell_text, hass), - DOORBELL_TEXT_SCHEMA, - ), - ( - SERVICE_REMOVE_DOORBELL_TEXT, - functools.partial(remove_doorbell_text, hass), - DOORBELL_TEXT_SCHEMA, - ), - ( - SERVICE_SET_CHIME_PAIRED, - functools.partial(set_chime_paired_doorbells, hass), - CHIME_PAIRED_SCHEMA, - ), - ( - SERVICE_REMOVE_PRIVACY_ZONE, - functools.partial(remove_privacy_zone, hass), - REMOVE_PRIVACY_ZONE_SCHEMA, - ), - ] - for name, method, schema in services: - if hass.services.has_service(DOMAIN, name): - continue - hass.services.async_register(DOMAIN, name, method, schema=schema) + + for name, method, schema, supports_response in SERVICES: + hass.services.async_register( + DOMAIN, name, method, schema=schema, supports_response=supports_response + ) diff --git a/homeassistant/components/unifiprotect/services.yaml b/homeassistant/components/unifiprotect/services.yaml index 192dfd0566f..b620c195fc2 100644 --- a/homeassistant/components/unifiprotect/services.yaml +++ b/homeassistant/components/unifiprotect/services.yaml @@ -53,3 +53,10 @@ remove_privacy_zone: required: true selector: text: +get_user_keyring_info: + fields: + device_id: + required: true + selector: + device: + integration: unifiprotect diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 8ecb4076409..cde8c88d169 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -225,6 +225,16 @@ "description": "The name of the zone to remove." } } + }, + "get_user_keyring_info": { + "name": "Retrieve Keyring Details for Users", + "description": "Fetch a detailed list of users with NFC and fingerprint associations for automations.", + "fields": { + "device_id": { + "name": "UniFi Protect NVR", + "description": "Any device from the UniFi Protect instance you want to retrieve keyring details. This is useful for systems with multiple Protect instances." + } + } } } } diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py index 00128492c67..cc2e1c6a5fc 100644 --- a/homeassistant/components/unifiprotect/views.py +++ b/homeassistant/components/unifiprotect/views.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime from http import HTTPStatus import logging -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlencode from aiohttp import web @@ -30,7 +30,9 @@ def async_generate_thumbnail_url( ) -> str: """Generate URL for event thumbnail.""" - url_format = ThumbnailProxyView.url or "{nvr_id}/{event_id}" + url_format = ThumbnailProxyView.url + if TYPE_CHECKING: + assert url_format is not None url = url_format.format(nvr_id=nvr_id, event_id=event_id) params = {} @@ -42,6 +44,34 @@ def async_generate_thumbnail_url( return f"{url}?{urlencode(params)}" +@callback +def async_generate_snapshot_url( + nvr_id: str, + camera_id: str, + timestamp: datetime, + width: int | None = None, + height: int | None = None, +) -> str: + """Generate URL for event thumbnail.""" + + url_format = SnapshotProxyView.url + if TYPE_CHECKING: + assert url_format is not None + url = url_format.format( + nvr_id=nvr_id, + camera_id=camera_id, + timestamp=timestamp.replace(microsecond=0).isoformat(), + ) + + params = {} + if width is not None: + params["width"] = str(width) + if height is not None: + params["height"] = str(height) + + return f"{url}?{urlencode(params)}" + + @callback def async_generate_event_video_url(event: Event) -> str: """Generate URL for event video.""" @@ -50,7 +80,9 @@ def async_generate_event_video_url(event: Event) -> str: if event.start is None or event.end is None: raise ValueError("Event is ongoing") - url_format = VideoProxyView.url or "{nvr_id}/{camera_id}/{start}/{end}" + url_format = VideoProxyView.url + if TYPE_CHECKING: + assert url_format is not None return url_format.format( nvr_id=event.api.bootstrap.nvr.id, camera_id=event.camera_id, @@ -59,6 +91,19 @@ def async_generate_event_video_url(event: Event) -> str: ) +@callback +def async_generate_proxy_event_video_url( + nvr_id: str, + event_id: str, +) -> str: + """Generate proxy URL for event video.""" + + url_format = VideoEventProxyView.url + if TYPE_CHECKING: + assert url_format is not None + return url_format.format(nvr_id=nvr_id, event_id=event_id) + + @callback def _client_error(message: Any, code: HTTPStatus) -> web.Response: _LOGGER.warning("Client error (%s): %s", code.value, message) @@ -107,6 +152,27 @@ class ProtectProxyView(HomeAssistantView): return data return _404("Invalid NVR ID") + @callback + def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None: + if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None: + return camera + + entity_registry = er.async_get(self.hass) + device_registry = dr.async_get(self.hass) + + if (entity := entity_registry.async_get(camera_id)) is None or ( + device := device_registry.async_get(entity.device_id or "") + ) is None: + return None + + macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC] + for mac in macs: + if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None: + if isinstance(ufp_device, Camera): + camera = ufp_device + break + return camera + class ThumbnailProxyView(ProtectProxyView): """View to proxy event thumbnails from UniFi Protect.""" @@ -150,33 +216,65 @@ class ThumbnailProxyView(ProtectProxyView): return web.Response(body=thumbnail, content_type="image/jpeg") +class SnapshotProxyView(ProtectProxyView): + """View to proxy snapshots at specified time from UniFi Protect.""" + + url = "/api/unifiprotect/snapshot/{nvr_id}/{camera_id}/{timestamp}" + name = "api:unifiprotect_snapshot" + + async def get( + self, request: web.Request, nvr_id: str, camera_id: str, timestamp: str + ) -> web.Response: + """Get snapshot.""" + + data = self._get_data_or_404(nvr_id) + if isinstance(data, web.Response): + return data + + camera = self._async_get_camera(data, camera_id) + if camera is None: + return _404(f"Invalid camera ID: {camera_id}") + if not camera.can_read_media(data.api.bootstrap.auth_user): + return _403(f"User cannot read media from camera: {camera.id}") + + width: int | str | None = request.query.get("width") + height: int | str | None = request.query.get("height") + + if width is not None: + try: + width = int(width) + except ValueError: + return _400("Invalid width param") + if height is not None: + try: + height = int(height) + except ValueError: + return _400("Invalid height param") + + try: + timestamp_dt = datetime.fromisoformat(timestamp) + except ValueError: + return _400("Invalid timestamp") + + try: + snapshot = await camera.get_snapshot( + width=width, height=height, dt=timestamp_dt + ) + except ClientError as err: + return _404(err) + + if snapshot is None: + return _404("snapshot not found") + + return web.Response(body=snapshot, content_type="image/jpeg") + + class VideoProxyView(ProtectProxyView): """View to proxy video clips from UniFi Protect.""" url = "/api/unifiprotect/video/{nvr_id}/{camera_id}/{start}/{end}" name = "api:unifiprotect_thumbnail" - @callback - def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None: - if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None: - return camera - - entity_registry = er.async_get(self.hass) - device_registry = dr.async_get(self.hass) - - if (entity := entity_registry.async_get(camera_id)) is None or ( - device := device_registry.async_get(entity.device_id or "") - ) is None: - return None - - macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC] - for mac in macs: - if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None: - if isinstance(ufp_device, Camera): - camera = ufp_device - break - return camera - async def get( self, request: web.Request, nvr_id: str, camera_id: str, start: str, end: str ) -> web.StreamResponse: @@ -226,3 +324,56 @@ class VideoProxyView(ProtectProxyView): if response.prepared: await response.write_eof() return response + + +class VideoEventProxyView(ProtectProxyView): + """View to proxy video clips for events from UniFi Protect.""" + + url = "/api/unifiprotect/video/{nvr_id}/{event_id}" + name = "api:unifiprotect_videoEventView" + + async def get( + self, request: web.Request, nvr_id: str, event_id: str + ) -> web.StreamResponse: + """Get Camera Video clip for an event.""" + + data = self._get_data_or_404(nvr_id) + if isinstance(data, web.Response): + return data + + try: + event = await data.api.get_event(event_id) + except ClientError: + return _404(f"Invalid event ID: {event_id}") + if event.start is None or event.end is None: + return _400("Event is still ongoing") + camera = self._async_get_camera(data, str(event.camera_id)) + if camera is None: + return _404(f"Invalid camera ID: {event.camera_id}") + if not camera.can_read_media(data.api.bootstrap.auth_user): + return _403(f"User cannot read media from camera: {camera.id}") + + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "video/mp4", + }, + ) + + async def iterator(total: int, chunk: bytes | None) -> None: + if not response.prepared: + response.content_length = total + await response.prepare(request) + + if chunk is not None: + await response.write(chunk) + + try: + await camera.get_video(event.start, event.end, iterator_callback=iterator) + except ClientError as err: + return _404(err) + + if response.prepared: + await response.write_eof() + return response diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 6b49c859771..1e61747b3f1 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb-lib==0.5.8"] + "requirements": ["upb-lib==0.5.9"] } diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 30d7cacba8e..a3fec73dca8 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -2,14 +2,12 @@ from __future__ import annotations -import dataclasses from datetime import timedelta import logging import requests.exceptions import upcloud_api -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, @@ -23,34 +21,21 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) -from .const import ( - CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, - DATA_UPCLOUD, - DEFAULT_SCAN_INTERVAL, -) -from .coordinator import UpCloudDataUpdateCoordinator +from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL +from .coordinator import UpCloudConfigEntry, UpCloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] -@dataclasses.dataclass -class UpCloudHassData: - """Home Assistant UpCloud runtime data.""" - - coordinators: dict[str, UpCloudDataUpdateCoordinator] = dataclasses.field( - default_factory=dict - ) - - -def _config_entry_update_signal_name(config_entry: ConfigEntry) -> str: +def _config_entry_update_signal_name(config_entry: UpCloudConfigEntry) -> str: """Get signal name for updates to a config entry.""" return CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE.format(config_entry.unique_id) async def _async_signal_options_update( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: UpCloudConfigEntry ) -> None: """Signal config entry options update.""" async_dispatcher_send( @@ -58,7 +43,7 @@ async def _async_signal_options_update( ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> bool: """Set up the UpCloud config entry.""" manager = upcloud_api.CloudManager( @@ -81,10 +66,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = UpCloudDataUpdateCoordinator( hass, + config_entry=entry, update_interval=update_interval, cloud_manager=manager, username=entry.data[CONF_USERNAME], ) + entry.runtime_data = coordinator # Call the UpCloud API to refresh data await coordinator.async_config_entry_first_refresh() @@ -99,21 +86,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data[DATA_UPCLOUD] = UpCloudHassData() - hass.data[DATA_UPCLOUD].coordinators[entry.data[CONF_USERNAME]] = coordinator - # Forward entry setup await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> bool: """Unload the config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - hass.data[DATA_UPCLOUD].coordinators.pop(config_entry.data[CONF_USERNAME]) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/upcloud/binary_sensor.py b/homeassistant/components/upcloud/binary_sensor.py index f135eea24b1..bca313d306f 100644 --- a/homeassistant/components/upcloud/binary_sensor.py +++ b/homeassistant/components/upcloud/binary_sensor.py @@ -4,23 +4,21 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_UPCLOUD +from .coordinator import UpCloudConfigEntry from .entity import UpCloudServerEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UpCloudConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UpCloud server binary sensor.""" - coordinator = hass.data[DATA_UPCLOUD].coordinators[config_entry.data[CONF_USERNAME]] - entities = [UpCloudBinarySensor(coordinator, uuid) for uuid in coordinator.data] + coordinator = config_entry.runtime_data + entities = [UpCloudBinarySensor(config_entry, uuid) for uuid in coordinator.data] async_add_entities(entities, True) diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index bb988726ba5..16adcc51ddf 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -9,16 +9,12 @@ import requests.exceptions import upcloud_api import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import callback from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import UpCloudConfigEntry _LOGGER = logging.getLogger(__name__) @@ -92,7 +88,7 @@ class UpCloudConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: UpCloudConfigEntry, ) -> UpCloudOptionsFlow: """Get options flow.""" return UpCloudOptionsFlow() diff --git a/homeassistant/components/upcloud/const.py b/homeassistant/components/upcloud/const.py index a967a43c46e..763462c37f4 100644 --- a/homeassistant/components/upcloud/const.py +++ b/homeassistant/components/upcloud/const.py @@ -3,6 +3,5 @@ from datetime import timedelta DOMAIN = "upcloud" -DATA_UPCLOUD = "data_upcloud" DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE = f"{DOMAIN}_config_entry_update:{{}}" diff --git a/homeassistant/components/upcloud/coordinator.py b/homeassistant/components/upcloud/coordinator.py index e10128a30e4..8088b3a72ea 100644 --- a/homeassistant/components/upcloud/coordinator.py +++ b/homeassistant/components/upcloud/coordinator.py @@ -15,6 +15,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +type UpCloudConfigEntry = ConfigEntry[UpCloudDataUpdateCoordinator] + + class UpCloudDataUpdateCoordinator( DataUpdateCoordinator[dict[str, upcloud_api.Server]] ): @@ -24,17 +27,22 @@ class UpCloudDataUpdateCoordinator( self, hass: HomeAssistant, *, + config_entry: UpCloudConfigEntry, cloud_manager: upcloud_api.CloudManager, update_interval: timedelta, username: str, ) -> None: """Initialize coordinator.""" super().__init__( - hass, _LOGGER, name=f"{username}@UpCloud", update_interval=update_interval + hass, + _LOGGER, + config_entry=config_entry, + name=f"{username}@UpCloud", + update_interval=update_interval, ) self.cloud_manager = cloud_manager - async def async_update_config(self, config_entry: ConfigEntry) -> None: + async def async_update_config(self, config_entry: UpCloudConfigEntry) -> None: """Handle config update.""" self.update_interval = timedelta( seconds=config_entry.options[CONF_SCAN_INTERVAL] diff --git a/homeassistant/components/upcloud/entity.py b/homeassistant/components/upcloud/entity.py index c64ca7be2ea..1ff5374bcaf 100644 --- a/homeassistant/components/upcloud/entity.py +++ b/homeassistant/components/upcloud/entity.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any import upcloud_api @@ -12,9 +11,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import UpCloudDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) +from .coordinator import UpCloudConfigEntry, UpCloudDataUpdateCoordinator ATTR_CORE_NUMBER = "core_number" ATTR_HOSTNAME = "hostname" @@ -33,11 +30,12 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): def __init__( self, - coordinator: UpCloudDataUpdateCoordinator, + config_entry: UpCloudConfigEntry, uuid: str, ) -> None: """Initialize the UpCloud server entity.""" - super().__init__(coordinator) + super().__init__(config_entry.runtime_data) + self.config_entry = config_entry self.uuid = uuid @property @@ -95,13 +93,11 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): @property def device_info(self) -> DeviceInfo: """Return info for device registry.""" - assert self.coordinator.config_entry is not None + assert self.config_entry is not None return DeviceInfo( configuration_url="https://hub.upcloud.com", model="Control Panel", entry_type=DeviceEntryType.SERVICE, - identifiers={ - (DOMAIN, f"{self.coordinator.config_entry.data[CONF_USERNAME]}@hub") - }, + identifiers={(DOMAIN, f"{self.config_entry.data[CONF_USERNAME]}@hub")}, manufacturer="UpCloud Ltd", ) diff --git a/homeassistant/components/upcloud/switch.py b/homeassistant/components/upcloud/switch.py index 7495357ca9e..97c08b19188 100644 --- a/homeassistant/components/upcloud/switch.py +++ b/homeassistant/components/upcloud/switch.py @@ -3,13 +3,12 @@ from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME, STATE_OFF +from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_UPCLOUD +from .coordinator import UpCloudConfigEntry from .entity import UpCloudServerEntity SIGNAL_UPDATE_UPCLOUD = "upcloud_update" @@ -17,12 +16,12 @@ SIGNAL_UPDATE_UPCLOUD = "upcloud_update" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UpCloudConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UpCloud server switch.""" - coordinator = hass.data[DATA_UPCLOUD].coordinators[config_entry.data[CONF_USERNAME]] - entities = [UpCloudSwitch(coordinator, uuid) for uuid in coordinator.data] + coordinator = config_entry.runtime_data + entities = [UpCloudSwitch(config_entry, uuid) for uuid in coordinator.data] async_add_entities(entities, True) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 6f0b56b14e8..0ff8c448197 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -9,7 +9,7 @@ import logging from typing import Any, Final, final from awesomeversion import AwesomeVersion, AwesomeVersionCompareException -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import websocket_api @@ -68,8 +68,8 @@ __all__ = [ "ATTR_VERSION", "DEVICE_CLASSES_SCHEMA", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "SERVICE_INSTALL", "SERVICE_SKIP", "UpdateDeviceClass", @@ -136,7 +136,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If version is specified, but not supported by the entity. if ( version is not None - and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features_compat + and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features ): raise HomeAssistantError( f"Installing a specific version is not supported for {entity.entity_id}" @@ -145,7 +145,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If backup is requested, but not supported by the entity. if ( backup := service_call.data[ATTR_BACKUP] - ) and UpdateEntityFeature.BACKUP not in entity.supported_features_compat: + ) and UpdateEntityFeature.BACKUP not in entity.supported_features: raise HomeAssistantError(f"Backup is not supported for {entity.entity_id}") # Update is already in progress. @@ -279,7 +279,7 @@ class UpdateEntity( return self._attr_entity_category if hasattr(self, "entity_description"): return self.entity_description.entity_category - if UpdateEntityFeature.INSTALL in self.supported_features_compat: + if UpdateEntityFeature.INSTALL in self.supported_features: return EntityCategory.CONFIG return EntityCategory.DIAGNOSTIC @@ -337,19 +337,6 @@ class UpdateEntity( """ return self._attr_title - @property - def supported_features_compat(self) -> UpdateEntityFeature: - """Return the supported features as UpdateEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = UpdateEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - @cached_property def update_percentage(self) -> int | float | None: """Update installation progress. @@ -451,7 +438,7 @@ class UpdateEntity( # If entity supports progress, return the in_progress value. # Otherwise, we use the internal progress value. - if UpdateEntityFeature.PROGRESS in self.supported_features_compat: + if UpdateEntityFeature.PROGRESS in self.supported_features: in_progress = self.in_progress update_percentage = self.update_percentage if in_progress else None if type(in_progress) is not bool and isinstance(in_progress, int): @@ -494,7 +481,7 @@ class UpdateEntity( Handles setting the in_progress state in case the entity doesn't support it natively. """ - if UpdateEntityFeature.PROGRESS not in self.supported_features_compat: + if UpdateEntityFeature.PROGRESS not in self.supported_features: self.__in_progress = True self.async_write_ha_state() @@ -539,7 +526,7 @@ async def websocket_release_notes( ) return - if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features_compat: + if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features: connection.send_error( msg["id"], websocket_api.ERR_NOT_SUPPORTED, diff --git a/homeassistant/components/update/services.yaml b/homeassistant/components/update/services.yaml index 036af10150a..45e30fee09e 100644 --- a/homeassistant/components/update/services.yaml +++ b/homeassistant/components/update/services.yaml @@ -9,6 +9,9 @@ install: selector: text: backup: + filter: + supported_features: + - update.UpdateEntityFeature.BACKUP required: false selector: boolean: diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index eb6db257bb2..5194965cf69 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -56,7 +56,7 @@ "services": { "install": { "name": "Install update", - "description": "Installs an update for this device or service.", + "description": "Installs an update for a device or service.", "fields": { "version": { "name": "Version", @@ -64,7 +64,7 @@ }, "backup": { "name": "Backup", - "description": "If supported by the integration, this creates a backup before starting the update ." + "description": "If supported by the integration, this creates a backup before starting the update." } } }, diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 214521ee9c0..aacb7538b61 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONFIG_ENTRY_FORCE_POLL, @@ -49,10 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool # Register device discovered-callback. device_discovered_event = asyncio.Event() - discovery_info: ssdp.SsdpServiceInfo | None = None + discovery_info: SsdpServiceInfo | None = None async def device_discovered( - headers: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + headers: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: if change == ssdp.SsdpChange.BYEBYE: return diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 41e481fa58c..95fd1ff0ea5 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse import voluptuous as vol from homeassistant.components import ssdp -from homeassistant.components.ssdp import SsdpServiceInfo from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -18,6 +17,12 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + SsdpServiceInfo, +) from .const import ( CONFIG_ENTRY_FORCE_POLL, @@ -37,17 +42,17 @@ from .const import ( from .device import async_get_mac_address_from_host, get_preferred_location -def _friendly_name_from_discovery(discovery_info: ssdp.SsdpServiceInfo) -> str: +def _friendly_name_from_discovery(discovery_info: SsdpServiceInfo) -> str: """Extract user-friendly name from discovery.""" return cast( str, - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) - or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) + or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or discovery_info.ssdp_headers.get("_host", ""), ) -def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_complete_discovery(discovery_info: SsdpServiceInfo) -> bool: """Test if discovery is complete and usable.""" return bool( discovery_info.ssdp_udn @@ -59,7 +64,7 @@ def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: async def _async_discovered_igd_devices( hass: HomeAssistant, -) -> list[ssdp.SsdpServiceInfo]: +) -> list[SsdpServiceInfo]: """Discovery IGD devices.""" return await ssdp.async_get_discovery_info_by_st( hass, ST_IGD_V1 @@ -76,10 +81,10 @@ async def _async_mac_address_from_discovery( return await async_get_mac_address_from_host(hass, host) -def _is_igd_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_igd_device(discovery_info: SsdpServiceInfo) -> bool: """Test if discovery is a complete IGD device.""" root_device_info = discovery_info.upnp - return root_device_info.get(ssdp.ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2} + return root_device_info.get(ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2} class UpnpFlowHandler(ConfigFlow, domain=DOMAIN): @@ -167,7 +172,7 @@ class UpnpFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UPnP/IGD device. diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index b0b4fe35b39..df4daa8782c 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.41.0", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 2da72d16ac6..ec65143b984 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,13 +2,15 @@ from __future__ import annotations -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine, Sequence import dataclasses +from datetime import datetime, timedelta import fnmatch +from functools import partial import logging import os import sys -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, overload from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo @@ -24,9 +26,16 @@ from homeassistant.core import ( HomeAssistant, callback as hass_callback, ) -from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import config_validation as cv, discovery_flow, system_info from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.service_info.usb import UsbServiceInfo as _UsbServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb @@ -39,13 +48,16 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown +PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None] + +POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5) +REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown __all__ = [ - "async_is_plugged_in", - "async_register_scan_request_callback", "USBCallbackMatcher", - "UsbServiceInfo", + "async_is_plugged_in", + "async_register_port_event_callback", + "async_register_scan_request_callback", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -76,6 +88,15 @@ def async_register_initial_scan_callback( return discovery.async_register_initial_scan_callback(callback) +@hass_callback +def async_register_port_event_callback( + hass: HomeAssistant, callback: PORT_EVENT_CALLBACK_TYPE +) -> CALLBACK_TYPE: + """Register to receive a callback when a USB device is connected or disconnected.""" + discovery: USBDiscovery = hass.data[DOMAIN] + return discovery.async_register_port_event_callback(callback) + + @hass_callback def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool: """Return True is a USB device is present.""" @@ -99,23 +120,36 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo usb_discovery: USBDiscovery = hass.data[DOMAIN] return any( - _is_matching(USBDevice(*device_tuple), matcher) - for device_tuple in usb_discovery.seen + _is_matching( + USBDevice( + device=device, + vid=vid, + pid=pid, + serial_number=serial_number, + manufacturer=manufacturer, + description=description, + ), + matcher, + ) + for ( + device, + vid, + pid, + serial_number, + manufacturer, + description, + ) in usb_discovery.seen ) -@dataclasses.dataclass(slots=True) -class UsbServiceInfo(BaseServiceInfo): - """Prepared info from usb entries.""" - - device: str - vid: str - pid: str - serial_number: str | None - manufacturer: str | None - description: str | None +_DEPRECATED_UsbServiceInfo = DeprecatedConstant( + _UsbServiceInfo, + "homeassistant.helpers.service_info.usb.UsbServiceInfo", + "2026.2", +) +@overload def human_readable_device_name( device: str, serial_number: str | None, @@ -123,11 +157,32 @@ def human_readable_device_name( description: str | None, vid: str | None, pid: str | None, +) -> str: ... + + +@overload +def human_readable_device_name( + device: str, + serial_number: str | None, + manufacturer: str | None, + description: str | None, + vid: int | None, + pid: int | None, +) -> str: ... + + +def human_readable_device_name( + device: str, + serial_number: str | None, + manufacturer: str | None, + description: str | None, + vid: str | int | None, + pid: str | int | None, ) -> str: """Return a human readable name from USBDevice attributes.""" device_details = f"{device}, s/n: {serial_number or 'n/a'}" manufacturer_details = f" - {manufacturer}" if manufacturer else "" - vendor_details = f" - {vid}:{pid}" if vid else "" + vendor_details = f" - {vid}:{pid}" if vid is not None else "" full_details = f"{device_details}{manufacturer_details}{vendor_details}" if not description: @@ -203,10 +258,14 @@ class USBDiscovery: self._request_callbacks: list[CALLBACK_TYPE] = [] self.initial_scan_done = False self._initial_scan_callbacks: list[CALLBACK_TYPE] = [] + self._port_event_callbacks: set[PORT_EVENT_CALLBACK_TYPE] = set() + self._last_processed_devices: set[USBDevice] = set() async def async_setup(self) -> None: """Set up USB Discovery.""" - await self._async_start_monitor() + if await self._async_supports_monitoring(): + await self._async_start_monitor() + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) @@ -220,26 +279,54 @@ class USBDiscovery: if self._request_debouncer: self._request_debouncer.async_shutdown() - async def _async_start_monitor(self) -> None: - """Start monitoring hardware with pyudev.""" - if not sys.platform.startswith("linux"): - return + async def _async_supports_monitoring(self) -> bool: info = await system_info.async_get_system_info(self.hass) - if info.get("docker"): - return + return not info.get("docker") + + async def _async_start_monitor(self) -> None: + """Start monitoring hardware.""" + if not await self._async_start_monitor_udev(): + _LOGGER.info( + "Falling back to periodic filesystem polling for development, libudev " + "is not present" + ) + self._async_start_monitor_polling() + + @hass_callback + def _async_start_monitor_polling(self) -> None: + """Start monitoring hardware with polling (for development only!).""" + + async def _scan(event_time: datetime) -> None: + await self._async_scan_serial() + + stop_callback = async_track_time_interval( + self.hass, _scan, POLLING_MONITOR_SCAN_PERIOD + ) + + @hass_callback + def _stop_polling(event: Event) -> None: + stop_callback() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) + + async def _async_start_monitor_udev(self) -> bool: + """Start monitoring hardware with pyudev. Returns True if successful.""" + if not sys.platform.startswith("linux"): + return False if not ( observer := await self.hass.async_add_executor_job( self._get_monitor_observer ) ): - return + return False def _stop_observer(event: Event) -> None: observer.stop() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_observer) self.observer_active = True + return True def _get_monitor_observer(self) -> MonitorObserver | None: """Get the monitor observer. @@ -268,20 +355,23 @@ class USBDiscovery: return None observer = MonitorObserver( - monitor, callback=self._device_discovered, name="usb-observer" + monitor, callback=self._device_event, name="usb-observer" ) observer.start() return observer - def _device_discovered(self, device: Device) -> None: - """Call when the observer discovers a new usb tty device.""" - if device.action != "add": + def _device_event(self, device: Device) -> None: + """Call when the observer receives a USB device event.""" + if device.action not in ("add", "remove"): return - _LOGGER.debug( - "Discovered Device at path: %s, triggering scan serial", - device.device_path, + + _LOGGER.info( + "Received a udev device event %r for %s, triggering scan", + device.action, + device.device_node, ) + self.hass.create_task(self._async_scan()) @hass_callback @@ -318,6 +408,20 @@ class USBDiscovery: return _async_remove_callback + @hass_callback + def async_register_port_event_callback( + self, + callback: PORT_EVENT_CALLBACK_TYPE, + ) -> CALLBACK_TYPE: + """Register a port event callback.""" + self._port_event_callbacks.add(callback) + + @hass_callback + def _async_remove_callback() -> None: + self._port_event_callbacks.discard(callback) + + return _async_remove_callback + async def _async_process_discovered_usb_device(self, device: USBDevice) -> None: """Process a USB discovery.""" _LOGGER.debug("Discovered USB Device: %s", device) @@ -330,7 +434,7 @@ class USBDiscovery: if not matched: return - service_info: UsbServiceInfo | None = None + service_info: _UsbServiceInfo | None = None sorted_by_most_targeted = sorted(matched, key=lambda item: -len(item)) most_matched_fields = len(sorted_by_most_targeted[0]) @@ -342,7 +446,7 @@ class USBDiscovery: break if service_info is None: - service_info = UsbServiceInfo( + service_info = _UsbServiceInfo( device=await self.hass.async_add_executor_job( get_serial_by_id, device.device ), @@ -360,13 +464,13 @@ class USBDiscovery: service_info, ) - async def _async_process_ports(self, ports: list[ListPortInfo]) -> None: + async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: """Process each discovered port.""" - usb_devices = [ + usb_devices = { usb_device_from_port(port) for port in ports if port.vid is not None or port.pid is not None - ] + } # CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and # `/dev/cu.SLAB_USBtoUART*`. The former does not work and we should ignore them. @@ -377,7 +481,7 @@ class USBDiscovery: if dev.device.startswith("/dev/cu.SLAB_USBtoUART") } - usb_devices = [ + usb_devices = { dev for dev in usb_devices if dev.serial_number not in silabs_serials @@ -385,7 +489,22 @@ class USBDiscovery: dev.serial_number in silabs_serials and dev.device.startswith("/dev/cu.SLAB_USBtoUART") ) - ] + } + + added_devices = usb_devices - self._last_processed_devices + removed_devices = self._last_processed_devices - usb_devices + self._last_processed_devices = usb_devices + + _LOGGER.debug( + "Added devices: %r, removed devices: %r", added_devices, removed_devices + ) + + if added_devices or removed_devices: + for callback in self._port_event_callbacks.copy(): + try: + callback(added_devices, removed_devices) + except Exception: + _LOGGER.exception("Error in USB port event callback") for usb_device in usb_devices: await self._async_process_discovered_usb_device(usb_device) @@ -435,3 +554,11 @@ async def websocket_usb_scan( if not usb_discovery.observer_active: await usb_discovery.async_request_scan() connection.send_result(msg["id"]) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py index efc5b11c26e..11eccd9cd9b 100644 --- a/homeassistant/components/usb/models.py +++ b/homeassistant/components/usb/models.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass -@dataclass +@dataclass(slots=True, frozen=True, kw_only=True) class USBDevice: """A usb device.""" diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index c6a8635f831..aac31e085a0 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -1,9 +1,9 @@ """Support for tracking consumption over given periods of time.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging -from croniter import croniter +from cronsim import CronSim, CronSimError import voluptuous as vol from homeassistant.components.select import DOMAIN as SELECT_DOMAIN @@ -47,9 +47,12 @@ DEFAULT_OFFSET = timedelta(hours=0) def validate_cron_pattern(pattern): """Check that the pattern is well-formed.""" - if croniter.is_valid(pattern): - return pattern - raise vol.Invalid("Invalid pattern") + try: + CronSim(pattern, datetime(2020, 1, 1)) # any date will do + except CronSimError as err: + _LOGGER.error("Invalid cron pattern %s: %s", pattern, err) + raise vol.Invalid("Invalid pattern") from err + return pattern def period_or_cron(config): diff --git a/homeassistant/components/utility_meter/manifest.json b/homeassistant/components/utility_meter/manifest.json index 31a2d4e9584..5167c51469d 100644 --- a/homeassistant/components/utility_meter/manifest.json +++ b/homeassistant/components/utility_meter/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/utility_meter", "integration_type": "helper", "iot_class": "local_push", - "loggers": ["croniter"], "quality_scale": "internal", "requirements": ["cronsim==2.6"] } diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 19ef3c1f3a8..9c13aa1984a 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -27,6 +27,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_UNIQUE_ID, + EVENT_CORE_CONFIG_UPDATE, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -404,6 +405,10 @@ class UtilityMeterSensor(RestoreSensor): self._tariff = tariff self._tariff_entity = tariff_entity self._next_reset = None + self._current_tz = None + self._config_scheduler() + + def _config_scheduler(self): self.scheduler = ( CronSim( self._cron_pattern, @@ -565,6 +570,7 @@ class UtilityMeterSensor(RestoreSensor): self._next_reset, ) ) + self.async_write_ha_state() async def _async_reset_meter(self, event): """Reset the utility meter status.""" @@ -601,6 +607,10 @@ class UtilityMeterSensor(RestoreSensor): """Handle entity which will be added.""" await super().async_added_to_hass() + # track current timezone in case it changes + # and we need to reconfigure the scheduler + self._current_tz = self.hass.config.time_zone + await self._program_reset() self.async_on_remove( @@ -655,6 +665,19 @@ class UtilityMeterSensor(RestoreSensor): self.async_on_remove(async_at_started(self.hass, async_source_tracking)) + async def async_track_time_zone(event): + """Reconfigure Scheduler after time zone changes.""" + + if self._current_tz != self.hass.config.time_zone: + self._current_tz = self.hass.config.time_zone + + self._config_scheduler() + await self._program_reset() + + self.async_on_remove( + self.hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, async_track_time_zone) + ) + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" if self._collecting: diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index a81dbeacee1..3b1eee8509c 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -2,13 +2,14 @@ from __future__ import annotations +import asyncio from datetime import timedelta from enum import IntFlag from functools import partial import logging -from typing import Any +from typing import TYPE_CHECKING, Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -18,11 +19,9 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_IDLE, STATE_ON, - STATE_PAUSED, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, @@ -32,12 +31,21 @@ from homeassistant.helpers.deprecation import ( ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, STATE_RETURNING +from .const import ( # noqa: F401 + _DEPRECATED_STATE_CLEANING, + _DEPRECATED_STATE_DOCKED, + _DEPRECATED_STATE_ERROR, + _DEPRECATED_STATE_RETURNING, + DOMAIN, + VacuumActivity, +) _LOGGER = logging.getLogger(__name__) @@ -64,11 +72,13 @@ SERVICE_START = "start" SERVICE_PAUSE = "pause" SERVICE_STOP = "stop" - -STATES = [STATE_CLEANING, STATE_DOCKED, STATE_RETURNING, STATE_ERROR] - DEFAULT_NAME = "Vacuum cleaner robot" +# These STATE_* constants are deprecated as of Home Assistant 2025.1. +# Please use the VacuumActivity enum instead. +_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(VacuumActivity.IDLE, "2026.1") +_DEPRECATED_STATE_PAUSED = DeprecatedConstantEnum(VacuumActivity.PAUSED, "2026.1") + class VacuumEntityFeature(IntFlag): """Supported features of the vacuum entity.""" @@ -216,7 +226,7 @@ STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { "battery_icon", "fan_speed", "fan_speed_list", - "state", + "activity", } @@ -233,9 +243,58 @@ class StateVacuumEntity( _attr_battery_level: int | None = None _attr_fan_speed: str | None = None _attr_fan_speed_list: list[str] - _attr_state: str | None = None + _attr_activity: VacuumActivity | None = None _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) + __vacuum_legacy_state: bool = False + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Post initialisation processing.""" + super().__init_subclass__(**kwargs) + if any(method in cls.__dict__ for method in ("_attr_state", "state")): + # Integrations should use the 'activity' property instead of + # setting the state directly. + cls.__vacuum_legacy_state = True + + def __setattr__(self, name: str, value: Any) -> None: + """Set attribute. + + Deprecation warning if setting '_attr_state' directly + unless already reported. + """ + if name == "_attr_state": + self._report_deprecated_activity_handling() + return super().__setattr__(name, value) + + @callback + def add_to_platform_start( + self, + hass: HomeAssistant, + platform: EntityPlatform, + parallel_updates: asyncio.Semaphore | None, + ) -> None: + """Start adding an entity to a platform.""" + super().add_to_platform_start(hass, platform, parallel_updates) + if self.__vacuum_legacy_state: + self._report_deprecated_activity_handling() + + @callback + def _report_deprecated_activity_handling(self) -> None: + """Report on deprecated handling of vacuum state. + + Integrations should implement activity instead of using state directly. + """ + report_usage( + "is setting state directly." + f" Entity {self.entity_id} ({type(self)}) should implement the 'activity'" + " property and return its state using the VacuumActivity enum", + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.1", + integration_domain=self.platform.platform_name if self.platform else None, + exclude_integrations={DOMAIN}, + ) + @cached_property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" @@ -244,7 +303,7 @@ class StateVacuumEntity( @property def battery_icon(self) -> str: """Return the battery icon for the vacuum cleaner.""" - charging = bool(self.state == STATE_DOCKED) + charging = bool(self.activity == VacuumActivity.DOCKED) return icon_for_battery_level( battery_level=self.battery_level, charging=charging @@ -282,10 +341,28 @@ class StateVacuumEntity( return data - @cached_property + @final + @property def state(self) -> str | None: """Return the state of the vacuum cleaner.""" - return self._attr_state + if (activity := self.activity) is not None: + return activity + if self._attr_state is not None: + # Backwards compatibility for integrations that set state directly + # Should be removed in 2026.1 + if TYPE_CHECKING: + assert isinstance(self._attr_state, str) + return self._attr_state + return None + + @cached_property + def activity(self) -> VacuumActivity | None: + """Return the current vacuum activity. + + Integrations should overwrite this or use the '_attr_activity' + attribute to set the vacuum activity using the 'VacuumActivity' enum. + """ + return self._attr_activity @cached_property def supported_features(self) -> VacuumEntityFeature: @@ -299,7 +376,7 @@ class StateVacuumEntity( Remove this compatibility shim in 2025.1 or later. """ features = self.supported_features - if type(features) is int: # noqa: E721 + if type(features) is int: new_features = VacuumEntityFeature(features) self._report_deprecated_supported_features_values(new_features) return new_features diff --git a/homeassistant/components/vacuum/const.py b/homeassistant/components/vacuum/const.py index af1558f8570..f153a11dcb9 100644 --- a/homeassistant/components/vacuum/const.py +++ b/homeassistant/components/vacuum/const.py @@ -1,10 +1,42 @@ """Support for vacuum cleaner robots (botvacs).""" +from __future__ import annotations + +from enum import StrEnum +from functools import partial + +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN = "vacuum" -STATE_CLEANING = "cleaning" -STATE_DOCKED = "docked" -STATE_RETURNING = "returning" -STATE_ERROR = "error" -STATES = [STATE_CLEANING, STATE_DOCKED, STATE_RETURNING, STATE_ERROR] +class VacuumActivity(StrEnum): + """Vacuum activity states.""" + + CLEANING = "cleaning" + DOCKED = "docked" + IDLE = "idle" + PAUSED = "paused" + RETURNING = "returning" + ERROR = "error" + + +# These STATE_* constants are deprecated as of Home Assistant 2025.1. +# Please use the VacuumActivity enum instead. +_DEPRECATED_STATE_CLEANING = DeprecatedConstantEnum(VacuumActivity.CLEANING, "2026.1") +_DEPRECATED_STATE_DOCKED = DeprecatedConstantEnum(VacuumActivity.DOCKED, "2026.1") +_DEPRECATED_STATE_RETURNING = DeprecatedConstantEnum(VacuumActivity.RETURNING, "2026.1") +_DEPRECATED_STATE_ERROR = DeprecatedConstantEnum(VacuumActivity.ERROR, "2026.1") + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/vacuum/device_condition.py b/homeassistant/components/vacuum/device_condition.py index f528b0918a1..4da64484bf7 100644 --- a/homeassistant/components/vacuum/device_condition.py +++ b/homeassistant/components/vacuum/device_condition.py @@ -20,7 +20,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN, STATE_CLEANING, STATE_DOCKED, STATE_RETURNING +from . import DOMAIN, VacuumActivity CONDITION_TYPES = {"is_cleaning", "is_docked"} @@ -62,9 +62,9 @@ def async_condition_from_config( ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" if config[CONF_TYPE] == "is_docked": - test_states = [STATE_DOCKED] + test_states = [VacuumActivity.DOCKED] else: - test_states = [STATE_CLEANING, STATE_RETURNING] + test_states = [VacuumActivity.CLEANING, VacuumActivity.RETURNING] registry = er.async_get(hass) entity_id = er.async_resolve_entity_id(registry, config[CONF_ENTITY_ID]) diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index 45b0696f871..fe682ef21d3 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, STATE_CLEANING, STATE_DOCKED +from . import DOMAIN, VacuumActivity TRIGGER_TYPES = {"cleaning", "docked"} @@ -77,9 +77,9 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "cleaning": - to_state = STATE_CLEANING + to_state = VacuumActivity.CLEANING else: - to_state = STATE_DOCKED + to_state = VacuumActivity.DOCKED state_config = { CONF_PLATFORM: "state", diff --git a/homeassistant/components/vacuum/intent.py b/homeassistant/components/vacuum/intent.py index 8952c13875d..48340252b6e 100644 --- a/homeassistant/components/vacuum/intent.py +++ b/homeassistant/components/vacuum/intent.py @@ -18,6 +18,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: DOMAIN, SERVICE_START, description="Starts a vacuum", + required_domains={DOMAIN}, platforms={DOMAIN}, ), ) @@ -28,6 +29,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: DOMAIN, SERVICE_RETURN_TO_BASE, description="Returns a vacuum to base", + required_domains={DOMAIN}, platforms={DOMAIN}, ), ) diff --git a/homeassistant/components/vacuum/reproduce_state.py b/homeassistant/components/vacuum/reproduce_state.py index 762cd6f2e90..ef3fb329686 100644 --- a/homeassistant/components/vacuum/reproduce_state.py +++ b/homeassistant/components/vacuum/reproduce_state.py @@ -11,10 +11,8 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_IDLE, STATE_OFF, STATE_ON, - STATE_PAUSED, ) from homeassistant.core import Context, HomeAssistant, State @@ -26,20 +24,18 @@ from . import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_DOCKED, - STATE_RETURNING, + VacuumActivity, ) _LOGGER = logging.getLogger(__name__) VALID_STATES_TOGGLE = {STATE_ON, STATE_OFF} VALID_STATES_STATE = { - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, + VacuumActivity.CLEANING, + VacuumActivity.DOCKED, + VacuumActivity.IDLE, + VacuumActivity.PAUSED, + VacuumActivity.RETURNING, } @@ -75,13 +71,13 @@ async def _async_reproduce_state( service = SERVICE_TURN_ON elif state.state == STATE_OFF: service = SERVICE_TURN_OFF - elif state.state == STATE_CLEANING: + elif state.state == VacuumActivity.CLEANING: service = SERVICE_START - elif state.state in [STATE_DOCKED, STATE_RETURNING]: + elif state.state in [VacuumActivity.DOCKED, VacuumActivity.RETURNING]: service = SERVICE_RETURN_TO_BASE - elif state.state == STATE_IDLE: + elif state.state == VacuumActivity.IDLE: service = SERVICE_STOP - elif state.state == STATE_PAUSED: + elif state.state == VacuumActivity.PAUSED: service = SERVICE_PAUSE await hass.services.async_call( diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 5fac46177cb..3a21ef060a7 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -83,7 +83,6 @@ class ValloxFanEntity(ValloxEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index ca8cfb0f2a7..41b8730eeb0 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -2,30 +2,25 @@ from __future__ import annotations -from contextlib import suppress +import asyncio +from dataclasses import dataclass import logging import os import shutil from velbusaio.controller import Velbus -import voluptuous as vol +from velbusaio.exceptions import VelbusConnectionFailed from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import PlatformNotReady +from homeassistant.const import CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_INTERFACE, - CONF_MEMO_TEXT, - DOMAIN, - SERVICE_CLEAR_CACHE, - SERVICE_SCAN, - SERVICE_SET_MEMO_TEXT, - SERVICE_SYNC, -) +from .const import DOMAIN +from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -40,17 +35,44 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def velbus_connect_task( +type VelbusConfigEntry = ConfigEntry[VelbusData] + + +@dataclass +class VelbusData: + """Runtime data for the Velbus config entry.""" + + controller: Velbus + scan_task: asyncio.Task + + +async def velbus_scan_task( controller: Velbus, hass: HomeAssistant, entry_id: str ) -> None: - """Task to offload the long running connect.""" + """Task to offload the long running scan.""" try: - await controller.connect() + await controller.start() except ConnectionError as ex: raise PlatformNotReady( f"Connection error while connecting to Velbus {entry_id}: {ex}" ) from ex + # create all modules + dev_reg = dr.async_get(hass) + for module in controller.get_modules().values(): + dev_reg.async_get_or_create( + config_entry_id=entry_id, + identifiers={ + (DOMAIN, str(module.get_addresses()[0])), + }, + manufacturer="Velleman", + model=module.get_type_name(), + model_id=str(module.get_type()), + name=f"{module.get_name()} ({module.get_type_name()})", + sw_version=module.get_sw_version(), + serial_number=module.get_serial(), + ) def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: @@ -67,133 +89,41 @@ def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: dev_reg.async_update_device(device.id, new_identifiers=new_identifier) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Establish connection with velbus.""" - hass.data.setdefault(DOMAIN, {}) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the actions for the Velbus component.""" + setup_services(hass) + return True + +async def async_setup_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bool: + """Establish connection with velbus.""" controller = Velbus( entry.data[CONF_PORT], cache_dir=hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}"), ) - hass.data[DOMAIN][entry.entry_id] = {} - hass.data[DOMAIN][entry.entry_id]["cntrl"] = controller - hass.data[DOMAIN][entry.entry_id]["tsk"] = hass.async_create_task( - velbus_connect_task(controller, hass, entry.entry_id) - ) + try: + await controller.connect() + except VelbusConnectionFailed as error: + raise ConfigEntryNotReady("Cannot connect to Velbus") from error + + task = hass.async_create_task(velbus_scan_task(controller, hass, entry.entry_id)) + entry.runtime_data = VelbusData(controller=controller, scan_task=task) _migrate_device_identifiers(hass, entry.entry_id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - if hass.services.has_service(DOMAIN, SERVICE_SCAN): - return True - - def check_entry_id(interface: str) -> str: - for config_entry in hass.config_entries.async_entries(DOMAIN): - if "port" in config_entry.data and config_entry.data["port"] == interface: - return config_entry.entry_id - raise vol.Invalid( - "The interface provided is not defined as a port in a Velbus integration" - ) - - async def scan(call: ServiceCall) -> None: - await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].scan() - - hass.services.async_register( - DOMAIN, - SERVICE_SCAN, - scan, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), - ) - - async def syn_clock(call: ServiceCall) -> None: - await hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"].sync_clock() - - hass.services.async_register( - DOMAIN, - SERVICE_SYNC, - syn_clock, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), - ) - - async def set_memo_text(call: ServiceCall) -> None: - """Handle Memo Text service call.""" - memo_text = call.data[CONF_MEMO_TEXT] - await ( - hass.data[DOMAIN][call.data[CONF_INTERFACE]]["cntrl"] - .get_module(call.data[CONF_ADDRESS]) - .set_memo_text(memo_text) - ) - - hass.services.async_register( - DOMAIN, - SERVICE_SET_MEMO_TEXT, - set_memo_text, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Required(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - vol.Optional(CONF_MEMO_TEXT, default=""): cv.string, - } - ), - ) - - async def clear_cache(call: ServiceCall) -> None: - """Handle a clear cache service call.""" - # clear the cache - with suppress(FileNotFoundError): - if call.data.get(CONF_ADDRESS): - await hass.async_add_executor_job( - os.unlink, - hass.config.path( - STORAGE_DIR, - f"velbuscache-{call.data[CONF_INTERFACE]}/{call.data[CONF_ADDRESS]}.p", - ), - ) - else: - await hass.async_add_executor_job( - shutil.rmtree, - hass.config.path( - STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/" - ), - ) - # call a scan to repopulate - await scan(call) - - hass.services.async_register( - DOMAIN, - SERVICE_CLEAR_CACHE, - clear_cache, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Optional(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - } - ), - ) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bool: """Unload (close) the velbus connection.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - await hass.data[DOMAIN][entry.entry_id]["cntrl"].stop() - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - hass.services.async_remove(DOMAIN, SERVICE_SCAN) - hass.services.async_remove(DOMAIN, SERVICE_SYNC) - hass.services.async_remove(DOMAIN, SERVICE_SET_MEMO_TEXT) - hass.services.async_remove(DOMAIN, SERVICE_CLEAR_CACHE) + await entry.runtime_data.controller.stop() return unload_ok -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> None: """Remove the velbus entry, so we also have to cleanup the cache dir.""" await hass.async_add_executor_job( shutil.rmtree, @@ -201,7 +131,9 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: ) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: VelbusConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) cache_path = hass.config.path(STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/") diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py index 5f363c1a035..88dc994efe8 100644 --- a/homeassistant/components/velbus/binary_sensor.py +++ b/homeassistant/components/velbus/binary_sensor.py @@ -3,24 +3,25 @@ from velbusaio.channels import Button as VelbusButton from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + await entry.runtime_data.scan_task async_add_entities( - VelbusBinarySensor(channel) for channel in cntrl.get_all("binary_sensor") + VelbusBinarySensor(channel) + for channel in entry.runtime_data.controller.get_all_binary_sensor() ) diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py index bd5b81d67a0..fc943159123 100644 --- a/homeassistant/components/velbus/button.py +++ b/homeassistant/components/velbus/button.py @@ -8,24 +8,27 @@ from velbusaio.channels import ( ) from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - async_add_entities(VelbusButton(channel) for channel in cntrl.get_all("button")) + await entry.runtime_data.scan_task + async_add_entities( + VelbusButton(channel) + for channel in entry.runtime_data.controller.get_all_button() + ) class VelbusButton(VelbusEntity, ButtonEntity): diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index ed47d8b0a91..b2f3077ecee 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -11,25 +11,29 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import VelbusConfigEntry from .const import DOMAIN, PRESET_MODES from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - async_add_entities(VelbusClimate(channel) for channel in cntrl.get_all("climate")) + await entry.runtime_data.scan_task + async_add_entities( + VelbusClimate(channel) + for channel in entry.runtime_data.controller.get_all_climate() + ) class VelbusClimate(VelbusEntity, ClimateEntity): @@ -42,7 +46,6 @@ class VelbusClimate(VelbusEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL] _attr_preset_modes = list(PRESET_MODES) - _enable_turn_on_off_backwards_compatibility = False @property def target_temperature(self) -> float | None: diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 0b47dfe6498..9e99b2631d4 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -8,9 +8,9 @@ import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol -from homeassistant.components import usb from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.util import slugify from .const import DOMAIN @@ -35,7 +35,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): """Try to connect to the velbus with the port specified.""" try: controller = velbusaio.controller.Velbus(prt) - await controller.connect(True) + await controller.connect() await controller.stop() except VelbusConnectionFailed: self._errors[CONF_PORT] = "cannot_connect" @@ -69,9 +69,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): errors=self._errors, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" await self.async_set_unique_id( f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index 2d9f6e98a4c..b40f64e8607 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ( DOMAIN: Final = "velbus" +CONF_CONFIG_ENTRY: Final = "config_entry" CONF_INTERFACE: Final = "interface" CONF_MEMO_TEXT: Final = "memo_text" diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index 8b9d927f3d7..2ddea37f2d6 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -11,23 +11,26 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - async_add_entities(VelbusCover(channel) for channel in cntrl.get_all("cover")) + await entry.runtime_data.scan_task + async_add_entities( + VelbusCover(channel) + for channel in entry.runtime_data.controller.get_all_cover() + ) class VelbusCover(VelbusEntity, CoverEntity): diff --git a/homeassistant/components/velbus/diagnostics.py b/homeassistant/components/velbus/diagnostics.py index f7e29e2f57e..5001ac80ab3 100644 --- a/homeassistant/components/velbus/diagnostics.py +++ b/homeassistant/components/velbus/diagnostics.py @@ -7,42 +7,30 @@ from typing import Any from velbusaio.channels import Channel as VelbusChannel from velbusaio.module import Module as VelbusModule -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry -from .const import DOMAIN +from . import VelbusConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: VelbusConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - controller = hass.data[DOMAIN][entry.entry_id]["cntrl"] + controller = entry.runtime_data.controller data: dict[str, Any] = {"entry": entry.as_dict(), "modules": []} for module in controller.get_modules().values(): - data["modules"].append(_build_module_diagnostics_info(module)) + data["modules"].append(await _build_module_diagnostics_info(module)) return data -async def async_get_device_diagnostics( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry -) -> dict[str, Any]: - """Return diagnostics for a device entry.""" - controller = hass.data[DOMAIN][entry.entry_id]["cntrl"] - channel = list(next(iter(device.identifiers)))[1] - modules = controller.get_modules() - return _build_module_diagnostics_info(modules[int(channel)]) - - -def _build_module_diagnostics_info(module: VelbusModule) -> dict[str, Any]: +async def _build_module_diagnostics_info(module: VelbusModule) -> dict[str, Any]: """Build per module diagnostics info.""" data: dict[str, Any] = { "type": module.get_type_name(), "address": module.get_addresses(), "name": module.get_name(), "sw_version": module.get_sw_version(), - "is_loaded": module.is_loaded(), + "is_loaded": await module.is_loaded(), "channels": _build_channels_diagnostics_info(module.get_channels()), } return data diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 65f8a1d8d31..07dac78b6f1 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -14,32 +14,57 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN +# device identifiers for modules +# (DOMAIN, module_address) + +# device identifiers for channels that are subdevices of a module +# (DOMAIN, f"{module_address}-{channel_number}") + class VelbusEntity(Entity): """Representation of a Velbus entity.""" + _attr_has_entity_name = True _attr_should_poll: bool = False def __init__(self, channel: VelbusChannel) -> None: """Initialize a Velbus entity.""" self._channel = channel + self._module_adress = str(channel.get_module_address()) self._attr_name = channel.get_name() self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, str(channel.get_module_address())), + (DOMAIN, self._get_identifier()), }, manufacturer="Velleman", model=channel.get_module_type_name(), + model_id=str(channel.get_module_type()), name=channel.get_full_name(), sw_version=channel.get_module_sw_version(), + serial_number=channel.get_module_serial(), ) - serial = channel.get_module_serial() or str(channel.get_module_address()) + if self._channel.is_sub_device(): + self._attr_device_info["via_device"] = ( + DOMAIN, + self._module_adress, + ) + serial = channel.get_module_serial() or self._module_adress self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" + def _get_identifier(self) -> str: + """Return the identifier of the entity.""" + if not self._channel.is_sub_device(): + return self._module_adress + return f"{self._module_adress}-{self._channel.get_channel_number()}" + async def async_added_to_hass(self) -> None: """Add listener for state changes.""" self._channel.on_status_update(self._on_update) + async def async_will_remove_from_hass(self) -> None: + """Remove listener for state changes.""" + self._channel.remove_on_status_update(self._on_update) + async def _on_update(self) -> None: self.async_write_ha_state() diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index 7145576be6a..c134095c2ff 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -20,28 +20,32 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + await entry.runtime_data.scan_task entities: list[Entity] = [ - VelbusLight(channel) for channel in cntrl.get_all("light") + VelbusLight(channel) + for channel in entry.runtime_data.controller.get_all_light() ] - entities.extend(VelbusButtonLight(channel) for channel in cntrl.get_all("led")) + entities.extend( + VelbusButtonLight(channel) + for channel in entry.runtime_data.controller.get_all_led() + ) async_add_entities(entities) @@ -118,19 +122,14 @@ class VelbusButtonLight(VelbusEntity, LightEntity): @api_call async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the Velbus light to turn on.""" - if ATTR_FLASH in kwargs: - if kwargs[ATTR_FLASH] == FLASH_LONG: - attr, *args = "set_led_state", "slow" - elif kwargs[ATTR_FLASH] == FLASH_SHORT: - attr, *args = "set_led_state", "fast" - else: - attr, *args = "set_led_state", "on" + if (flash := ATTR_FLASH in kwargs) and kwargs[ATTR_FLASH] == FLASH_LONG: + await self._channel.set_led_state("slow") + elif flash and kwargs[ATTR_FLASH] == FLASH_SHORT: + await self._channel.set_led_state("fast") else: - attr, *args = "set_led_state", "on" - await getattr(self._channel, attr)(*args) + await self._channel.set_led_state("on") @api_call async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the velbus light to turn off.""" - attr, *args = "set_led_state", "off" - await getattr(self._channel, attr)(*args) + await self._channel.set_led_state("off") diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 84262ebd61c..960f127d16e 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2024.11.1"], + "requirements": ["velbus-aio==2025.1.1"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml new file mode 100644 index 00000000000..0ad3e3ce485 --- /dev/null +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + Dynamically build up the port parameter based on inputs provided by the user, do not fill-in a name parameter, build it up in the config flow + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: todo + comment: | + Dynamic devices are discovered, but no entities are created for them + entity-category: done + entity-device-class: todo + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration communicates via serial/usb/tcp and does not require a web session. + strict-typing: done diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py index 7eecb85fc47..6c2dfe0a3b1 100644 --- a/homeassistant/components/velbus/select.py +++ b/homeassistant/components/velbus/select.py @@ -3,24 +3,27 @@ from velbusaio.channels import SelectedProgram from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus select based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - async_add_entities(VelbusSelect(channel) for channel in cntrl.get_all("select")) + await entry.runtime_data.scan_task + async_add_entities( + VelbusSelect(channel) + for channel in entry.runtime_data.controller.get_all_select() + ) class VelbusSelect(VelbusEntity, SelectEntity): diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index b765eebcddc..77833da3ee1 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -9,24 +9,24 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + await entry.runtime_data.scan_task entities = [] - for channel in cntrl.get_all("sensor"): + for channel in entry.runtime_data.controller.get_all_sensor(): entities.append(VelbusSensor(channel)) if channel.is_counter_channel(): entities.append(VelbusSensor(channel, True)) diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py new file mode 100644 index 00000000000..765c5a0f674 --- /dev/null +++ b/homeassistant/components/velbus/services.py @@ -0,0 +1,216 @@ +"""Support for Velbus devices.""" + +from __future__ import annotations + +from contextlib import suppress +import os +import shutil +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.storage import STORAGE_DIR + +if TYPE_CHECKING: + from . import VelbusConfigEntry + +from .const import ( + CONF_CONFIG_ENTRY, + CONF_INTERFACE, + CONF_MEMO_TEXT, + DOMAIN, + SERVICE_CLEAR_CACHE, + SERVICE_SCAN, + SERVICE_SET_MEMO_TEXT, + SERVICE_SYNC, +) + + +def setup_services(hass: HomeAssistant) -> None: + """Register the velbus services.""" + + def check_entry_id(interface: str) -> str: + """Check the config_entry for a specific interface.""" + for config_entry in hass.config_entries.async_entries(DOMAIN): + if "port" in config_entry.data and config_entry.data["port"] == interface: + return config_entry.entry_id + raise vol.Invalid( + "The interface provided is not defined as a port in a Velbus integration" + ) + + async def get_config_entry(call: ServiceCall) -> VelbusConfigEntry: + """Get the config entry for this service call.""" + if CONF_CONFIG_ENTRY in call.data: + entry_id = call.data[CONF_CONFIG_ENTRY] + elif CONF_INTERFACE in call.data: + # Deprecated in 2025.2, to remove in 2025.8 + async_create_issue( + hass, + DOMAIN, + "deprecated_interface_parameter", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_interface_parameter", + ) + entry_id = call.data[CONF_INTERFACE] + if not (entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return entry + + async def scan(call: ServiceCall) -> None: + """Handle a scan service call.""" + entry = await get_config_entry(call) + await entry.runtime_data.controller.scan() + + async def syn_clock(call: ServiceCall) -> None: + """Handle a sync clock service call.""" + entry = await get_config_entry(call) + await entry.runtime_data.controller.sync_clock() + + async def set_memo_text(call: ServiceCall) -> None: + """Handle Memo Text service call.""" + entry = await get_config_entry(call) + memo_text = call.data[CONF_MEMO_TEXT] + module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS]) + if not module: + raise ServiceValidationError("Module not found") + await module.set_memo_text(memo_text.async_render()) + + async def clear_cache(call: ServiceCall) -> None: + """Handle a clear cache service call.""" + entry = await get_config_entry(call) + with suppress(FileNotFoundError): + if call.data.get(CONF_ADDRESS): + await hass.async_add_executor_job( + os.unlink, + hass.config.path( + STORAGE_DIR, + f"velbuscache-{entry.entry_id}/{call.data[CONF_ADDRESS]}.p", + ), + ) + else: + await hass.async_add_executor_job( + shutil.rmtree, + hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}/"), + ) + # call a scan to repopulate + await scan(call) + + hass.services.async_register( + DOMAIN, + SERVICE_SCAN, + scan, + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } + ), + ), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SYNC, + syn_clock, + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } + ), + ), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + set_memo_text, + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } + ), + ), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_CACHE, + clear_cache, + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), + ), + ) diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index e3ecc3556f0..39886913692 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -1,29 +1,38 @@ sync_clock: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus scan: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus clear_cache: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus address: required: false selector: @@ -34,11 +43,14 @@ clear_cache: set_memo_text: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus address: required: true selector: diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 55c7fda84ac..90938a6c1d2 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -20,6 +20,12 @@ "exceptions": { "invalid_hvac_mode": { "message": "Climate mode {hvac_mode} is not supported." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." } }, "services": { @@ -30,16 +36,24 @@ "interface": { "name": "Interface", "description": "The velbus interface to send the command to, this will be the same value as used during configuration." + }, + "config_entry": { + "name": "Config entry", + "description": "The config entry of the velbus integration" } } }, "scan": { "name": "Scan", - "description": "Scans the velbus modules, this will be need if you see unknown module warnings in the logs, or when you added new modules.", + "description": "Scans the velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" + }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" } } }, @@ -51,29 +65,43 @@ "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" + }, "address": { "name": "Address", - "description": "The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) The decimal addresses are displayed in front of the modules listed at the integration page.\n." + "description": "The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) The decimal addresses are displayed in front of the modules listed at the integration page." } } }, "set_memo_text": { "name": "Set memo text", - "description": "Sets the memo text to the display of modules like VMBGPO, VMBGPOD Be sure the page(s) of the module is configured to display the memo text.\n.", + "description": "Sets the memo text to the display of modules like VMBGPO, VMBGPOD Be sure the page(s) of the module is configured to display the memo text.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" + }, "address": { "name": "Address", - "description": "The module address in decimal format. The decimal addresses are displayed in front of the modules listed at the integration page.\n." + "description": "The module address in decimal format. The decimal addresses are displayed in front of the modules listed at the integration page." }, "memo_text": { "name": "Memo text", - "description": "The actual text to be displayed. Text is limited to 64 characters.\n." + "description": "The actual text to be displayed. Text is limited to 64 characters." } } } + }, + "issues": { + "deprecated_interface_parameter": { + "title": "Deprecated 'interface' parameter", + "description": "The 'interface' parameter in the Velbus service calls is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + } } } diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py index 1e6014b8d90..8256e716d4f 100644 --- a/homeassistant/components/velbus/switch.py +++ b/homeassistant/components/velbus/switch.py @@ -5,23 +5,26 @@ from typing import Any from velbusaio.channels import Relay as VelbusRelay from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import VelbusConfigEntry from .entity import VelbusEntity, api_call +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VelbusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - await hass.data[DOMAIN][entry.entry_id]["tsk"] - cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - async_add_entities(VelbusSwitch(channel) for channel in cntrl.get_all("switch")) + await entry.runtime_data.scan_task + async_add_entities( + VelbusSwitch(channel) + for channel in entry.runtime_data.controller.get_all_switch() + ) class VelbusSwitch(VelbusEntity, SwitchEntity): diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py index f4bfa13b4d5..fba023f7638 100644 --- a/homeassistant/components/velux/config_flow.py +++ b/homeassistant/components/velux/config_flow.py @@ -1,15 +1,19 @@ """Config flow for Velux integration.""" +from typing import Any + from pyvlx import PyVLX, PyVLXException import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, LOGGER -DATA_SCHEMA = vol.Schema( +USER_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -17,9 +21,31 @@ DATA_SCHEMA = vol.Schema( ) +async def _check_connection(host: str, password: str) -> dict[str, Any]: + """Check if we can connect to the Velux bridge.""" + pyvlx = PyVLX(host=host, password=password) + try: + await pyvlx.connect() + await pyvlx.disconnect() + except (PyVLXException, ConnectionError) as err: + LOGGER.debug("Cannot connect: %s", err) + return {"base": "cannot_connect"} + except Exception as err: # noqa: BLE001 + LOGGER.exception("Unexpected exception: %s", err) + return {"base": "unknown"} + + return {} + + class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for velux.""" + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.discovery_data: dict[str, Any] = {} + async def async_step_user( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: @@ -28,28 +54,78 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - - pyvlx = PyVLX( - host=user_input[CONF_HOST], password=user_input[CONF_PASSWORD] + errors = await _check_connection( + user_input[CONF_HOST], user_input[CONF_PASSWORD] ) - try: - await pyvlx.connect() - await pyvlx.disconnect() - except (PyVLXException, ConnectionError) as err: - errors["base"] = "cannot_connect" - LOGGER.debug("Cannot connect: %s", err) - except Exception as err: # noqa: BLE001 - LOGGER.exception("Unexpected exception: %s", err) - errors["base"] = "unknown" - else: + if not errors: return self.async_create_entry( title=user_input[CONF_HOST], data=user_input, ) - data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) return self.async_show_form( step_id="user", - data_schema=data_schema, + data_schema=USER_SCHEMA, errors=errors, ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle discovery by DHCP.""" + # The hostname ends with the last 4 digits of the device MAC address. + self.discovery_data[CONF_HOST] = discovery_info.ip + self.discovery_data[CONF_MAC] = format_mac(discovery_info.macaddress) + self.discovery_data[CONF_NAME] = discovery_info.hostname.upper().replace( + "LAN_", "" + ) + + await self.async_set_unique_id(self.discovery_data[CONF_NAME]) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.discovery_data[CONF_HOST]} + ) + + # Abort if config_entry already exists without unigue_id configured. + for entry in self.hass.config_entries.async_entries(DOMAIN): + if ( + entry.data[CONF_HOST] == self.discovery_data[CONF_HOST] + and entry.unique_id is None + and entry.state is ConfigEntryState.LOADED + ): + self.hass.config_entries.async_update_entry( + entry=entry, + unique_id=self.discovery_data[CONF_NAME], + data={**entry.data, **self.discovery_data}, + ) + return self.async_abort(reason="already_configured") + self._async_abort_entries_match({CONF_HOST: self.discovery_data[CONF_HOST]}) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare configuration for a discovered Velux device.""" + errors: dict[str, str] = {} + if user_input is not None: + errors = await _check_connection( + self.discovery_data[CONF_HOST], user_input[CONF_PASSWORD] + ) + if not errors: + return self.async_create_entry( + title=self.discovery_data[CONF_NAME], + data={**self.discovery_data, **user_input}, + ) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + } + ), + errors=errors, + description_placeholders={ + "name": self.discovery_data[CONF_NAME], + "host": self.discovery_data[CONF_HOST], + }, + ) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index c3576aca925..cb21fef299d 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -1,10 +1,16 @@ { "domain": "velux", "name": "Velux", - "codeowners": ["@Julius2342", "@DeerMaximum"], + "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio"], "config_flow": true, + "dhcp": [ + { + "hostname": "velux_klf*", + "macaddress": "646184*" + } + ], "documentation": "https://www.home-assistant.io/integrations/velux", "iot_class": "local_polling", "loggers": ["pyvlx"], - "requirements": ["pyvlx==0.2.21"] + "requirements": ["pyvlx==0.2.26"] } diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 5b7b459a3f7..0cf578732fb 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -2,11 +2,16 @@ "config": { "step": { "user": { - "title": "Setup Velux", "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "discovery_confirm": { + "description": "Please enter the password for {name} ({host})", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index 2865d64201e..c5323e1e9a8 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -110,7 +110,6 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF, HVACMode.AUTO] _attr_precision = PRECISION_HALVES _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index 01fe26be6bc..eb2a5206f30 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -54,7 +54,6 @@ class VeraThermostat(VeraEntity[veraApi.VeraThermostat], ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, vera_device: veraApi.VeraThermostat, controller_data: ControllerData diff --git a/homeassistant/components/vera/strings.json b/homeassistant/components/vera/strings.json index 3bfb58f8104..dcb8f6fc3a2 100644 --- a/homeassistant/components/vera/strings.json +++ b/homeassistant/components/vera/strings.json @@ -8,8 +8,8 @@ "user": { "data": { "vera_controller_url": "Controller URL", - "lights": "Vera switch device ids to treat as lights in Home Assistant.", - "exclude": "Vera device ids to exclude from Home Assistant." + "lights": "Vera switch device IDs to treat as lights in Home Assistant.", + "exclude": "Vera device IDs to exclude from Home Assistant." }, "data_description": { "vera_controller_url": "It should look like this: http://192.168.1.161:3480" @@ -21,7 +21,7 @@ "step": { "init": { "title": "Vera controller options", - "description": "See the vera documentation for details on optional parameters: https://www.home-assistant.io/integrations/vera/. Note: Any changes here will need a restart to the home assistant server. To clear values, provide a space.", + "description": "See the Vera documentation for details on optional parameters: https://www.home-assistant.io/integrations/vera/. Note: Any changes here require a restart of the Home Assistant server. To clear values, provide a space.", "data": { "lights": "[%key:component::vera::config::step::user::data::lights%]", "exclude": "[%key:component::vera::config::step::user::data::exclude%]" diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index b6f263f3037..240a793f518 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -9,19 +9,25 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.dispatcher import async_dispatcher_send -from .common import async_process_devices +from .common import async_generate_device_list from .const import ( DOMAIN, SERVICE_UPDATE_DEVS, + VS_COORDINATOR, + VS_DEVICES, VS_DISCOVERY, - VS_FANS, - VS_LIGHTS, VS_MANAGER, - VS_SENSORS, - VS_SWITCHES, ) +from .coordinator import VeSyncDataCoordinator -PLATFORMS = [Platform.FAN, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) @@ -41,90 +47,33 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.error("Unable to login to the VeSync server") return False - device_dict = await async_process_devices(hass, manager) - - forward_setups = hass.config_entries.async_forward_entry_setups - hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager - switches = hass.data[DOMAIN][VS_SWITCHES] = [] - fans = hass.data[DOMAIN][VS_FANS] = [] - lights = hass.data[DOMAIN][VS_LIGHTS] = [] - sensors = hass.data[DOMAIN][VS_SENSORS] = [] - platforms = [] + coordinator = VeSyncDataCoordinator(hass, manager) - if device_dict[VS_SWITCHES]: - switches.extend(device_dict[VS_SWITCHES]) - platforms.append(Platform.SWITCH) + # Store coordinator at domain level since only single integration instance is permitted. + hass.data[DOMAIN][VS_COORDINATOR] = coordinator - if device_dict[VS_FANS]: - fans.extend(device_dict[VS_FANS]) - platforms.append(Platform.FAN) + hass.data[DOMAIN][VS_DEVICES] = await async_generate_device_list(hass, manager) - if device_dict[VS_LIGHTS]: - lights.extend(device_dict[VS_LIGHTS]) - platforms.append(Platform.LIGHT) - - if device_dict[VS_SENSORS]: - sensors.extend(device_dict[VS_SENSORS]) - platforms.append(Platform.SENSOR) - - await hass.config_entries.async_forward_entry_setups(config_entry, platforms) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async def async_new_device_discovery(service: ServiceCall) -> None: """Discover if new devices should be added.""" manager = hass.data[DOMAIN][VS_MANAGER] - switches = hass.data[DOMAIN][VS_SWITCHES] - fans = hass.data[DOMAIN][VS_FANS] - lights = hass.data[DOMAIN][VS_LIGHTS] - sensors = hass.data[DOMAIN][VS_SENSORS] + devices = hass.data[DOMAIN][VS_DEVICES] - dev_dict = await async_process_devices(hass, manager) - switch_devs = dev_dict.get(VS_SWITCHES, []) - fan_devs = dev_dict.get(VS_FANS, []) - light_devs = dev_dict.get(VS_LIGHTS, []) - sensor_devs = dev_dict.get(VS_SENSORS, []) + new_devices = await async_generate_device_list(hass, manager) - switch_set = set(switch_devs) - new_switches = list(switch_set.difference(switches)) - if new_switches and switches: - switches.extend(new_switches) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_SWITCHES), new_switches) + device_set = set(new_devices) + new_devices = list(device_set.difference(devices)) + if new_devices and devices: + devices.extend(new_devices) + async_dispatcher_send(hass, VS_DISCOVERY.format(VS_DEVICES), new_devices) return - if new_switches and not switches: - switches.extend(new_switches) - hass.async_create_task(forward_setups(config_entry, [Platform.SWITCH])) - - fan_set = set(fan_devs) - new_fans = list(fan_set.difference(fans)) - if new_fans and fans: - fans.extend(new_fans) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_FANS), new_fans) - return - if new_fans and not fans: - fans.extend(new_fans) - hass.async_create_task(forward_setups(config_entry, [Platform.FAN])) - - light_set = set(light_devs) - new_lights = list(light_set.difference(lights)) - if new_lights and lights: - lights.extend(new_lights) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_LIGHTS), new_lights) - return - if new_lights and not lights: - lights.extend(new_lights) - hass.async_create_task(forward_setups(config_entry, [Platform.LIGHT])) - - sensor_set = set(sensor_devs) - new_sensors = list(sensor_set.difference(sensors)) - if new_sensors and sensors: - sensors.extend(new_sensors) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_SENSORS), new_sensors) - return - if new_sensors and not sensors: - sensors.extend(new_sensors) - hass.async_create_task(forward_setups(config_entry, [Platform.SENSOR])) + if new_devices and not devices: + devices.extend(new_devices) hass.services.async_register( DOMAIN, SERVICE_UPDATE_DEVS, async_new_device_discovery @@ -135,6 +84,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 5f7b2a3a29e..c51b6a913d3 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -2,43 +2,33 @@ import logging -from .const import VS_FANS, VS_LIGHTS, VS_SENSORS, VS_SWITCHES +from pyvesync import VeSync +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.core import HomeAssistant + +from .const import VeSyncHumidifierDevice _LOGGER = logging.getLogger(__name__) -async def async_process_devices(hass, manager): +async def async_generate_device_list( + hass: HomeAssistant, manager: VeSync +) -> list[VeSyncBaseDevice]: """Assign devices to proper component.""" - devices = {} - devices[VS_SWITCHES] = [] - devices[VS_FANS] = [] - devices[VS_LIGHTS] = [] - devices[VS_SENSORS] = [] + devices: list[VeSyncBaseDevice] = [] await hass.async_add_executor_job(manager.update) - if manager.fans: - devices[VS_FANS].extend(manager.fans) - # Expose fan sensors separately - devices[VS_SENSORS].extend(manager.fans) - _LOGGER.debug("%d VeSync fans found", len(manager.fans)) - - if manager.bulbs: - devices[VS_LIGHTS].extend(manager.bulbs) - _LOGGER.debug("%d VeSync lights found", len(manager.bulbs)) - - if manager.outlets: - devices[VS_SWITCHES].extend(manager.outlets) - # Expose outlets' voltage, power & energy usage as separate sensors - devices[VS_SENSORS].extend(manager.outlets) - _LOGGER.debug("%d VeSync outlets found", len(manager.outlets)) - - if manager.switches: - for switch in manager.switches: - if not switch.is_dimmable(): - devices[VS_SWITCHES].append(switch) - else: - devices[VS_LIGHTS].append(switch) - _LOGGER.debug("%d VeSync switches found", len(manager.switches)) + devices.extend(manager.fans) + devices.extend(manager.bulbs) + devices.extend(manager.outlets) + devices.extend(manager.switches) return devices + + +def is_humidifier(device: VeSyncBaseDevice) -> bool: + """Check if the device represents a humidifier.""" + + return isinstance(device, VeSyncHumidifierDevice) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 48215819ce5..841185e4308 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -1,14 +1,36 @@ """Constants for VeSync Component.""" +from pyvesync.vesyncfan import VeSyncHumid200300S, VeSyncSuperior6000S + DOMAIN = "vesync" VS_DISCOVERY = "vesync_discovery_{}" SERVICE_UPDATE_DEVS = "update_devices" -VS_SWITCHES = "switches" -VS_FANS = "fans" -VS_LIGHTS = "lights" -VS_SENSORS = "sensors" +UPDATE_INTERVAL = 60 +""" +Update interval for DataCoordinator. + +The vesync daily quota formula is 3200 + 1500 * device_count. + +An interval of 60 seconds amounts 1440 calls/day which +would be below the 4700 daily quota. For 2 devices, the +total would be 2880. + +Using 30 seconds interval gives 8640 for 3 devices which +exceeds the quota of 7700. +""" +VS_DEVICES = "devices" +VS_COORDINATOR = "coordinator" VS_MANAGER = "manager" +VS_NUMBERS = "numbers" + +VS_HUMIDIFIER_MODE_AUTO = "auto" +VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" +VS_HUMIDIFIER_MODE_MANUAL = "manual" +VS_HUMIDIFIER_MODE_SLEEP = "sleep" + +VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S +"""Humidifier device types""" DEV_TYPE_TO_HA = { "wifi-switch-1.3": "outlet", @@ -42,6 +64,7 @@ SKU_TO_BASE_DEVICE = { "Core300S": "Core300S", "LAP-C301S-WJP": "Core300S", # Alt ID Model Core300S "LAP-C301S-WAAA": "Core300S", # Alt ID Model Core300S + "LAP-C302S-WUSB": "Core300S", # Alt ID Model Core300S "Core400S": "Core400S", "LAP-C401S-WJP": "Core400S", # Alt ID Model Core400S "LAP-C401S-WUSR": "Core400S", # Alt ID Model Core400S @@ -56,6 +79,7 @@ SKU_TO_BASE_DEVICE = { "LAP-V201S-WEU": "Vital200S", # Alt ID Model Vital200S "LAP-V201S-WUS": "Vital200S", # Alt ID Model Vital200S "LAP-V201-AUSR": "Vital200S", # Alt ID Model Vital200S + "LAP-V201S-AEUR": "Vital200S", # Alt ID Model Vital200S "LAP-V201S-AUSR": "Vital200S", # Alt ID Model Vital200S "Vital100S": "Vital100S", "LAP-V102S-WUS": "Vital100S", # Alt ID Model Vital100S diff --git a/homeassistant/components/vesync/coordinator.py b/homeassistant/components/vesync/coordinator.py new file mode 100644 index 00000000000..f3df2970fdb --- /dev/null +++ b/homeassistant/components/vesync/coordinator.py @@ -0,0 +1,43 @@ +"""Class to manage VeSync data updates.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pyvesync import VeSync + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class VeSyncDataCoordinator(DataUpdateCoordinator[None]): + """Class representing data coordinator for VeSync devices.""" + + def __init__(self, hass: HomeAssistant, manager: VeSync) -> None: + """Initialize.""" + self._manager = manager + + super().__init__( + hass, + _LOGGER, + name="VeSyncDataCoordinator", + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + + return await self.hass.async_add_executor_job(self.update_data_all) + + def update_data_all(self) -> None: + """Update all the devices.""" + + # Using `update_all_devices` instead of `update` to avoid fetching device list every time. + self._manager.update_all_devices() + # Vesync updates energy on applicable devices every 6 hours + self._manager.update_energy() diff --git a/homeassistant/components/vesync/entity.py b/homeassistant/components/vesync/entity.py index fd636561e9e..3aa7b008cc5 100644 --- a/homeassistant/components/vesync/entity.py +++ b/homeassistant/components/vesync/entity.py @@ -1,22 +1,24 @@ """Common entity for VeSync Component.""" -from typing import Any - from pyvesync.vesyncbasedevice import VeSyncBaseDevice from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, ToggleEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import VeSyncDataCoordinator -class VeSyncBaseEntity(Entity): +class VeSyncBaseEntity(CoordinatorEntity[VeSyncDataCoordinator]): """Base class for VeSync Entity Representations.""" _attr_has_entity_name = True - def __init__(self, device: VeSyncBaseDevice) -> None: + def __init__( + self, device: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator + ) -> None: """Initialize the VeSync device.""" + super().__init__(coordinator) self.device = device self._attr_unique_id = self.base_unique_id @@ -45,25 +47,3 @@ class VeSyncBaseEntity(Entity): manufacturer="VeSync", sw_version=self.device.current_firm_version, ) - - def update(self) -> None: - """Update vesync device.""" - self.device.update() - - -class VeSyncDevice(VeSyncBaseEntity, ToggleEntity): - """Base class for VeSync Device Representations.""" - - @property - def details(self): - """Provide access to the device details dictionary.""" - return self.device.details - - @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self.device.device_status == "on" - - def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - self.device.turn_off() diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 098a17e90f0..ba1880f2492 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -6,6 +6,8 @@ import logging import math from typing import Any +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -17,8 +19,16 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS -from .entity import VeSyncDevice +from .const import ( + DEV_TYPE_TO_HA, + DOMAIN, + SKU_TO_BASE_DEVICE, + VS_COORDINATOR, + VS_DEVICES, + VS_DISCOVERY, +) +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) @@ -56,35 +66,37 @@ async def async_setup_entry( ) -> None: """Set up the VeSync fan platform.""" + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + @callback def discover(devices): """Add new devices to platform.""" - _setup_entities(devices, async_add_entities) + _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_FANS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_FANS], async_add_entities) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback -def _setup_entities(devices, async_add_entities): - """Check if device is online and add entity.""" - entities = [] - for dev in devices: - if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type)) == "fan": - entities.append(VeSyncFanHA(dev)) - else: - _LOGGER.warning( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities, + coordinator: VeSyncDataCoordinator, +): + """Check if device is fan and add entity.""" + entities = [ + VeSyncFanHA(dev, coordinator) + for dev in devices + if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan" + ] async_add_entities(entities, update_before_add=True) -class VeSyncFanHA(VeSyncDevice, FanEntity): +class VeSyncFanHA(VeSyncBaseEntity, FanEntity): """Representation of a VeSync fan.""" _attr_supported_features = ( @@ -95,13 +107,19 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): ) _attr_name = None _attr_translation_key = "vesync" - _enable_turn_on_off_backwards_compatibility = False - def __init__(self, fan) -> None: + def __init__( + self, fan: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator + ) -> None: """Initialize the VeSync fan device.""" - super().__init__(fan) + super().__init__(fan, coordinator) self.smartfan = fan + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.device.device_status == "on" + @property def percentage(self) -> int | None: """Return the current speed.""" @@ -214,3 +232,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): if percentage is None: percentage = 50 self.set_percentage(percentage) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self.device.turn_off() diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py new file mode 100644 index 00000000000..3d89d5dc6db --- /dev/null +++ b/homeassistant/components/vesync/humidifier.py @@ -0,0 +1,183 @@ +"""Support for VeSync humidifiers.""" + +import logging +from typing import Any + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.humidifier import ( + MODE_AUTO, + MODE_NORMAL, + MODE_SLEEP, + HumidifierEntity, + HumidifierEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .common import is_humidifier +from .const import ( + DOMAIN, + VS_COORDINATOR, + VS_DEVICES, + VS_DISCOVERY, + VS_HUMIDIFIER_MODE_AUTO, + VS_HUMIDIFIER_MODE_HUMIDITY, + VS_HUMIDIFIER_MODE_MANUAL, + VS_HUMIDIFIER_MODE_SLEEP, + VeSyncHumidifierDevice, +) +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +MIN_HUMIDITY = 30 +MAX_HUMIDITY = 80 + +VS_TO_HA_MODE_MAP = { + VS_HUMIDIFIER_MODE_AUTO: MODE_AUTO, + VS_HUMIDIFIER_MODE_HUMIDITY: MODE_AUTO, + VS_HUMIDIFIER_MODE_MANUAL: MODE_NORMAL, + VS_HUMIDIFIER_MODE_SLEEP: MODE_SLEEP, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the VeSync humidifier platform.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities, coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add humidifier entities.""" + async_add_entities( + VeSyncHumidifierHA(dev, coordinator) for dev in devices if is_humidifier(dev) + ) + + +def _get_ha_mode(vs_mode: str) -> str | None: + ha_mode = VS_TO_HA_MODE_MAP.get(vs_mode) + if ha_mode is None: + _LOGGER.warning("Unknown mode '%s'", vs_mode) + return ha_mode + + +class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): + """Representation of a VeSync humidifier.""" + + # The base VeSyncBaseEntity has _attr_has_entity_name and this is to follow the device name + _attr_name = None + + _attr_max_humidity = MAX_HUMIDITY + _attr_min_humidity = MIN_HUMIDITY + _attr_supported_features = HumidifierEntityFeature.MODES + + device: VeSyncHumidifierDevice + + def __init__( + self, + device: VeSyncBaseDevice, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSyncHumidifierHA device.""" + super().__init__(device, coordinator) + + # 2 Vesync humidifier modes (humidity and auto) maps to the HA mode auto. + # They are on different devices though. We need to map HA mode to the + # device specific mode when setting it. + + self._ha_to_vs_mode_map: dict[str, str] = {} + self._available_modes: list[str] = [] + + # Populate maps once. + for vs_mode in self.device.mist_modes: + ha_mode = _get_ha_mode(vs_mode) + if ha_mode: + self._available_modes.append(ha_mode) + self._ha_to_vs_mode_map[ha_mode] = vs_mode + + def _get_vs_mode(self, ha_mode: str) -> str | None: + return self._ha_to_vs_mode_map.get(ha_mode) + + @property + def available_modes(self) -> list[str]: + """Return the available mist modes.""" + return self._available_modes + + @property + def target_humidity(self) -> int: + """Return the humidity we try to reach.""" + return self.device.auto_humidity + + @property + def mode(self) -> str | None: + """Get the current preset mode.""" + return _get_ha_mode(self.device.mode) + + def set_humidity(self, humidity: int) -> None: + """Set the target humidity of the device.""" + if not self.device.set_humidity(humidity): + raise HomeAssistantError( + f"An error occurred while setting humidity {humidity}." + ) + + def set_mode(self, mode: str) -> None: + """Set the mode of the device.""" + if mode not in self.available_modes: + raise HomeAssistantError( + "{mode} is not one of the valid available modes: {self.available_modes}" + ) + if not self.device.set_humidity_mode(self._get_vs_mode(mode)): + raise HomeAssistantError(f"An error occurred while setting mode {mode}.") + + # Changing mode while humidifier is off actually turns it on, as per the app. But + # the library does not seem to update the device_status. It is also possible that + # other attributes get updated. Scheduling a forced refresh to get device status. + # updated. + self.schedule_update_ha_state(force_refresh=True) + + def turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + success = self.device.turn_on() + if not success: + raise HomeAssistantError("An error occurred while turning on.") + + self.schedule_update_ha_state() + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + success = self.device.turn_off() + if not success: + raise HomeAssistantError("An error occurred while turning off.") + + self.schedule_update_ha_state() + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.device.device_status == "on" diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index 6e449f63394..40f68986145 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -3,9 +3,11 @@ import logging from typing import Any +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ColorMode, LightEntity, ) @@ -13,11 +15,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_LIGHTS -from .entity import VeSyncDevice +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) +MAX_MIREDS = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds +MIN_MIREDS = 153 # 1,000,000 divided by 6500 Kelvin = 153 Mireds async def async_setup_entry( @@ -27,41 +33,47 @@ async def async_setup_entry( ) -> None: """Set up lights.""" + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + @callback def discover(devices): """Add new devices to platform.""" - _setup_entities(devices, async_add_entities) + _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_LIGHTS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_LIGHTS], async_add_entities) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback -def _setup_entities(devices, async_add_entities): - """Check if device is online and add entity.""" - entities = [] +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities, + coordinator: VeSyncDataCoordinator, +): + """Check if device is a light and add entity.""" + entities: list[VeSyncBaseLightHA] = [] for dev in devices: if DEV_TYPE_TO_HA.get(dev.device_type) in ("walldimmer", "bulb-dimmable"): - entities.append(VeSyncDimmableLightHA(dev)) + entities.append(VeSyncDimmableLightHA(dev, coordinator)) elif DEV_TYPE_TO_HA.get(dev.device_type) in ("bulb-tunable-white",): - entities.append(VeSyncTunableWhiteLightHA(dev)) - else: - _LOGGER.debug( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue + entities.append(VeSyncTunableWhiteLightHA(dev, coordinator)) async_add_entities(entities, update_before_add=True) -class VeSyncBaseLight(VeSyncDevice, LightEntity): +class VeSyncBaseLightHA(VeSyncBaseEntity, LightEntity): """Base class for VeSync Light Devices Representations.""" _attr_name = None + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.device.device_status == "on" + @property def brightness(self) -> int: """Get light brightness.""" @@ -84,15 +96,16 @@ class VeSyncBaseLight(VeSyncDevice, LightEntity): """Turn the device on.""" attribute_adjustment_only = False # set white temperature - if self.color_mode == ColorMode.COLOR_TEMP and ATTR_COLOR_TEMP in kwargs: + if self.color_mode == ColorMode.COLOR_TEMP and ATTR_COLOR_TEMP_KELVIN in kwargs: # get white temperature from HA data - color_temp = int(kwargs[ATTR_COLOR_TEMP]) + color_temp = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) # ensure value between min-max supported Mireds - color_temp = max(self.min_mireds, min(color_temp, self.max_mireds)) + color_temp = max(MIN_MIREDS, min(color_temp, MAX_MIREDS)) # convert Mireds to Percent value that api expects color_temp = round( - ((color_temp - self.min_mireds) / (self.max_mireds - self.min_mireds)) - * 100 + ((color_temp - MIN_MIREDS) / (MAX_MIREDS - MIN_MIREDS)) * 100 ) # flip cold/warm to what pyvesync api expects color_temp = 100 - color_temp @@ -126,25 +139,29 @@ class VeSyncBaseLight(VeSyncDevice, LightEntity): # send turn_on command to pyvesync api self.device.turn_on() + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self.device.turn_off() -class VeSyncDimmableLightHA(VeSyncBaseLight, LightEntity): + +class VeSyncDimmableLightHA(VeSyncBaseLightHA, LightEntity): """Representation of a VeSync dimmable light device.""" _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} -class VeSyncTunableWhiteLightHA(VeSyncBaseLight, LightEntity): +class VeSyncTunableWhiteLightHA(VeSyncBaseLightHA, LightEntity): """Representation of a VeSync Tunable White Light device.""" _attr_color_mode = ColorMode.COLOR_TEMP - _attr_max_mireds = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds - _attr_min_mireds = 154 # 1,000,000 divided by 6500 Kelvin = 154 Mireds + _attr_min_color_temp_kelvin = 2700 # 370 Mireds + _attr_max_color_temp_kelvin = 6500 # 153 Mireds _attr_supported_color_modes = {ColorMode.COLOR_TEMP} @property - def color_temp(self) -> int: - """Get device white temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" # get value from pyvesync library api, result = self.device.color_temp_pct try: @@ -159,15 +176,16 @@ class VeSyncTunableWhiteLightHA(VeSyncBaseLight, LightEntity): ), result, ) - return 0 + return None # flip cold/warm color_temp_value = 100 - color_temp_value # ensure value between 0-100 color_temp_value = max(0, min(color_temp_value, 100)) # convert percent value to Mireds color_temp_value = round( - self.min_mireds - + ((self.max_mireds - self.min_mireds) / 100 * color_temp_value) + MIN_MIREDS + ((MAX_MIREDS - MIN_MIREDS) / 100 * color_temp_value) ) # ensure value between minimum and maximum Mireds - return max(self.min_mireds, min(color_temp_value, self.max_mireds)) + return color_util.color_temperature_mired_to_kelvin( + max(MIN_MIREDS, min(color_temp_value, MAX_MIREDS)) + ) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index c5926cc224a..cdb5ed96652 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -1,10 +1,16 @@ { "domain": "vesync", "name": "VeSync", - "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey", "@cdnninja"], + "codeowners": [ + "@markperdue", + "@webdjoe", + "@thegardenmonkey", + "@cdnninja", + "@iprak" + ], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==2.1.12"] + "requirements": ["pyvesync==2.1.16"] } diff --git a/homeassistant/components/vesync/number.py b/homeassistant/components/vesync/number.py new file mode 100644 index 00000000000..3c43cce28cf --- /dev/null +++ b/homeassistant/components/vesync/number.py @@ -0,0 +1,114 @@ +"""Support for VeSync numeric entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .common import is_humidifier +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VeSyncNumberEntityDescription(NumberEntityDescription): + """Class to describe a Vesync number entity.""" + + exists_fn: Callable[[VeSyncBaseDevice], bool] + value_fn: Callable[[VeSyncBaseDevice], float] + set_value_fn: Callable[[VeSyncBaseDevice, float], bool] + + +NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [ + VeSyncNumberEntityDescription( + key="mist_level", + translation_key="mist_level", + native_min_value=1, + native_max_value=9, + native_step=1, + mode=NumberMode.SLIDER, + exists_fn=is_humidifier, + set_value_fn=lambda device, value: device.set_mist_level(value), + value_fn=lambda device: device.mist_level, + ) +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up number entities.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities, coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add number entities.""" + + async_add_entities( + VeSyncNumberEntity(dev, description, coordinator) + for dev in devices + for description in NUMBER_DESCRIPTIONS + if description.exists_fn(dev) + ) + + +class VeSyncNumberEntity(VeSyncBaseEntity, NumberEntity): + """A class to set numeric options on Vesync device.""" + + entity_description: VeSyncNumberEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncNumberEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSync number device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def native_value(self) -> float: + """Return the value reported by the number.""" + return self.entity_description.value_fn(self.device) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + if await self.hass.async_add_executor_job( + self.entity_description.set_value_fn, self.device, value + ): + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index 79061ec0c4c..bf52050d745 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -6,9 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass import logging -from pyvesync.vesyncfan import VeSyncAirBypass -from pyvesync.vesyncoutlet import VeSyncOutlet -from pyvesync.vesyncswitch import VeSyncSwitch +from pyvesync.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.sensor import ( SensorDeviceClass, @@ -30,7 +28,16 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_SENSORS +from .common import is_humidifier +from .const import ( + DEV_TYPE_TO_HA, + DOMAIN, + SKU_TO_BASE_DEVICE, + VS_COORDINATOR, + VS_DEVICES, + VS_DISCOVERY, +) +from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) @@ -40,14 +47,10 @@ _LOGGER = logging.getLogger(__name__) class VeSyncSensorEntityDescription(SensorEntityDescription): """Describe VeSync sensor entity.""" - value_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], StateType] + value_fn: Callable[[VeSyncBaseDevice], StateType] - exists_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], bool] = ( - lambda _: True - ) - update_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], None] = ( - lambda _: None - ) + exists_fn: Callable[[VeSyncBaseDevice], bool] = lambda _: True + update_fn: Callable[[VeSyncBaseDevice], None] = lambda _: None def update_energy(device): @@ -177,6 +180,14 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( update_fn=update_energy, exists_fn=lambda device: ha_dev_type(device) == "outlet", ), + VeSyncSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["humidity"], + exists_fn=is_humidifier, + ), ) @@ -187,24 +198,31 @@ async def async_setup_entry( ) -> None: """Set up switches.""" + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + @callback def discover(devices): """Add new devices to platform.""" - _setup_entities(devices, async_add_entities) + _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SENSORS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_SENSORS], async_add_entities) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback -def _setup_entities(devices, async_add_entities): +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): """Check if device is online and add entity.""" + async_add_entities( ( - VeSyncSensorEntity(dev, description) + VeSyncSensorEntity(dev, description, coordinator) for dev in devices for description in SENSORS if description.exists_fn(dev) @@ -220,11 +238,12 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity): def __init__( self, - device: VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch, + device: VeSyncBaseDevice, description: VeSyncSensorEntityDescription, + coordinator: VeSyncDataCoordinator, ) -> None: """Initialize the VeSync outlet device.""" - super().__init__(device) + super().__init__(device, coordinator) self.entity_description = description self._attr_unique_id = f"{super().unique_id}-{description.key}" diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index b6e4e2fd957..a23fe7936e7 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -43,6 +43,11 @@ "name": "Current voltage" } }, + "number": { + "mist_level": { + "name": "Mist level" + } + }, "fan": { "vesync": { "state_attributes": { diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index a162a648ad7..ef8e6c6051f 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -3,14 +3,17 @@ import logging from typing import Any +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_SWITCHES -from .entity import VeSyncDevice +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) @@ -22,37 +25,38 @@ async def async_setup_entry( ) -> None: """Set up switches.""" + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + @callback def discover(devices): """Add new devices to platform.""" - _setup_entities(devices, async_add_entities) + _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SWITCHES), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_SWITCHES], async_add_entities) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback -def _setup_entities(devices, async_add_entities): - """Check if device is online and add entity.""" - entities = [] +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities, + coordinator: VeSyncDataCoordinator, +): + """Check if device is a switch and add entity.""" + entities: list[VeSyncBaseSwitch] = [] for dev in devices: if DEV_TYPE_TO_HA.get(dev.device_type) == "outlet": - entities.append(VeSyncSwitchHA(dev)) + entities.append(VeSyncSwitchHA(dev, coordinator)) elif DEV_TYPE_TO_HA.get(dev.device_type) == "switch": - entities.append(VeSyncLightSwitch(dev)) - else: - _LOGGER.warning( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue + entities.append(VeSyncLightSwitch(dev, coordinator)) async_add_entities(entities, update_before_add=True) -class VeSyncBaseSwitch(VeSyncDevice, SwitchEntity): +class VeSyncBaseSwitch(VeSyncBaseEntity, SwitchEntity): """Base class for VeSync switch Device Representations.""" _attr_name = None @@ -61,25 +65,33 @@ class VeSyncBaseSwitch(VeSyncDevice, SwitchEntity): """Turn the device on.""" self.device.turn_on() + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.device.device_status == "on" + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self.device.turn_off() + class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): """Representation of a VeSync switch.""" - def __init__(self, plug): + def __init__( + self, plug: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator + ) -> None: """Initialize the VeSync switch device.""" - super().__init__(plug) + super().__init__(plug, coordinator) self.smartplug = plug - def update(self) -> None: - """Update outlet details and energy usage.""" - self.smartplug.update() - self.smartplug.update_energy() - class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity): """Handle representation of VeSync Light Switch.""" - def __init__(self, switch): + def __init__( + self, switch: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator + ) -> None: """Initialize Light Switch device class.""" - super().__init__(switch) + super().__init__(switch, coordinator) self.switch = switch diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index d6b9e4b923a..12d8ba520f1 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -2,11 +2,9 @@ from __future__ import annotations -from collections.abc import Mapping from contextlib import suppress import logging import os -from typing import Any from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig @@ -16,8 +14,6 @@ from PyViCare.PyViCareUtils import ( ) from homeassistant.components.climate import DOMAIN as DOMAIN_CLIMATE -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -25,31 +21,28 @@ from homeassistant.helpers.storage import STORAGE_DIR from .const import ( DEFAULT_CACHE_DURATION, - DEVICE_LIST, DOMAIN, PLATFORMS, UNSUPPORTED_DEVICES, + VICARE_TOKEN_FILENAME, ) -from .types import ViCareDevice -from .utils import get_device, get_device_serial +from .types import ViCareConfigEntry, ViCareData, ViCareDevice +from .utils import get_device, get_device_serial, login _LOGGER = logging.getLogger(__name__) -_TOKEN_FILENAME = "vicare_token.save" -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bool: """Set up from config entry.""" _LOGGER.debug("Setting up ViCare component") - - hass.data[DOMAIN] = {} - hass.data[DOMAIN][entry.entry_id] = {} - try: - await hass.async_add_executor_job(setup_vicare_api, hass, entry) + entry.runtime_data = await hass.async_add_executor_job( + setup_vicare_api, hass, entry + ) except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError) as err: raise ConfigEntryAuthFailed("Authentication failed") from err - for device in hass.data[DOMAIN][entry.entry_id][DEVICE_LIST]: + for device in entry.runtime_data.devices: # Migration can be removed in 2025.4.0 await async_migrate_devices_and_entities(hass, entry, device) @@ -58,28 +51,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def vicare_login( - hass: HomeAssistant, - entry_data: Mapping[str, Any], - cache_duration=DEFAULT_CACHE_DURATION, -) -> PyViCare: - """Login via PyVicare API.""" - vicare_api = PyViCare() - vicare_api.setCacheDuration(cache_duration) - vicare_api.initWithCredentials( - entry_data[CONF_USERNAME], - entry_data[CONF_PASSWORD], - entry_data[CONF_CLIENT_ID], - hass.config.path(STORAGE_DIR, _TOKEN_FILENAME), - ) - return vicare_api - - -def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None: +def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> PyViCare: """Set up PyVicare API.""" - vicare_api = vicare_login(hass, entry.data) + client = login(hass, entry.data) - device_config_list = get_supported_devices(vicare_api.devices) + device_config_list = get_supported_devices(client.devices) + + # increase cache duration to fit rate limit to number of devices if (number_of_devices := len(device_config_list)) > 1: cache_duration = DEFAULT_CACHE_DURATION * number_of_devices _LOGGER.debug( @@ -87,36 +65,35 @@ def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None: number_of_devices, cache_duration, ) - vicare_api = vicare_login(hass, entry.data, cache_duration) - device_config_list = get_supported_devices(vicare_api.devices) + client = login(hass, entry.data, cache_duration) + device_config_list = get_supported_devices(client.devices) for device in device_config_list: _LOGGER.debug( "Found device: %s (online: %s)", device.getModel(), str(device.isOnline()) ) - hass.data[DOMAIN][entry.entry_id][DEVICE_LIST] = [ + devices = [ ViCareDevice(config=device_config, api=get_device(entry, device_config)) for device_config in device_config_list ] + return ViCareData(client=client, devices=devices) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bool: """Unload ViCare config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) with suppress(FileNotFoundError): await hass.async_add_executor_job( - os.remove, hass.config.path(STORAGE_DIR, _TOKEN_FILENAME) + os.remove, hass.config.path(STORAGE_DIR, VICARE_TOKEN_FILENAME) ) return unload_ok async def async_migrate_devices_and_entities( - hass: HomeAssistant, entry: ConfigEntry, device: ViCareDevice + hass: HomeAssistant, entry: ViCareConfigEntry, device: ViCareDevice ) -> None: """Migrate old entry.""" device_registry = dr.async_get(hass) @@ -169,7 +146,8 @@ async def async_migrate_devices_and_entities( # to `-heating-` if entity_entry.domain == DOMAIN_CLIMATE: unique_id_parts[len(unique_id_parts) - 1] = ( - f"{entity_entry.translation_key}-{unique_id_parts[len(unique_id_parts)-1]}" + f"{entity_entry.translation_key}-" + f"{unique_id_parts[len(unique_id_parts) - 1]}" ) entity_new_unique_id = "-".join(unique_id_parts) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 55f0ab96ed0..ced02dae97e 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -24,13 +24,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity -from .types import ViCareDevice, ViCareRequiredKeysMixin +from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixin from .utils import ( get_burners, get_circuits, @@ -152,16 +150,14 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ViCareConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare binary sensor devices.""" - device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] - async_add_entities( await hass.async_add_executor_job( _build_entities, - device_list, + config_entry.runtime_data.devices, ) ) diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 49d142c1edb..ad7d600eba3 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -16,14 +16,12 @@ from PyViCare.PyViCareUtils import ( import requests from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity -from .types import ViCareDevice, ViCareRequiredKeysMixinWithSet +from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixinWithSet from .utils import get_device_serial, is_supported _LOGGER = logging.getLogger(__name__) @@ -67,16 +65,14 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ViCareConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare button entities.""" - device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] - async_add_entities( await hass.async_add_executor_job( _build_entities, - device_list, + config_entry.runtime_data.devices, ) ) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 8a116038533..62231a4e2fe 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -24,7 +24,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_TENTHS, @@ -37,9 +36,9 @@ from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEVICE_LIST, DOMAIN +from .const import DOMAIN from .entity import ViCareEntity -from .types import HeatingProgram, ViCareDevice +from .types import HeatingProgram, ViCareConfigEntry, ViCareDevice from .utils import get_burners, get_circuits, get_compressors, get_device_serial _LOGGER = logging.getLogger(__name__) @@ -99,25 +98,22 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ViCareConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( SERVICE_SET_VICARE_MODE, {vol.Required(SERVICE_SET_VICARE_MODE_ATTR_MODE): cv.string}, "set_vicare_mode", ) - device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] - async_add_entities( await hass.async_add_executor_job( _build_entities, - device_list, + config_entry.runtime_data.devices, ) ) @@ -140,7 +136,6 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _current_action: bool | None = None _current_mode: str | None = None _current_program: str | None = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index c711cc06074..36db8e92cc7 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -12,13 +12,12 @@ from PyViCare.PyViCareUtils import ( ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from . import vicare_login from .const import ( CONF_HEATING_TYPE, DEFAULT_HEATING_TYPE, @@ -26,6 +25,7 @@ from .const import ( VICARE_NAME, HeatingType, ) +from .utils import login _LOGGER = logging.getLogger(__name__) @@ -62,9 +62,7 @@ class ViCareConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await self.hass.async_add_executor_job( - vicare_login, self.hass, user_input - ) + await self.hass.async_add_executor_job(login, self.hass, user_input) except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError): errors["base"] = "invalid_auth" else: @@ -96,7 +94,7 @@ class ViCareConfigFlow(ConfigFlow, domain=DOMAIN): } try: - await self.hass.async_add_executor_job(vicare_login, self.hass, data) + await self.hass.async_add_executor_job(login, self.hass, data) except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError): errors["base"] = "invalid_auth" else: @@ -111,7 +109,7 @@ class ViCareConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Invoke when a Viessmann MAC address is discovered on the network.""" formatted_mac = format_mac(discovery_info.macaddress) diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index 828a879927d..bcf41223d3f 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -25,8 +25,8 @@ UNSUPPORTED_DEVICES = [ "E3_RoomControl_One_522", ] -DEVICE_LIST = "device_list" VICARE_NAME = "ViCare" +VICARE_TOKEN_FILENAME = "vicare_token.save" CONF_CIRCUIT = "circuit" CONF_HEATING_TYPE = "heating_type" diff --git a/homeassistant/components/vicare/diagnostics.py b/homeassistant/components/vicare/diagnostics.py index 9182e96509f..7695c304451 100644 --- a/homeassistant/components/vicare/diagnostics.py +++ b/homeassistant/components/vicare/diagnostics.py @@ -6,25 +6,24 @@ import json from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DEVICE_LIST, DOMAIN +from .types import ViCareConfigEntry TO_REDACT = {CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ViCareConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" def dump_devices() -> list[dict[str, Any]]: """Dump devices.""" return [ - json.loads(device.config.dump_secure()) - for device in hass.data[DOMAIN][entry.entry_id][DEVICE_LIST] + json.loads(device.dump_secure()) + for device in entry.runtime_data.client.devices ] return { diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index 2d858185b9f..11955a94b94 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -29,7 +29,11 @@ class ViCareEntity(Entity): gateway_serial = device_config.getConfig().serial device_id = device_config.getId() - identifier = f"{gateway_serial}_{device_serial.replace("zigbee-", "zigbee_") if device_serial is not None else device_id}" + identifier = ( + f"{gateway_serial}_{device_serial.replace('zigbee-', 'zigbee_')}" + if device_serial is not None + else f"{gateway_serial}_{device_id}" + ) self._api: PyViCareDevice | PyViCareHeatingDeviceComponent = ( component if component else device diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 1800704a16f..fc18bdbd8da 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -19,7 +19,6 @@ from PyViCare.PyViCareVentilationDevice import ( from requests.exceptions import ConnectionError as RequestConnectionError from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -27,10 +26,9 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity -from .types import ViCareDevice -from .utils import get_device_serial +from .types import ViCareConfigEntry, ViCareDevice +from .utils import filter_state, get_device_serial _LOGGER = logging.getLogger(__name__) @@ -104,17 +102,14 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ViCareConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare fan platform.""" - - device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] - async_add_entities( await hass.async_add_executor_job( _build_entities, - device_list, + config_entry.runtime_data.devices, ) ) @@ -125,7 +120,6 @@ class ViCareFan(ViCareEntity, FanEntity): _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) _attr_supported_features = FanEntityFeature.SET_SPEED _attr_translation_key = "ventilation" - _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -149,15 +143,20 @@ class ViCareFan(ViCareEntity, FanEntity): def update(self) -> None: """Update state of fan.""" + level: str | None = None try: with suppress(PyViCareNotSupportedFeatureError): self._attr_preset_mode = VentilationMode.from_vicare_mode( self._api.getActiveMode() ) with suppress(PyViCareNotSupportedFeatureError): + level = filter_state(self._api.getVentilationLevel()) + if level is not None and level in ORDERED_NAMED_FAN_SPEEDS: self._attr_percentage = ordered_list_item_to_percentage( - ORDERED_NAMED_FAN_SPEEDS, self._api.getActiveProgram() + ORDERED_NAMED_FAN_SPEEDS, VentilationProgram(level) ) + else: + self._attr_percentage = 0 except RequestConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: diff --git a/homeassistant/components/vicare/icons.json b/homeassistant/components/vicare/icons.json index 9d0f27a863c..52148b1fa32 100644 --- a/homeassistant/components/vicare/icons.json +++ b/homeassistant/components/vicare/icons.json @@ -84,6 +84,9 @@ }, "compressor_phase": { "default": "mdi:information" + }, + "ventilation_level": { + "default": "mdi:fan" } } }, diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 8ce996ab81d..766cf22cb94 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.35.0"] + "requirements": ["PyViCare==2.41.0"] } diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index f9af9636941..8ffaa727634 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -25,14 +25,17 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity -from .types import HeatingProgram, ViCareDevice, ViCareRequiredKeysMixin +from .types import ( + HeatingProgram, + ViCareConfigEntry, + ViCareDevice, + ViCareRequiredKeysMixin, +) from .utils import get_circuits, get_device_serial, is_supported _LOGGER = logging.getLogger(__name__) @@ -370,16 +373,14 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ViCareConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare number devices.""" - device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] - async_add_entities( await hass.async_add_executor_job( _build_entities, - device_list, + config_entry.runtime_data.devices, ) ) diff --git a/homeassistant/components/vicare/quality_scale.yaml b/homeassistant/components/vicare/quality_scale.yaml index 436e046204f..55b7590a092 100644 --- a/homeassistant/components/vicare/quality_scale.yaml +++ b/homeassistant/components/vicare/quality_scale.yaml @@ -1,16 +1,12 @@ rules: # Bronze - config-flow: - status: todo - comment: data_description is missing. + config-flow: done test-before-configure: done unique-config-entry: status: todo comment: Uniqueness is not checked yet. config-flow-test-coverage: done - runtime-data: - status: todo - comment: runtime_data is not used yet. + runtime-data: done test-before-setup: done appropriate-polling: done entity-unique-id: done @@ -27,9 +23,7 @@ rules: comment: No coordinator is used, data update is centrally handled by the library. docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: - status: todo - comment: removal instructions missing + docs-removal-instructions: done docs-actions: done brands: done # Silver diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 57b7c0bec9a..44c3f3cfc0f 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -25,12 +25,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, UnitOfEnergy, UnitOfPower, + UnitOfPressure, UnitOfTemperature, UnitOfTime, UnitOfVolume, @@ -40,8 +40,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - DEVICE_LIST, - DOMAIN, VICARE_CUBIC_METER, VICARE_KW, VICARE_KWH, @@ -50,8 +48,9 @@ from .const import ( VICARE_WH, ) from .entity import ViCareEntity -from .types import ViCareDevice, ViCareRequiredKeysMixin +from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixin from .utils import ( + filter_state, get_burners, get_circuits, get_compressors, @@ -799,7 +798,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( translation_key="photovoltaic_status", device_class=SensorDeviceClass.ENUM, options=["ready", "production"], - value_getter=lambda api: _filter_pv_states(api.getPhotovoltaicStatus()), + value_getter=lambda api: filter_state(api.getPhotovoltaicStatus()), ), ViCareSensorEntityDescription( key="room_temperature", @@ -815,6 +814,39 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value_getter=lambda api: api.getHumidity(), ), + ViCareSensorEntityDescription( + key="ventilation_level", + translation_key="ventilation_level", + value_getter=lambda api: filter_state(api.getVentilationLevel().lower()), + device_class=SensorDeviceClass.ENUM, + options=["standby", "levelone", "leveltwo", "levelthree", "levelfour"], + ), + ViCareSensorEntityDescription( + key="ventilation_reason", + translation_key="ventilation_reason", + value_getter=lambda api: api.getVentilationReason().lower(), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=[ + "standby", + "permanent", + "schedule", + "sensordriven", + "silent", + "forcedlevelfour", + ], + ), + ViCareSensorEntityDescription( + key="supply_pressure", + translation_key="supply_pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.BAR, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSupplyPressure(), + unit_getter=lambda api: api.getSupplyPressureUnit(), + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( @@ -923,10 +955,6 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ) -def _filter_pv_states(state: str) -> str | None: - return None if state in ("nothing", "unknown") else state - - def _build_entities( device_list: list[ViCareDevice], ) -> list[ViCareSensor]: @@ -968,16 +996,14 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ViCareConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" - device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] - async_add_entities( await hass.async_add_executor_job( _build_entities, - device_list, + config_entry.runtime_data.devices, ), # run update to have device_class set depending on unit_of_measurement True, diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 77e570da779..f49a73f1659 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -9,6 +9,12 @@ "password": "[%key:common::config_flow::data::password%]", "client_id": "Client ID", "heating_type": "Heating type" + }, + "data_description": { + "username": "The email address to login to your ViCare account.", + "password": "The password to login to your ViCare account.", + "client_id": "The ID of the API client created in the Viessmann developer portal.", + "heating_type": "Allows to overrule the device auto detection." } }, "reauth_confirm": { @@ -16,6 +22,10 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "client_id": "[%key:component::vicare::config::step::user::data::client_id%]" + }, + "data_description": { + "password": "[%key:component::vicare::config::step::user::data_description::password%]", + "client_id": "[%key:component::vicare::config::step::user::data_description::client_id%]" } } }, @@ -424,6 +434,30 @@ }, "compressor_phase": { "name": "Compressor phase" + }, + "ventilation_level": { + "name": "Ventilation level", + "state": { + "standby": "[%key:common::state::standby%]", + "levelone": "1", + "leveltwo": "2", + "levelthree": "3", + "levelfour": "4" + } + }, + "ventilation_reason": { + "name": "Ventilation reason", + "state": { + "standby": "[%key:common::state::standby%]", + "permanent": "Permanent", + "schedule": "Schedule", + "sensordriven": "Sensor-driven", + "silent": "Silent", + "forcedlevelfour": "Boost" + } + }, + "supply_pressure": { + "name": "Supply pressure" } }, "water_heater": { diff --git a/homeassistant/components/vicare/types.py b/homeassistant/components/vicare/types.py index 98d1c0566ce..65ae2a53c3e 100644 --- a/homeassistant/components/vicare/types.py +++ b/homeassistant/components/vicare/types.py @@ -6,6 +6,7 @@ from dataclasses import dataclass import enum from typing import Any +from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig @@ -15,6 +16,7 @@ from homeassistant.components.climate import ( PRESET_HOME, PRESET_SLEEP, ) +from homeassistant.config_entries import ConfigEntry class HeatingProgram(enum.StrEnum): @@ -80,6 +82,17 @@ class ViCareDevice: api: PyViCareDevice +@dataclass(frozen=True) +class ViCareData: + """ViCare data class.""" + + client: PyViCare + devices: list[ViCareDevice] + + +type ViCareConfigEntry = ConfigEntry[ViCareData] + + @dataclass(frozen=True) class ViCareRequiredKeysMixin: """Mixin for required keys.""" diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 5156ea4a41e..a2c31df4259 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -1,7 +1,12 @@ """ViCare helpers functions.""" -import logging +from __future__ import annotations +from collections.abc import Mapping +import logging +from typing import Any + +from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareHeatingDevice import ( @@ -14,16 +19,41 @@ from PyViCare.PyViCareUtils import ( ) import requests -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import STORAGE_DIR -from .const import CONF_HEATING_TYPE, HEATING_TYPE_TO_CREATOR_METHOD, HeatingType -from .types import ViCareRequiredKeysMixin +from .const import ( + CONF_HEATING_TYPE, + DEFAULT_CACHE_DURATION, + HEATING_TYPE_TO_CREATOR_METHOD, + VICARE_TOKEN_FILENAME, + HeatingType, +) +from .types import ViCareConfigEntry, ViCareRequiredKeysMixin _LOGGER = logging.getLogger(__name__) +def login( + hass: HomeAssistant, + entry_data: Mapping[str, Any], + cache_duration=DEFAULT_CACHE_DURATION, +) -> PyViCare: + """Login via PyVicare API.""" + vicare_api = PyViCare() + vicare_api.setCacheDuration(cache_duration) + vicare_api.initWithCredentials( + entry_data[CONF_USERNAME], + entry_data[CONF_PASSWORD], + entry_data[CONF_CLIENT_ID], + hass.config.path(STORAGE_DIR, VICARE_TOKEN_FILENAME), + ) + return vicare_api + + def get_device( - entry: ConfigEntry, device_config: PyViCareDeviceConfig + entry: ViCareConfigEntry, device_config: PyViCareDeviceConfig ) -> PyViCareDevice: """Get device for device config.""" return getattr( @@ -98,3 +128,8 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone except AttributeError as error: _LOGGER.debug("No compressors found: %s", error) return [] + + +def filter_state(state: str) -> str | None: + """Remove invalid states.""" + return None if state in ("nothing", "unknown") else state diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 5e241c9a3be..114ff620c3f 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -20,14 +20,12 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity -from .types import ViCareDevice +from .types import ViCareConfigEntry, ViCareDevice from .utils import get_circuits, get_device_serial _LOGGER = logging.getLogger(__name__) @@ -81,16 +79,14 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ViCareConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare water heater platform.""" - device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] - async_add_entities( await hass.async_add_executor_job( _build_entities, - device_list, + config_entry.runtime_data.devices, ) ) diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 09d6f3be090..4af42d76b62 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -4,55 +4,18 @@ from __future__ import annotations from typing import Any -import voluptuous as vol - from homeassistant.components.media_player import MediaPlayerDeviceClass -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_DEVICE_CLASS, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store -from homeassistant.helpers.typing import ConfigType -from .const import CONF_APPS, CONF_DEVICE_CLASS, DOMAIN, VIZIO_SCHEMA +from .const import CONF_APPS, DOMAIN from .coordinator import VizioAppsDataUpdateCoordinator - -def validate_apps(config: ConfigType) -> ConfigType: - """Validate CONF_APPS is only used when CONF_DEVICE_CLASS is MediaPlayerDeviceClass.TV.""" - if ( - config.get(CONF_APPS) is not None - and config[CONF_DEVICE_CLASS] != MediaPlayerDeviceClass.TV - ): - raise vol.Invalid( - f"'{CONF_APPS}' can only be used if {CONF_DEVICE_CLASS}' is" - f" '{MediaPlayerDeviceClass.TV}'" - ) - - return config - - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.All(cv.ensure_list, [vol.All(VIZIO_SCHEMA, validate_apps)])}, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Component setup, run import config flow for each entry in config.""" - if DOMAIN in config: - for entry in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load the saved entities.""" diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 49f6a709565..572f093dfd3 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -11,11 +11,8 @@ from pyvizio import VizioAsync, async_guess_device_type from pyvizio.const import APP_HOME import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.config_entries import ( - SOURCE_IGNORE, - SOURCE_IMPORT, SOURCE_ZEROCONF, ConfigEntry, ConfigFlow, @@ -34,6 +31,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.network import is_ip_address from .const import ( @@ -251,100 +249,15 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: return await self._create_entry(user_input) - elif self._must_show_form and self.context["source"] == SOURCE_IMPORT: - # Import should always display the config form if CONF_ACCESS_TOKEN - # wasn't included but is needed so that the user can choose to update - # their configuration.yaml or to proceed with config flow pairing. We - # will also provide contextual message to user explaining why - _LOGGER.warning( - ( - "Couldn't complete configuration.yaml import: '%s' key is " - "missing. Either provide '%s' key in configuration.yaml or " - "finish setup by completing configuration via frontend" - ), - CONF_ACCESS_TOKEN, - CONF_ACCESS_TOKEN, - ) - self._must_show_form = False else: self._data = copy.deepcopy(user_input) return await self.async_step_pair_tv() schema = self._user_schema or _get_config_schema() - - if errors and self.context["source"] == SOURCE_IMPORT: - # Log an error message if import config flow fails since otherwise failure is silent - _LOGGER.error( - "Importing from configuration.yaml failed: %s", - ", ".join(errors.values()), - ) - return self.async_show_form(step_id="user", data_schema=schema, errors=errors) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import a config entry from configuration.yaml.""" - # Check if new config entry matches any existing config entries - for entry in self._async_current_entries(): - # If source is ignore bypass host check and continue through loop - if entry.source == SOURCE_IGNORE: - continue - - if await self.hass.async_add_executor_job( - _host_is_same, entry.data[CONF_HOST], import_data[CONF_HOST] - ): - updated_options: dict[str, Any] = {} - updated_data: dict[str, Any] = {} - remove_apps = False - - if entry.data[CONF_HOST] != import_data[CONF_HOST]: - updated_data[CONF_HOST] = import_data[CONF_HOST] - - if entry.data[CONF_NAME] != import_data[CONF_NAME]: - updated_data[CONF_NAME] = import_data[CONF_NAME] - - # Update entry.data[CONF_APPS] if import_config[CONF_APPS] differs, and - # pop entry.data[CONF_APPS] if import_config[CONF_APPS] is not specified - if entry.data.get(CONF_APPS) != import_data.get(CONF_APPS): - if not import_data.get(CONF_APPS): - remove_apps = True - else: - updated_options[CONF_APPS] = import_data[CONF_APPS] - - if entry.data.get(CONF_VOLUME_STEP) != import_data[CONF_VOLUME_STEP]: - updated_options[CONF_VOLUME_STEP] = import_data[CONF_VOLUME_STEP] - - if updated_options or updated_data or remove_apps: - new_data = entry.data.copy() - new_options = entry.options.copy() - - if remove_apps: - new_data.pop(CONF_APPS) - new_options.pop(CONF_APPS) - - if updated_data: - new_data.update(updated_data) - - # options are stored in entry options and data so update both - if updated_options: - new_data.update(updated_options) - new_options.update(updated_options) - - self.hass.config_entries.async_update_entry( - entry=entry, data=new_data, options=new_options - ) - return self.async_abort(reason="updated_entry") - - return self.async_abort(reason="already_configured_device") - - self._must_show_form = True - # Store config key/value pairs that are not configurable in user step so they - # don't get lost on user step - if import_data.get(CONF_APPS): - self._apps = copy.deepcopy(import_data[CONF_APPS]) - return await self.async_step_user(user_input=import_data) - async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.host @@ -433,11 +346,6 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): if pair_data: self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token self._must_show_form = True - - if self.context["source"] == SOURCE_IMPORT: - # If user is pairing via config import, show different message - return await self.async_step_pairing_complete_import() - return await self.async_step_pairing_complete() # If no data was retrieved, it's assumed that the pairing attempt was not diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index 4eb96256d2e..8451ae747de 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -10,14 +10,6 @@ from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntityFeature, ) -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_DEVICE_CLASS, - CONF_EXCLUDE, - CONF_HOST, - CONF_INCLUDE, - CONF_NAME, -) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import VolDictType @@ -84,43 +76,3 @@ VIZIO_DEVICE_CLASSES = { MediaPlayerDeviceClass.SPEAKER: VIZIO_DEVICE_CLASS_SPEAKER, MediaPlayerDeviceClass.TV: VIZIO_DEVICE_CLASS_TV, } - -VIZIO_SCHEMA = { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): vol.All( - cv.string, - vol.Lower, - vol.In([MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.SPEAKER]), - ), - vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): vol.All( - vol.Coerce(int), vol.Range(min=1, max=10) - ), - vol.Optional(CONF_APPS): vol.All( - { - vol.Exclusive(CONF_INCLUDE, "apps_filter"): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Exclusive(CONF_EXCLUDE, "apps_filter"): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_ADDITIONAL_CONFIGS): vol.All( - cv.ensure_list, - [ - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_CONFIG): { - vol.Required(CONF_APP_ID): cv.string, - vol.Required(CONF_NAME_SPACE): vol.Coerce(int), - vol.Optional(CONF_MESSAGE, default=None): vol.Or( - cv.string, None - ), - }, - }, - ], - ), - }, - cv.has_at_least_one_key(CONF_INCLUDE, CONF_EXCLUDE, CONF_ADDITIONAL_CONFIGS), - ), -} diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 6091cd72f3f..2f97bb332e8 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -6,7 +6,7 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", - "device_class": "Device Type", + "device_class": "Device type", "access_token": "[%key:common::config_flow::data::access_token%]" }, "data_description": { @@ -14,25 +14,25 @@ } }, "pair_tv": { - "title": "Complete Pairing Process", + "title": "Complete pairing process", "description": "Your TV should be displaying a code. Enter that code into the form and then continue to the next step to complete the pairing.", "data": { "pin": "[%key:common::config_flow::data::pin%]" } }, "pairing_complete": { - "title": "Pairing Complete", - "description": "Your VIZIO SmartCast Device is now connected to Home Assistant." + "title": "Pairing complete", + "description": "Your VIZIO SmartCast device is now connected to Home Assistant." }, "pairing_complete_import": { "title": "[%key:component::vizio::config::step::pairing_complete::title%]", - "description": "Your VIZIO SmartCast Device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'." + "description": "Your VIZIO SmartCast device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'." } }, "error": { "complete_pairing_failed": "Unable to complete pairing. Ensure the PIN you provided is correct and the TV is still powered and connected to the network before resubmitting.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "existing_config_entry_found": "An existing VIZIO SmartCast Device config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one." + "existing_config_entry_found": "An existing VIZIO SmartCast device config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one." }, "abort": { "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", @@ -43,12 +43,12 @@ "options": { "step": { "init": { - "title": "Update VIZIO SmartCast Device Options", + "title": "Update VIZIO SmartCast device options", "description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list.", "data": { - "volume_step": "Volume Step Size", - "include_or_exclude": "Include or Exclude Apps?", - "apps_to_include_or_exclude": "Apps to Include or Exclude" + "volume_step": "Volume step size", + "include_or_exclude": "Include or exclude apps?", + "apps_to_include_or_exclude": "Apps to include or exclude" } } } diff --git a/homeassistant/components/vodafone_station/const.py b/homeassistant/components/vodafone_station/const.py index 14cfaabdf7a..99f953d50d5 100644 --- a/homeassistant/components/vodafone_station/const.py +++ b/homeassistant/components/vodafone_station/const.py @@ -5,6 +5,7 @@ import logging _LOGGER = logging.getLogger(__package__) DOMAIN = "vodafone_station" +SCAN_INTERVAL = 30 DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_HOST = "192.168.1.1" diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index d2f408e355b..de794488040 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta +from json.decoder import JSONDecodeError from typing import Any from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions @@ -13,7 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import _LOGGER, DOMAIN +from .const import _LOGGER, DOMAIN, SCAN_INTERVAL CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds() @@ -58,7 +59,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): hass=hass, logger=_LOGGER, name=f"{DOMAIN}-{host}-coordinator", - update_interval=timedelta(seconds=30), + update_interval=timedelta(seconds=SCAN_INTERVAL), ) def _calculate_update_time_and_consider_home( @@ -107,6 +108,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): exceptions.CannotConnect, exceptions.AlreadyLogged, exceptions.GenericLoginError, + JSONDecodeError, ) as err: raise UpdateFailed(f"Error fetching data: {err!r}") from err except (ConfigEntryAuthFailed, UpdateFailed): diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py index cee0cbb0766..96e758e91f4 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -30,9 +30,9 @@ _IP_WILDCARD = "0.0.0.0" __all__ = [ "DOMAIN", + "async_remove_config_entry_device", "async_setup_entry", "async_unload_entry", - "async_remove_config_entry_device", ] diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py index 613d05fc614..c33ec048cbd 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator from dataclasses import dataclass, field +from typing import Any from voip_utils import CallInfo, VoipDatagramProtocol @@ -136,25 +137,56 @@ class VoIPDevices: fw_version = None dev_reg = dr.async_get(self.hass) - voip_id = call_info.caller_ip + if call_info.caller_endpoint is None: + raise RuntimeError("Could not identify VOIP caller") + voip_id = call_info.caller_endpoint.uri voip_device = self.devices.get(voip_id) - if voip_device is not None: - device = dev_reg.async_get(voip_device.device_id) - if device and fw_version and device.sw_version != fw_version: - dev_reg.async_update_device(device.id, sw_version=fw_version) + if voip_device is None: + # If we couldn't find the device based on SIP URI, see if we can + # find an old device based on just the host/IP and migrate it + old_id = call_info.caller_endpoint.host + voip_device = self.devices.get(old_id) + if voip_device is not None: + voip_device.voip_id = voip_id + self.devices[voip_id] = voip_device + dev_reg.async_update_device( + voip_device.device_id, new_identifiers={(DOMAIN, voip_id)} + ) + # Migrate entities + old_prefix = f"{old_id}-" - return voip_device + def entity_migrator(entry: er.RegistryEntry) -> dict[str, Any] | None: + """Migrate entities.""" + if not entry.unique_id.startswith(old_prefix): + return None + key = entry.unique_id[len(old_prefix) :] + return { + "new_unique_id": f"{voip_id}-{key}", + } + self.config_entry.async_create_task( + self.hass, + er.async_migrate_entries( + self.hass, self.config_entry.entry_id, entity_migrator + ), + f"voip migrating entities {voip_id}", + ) + + # Update device with latest info device = dev_reg.async_get_or_create( config_entry_id=self.config_entry.entry_id, identifiers={(DOMAIN, voip_id)}, - name=voip_id, + name=call_info.caller_endpoint.host, manufacturer=manuf, model=model, sw_version=fw_version, configuration_url=f"http://{call_info.caller_ip}", ) + + if voip_device is not None: + return voip_device + voip_device = self.devices[voip_id] = VoIPDevice( voip_id=voip_id, device_id=device.id, diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 964193fca53..ed7f11f8fbc 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.1.0"] + "requirements": ["voip-utils==0.2.2"] } diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py index 7cc58556f3e..00b3ab911ae 100644 --- a/homeassistant/components/volumio/config_flow.py +++ b/homeassistant/components/volumio/config_flow.py @@ -8,12 +8,12 @@ from typing import Any from pyvolumio import CannotConnectError, Volumio import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -97,7 +97,7 @@ class VolumioConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self._host = discovery_info.host diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index 814621b5403..d8344cbdeec 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -4,20 +4,20 @@ "already_configured": "That student has already been added.", "all_student_already_configured": "All students have already been added.", "reauth_successful": "Reauth successful", - "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student." + "no_matching_entries": "No matching entries found, please use different account or remove outdated student integration." }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", "invalid_token": "[%key:common::config_flow::error::invalid_access_token%]", "expired_token": "Expired token - please generate a new token", - "invalid_pin": "Invalid pin", + "invalid_pin": "Invalid PIN", "invalid_symbol": "Invalid symbol", "expired_credentials": "Expired credentials - please create new on Vulcan mobile app registration page", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { "auth": { - "description": "Login to your Vulcan Account using mobile app registration page.", + "description": "Log in to your Vulcan Account using mobile app registration page.", "data": { "token": "Token", "region": "Symbol", diff --git a/homeassistant/components/wake_on_lan/services.yaml b/homeassistant/components/wake_on_lan/services.yaml index 48d3df5c4f9..e7c048daf64 100644 --- a/homeassistant/components/wake_on_lan/services.yaml +++ b/homeassistant/components/wake_on_lan/services.yaml @@ -15,3 +15,4 @@ send_magic_packet: number: min: 1 max: 65535 + mode: "box" diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 8b3a5bbf331..65556668bac 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -25,12 +25,12 @@ from .const import DOMAIN from .models import DetectionResult, WakeWord __all__ = [ - "async_default_entity", - "async_get_wake_word_detection_entity", - "DetectionResult", "DOMAIN", + "DetectionResult", "WakeWord", "WakeWordDetectionEntity", + "async_default_entity", + "async_get_wake_word_detection_entity", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 4bfe1ce4481..3e1387cb714 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -8,7 +8,7 @@ import functools as ft import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -25,12 +25,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) +from homeassistant.helpers.deprecation import deprecated_class from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp @@ -62,7 +57,7 @@ STATE_GAS = "gas" class WaterHeaterEntityFeature(IntFlag): - """Supported features of the fan entity.""" + """Supported features of the water heater entity.""" TARGET_TEMPERATURE = 1 OPERATION_MODE = 2 @@ -70,18 +65,6 @@ class WaterHeaterEntityFeature(IntFlag): ON_OFF = 8 -# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. -# Please use the WaterHeaterEntityFeature enum instead. -_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum( - WaterHeaterEntityFeature.TARGET_TEMPERATURE, "2025.1" -) -_DEPRECATED_SUPPORT_OPERATION_MODE = DeprecatedConstantEnum( - WaterHeaterEntityFeature.OPERATION_MODE, "2025.1" -) -_DEPRECATED_SUPPORT_AWAY_MODE = DeprecatedConstantEnum( - WaterHeaterEntityFeature.AWAY_MODE, "2025.1" -) - ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" ATTR_AWAY_MODE = "away_mode" @@ -147,10 +130,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -class WaterHeaterEntityEntityDescription(EntityDescription, frozen_or_thawed=True): +class WaterHeaterEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes water heater entities.""" +@deprecated_class("WaterHeaterEntityDescription", breaks_in_ha_version="2026.1") +class WaterHeaterEntityEntityDescription( + WaterHeaterEntityDescription, frozen_or_thawed=True +): + """A (deprecated) class that describes water heater entities.""" + + CACHED_PROPERTIES_WITH_ATTR_ = { "temperature_unit", "current_operation", @@ -170,7 +160,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): {ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP} ) - entity_description: WaterHeaterEntityEntityDescription + entity_description: WaterHeaterEntityDescription _attr_current_operation: str | None = None _attr_current_temperature: float | None = None _attr_is_away_mode_on: bool | None = None @@ -212,7 +202,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ), } - if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features_compat: + if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features: data[ATTR_OPERATION_LIST] = self.operation_list return data @@ -248,7 +238,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ), } - supported_features = self.supported_features_compat + supported_features = self.supported_features if WaterHeaterEntityFeature.OPERATION_MODE in supported_features: data[ATTR_OPERATION_MODE] = self.current_operation @@ -397,19 +387,6 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the list of supported features.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> WaterHeaterEntityFeature: - """Return the supported features as WaterHeaterEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = WaterHeaterEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - async def async_service_away_mode( entity: WaterHeaterEntity, service: ServiceCall @@ -437,11 +414,3 @@ async def async_service_temperature_set( kwargs[value] = temp await entity.async_set_temperature(**kwargs) - - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = ft.partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py new file mode 100644 index 00000000000..fa761110339 --- /dev/null +++ b/homeassistant/components/watergate/__init__.py @@ -0,0 +1,131 @@ +"""The Watergate integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +import logging + +from watergate_local_api import WatergateLocalApiClient +from watergate_local_api.models import WebhookEvent + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.webhook import ( + Request, + Response, + async_generate_url, + async_register, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import WatergateDataCoordinator + +_LOGGER = logging.getLogger(__name__) + +WEBHOOK_TELEMETRY_TYPE = "telemetry" +WEBHOOK_VALVE_TYPE = "valve" +WEBHOOK_WIFI_CHANGED_TYPE = "wifi-changed" +WEBHOOK_POWER_SUPPLY_CHANGED_TYPE = "power-supply-changed" + +PLATFORMS: list[Platform] = [ + Platform.SENSOR, + Platform.VALVE, +] + +type WatergateConfigEntry = ConfigEntry[WatergateDataCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool: + """Set up Watergate from a config entry.""" + sonic_address = entry.data[CONF_IP_ADDRESS] + webhook_id = entry.data[CONF_WEBHOOK_ID] + + _LOGGER.debug( + "Setting up watergate local api integration for device: IP: %s)", + sonic_address, + ) + + watergate_client = WatergateLocalApiClient( + sonic_address if sonic_address.startswith("http") else f"http://{sonic_address}" + ) + + coordinator = WatergateDataCoordinator(hass, watergate_client) + entry.runtime_data = coordinator + + async_register( + hass, DOMAIN, "Watergate", webhook_id, get_webhook_handler(coordinator) + ) + + _LOGGER.debug("Registered webhook: %s", webhook_id) + + await coordinator.async_config_entry_first_refresh() + + await watergate_client.async_set_webhook_url( + async_generate_url(hass, webhook_id, allow_ip=True, prefer_external=False) + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool: + """Unload a config entry.""" + webhook_id = entry.data[CONF_WEBHOOK_ID] + hass.components.webhook.async_unregister(webhook_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +def get_webhook_handler( + coordinator: WatergateDataCoordinator, +) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: + """Return webhook handler.""" + + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + if not request.body_exists: + return HomeAssistantView.json( + result="No Body", status_code=HTTPStatus.BAD_REQUEST + ) + + body = await request.json() + + _LOGGER.debug("Received webhook: %s", body) + + data = WebhookEvent.parse_webhook_event(body) + + body_type = body.get("type") + + if not (coordinator_data := coordinator.data): + pass + elif body_type == WEBHOOK_VALVE_TYPE: + coordinator_data.state.valve_state = data.state + elif body_type == WEBHOOK_TELEMETRY_TYPE: + errors = data.errors or {} + coordinator_data.telemetry.flow = ( + data.flow if "flow" not in errors else None + ) + coordinator_data.telemetry.pressure = ( + data.pressure if "pressure" not in errors else None + ) + coordinator_data.telemetry.water_temperature = ( + data.temperature if "temperature" not in errors else None + ) + elif body_type == WEBHOOK_WIFI_CHANGED_TYPE: + coordinator_data.networking.ip = data.ip + coordinator_data.networking.gateway = data.gateway + coordinator_data.networking.subnet = data.subnet + coordinator_data.networking.ssid = data.ssid + coordinator_data.networking.rssi = data.rssi + elif body_type == WEBHOOK_POWER_SUPPLY_CHANGED_TYPE: + coordinator_data.state.power_supply = data.supply + + coordinator.async_set_updated_data(coordinator_data) + + return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK) + + return async_webhook_handler diff --git a/homeassistant/components/watergate/config_flow.py b/homeassistant/components/watergate/config_flow.py new file mode 100644 index 00000000000..de8494053a3 --- /dev/null +++ b/homeassistant/components/watergate/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Watergate.""" + +import logging + +import voluptuous as vol +from watergate_local_api.watergate_api import ( + WatergateApiException, + WatergateLocalApiClient, +) + +from homeassistant.components.webhook import async_generate_id as webhook_generate_id +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SONIC = "Sonic" +WATERGATE_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + } +) + + +class WatergateConfigFlow(ConfigFlow, domain=DOMAIN): + """Watergate config flow.""" + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + watergate_client = WatergateLocalApiClient( + self.prepare_ip_address(user_input[CONF_IP_ADDRESS]) + ) + try: + state = await watergate_client.async_get_device_state() + except WatergateApiException as exception: + _LOGGER.error("Error connecting to Watergate device: %s", exception) + errors[CONF_IP_ADDRESS] = "cannot_connect" + else: + if state is None: + _LOGGER.error("Device state returned as None") + errors[CONF_IP_ADDRESS] = "cannot_connect" + else: + await self.async_set_unique_id(state.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + data={**user_input, CONF_WEBHOOK_ID: webhook_generate_id()}, + title=SONIC, + ) + + return self.async_show_form( + step_id="user", data_schema=WATERGATE_SCHEMA, errors=errors + ) + + def prepare_ip_address(self, ip_address: str) -> str: + """Prepare the IP address for the Watergate device.""" + return ip_address if ip_address.startswith("http") else f"http://{ip_address}" diff --git a/homeassistant/components/watergate/const.py b/homeassistant/components/watergate/const.py new file mode 100644 index 00000000000..22a14330af9 --- /dev/null +++ b/homeassistant/components/watergate/const.py @@ -0,0 +1,5 @@ +"""Constants for the Watergate integration.""" + +DOMAIN = "watergate" + +MANUFACTURER = "Watergate" diff --git a/homeassistant/components/watergate/coordinator.py b/homeassistant/components/watergate/coordinator.py new file mode 100644 index 00000000000..1d83b7a3ccb --- /dev/null +++ b/homeassistant/components/watergate/coordinator.py @@ -0,0 +1,58 @@ +"""Coordinator for Watergate API.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from watergate_local_api import WatergateApiException, WatergateLocalApiClient +from watergate_local_api.models import DeviceState, NetworkingData, TelemetryData + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class WatergateAgregatedRequests: + """Class to hold aggregated requests.""" + + state: DeviceState + telemetry: TelemetryData + networking: NetworkingData + + +class WatergateDataCoordinator(DataUpdateCoordinator[WatergateAgregatedRequests]): + """Class to manage fetching watergate data.""" + + def __init__(self, hass: HomeAssistant, api: WatergateLocalApiClient) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=2), + ) + self.api = api + + async def _async_update_data(self) -> WatergateAgregatedRequests: + try: + state = await self.api.async_get_device_state() + telemetry = await self.api.async_get_telemetry_data() + networking = await self.api.async_get_networking() + except WatergateApiException as exc: + raise UpdateFailed(f"Sonic device is unavailable: {exc}") from exc + return WatergateAgregatedRequests(state, telemetry, networking) + + def async_set_updated_data(self, data: WatergateAgregatedRequests) -> None: + """Manually update data, notify listeners and DO NOT reset refresh interval.""" + + self.data = data + self.logger.debug( + "Manually updated %s data", + self.name, + ) + + self.async_update_listeners() diff --git a/homeassistant/components/watergate/entity.py b/homeassistant/components/watergate/entity.py new file mode 100644 index 00000000000..8f43643029f --- /dev/null +++ b/homeassistant/components/watergate/entity.py @@ -0,0 +1,32 @@ +"""Watergate Base Entity Definition.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import WatergateDataCoordinator + + +class WatergateEntity(CoordinatorEntity[WatergateDataCoordinator]): + """Define a base Watergate entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: WatergateDataCoordinator, + entity_name: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._api_client = coordinator.api + self._attr_unique_id = f"{coordinator.data.state.serial_number}.{entity_name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.state.serial_number)}, + name="Sonic", + serial_number=coordinator.data.state.serial_number, + manufacturer=MANUFACTURER, + sw_version=( + coordinator.data.state.firmware_version if coordinator.data else None + ), + ) diff --git a/homeassistant/components/watergate/manifest.json b/homeassistant/components/watergate/manifest.json new file mode 100644 index 00000000000..46a80e15671 --- /dev/null +++ b/homeassistant/components/watergate/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "watergate", + "name": "Watergate", + "codeowners": ["@adam-the-hero"], + "config_flow": true, + "dependencies": ["http", "webhook"], + "documentation": "https://www.home-assistant.io/integrations/watergate", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["watergate-local-api==2024.4.1"] +} diff --git a/homeassistant/components/watergate/quality_scale.yaml b/homeassistant/components/watergate/quality_scale.yaml new file mode 100644 index 00000000000..b116eff970e --- /dev/null +++ b/homeassistant/components/watergate/quality_scale.yaml @@ -0,0 +1,44 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: done + action-exceptions: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: todo + docs-configuration-parameters: todo diff --git a/homeassistant/components/watergate/sensor.py b/homeassistant/components/watergate/sensor.py new file mode 100644 index 00000000000..6782a93541b --- /dev/null +++ b/homeassistant/components/watergate/sensor.py @@ -0,0 +1,218 @@ +"""Support for Watergate sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import StrEnum +import logging + +from homeassistant.components.sensor import ( + HomeAssistant, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, + UnitOfVolumeFlowRate, +) +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util + +from . import WatergateConfigEntry +from .coordinator import WatergateAgregatedRequests, WatergateDataCoordinator +from .entity import WatergateEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +class PowerSupplyMode(StrEnum): + """LED bar mode.""" + + BATTERY = "battery" + EXTERNAL = "external" + BATTERY_EXTERNAL = "battery_external" + + +@dataclass(kw_only=True, frozen=True) +class WatergateSensorEntityDescription(SensorEntityDescription): + """Description for Watergate sensor entities.""" + + value_fn: Callable[ + [WatergateAgregatedRequests], + StateType | datetime | PowerSupplyMode, + ] + + +DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ + WatergateSensorEntityDescription( + value_fn=lambda data: ( + data.state.water_meter.volume + if data.state and data.state.water_meter + else None + ), + translation_key="water_meter_volume", + key="water_meter_volume", + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + WatergateSensorEntityDescription( + value_fn=lambda data: ( + data.state.water_meter.duration + if data.state and data.state.water_meter + else None + ), + translation_key="water_meter_duration", + key="water_meter_duration", + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + WatergateSensorEntityDescription( + value_fn=lambda data: data.networking.rssi if data.networking else None, + key="rssi", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), + WatergateSensorEntityDescription( + value_fn=lambda data: ( + dt_util.as_utc( + dt_util.now() - timedelta(milliseconds=data.networking.wifi_uptime) + ) + if data.networking + else None + ), + translation_key="wifi_up_since", + key="wifi_up_since", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + ), + WatergateSensorEntityDescription( + value_fn=lambda data: ( + dt_util.as_utc( + dt_util.now() - timedelta(milliseconds=data.networking.mqtt_uptime) + ) + if data.networking + else None + ), + translation_key="mqtt_up_since", + key="mqtt_up_since", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + ), + WatergateSensorEntityDescription( + value_fn=lambda data: ( + data.telemetry.water_temperature if data.telemetry else None + ), + translation_key="water_temperature", + key="water_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + WatergateSensorEntityDescription( + value_fn=lambda data: data.telemetry.pressure if data.telemetry else None, + translation_key="water_pressure", + key="water_pressure", + native_unit_of_measurement=UnitOfPressure.MBAR, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + WatergateSensorEntityDescription( + value_fn=lambda data: ( + data.telemetry.flow / 1000 + if data.telemetry and data.telemetry.flow is not None + else None + ), + key="water_flow_rate", + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + ), + WatergateSensorEntityDescription( + value_fn=lambda data: ( + dt_util.as_utc(dt_util.now() - timedelta(seconds=data.state.uptime)) + if data.state + else None + ), + translation_key="up_since", + key="up_since", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + ), + WatergateSensorEntityDescription( + value_fn=lambda data: ( + PowerSupplyMode( + data.state.power_supply.replace("+", "_").replace( + "external_battery", "battery_external" + ) + ) + if data.state + else None + ), + translation_key="power_supply_mode", + key="power_supply_mode", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=[member.value for member in PowerSupplyMode], + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WatergateConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up all entries for Watergate Platform.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + SonicSensor(coordinator, description) for description in DESCRIPTIONS + ) + + +class SonicSensor(WatergateEntity, SensorEntity): + """Define a Sonic Sensor entity.""" + + entity_description: WatergateSensorEntityDescription + + def __init__( + self, + coordinator: WatergateDataCoordinator, + entity_description: WatergateSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and self.entity_description.value_fn(self.coordinator.data) is not None + ) + + @property + def native_value(self) -> str | int | float | datetime | PowerSupplyMode | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/watergate/strings.json b/homeassistant/components/watergate/strings.json new file mode 100644 index 00000000000..c312525e420 --- /dev/null +++ b/homeassistant/components/watergate/strings.json @@ -0,0 +1,54 @@ +{ + "config": { + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "title": "Configure Watergate device", + "data_description": { + "ip_address": "Provide an IP address of your Watergate device." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "water_meter_volume": { + "name": "Water meter volume" + }, + "water_meter_duration": { + "name": "Water meter duration" + }, + "wifi_up_since": { + "name": "Wi-Fi up since" + }, + "mqtt_up_since": { + "name": "MQTT up since" + }, + "water_temperature": { + "name": "Water temperature" + }, + "water_pressure": { + "name": "Water pressure" + }, + "up_since": { + "name": "Up since" + }, + "power_supply_mode": { + "name": "Power supply mode", + "state": { + "battery": "Battery", + "external": "Mains", + "battery_external": "Battery and mains" + } + } + } + } +} diff --git a/homeassistant/components/watergate/valve.py b/homeassistant/components/watergate/valve.py new file mode 100644 index 00000000000..556b53e1d3c --- /dev/null +++ b/homeassistant/components/watergate/valve.py @@ -0,0 +1,91 @@ +"""Support for Watergate Valve.""" + +from homeassistant.components.sensor import Any, HomeAssistant +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, + ValveState, +) +from homeassistant.core import callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import WatergateConfigEntry +from .coordinator import WatergateDataCoordinator +from .entity import WatergateEntity + +ENTITY_NAME = "valve" +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WatergateConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up all entries for Watergate Platform.""" + + async_add_entities([SonicValve(config_entry.runtime_data)]) + + +class SonicValve(WatergateEntity, ValveEntity): + """Define a Sonic Valve entity.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_reports_position = False + _valve_state: str | None = None + _attr_device_class = ValveDeviceClass.WATER + _attr_name = None + + def __init__( + self, + coordinator: WatergateDataCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, ENTITY_NAME) + self._valve_state = ( + coordinator.data.state.valve_state if coordinator.data.state else None + ) + + @property + def is_closed(self) -> bool: + """Return if the valve is closed or not.""" + return self._valve_state == ValveState.CLOSED + + @property + def is_opening(self) -> bool | None: + """Return if the valve is opening or not.""" + return self._valve_state == ValveState.OPENING + + @property + def is_closing(self) -> bool | None: + """Return if the valve is closing or not.""" + return self._valve_state == ValveState.CLOSING + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + self._attr_available = self.coordinator.data is not None + self._valve_state = ( + self.coordinator.data.state.valve_state + if self.coordinator.data.state + else None + ) + self.async_write_ha_state() + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + await self._api_client.async_set_valve_state(ValveState.OPEN) + self._valve_state = ValveState.OPENING + self.async_write_ha_state() + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close the valve.""" + await self._api_client.async_set_valve_state(ValveState.CLOSED) + self._valve_state = ValveState.CLOSING + self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.coordinator.data.state is not None diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 1abcf9d391d..34f22c9218f 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -3,12 +3,13 @@ import asyncio from collections.abc import Collection import logging +from typing import Literal from pywaze.route_calculator import CalcRoutesResponse, WazeRouteCalculator, WRCError import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_REGION, Platform +from homeassistant.const import CONF_REGION, Platform, UnitOfLength from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -22,7 +23,10 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, TextSelector, + TextSelectorConfig, + TextSelectorType, ) +from homeassistant.util.unit_conversion import DistanceConverter from .const import ( CONF_AVOID_FERRIES, @@ -38,6 +42,7 @@ from .const import ( DEFAULT_FILTER, DEFAULT_VEHICLE_TYPE, DOMAIN, + IMPERIAL_UNITS, METRIC_UNITS, REGIONS, SEMAPHORE, @@ -80,6 +85,18 @@ SERVICE_GET_TRAVEL_TIMES_SCHEMA = vol.Schema( vol.Optional(CONF_AVOID_TOLL_ROADS, default=False): BooleanSelector(), vol.Optional(CONF_AVOID_SUBSCRIPTION_ROADS, default=False): BooleanSelector(), vol.Optional(CONF_AVOID_FERRIES, default=False): BooleanSelector(), + vol.Optional(CONF_INCL_FILTER): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + multiple=True, + ), + ), + vol.Optional(CONF_EXCL_FILTER): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + multiple=True, + ), + ), } ) @@ -107,6 +124,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b avoid_subscription_roads=service.data[CONF_AVOID_SUBSCRIPTION_ROADS], avoid_ferries=service.data[CONF_AVOID_FERRIES], realtime=service.data[CONF_REALTIME], + units=service.data[CONF_UNITS], + incl_filters=service.data.get(CONF_INCL_FILTER, DEFAULT_FILTER), + excl_filters=service.data.get(CONF_EXCL_FILTER, DEFAULT_FILTER), ) return {"routes": [vars(route) for route in response]} if response else None @@ -129,6 +149,7 @@ async def async_get_travel_times( avoid_subscription_roads: bool, avoid_ferries: bool, realtime: bool, + units: Literal["metric", "imperial"] = "metric", incl_filters: Collection[str] | None = None, excl_filters: Collection[str] | None = None, ) -> list[CalcRoutesResponse] | None: @@ -194,6 +215,20 @@ async def async_get_travel_times( route for route in incl_routes if not should_exclude_route(route) ] + if units == IMPERIAL_UNITS: + filtered_routes = [ + CalcRoutesResponse( + name=route.name, + distance=DistanceConverter.convert( + route.distance, UnitOfLength.KILOMETERS, UnitOfLength.MILES + ), + duration=route.duration, + street_names=route.street_names, + ) + for route in filtered_routes + if route.distance is not None + ] + if len(filtered_routes) < 1: _LOGGER.warning("No routes found") return None diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index c2d3ee12cf8..a216a02f61e 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -20,7 +20,6 @@ from homeassistant.const import ( CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_STARTED, - UnitOfLength, UnitOfTime, ) from homeassistant.core import CoreState, HomeAssistant @@ -28,7 +27,6 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.location import find_coordinates -from homeassistant.util.unit_conversion import DistanceConverter from . import async_get_travel_times from .const import ( @@ -44,7 +42,6 @@ from .const import ( CONF_VEHICLE_TYPE, DEFAULT_NAME, DOMAIN, - IMPERIAL_UNITS, SEMAPHORE, ) @@ -201,6 +198,7 @@ class WazeTravelTimeData: avoid_subscription_roads, avoid_ferries, realtime, + self.config_entry.options[CONF_UNITS], incl_filter, excl_filter, ) @@ -211,14 +209,5 @@ class WazeTravelTimeData: return self.duration = route.duration - distance = route.distance - - if self.config_entry.options[CONF_UNITS] == IMPERIAL_UNITS: - # Convert to miles. - self.distance = DistanceConverter.convert( - distance, UnitOfLength.KILOMETERS, UnitOfLength.MILES - ) - else: - self.distance = distance - + self.distance = route.distance self.route = route.name diff --git a/homeassistant/components/waze_travel_time/services.yaml b/homeassistant/components/waze_travel_time/services.yaml index 7fba565dd47..fd5f2e9adea 100644 --- a/homeassistant/components/waze_travel_time/services.yaml +++ b/homeassistant/components/waze_travel_time/services.yaml @@ -55,3 +55,13 @@ get_travel_times: required: false selector: boolean: + incl_filter: + required: false + selector: + text: + multiple: true + excl_filter: + required: false + selector: + text: + multiple: true diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index f053f033307..8f8de694b2d 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "For Origin and Destination, enter the address or the GPS coordinates of the location (GPS coordinates has to be separated by a comma). You can also enter an entity id which provides this information in its state, an entity id with latitude and longitude attributes, or zone friendly name.", + "description": "For Origin and Destination, enter the address or the GPS coordinates of the location (GPS coordinates has to be separated by a comma). You can also enter an entity ID which provides this information in its state, an entity ID with latitude and longitude attributes, or zone friendly name.", "data": { "name": "[%key:common::config_flow::data::name%]", "origin": "Origin", @@ -26,13 +26,13 @@ "description": "Some options will allow you to force the integration to use a particular route or avoid a particular route in its time travel calculation.", "data": { "units": "Units", - "vehicle_type": "Vehicle Type", + "vehicle_type": "Vehicle type", "incl_filter": "Exact streetname which must be part of the selected route", "excl_filter": "Exact streetname which must NOT be part of the selected route", - "realtime": "Realtime Travel Time?", - "avoid_toll_roads": "Avoid Toll Roads?", - "avoid_ferries": "Avoid Ferries?", - "avoid_subscription_roads": "Avoid Roads Needing a Vignette / Subscription?" + "realtime": "Realtime travel time?", + "avoid_toll_roads": "Avoid toll roads?", + "avoid_ferries": "Avoid ferries?", + "avoid_subscription_roads": "Avoid roads needing a vignette / subscription?" } } } @@ -47,8 +47,8 @@ }, "units": { "options": { - "metric": "Metric System", - "imperial": "Imperial System" + "metric": "Metric system", + "imperial": "Imperial system" } }, "region": { @@ -63,8 +63,8 @@ }, "services": { "get_travel_times": { - "name": "Get Travel Times", - "description": "Get route alternatives and travel times between two locations.", + "name": "Get travel times", + "description": "Retrieves route alternatives and travel times between two locations.", "fields": { "origin": { "name": "[%key:component::waze_travel_time::config::step::user::data::origin%]", @@ -76,7 +76,7 @@ }, "region": { "name": "[%key:component::waze_travel_time::config::step::user::data::region%]", - "description": "The region. Controls which waze server is used." + "description": "The region. Controls which Waze server is used." }, "units": { "name": "[%key:component::waze_travel_time::options::step::init::data::units%]", @@ -101,6 +101,14 @@ "avoid_subscription_roads": { "name": "[%key:component::waze_travel_time::options::step::init::data::avoid_subscription_roads%]", "description": "Whether to avoid subscription roads." + }, + "incl_filter": { + "name": "[%key:component::waze_travel_time::options::step::init::data::incl_filter%]", + "description": "Exact streetname which must be part of the selected route." + }, + "excl_filter": { + "name": "[%key:component::waze_travel_time::options::step::init::data::excl_filter%]", + "description": "Exact streetname which must NOT be part of the selected route." } } } diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 557765795ee..e9436922a4b 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -8,10 +8,19 @@ from contextlib import suppress from datetime import timedelta from functools import partial import logging -from typing import Any, Final, Generic, Literal, Required, TypedDict, cast, final +from typing import ( + Any, + Final, + Generic, + Literal, + Required, + TypedDict, + TypeVar, + cast, + final, +) -from propcache import cached_property -from typing_extensions import TypeVar +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index ddabba2fc1f..6438d7503db 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta from apple_weatherkit import DataSetType from apple_weatherkit.client import WeatherKitApiClient, WeatherKitApiClientError @@ -20,12 +20,15 @@ REQUESTED_DATA_SETS = [ DataSetType.HOURLY_FORECAST, ] +STALE_DATA_THRESHOLD = timedelta(hours=1) + class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" config_entry: ConfigEntry supported_data_sets: list[DataSetType] | None = None + last_updated_at: datetime | None = None def __init__( self, @@ -62,10 +65,20 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): if not self.supported_data_sets: await self.update_supported_data_sets() - return await self.client.get_weather_data( + updated_data = await self.client.get_weather_data( self.config_entry.data[CONF_LATITUDE], self.config_entry.data[CONF_LONGITUDE], self.supported_data_sets, ) except WeatherKitApiClientError as exception: - raise UpdateFailed(exception) from exception + if self.data is None or ( + self.last_updated_at is not None + and datetime.now() - self.last_updated_at > STALE_DATA_THRESHOLD + ): + raise UpdateFailed(exception) from exception + + LOGGER.debug("Using stale data because update failed: %s", exception) + return self.data + else: + self.last_updated_at = datetime.now() + return updated_data diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 34e11f49978..92ef59db908 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import get_url, is_cloud_connection from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util import network +from homeassistant.util import network as network_util from homeassistant.util.aiohttp import MockRequest, MockStreamReader, serialize_response _LOGGER = logging.getLogger(__name__) @@ -97,14 +97,16 @@ def async_generate_url( ) -> str: """Generate the full URL for a webhook_id.""" return ( - f"{get_url( - hass, - allow_internal=allow_internal, - allow_external=allow_external, - allow_cloud=False, - allow_ip=allow_ip, - prefer_external=prefer_external, - )}" + f"{ + get_url( + hass, + allow_internal=allow_internal, + allow_external=allow_external, + allow_cloud=False, + allow_ip=allow_ip, + prefer_external=prefer_external, + ) + }" f"{async_generate_path(webhook_id)}" ) @@ -172,7 +174,7 @@ async def async_handle_webhook( _LOGGER.debug("Unable to parse remote ip %s", request.remote) return Response(status=HTTPStatus.OK) - is_local = network.is_local(request_remote) + is_local = network_util.is_local(request_remote) if not is_local: _LOGGER.warning("Received remote request for local webhook %s", webhook_id) diff --git a/homeassistant/components/webmin/config_flow.py b/homeassistant/components/webmin/config_flow.py index 64f8c684dfa..903d6c50a09 100644 --- a/homeassistant/components/webmin/config_flow.py +++ b/homeassistant/components/webmin/config_flow.py @@ -45,7 +45,7 @@ async def validate_user_input( raise SchemaFlowError("invalid_auth") from err raise SchemaFlowError("cannot_connect") from err except Fault as fault: - LOGGER.exception(f"Fault {fault.faultCode}: {fault.faultString}") + LOGGER.exception("Fault %s: %s", fault.faultCode, fault.faultString) raise SchemaFlowError("unknown") from fault except ClientConnectionError as err: raise SchemaFlowError("cannot_connect") from err diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index 499d0a85518..c1a1c698f92 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -1,95 +1,53 @@ -"""Support for LG webOS Smart TV.""" +"""The LG webOS TV integration.""" from __future__ import annotations from contextlib import suppress -import logging -from typing import NamedTuple from aiowebostv import WebOsClient, WebOsTvPairError -import voluptuous as vol from homeassistant.components import notify as hass_notify -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_COMMAND, - ATTR_ENTITY_ID, CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, + Platform, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import ( - ATTR_BUTTON, ATTR_CONFIG_ENTRY_ID, - ATTR_PAYLOAD, - ATTR_SOUND_OUTPUT, - DATA_CONFIG_ENTRY, DATA_HASS_CONFIG, DOMAIN, PLATFORMS, - SERVICE_BUTTON, - SERVICE_COMMAND, - SERVICE_SELECT_SOUND_OUTPUT, WEBOSTV_EXCEPTIONS, ) +from .helpers import WebOsTvConfigEntry, update_client_key CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -CALL_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids}) - - -class ServiceMethodDetails(NamedTuple): - """Details for SERVICE_TO_METHOD mapping.""" - - method: str - schema: vol.Schema - - -BUTTON_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_BUTTON): cv.string}) - -COMMAND_SCHEMA = CALL_SCHEMA.extend( - {vol.Required(ATTR_COMMAND): cv.string, vol.Optional(ATTR_PAYLOAD): dict} -) - -SOUND_OUTPUT_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_SOUND_OUTPUT): cv.string}) - -SERVICE_TO_METHOD = { - SERVICE_BUTTON: ServiceMethodDetails(method="async_button", schema=BUTTON_SCHEMA), - SERVICE_COMMAND: ServiceMethodDetails( - method="async_command", schema=COMMAND_SCHEMA - ), - SERVICE_SELECT_SOUND_OUTPUT: ServiceMethodDetails( - method="async_select_sound_output", - schema=SOUND_OUTPUT_SCHEMA, - ), -} - -_LOGGER = logging.getLogger(__name__) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the LG WebOS TV platform.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault(DATA_CONFIG_ENTRY, {}) - hass.data[DOMAIN][DATA_HASS_CONFIG] = config + """Set up the LG webOS TV platform.""" + hass.data.setdefault(DOMAIN, {DATA_HASS_CONFIG: config}) return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Set the config entry up.""" host = entry.data[CONF_HOST] key = entry.data[CONF_CLIENT_SECRET] # Attempt a connection, but fail gracefully if tv is off for example. - client = WebOsClient(host, key) + entry.runtime_data = client = WebOsClient( + host, key, client_session=async_get_clientsession(hass) + ) with suppress(*WEBOSTV_EXCEPTIONS): try: await client.connect() @@ -98,20 +56,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If pairing request accepted there will be no error # Update the stored key without triggering reauth - update_client_key(hass, entry, client) + update_client_key(hass, entry) - async def async_service_handler(service: ServiceCall) -> None: - method = SERVICE_TO_METHOD[service.service] - data = service.data.copy() - data["method"] = method.method - async_dispatcher_send(hass, DOMAIN, data) - - for service, method in SERVICE_TO_METHOD.items(): - hass.services.async_register( - DOMAIN, service, async_service_handler, schema=method.schema - ) - - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # set up notify platform, no entry support for notify component yet, @@ -119,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task( discovery.async_load_platform( hass, - "notify", + Platform.NOTIFY, DOMAIN, { CONF_NAME: entry.title, @@ -129,8 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - if not entry.update_listeners: - entry.async_on_unload(entry.add_update_listener(async_update_options)) + entry.async_on_unload(entry.add_update_listener(async_update_options)) async def async_on_stop(_event: Event) -> None: """Unregister callbacks and disconnect.""" @@ -143,49 +88,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_control_connect(host: str, key: str | None) -> WebOsClient: - """LG Connection.""" - client = WebOsClient(host, key) - try: - await client.connect() - except WebOsTvPairError: - _LOGGER.warning("Connected to LG webOS TV %s but not paired", host) - raise - - return client - - -def update_client_key( - hass: HomeAssistant, entry: ConfigEntry, client: WebOsClient -) -> None: - """Check and update stored client key if key has changed.""" - host = entry.data[CONF_HOST] - key = entry.data[CONF_CLIENT_SECRET] - - if client.client_key != key: - _LOGGER.debug("Updating client key for host %s", host) - data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} - hass.config_entries.async_update_entry(entry, data=data) - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + client = entry.runtime_data await hass_notify.async_reload(hass, DOMAIN) client.clear_state_update_callbacks() await client.disconnect() - # unregister service calls, check if this is the last entry to unload - if unload_ok and not hass.data[DOMAIN][DATA_CONFIG_ENTRY]: - for service in SERVICE_TO_METHOD: - hass.services.async_remove(DOMAIN, service) - return unload_ok diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 45395bd282a..fbc3eb958dd 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -1,44 +1,54 @@ -"""Config flow to configure webostv component.""" +"""Config flow for LG webOS TV integration.""" from __future__ import annotations from collections.abc import Mapping -import logging from typing import Any, Self from urllib.parse import urlparse -from aiowebostv import WebOsTvPairError +from aiowebostv import WebOsClient, WebOsTvPairError import voluptuous as vol -from homeassistant.components import ssdp -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) -from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME -from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) -from . import async_control_connect, update_client_key +from . import WebOsTvConfigEntry from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS -from .helpers import async_get_sources +from .helpers import get_sources DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }, extra=vol.ALLOW_EXTRA, ) -_LOGGER = logging.getLogger(__name__) + +async def async_control_connect( + hass: HomeAssistant, host: str, key: str | None +) -> WebOsClient: + """Create LG webOS client and connect to the TV.""" + client = WebOsClient( + host, + key, + client_session=async_get_clientsession(hass), + ) + + await client.connect() + + return client class FlowHandler(ConfigFlow, domain=DOMAIN): - """WebosTV configuration flow.""" + """LG webOS TV configuration flow.""" VERSION = 1 @@ -50,7 +60,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -58,47 +68,26 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors: dict[str, str] = {} if user_input is not None: self._host = user_input[CONF_HOST] - self._name = user_input[CONF_NAME] return await self.async_step_pairing() - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) - - @callback - def _async_check_configured_entry(self) -> None: - """Check if entry is configured, update unique_id if needed.""" - for entry in self._async_current_entries(include_ignore=False): - if entry.data[CONF_HOST] != self._host: - continue - - if self._uuid and not entry.unique_id: - _LOGGER.debug( - "Updating unique_id for host %s, unique_id: %s", - self._host, - self._uuid, - ) - self.hass.config_entries.async_update_entry(entry, unique_id=self._uuid) - - raise AbortFlow("already_configured") + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) async def async_step_pairing( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Display pairing form.""" - self._async_check_configured_entry() + self._async_abort_entries_match({CONF_HOST: self._host}) self.context["title_placeholders"] = {"name": self._name} - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: - client = await async_control_connect(self._host, None) + client = await async_control_connect(self.hass, self._host, None) except WebOsTvPairError: - return self.async_abort(reason="error_pairing") + errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: errors["base"] = "cannot_connect" else: @@ -107,24 +96,28 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured({CONF_HOST: self._host}) data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} + + if not self._name: + self._name = f"{DEFAULT_NAME} {client.system_info['modelName']}" return self.async_create_entry(title=self._name, data=data) return self.async_show_form(step_id="pairing", errors=errors) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" assert discovery_info.ssdp_location host = urlparse(discovery_info.ssdp_location).hostname assert host self._host = host - self._name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME) + self._name = discovery_info.upnp.get( + ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME + ).replace("[LG]", "LG") - uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + uuid = discovery_info.upnp[ATTR_UPNP_UDN] assert uuid - if uuid.startswith("uuid:"): - uuid = uuid[5:] + uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: self._host}) @@ -149,26 +142,62 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + if user_input is not None: try: - client = await async_control_connect(self._host, None) + client = await async_control_connect(self.hass, self._host, None) except WebOsTvPairError: - return self.async_abort(reason="error_pairing") + errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: - return self.async_abort(reason="reauth_unsuccessful") + errors["base"] = "cannot_connect" + else: + reauth_entry = self._get_reauth_entry() + data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} + return self.async_update_reload_and_abort(reauth_entry, data=data) - reauth_entry = self._get_reauth_entry() - update_client_key(self.hass, reauth_entry, client) - await self.hass.config_entries.async_reload(reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_show_form(step_id="reauth_confirm", errors=errors) - return self.async_show_form(step_id="reauth_confirm") + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + host = user_input[CONF_HOST] + client_key = reconfigure_entry.data.get(CONF_CLIENT_SECRET) + + try: + client = await async_control_connect(self.hass, host, client_key) + except WebOsTvPairError: + errors["base"] = "error_pairing" + except WEBOSTV_EXCEPTIONS: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(client.hello_info["deviceUUID"]) + self._abort_if_unique_id_mismatch(reason="wrong_device") + data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} + return self.async_update_reload_and_abort(reconfigure_entry, data=data) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=reconfigure_entry.data.get(CONF_HOST) + ): cv.string + } + ), + errors=errors, + ) class OptionsFlowHandler(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: WebOsTvConfigEntry) -> None: """Initialize options flow.""" self.host = config_entry.data[CONF_HOST] self.key = config_entry.data[CONF_CLIENT_SECRET] @@ -182,9 +211,14 @@ class OptionsFlowHandler(OptionsFlow): options_input = {CONF_SOURCES: user_input[CONF_SOURCES]} return self.async_create_entry(title="", data=options_input) # Get sources - sources_list = await async_get_sources(self.host, self.key) - if not sources_list: - errors["base"] = "cannot_retrieve" + sources_list = [] + try: + client = await async_control_connect(self.hass, self.host, self.key) + sources_list = get_sources(client) + except WebOsTvPairError: + errors["base"] = "error_pairing" + except WEBOSTV_EXCEPTIONS: + errors["base"] = "cannot_connect" option_sources = self.config_entry.options.get(CONF_SOURCES, []) sources = [s for s in option_sources if s in sources_list] diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index c20060cae91..e505611db52 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -1,17 +1,16 @@ -"""Constants used for LG webOS Smart TV.""" +"""Constants for the LG webOS TV integration.""" import asyncio +import aiohttp from aiowebostv import WebOsTvCommandError -from websockets.exceptions import ConnectionClosed, ConnectionClosedOK from homeassistant.const import Platform DOMAIN = "webostv" PLATFORMS = [Platform.MEDIA_PLAYER] -DATA_CONFIG_ENTRY = "config_entry" DATA_HASS_CONFIG = "hass_config" -DEFAULT_NAME = "LG webOS Smart TV" +DEFAULT_NAME = "LG webOS TV" ATTR_BUTTON = "button" ATTR_CONFIG_ENTRY_ID = "entry_id" @@ -28,11 +27,10 @@ SERVICE_SELECT_SOUND_OUTPUT = "select_sound_output" LIVE_TV_APP_ID = "com.webos.app.livetv" WEBOSTV_EXCEPTIONS = ( - OSError, - ConnectionClosed, - ConnectionClosedOK, - ConnectionRefusedError, + ConnectionResetError, WebOsTvCommandError, - TimeoutError, + aiohttp.ClientConnectorError, + aiohttp.ServerDisconnectedError, asyncio.CancelledError, + asyncio.TimeoutError, ) diff --git a/homeassistant/components/webostv/device_trigger.py b/homeassistant/components/webostv/device_trigger.py index f16b1cec4f5..951c11525b1 100644 --- a/homeassistant/components/webostv/device_trigger.py +++ b/homeassistant/components/webostv/device_trigger.py @@ -1,4 +1,4 @@ -"""Provides device automations for control of LG webOS Smart TV.""" +"""Provides device automations for control of LG webOS TV.""" from __future__ import annotations @@ -14,8 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import trigger -from .const import DOMAIN +from . import DOMAIN, trigger from .helpers import ( async_get_client_by_device_entry, async_get_device_entry_by_device_id, @@ -43,8 +42,7 @@ async def async_validate_trigger_config( device_id = config[CONF_DEVICE_ID] try: device = async_get_device_entry_by_device_id(hass, device_id) - if DOMAIN in hass.data: - async_get_client_by_device_entry(hass, device) + async_get_client_by_device_entry(hass, device) except ValueError as err: raise InvalidDeviceAutomationConfig(err) from err @@ -77,4 +75,8 @@ async def async_attach_trigger( hass, trigger_config, action, trigger_info ) - raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unhandled_trigger_type", + translation_placeholders={"trigger_type": trigger_type}, + ) diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index 1657fb71d26..7fb64a2cb8f 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -1,4 +1,4 @@ -"""Diagnostics support for LG webOS Smart TV.""" +"""Diagnostics support for LG webOS TV.""" from __future__ import annotations @@ -7,11 +7,10 @@ from typing import Any from aiowebostv import WebOsClient from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from .const import DATA_CONFIG_ENTRY, DOMAIN +from . import WebOsTvConfigEntry TO_REDACT = { CONF_CLIENT_SECRET, @@ -25,10 +24,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: WebOsTvConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - client: WebOsClient = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] + client: WebOsClient = entry.runtime_data client_data = { "is_registered": client.is_registered(), diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index edcfdcfed8b..3c509a56d1e 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -1,15 +1,23 @@ -"""Helper functions for webOS Smart TV.""" +"""Helper functions for LG webOS TV.""" from __future__ import annotations +import logging + from aiowebostv import WebOsClient +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from . import async_control_connect -from .const import DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS +from .const import DOMAIN, LIVE_TV_APP_ID + +_LOGGER = logging.getLogger(__name__) + +type WebOsTvConfigEntry = ConfigEntry[WebOsClient] @callback @@ -31,7 +39,7 @@ def async_get_device_entry_by_device_id( def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> str: """Get device ID from an entity ID. - Raises ValueError if entity or device ID is invalid. + Raises HomeAssistantError if entity or device ID is invalid. """ ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(entity_id) @@ -41,7 +49,11 @@ def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> s or entity_entry.device_id is None or entity_entry.platform != DOMAIN ): - raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_entity_id", + translation_placeholders={"entity_id": entity_id}, + ) return entity_entry.device_id @@ -55,24 +67,24 @@ def async_get_client_by_device_entry( Raises ValueError if client is not found. """ for config_entry_id in device.config_entries: - if client := hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry_id): - break - - if not client: - raise ValueError( - f"Device {device.id} is not from an existing {DOMAIN} config entry" + entry: WebOsTvConfigEntry | None = hass.config_entries.async_get_entry( + config_entry_id ) + if entry and entry.domain == DOMAIN: + if entry.state is ConfigEntryState.LOADED: + return entry.runtime_data - return client + raise ValueError( + f"Device {device.id} is not from a loaded {DOMAIN} config entry" + ) + + raise ValueError( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) -async def async_get_sources(host: str, key: str) -> list[str]: +def get_sources(client: WebOsClient) -> list[str]: """Construct sources list.""" - try: - client = await async_control_connect(host, key) - except WEBOSTV_EXCEPTIONS: - return [] - sources = [] found_live_tv = False for app in client.apps.values(): @@ -90,3 +102,15 @@ async def async_get_sources(host: str, key: str) -> list[str]: # Preserve order when filtering duplicates return list(dict.fromkeys(sources)) + + +def update_client_key(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None: + """Check and update stored client key if key has changed.""" + client: WebOsClient = entry.runtime_data + host = entry.data[CONF_HOST] + key = entry.data[CONF_CLIENT_SECRET] + + if client.client_key != key: + _LOGGER.debug("Updating client key for host %s", host) + data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} + hass.config_entries.async_update_entry(entry, data=data) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 6c826c2f997..f1a8e163398 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -1,12 +1,12 @@ { "domain": "webostv", - "name": "LG webOS Smart TV", + "name": "LG webOS TV", "codeowners": ["@thecode"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.4.2"], + "requirements": ["aiowebostv==0.6.0"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 239780e3f01..4b39841e29d 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -1,4 +1,4 @@ -"""Support for interface with an LG webOS Smart TV.""" +"""Support for interface with an LG webOS TV.""" from __future__ import annotations @@ -12,6 +12,7 @@ import logging from typing import Any, Concatenate, cast from aiowebostv import WebOsClient, WebOsTvPairError +import voluptuous as vol from homeassistant import util from homeassistant.components.media_player import ( @@ -21,32 +22,30 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - ENTITY_MATCH_ALL, - ENTITY_MATCH_NONE, -) +from homeassistant.const import ATTR_COMMAND, ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.trigger import PluggableAction +from homeassistant.helpers.typing import VolDictType -from . import update_client_key from .const import ( + ATTR_BUTTON, ATTR_PAYLOAD, ATTR_SOUND_OUTPUT, CONF_SOURCES, - DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, + SERVICE_BUTTON, + SERVICE_COMMAND, + SERVICE_SELECT_SOUND_OUTPUT, WEBOSTV_EXCEPTIONS, ) +from .helpers import WebOsTvConfigEntry, update_client_key from .triggers.turn_on import async_get_turn_on_trigger _LOGGER = logging.getLogger(__name__) @@ -68,15 +67,35 @@ SUPPORT_WEBOSTV_VOLUME = ( MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=10) +BUTTON_SCHEMA: VolDictType = {vol.Required(ATTR_BUTTON): cv.string} +COMMAND_SCHEMA: VolDictType = { + vol.Required(ATTR_COMMAND): cv.string, + vol.Optional(ATTR_PAYLOAD): dict, +} +SOUND_OUTPUT_SCHEMA: VolDictType = {vol.Required(ATTR_SOUND_OUTPUT): cv.string} + +SERVICES = ( + (SERVICE_BUTTON, BUTTON_SCHEMA, "async_button"), + (SERVICE_COMMAND, COMMAND_SCHEMA, "async_command"), + (SERVICE_SELECT_SOUND_OUTPUT, SOUND_OUTPUT_SCHEMA, "async_select_sound_output"), +) + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: WebOsTvConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the LG webOS Smart TV platform.""" - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] - async_add_entities([LgWebOSMediaPlayerEntity(entry, client)]) + """Set up the LG webOS TV platform.""" + platform = entity_platform.async_get_current_platform() + + for service_name, schema, method in SERVICES: + platform.async_register_entity_service(service_name, schema, method) + + async_add_entities([LgWebOSMediaPlayerEntity(entry)]) def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( @@ -87,36 +106,42 @@ def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( @wraps(func) async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap all command methods.""" + if self.state is MediaPlayerState.OFF: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_off", + translation_placeholders={ + "name": str(self._entry.title), + "func": func.__name__, + }, + ) try: await func(self, *args, **kwargs) - except WEBOSTV_EXCEPTIONS as exc: - if self.state != MediaPlayerState.OFF: - raise HomeAssistantError( - f"Error calling {func.__name__} on entity {self.entity_id}," - f" state:{self.state}" - ) from exc - _LOGGER.warning( - "Error calling %s on entity %s, state:%s, error: %r", - func.__name__, - self.entity_id, - self.state, - exc, - ) + except WEBOSTV_EXCEPTIONS as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={ + "name": str(self._entry.title), + "func": func.__name__, + "error": str(error), + }, + ) from error return cmd_wrapper class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): - """Representation of a LG webOS Smart TV.""" + """Representation of a LG webOS TV.""" _attr_device_class = MediaPlayerDeviceClass.TV _attr_has_entity_name = True _attr_name = None - def __init__(self, entry: ConfigEntry, client: WebOsClient) -> None: + def __init__(self, entry: WebOsTvConfigEntry) -> None: """Initialize the webos device.""" self._entry = entry - self._client = client + self._client = entry.runtime_data self._attr_assumed_state = True self._device_name = entry.title self._attr_unique_id = entry.unique_id @@ -142,10 +167,6 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ) ) - self.async_on_remove( - async_dispatcher_connect(self.hass, DOMAIN, self.async_signal_handler) - ) - await self._client.register_state_update_callback( self.async_handle_state_update ) @@ -165,19 +186,6 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Call disconnect on removal.""" self._client.unregister_state_update_callback(self.async_handle_state_update) - async def async_signal_handler(self, data: dict[str, Any]) -> None: - """Handle domain-specific signal by calling appropriate method.""" - if (entity_ids := data[ATTR_ENTITY_ID]) == ENTITY_MATCH_NONE: - return - - if entity_ids == ENTITY_MATCH_ALL or self.entity_id in entity_ids: - params = { - key: value - for key, value in data.items() - if key not in ["entity_id", "method"] - } - await getattr(self, data["method"])(**params) - async def async_handle_state_update(self, _client: WebOsClient) -> None: """Update state from WebOsClient.""" self._update_states() @@ -194,7 +202,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): self._attr_volume_level = None if self._client.volume is not None: - self._attr_volume_level = cast(float, self._client.volume / 100.0) + self._attr_volume_level = self._client.volume / 100.0 self._attr_source = self._current_source self._attr_source_list = sorted(self._source_list) @@ -238,13 +246,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ) self._attr_assumed_state = True - if ( - self._client.is_on - and self._client.media_state is not None - and self._client.media_state.get("foregroundAppInfo") is not None - ): + if self._client.is_on and self._client.media_state: self._attr_assumed_state = False - for entry in self._client.media_state.get("foregroundAppInfo"): + for entry in self._client.media_state: if entry.get("playState") == "playing": self._attr_state = MediaPlayerState.PLAYING elif entry.get("playState") == "paused": @@ -252,7 +256,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): elif entry.get("playState") == "unloaded": self._attr_state = MediaPlayerState.IDLE - if self._client.system_info is not None or self.state != MediaPlayerState.OFF: + if self.state != MediaPlayerState.OFF: maj_v = self._client.software_info.get("major_ver") min_v = self._client.software_info.get("minor_ver") if maj_v and min_v: @@ -325,13 +329,13 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): if self._client.is_connected(): return - with suppress(*WEBOSTV_EXCEPTIONS, WebOsTvPairError): + with suppress(*WEBOSTV_EXCEPTIONS): try: await self._client.connect() except WebOsTvPairError: self._entry.async_start_reauth(self.hass) else: - update_client_key(self.hass, self._entry, self._client) + update_client_key(self.hass, self._entry) @property def supported_features(self) -> MediaPlayerEntityFeature: @@ -388,10 +392,14 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" if (source_dict := self._source_list.get(source)) is None: - _LOGGER.warning( - "Source %s not found for %s", source, self._friendly_name_internal() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="source_not_found", + translation_placeholders={ + "source": source, + "name": str(self._friendly_name_internal()), + }, ) - return if source_dict.get("title"): await self._client.launch_app(source_dict["id"]) elif source_dict.get("label"): @@ -404,7 +412,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Play a piece of media.""" _LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id) - if media_type == MediaType.CHANNEL: + if media_type == MediaType.CHANNEL and self._client.channels: _LOGGER.debug("Searching channel") partial_match_channel_id = None perfect_match_channel_id = None diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 43320687ce8..2393cb4cd07 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -1,20 +1,21 @@ -"""Support for LG WebOS TV notification service.""" +"""Support for LG webOS TV notification service.""" from __future__ import annotations -import logging from typing import Any -from aiowebostv import WebOsClient, WebOsTvPairError +from aiowebostv import WebOsClient from homeassistant.components.notify import ATTR_DATA, BaseNotificationService from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_CONFIG_ENTRY_ID, DATA_CONFIG_ENTRY, DOMAIN, WEBOSTV_EXCEPTIONS +from . import WebOsTvConfigEntry +from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, WEBOSTV_EXCEPTIONS -_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 async def async_get_service( @@ -27,30 +28,53 @@ async def async_get_service( if discovery_info is None: return None - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][discovery_info[ATTR_CONFIG_ENTRY_ID]] + config_entry = hass.config_entries.async_get_entry( + discovery_info[ATTR_CONFIG_ENTRY_ID] + ) + assert config_entry is not None - return LgWebOSNotificationService(client) + return LgWebOSNotificationService(config_entry) class LgWebOSNotificationService(BaseNotificationService): - """Implement the notification service for LG WebOS TV.""" + """Implement the notification service for LG webOS TV.""" - def __init__(self, client: WebOsClient) -> None: + def __init__(self, entry: WebOsTvConfigEntry) -> None: """Initialize the service.""" - self._client = client + self._entry = entry async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to the tv.""" - try: - if not self._client.is_connected(): - await self._client.connect() + client: WebOsClient = self._entry.runtime_data + data = kwargs[ATTR_DATA] + icon_path = data.get(ATTR_ICON) if data else None - data = kwargs[ATTR_DATA] - icon_path = data.get(ATTR_ICON) if data else None - await self._client.send_message(message, icon_path=icon_path) - except WebOsTvPairError: - _LOGGER.error("Pairing with TV failed") - except FileNotFoundError: - _LOGGER.error("Icon %s not found", icon_path) - except WEBOSTV_EXCEPTIONS: - _LOGGER.error("TV unreachable") + if not client.is_on: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="notify_device_off", + translation_placeholders={ + "name": str(self._entry.title), + "func": __name__, + }, + ) + try: + await client.send_message(message, icon_path=icon_path) + except FileNotFoundError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="notify_icon_not_found", + translation_placeholders={ + "name": str(self._entry.title), + "icon_path": str(icon_path), + }, + ) from error + except WEBOSTV_EXCEPTIONS as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="notify_communication_error", + translation_placeholders={ + "name": str(self._entry.title), + "error": str(error), + }, + ) from error diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml new file mode 100644 index 00000000000..70f845404cd --- /dev/null +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: + status: exempt + comment: The integration does not use common patterns. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The integration connects to a single device. + entity-category: + status: exempt + comment: The integration only registers one entity. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: The integration only registers one entity. + entity-translations: + status: exempt + comment: There are no entities to translate. + exception-translations: done + icon-translations: + status: exempt + comment: The only entity can use the device class. + reconfiguration-flow: done + repair-issues: + status: exempt + comment: The integration does not have anything to repair. + stale-devices: + status: exempt + comment: The integration connects to a single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index 3ceab5f50a3..f6d033af632 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -1,49 +1,61 @@ { "config": { - "flow_title": "LG webOS Smart TV", + "flow_title": "{name}", "step": { "user": { - "description": "Turn on TV, fill the following fields and select **Submit**", + "description": "Turn on the TV, fill the host field and select **Submit**", "data": { - "host": "[%key:common::config_flow::data::host%]", - "name": "[%key:common::config_flow::data::name%]" + "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address of your webOS TV." + "host": "Hostname or IP address of your LG webOS TV." } }, "pairing": { - "title": "webOS TV Pairing", + "title": "LG webOS TV Pairing", "description": "Select **Submit** and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" }, "reauth_confirm": { "title": "[%key:component::webostv::config::step::pairing::title%]", "description": "[%key:component::webostv::config::step::pairing::description%]" + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::webostv::config::step::user::data_description::host%]" + } } }, "error": { - "cannot_connect": "Failed to connect, please turn on your TV or check the IP address" + "cannot_connect": "Failed to connect, please turn on your TV and try again.", + "error_pairing": "Pairing failed, make sure to accept the pairing request on the TV and try again." }, "abort": { - "error_pairing": "Connected to LG webOS TV but not paired", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reauth_unsuccessful": "Re-authentication was unsuccessful, please turn on your TV and try again." + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "The configured device is not the same found on this Hostname or IP address." } }, "options": { "step": { "init": { - "title": "Options for webOS Smart TV", + "title": "Options for LG webOS TV", "description": "Select enabled sources", "data": { "sources": "Sources list" + }, + "data_description": { + "sources": "List of sources to enable" } } }, "error": { - "cannot_retrieve": "Unable to retrieve the list of sources. Make sure device is switched on" + "cannot_connect": "[%key:component::webostv::config::error::cannot_connect%]", + "error_pairing": "[%key:component::webostv::config::error::error_pairing%]" } }, "device_automation": { @@ -98,5 +110,34 @@ } } } + }, + "exceptions": { + "device_off": { + "message": "Error calling {func} for device {name}: Device is off and cannot be controlled." + }, + "communication_error": { + "message": "Communication error while calling {func} for device {name}: {error}" + }, + "notify_device_off": { + "message": "Error sending notification to device {name}: Device is off and cannot be controlled." + }, + "notify_icon_not_found": { + "message": "Icon {icon_path} not found when sending notification for device {name}" + }, + "notify_communication_error": { + "message": "Communication error while sending notification to device {name}: {error}" + }, + "unhandled_trigger_type": { + "message": "Unhandled trigger type: {trigger_type}" + }, + "unknown_trigger_platform": { + "message": "Unknown trigger platform: {platform}" + }, + "invalid_entity_id": { + "message": "Entity {entity_id} is not a valid webostv entity." + }, + "source_not_found": { + "message": "Source {source} not found in the sources list for {name}." + } } } diff --git a/homeassistant/components/webostv/trigger.py b/homeassistant/components/webostv/trigger.py index 3290aa4a448..f121daafb91 100644 --- a/homeassistant/components/webostv/trigger.py +++ b/homeassistant/components/webostv/trigger.py @@ -1,4 +1,4 @@ -"""webOS Smart TV trigger dispatcher.""" +"""LG webOS TV trigger dispatcher.""" from __future__ import annotations @@ -6,6 +6,7 @@ from typing import cast from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.trigger import ( TriggerActionType, TriggerInfo, @@ -13,6 +14,7 @@ from homeassistant.helpers.trigger import ( ) from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .triggers import turn_on TRIGGERS = { @@ -24,8 +26,10 @@ def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: """Return trigger platform.""" platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: - raise ValueError( - f"Unknown webOS Smart TV trigger platform {config[CONF_PLATFORM]}" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_trigger_platform", + translation_placeholders={"platform": config[CONF_PLATFORM]}, ) return cast(TriggerProtocol, TRIGGERS[platform_split[1]]) diff --git a/homeassistant/components/webostv/triggers/__init__.py b/homeassistant/components/webostv/triggers/__init__.py index d8c5a28ef3f..89bdf5f90ee 100644 --- a/homeassistant/components/webostv/triggers/__init__.py +++ b/homeassistant/components/webostv/triggers/__init__.py @@ -1 +1 @@ -"""webOS Smart TV triggers.""" +"""LG webOS TV triggers.""" diff --git a/homeassistant/components/webostv/triggers/turn_on.py b/homeassistant/components/webostv/triggers/turn_on.py index f2ecb8aa98d..648da690715 100644 --- a/homeassistant/components/webostv/triggers/turn_on.py +++ b/homeassistant/components/webostv/triggers/turn_on.py @@ -1,4 +1,4 @@ -"""webOS Smart TV device turn on trigger.""" +"""LG webOS TV device turn on trigger.""" from __future__ import annotations diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 62f1adc39b9..12473c86255 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -40,17 +40,17 @@ class ActiveConnection: """Handle an active websocket client connection.""" __slots__ = ( - "logger", - "hass", - "send_message", - "user", - "refresh_token_id", - "subscriptions", - "last_id", - "can_coalesce", - "supported_features", - "handlers", "binary_handlers", + "can_coalesce", + "handlers", + "hass", + "last_id", + "logger", + "refresh_token_id", + "send_message", + "subscriptions", + "supported_features", + "user", ) def __init__( @@ -189,13 +189,13 @@ class ActiveConnection: if ( # Not using isinstance as we don't care about children # as these are always coming from JSON - type(msg) is not dict # noqa: E721 + type(msg) is not dict or ( not (cur_id := msg.get("id")) - or type(cur_id) is not int # noqa: E721 + or type(cur_id) is not int or cur_id < 0 or not (type_ := msg.get("type")) - or type(type_) is not str # noqa: E721 + or type(type_) is not str ) ): self.logger.error("Received invalid command: %s", msg) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index e7d57aebab6..8bfa9480ff4 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -63,27 +63,27 @@ class WebSocketAdapter(logging.LoggerAdapter): def process(self, msg: str, kwargs: Any) -> tuple[str, Any]: """Add connid to websocket log messages.""" assert self.extra is not None - return f'[{self.extra["connid"]}] {msg}', kwargs + return f"[{self.extra['connid']}] {msg}", kwargs class WebSocketHandler: """Handle an active websocket client connection.""" __slots__ = ( - "_hass", - "_loop", - "_request", - "_wsock", - "_handle_task", - "_writer_task", - "_closing", "_authenticated", - "_logger", - "_peak_checker_unsub", + "_closing", "_connection", + "_handle_task", + "_hass", + "_logger", + "_loop", "_message_queue", + "_peak_checker_unsub", "_ready_future", "_release_ready_queue_size", + "_request", + "_writer_task", + "_wsock", ) def __init__(self, hass: HomeAssistant, request: web.Request) -> None: @@ -197,7 +197,7 @@ class WebSocketHandler: # max pending messages. return - if type(message) is not bytes: # noqa: E721 + if type(message) is not bytes: if isinstance(message, dict): message = message_to_json_bytes(message) elif isinstance(message, str): @@ -490,7 +490,7 @@ class WebSocketHandler: ) # command_msg_data is always deserialized from JSON as a list - if type(command_msg_data) is not list: # noqa: E721 + if type(command_msg_data) is not list: async_handle_str(command_msg_data) continue diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index d924d6ceaab..d8d8616c867 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -2,13 +2,17 @@ from __future__ import annotations +from http import HTTPStatus + +import aiohttp from weheat.abstractions.discovery import HeatPumpDiscovery from weheat.exceptions import UnauthorizedException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -17,7 +21,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from .const import API_URL, LOGGER from .coordinator import WeheatDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] @@ -28,12 +32,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo session = OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as ex: + LOGGER.warning("API error: %s (%s)", ex.status, ex.message) + if ex.status in ( + HTTPStatus.BAD_REQUEST, + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + raise ConfigEntryNotReady from ex + token = session.token[CONF_ACCESS_TOKEN] entry.runtime_data = [] # fetch a list of the heat pumps the entry can access try: - discovered_heat_pumps = await HeatPumpDiscovery.discover_active(API_URL, token) + discovered_heat_pumps = await HeatPumpDiscovery.async_discover_active( + API_URL, token, async_get_clientsession(hass) + ) except UnauthorizedException as error: raise ConfigEntryAuthFailed from error diff --git a/homeassistant/components/weheat/api.py b/homeassistant/components/weheat/api.py deleted file mode 100644 index b1f5c0b3eff..00000000000 --- a/homeassistant/components/weheat/api.py +++ /dev/null @@ -1,28 +0,0 @@ -"""API for Weheat bound to Home Assistant OAuth.""" - -from aiohttp import ClientSession -from weheat.abstractions import AbstractAuth - -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session - -from .const import API_URL - - -class AsyncConfigEntryAuth(AbstractAuth): - """Provide Weheat authentication tied to an OAuth2 based config entry.""" - - def __init__( - self, - websession: ClientSession, - oauth_session: OAuth2Session, - ) -> None: - """Initialize Weheat auth.""" - super().__init__(websession, host=API_URL) - self._oauth_session = oauth_session - - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - await self._oauth_session.async_ensure_token_valid() - - return self._oauth_session.token[CONF_ACCESS_TOKEN] diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py new file mode 100644 index 00000000000..1fb8f614a40 --- /dev/null +++ b/homeassistant/components/weheat/binary_sensor.py @@ -0,0 +1,103 @@ +"""Binary sensor platform for Weheat integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from weheat.abstractions.heat_pump import HeatPump + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import WeheatConfigEntry +from .coordinator import WeheatDataUpdateCoordinator +from .entity import WeheatEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class WeHeatBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Weheat binary sensor entity.""" + + value_fn: Callable[[HeatPump], StateType] + + +BINARY_SENSORS = [ + WeHeatBinarySensorEntityDescription( + translation_key="indoor_unit_water_pump_state", + key="indoor_unit_water_pump_state", + device_class=BinarySensorDeviceClass.RUNNING, + value_fn=lambda status: status.indoor_unit_water_pump_state, + ), + WeHeatBinarySensorEntityDescription( + translation_key="indoor_unit_auxiliary_pump_state", + key="indoor_unit_auxiliary_pump_state", + device_class=BinarySensorDeviceClass.RUNNING, + value_fn=lambda status: status.indoor_unit_auxiliary_pump_state, + ), + WeHeatBinarySensorEntityDescription( + translation_key="indoor_unit_dhw_valve_or_pump_state", + key="indoor_unit_dhw_valve_or_pump_state", + device_class=BinarySensorDeviceClass.RUNNING, + value_fn=lambda status: status.indoor_unit_dhw_valve_or_pump_state, + ), + WeHeatBinarySensorEntityDescription( + translation_key="indoor_unit_gas_boiler_state", + key="indoor_unit_gas_boiler_state", + value_fn=lambda status: status.indoor_unit_gas_boiler_state, + ), + WeHeatBinarySensorEntityDescription( + translation_key="indoor_unit_electric_heater_state", + key="indoor_unit_electric_heater_state", + device_class=BinarySensorDeviceClass.RUNNING, + value_fn=lambda status: status.indoor_unit_electric_heater_state, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WeheatConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensors for weheat heat pump.""" + entities = [ + WeheatHeatPumpBinarySensor(coordinator, entity_description) + for entity_description in BINARY_SENSORS + for coordinator in entry.runtime_data + if entity_description.value_fn(coordinator.data) is not None + ] + + async_add_entities(entities) + + +class WeheatHeatPumpBinarySensor(WeheatEntity, BinarySensorEntity): + """Defines a Weheat heat pump binary sensor.""" + + coordinator: WeheatDataUpdateCoordinator + entity_description: WeHeatBinarySensorEntityDescription + + def __init__( + self, + coordinator: WeheatDataUpdateCoordinator, + entity_description: WeHeatBinarySensorEntityDescription, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + + self.entity_description = entity_description + + self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}" + + @property + def is_on(self) -> bool | None: + """Return True if the binary sensor is on.""" + value = self.entity_description.value_fn(self.coordinator.data) + return bool(value) if value is not None else None diff --git a/homeassistant/components/weheat/config_flow.py b/homeassistant/components/weheat/config_flow.py index b1a0b5dd4ea..2911ebdd49b 100644 --- a/homeassistant/components/weheat/config_flow.py +++ b/homeassistant/components/weheat/config_flow.py @@ -4,10 +4,11 @@ from collections.abc import Mapping import logging from typing import Any -from weheat.abstractions.user import get_user_id_from_token +from weheat.abstractions.user import async_get_user_id_from_token from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler from .const import API_URL, DOMAIN, ENTRY_TITLE, OAUTH2_SCOPES @@ -33,8 +34,10 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Override the create entry method to change to the step to find the heat pumps.""" # get the user id and use that as unique id for this entry - user_id = await get_user_id_from_token( - API_URL, data[CONF_TOKEN][CONF_ACCESS_TOKEN] + user_id = await async_get_user_id_from_token( + API_URL, + data[CONF_TOKEN][CONF_ACCESS_TOKEN], + async_get_clientsession(self.hass), ) await self.async_set_unique_id(user_id) if self.source != SOURCE_REAUTH: diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index a50e9daec18..4a85380e4a3 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -16,6 +16,7 @@ from weheat.exceptions import ( from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -47,7 +48,9 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): update_interval=timedelta(seconds=UPDATE_INTERVAL), ) self.heat_pump_info = heat_pump - self._heat_pump_data = HeatPump(API_URL, heat_pump.uuid) + self._heat_pump_data = HeatPump( + API_URL, heat_pump.uuid, async_get_clientsession(hass) + ) self.session = session @@ -68,19 +71,17 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): """Return the model of the heat pump.""" return self.heat_pump_info.model - def fetch_data(self) -> HeatPump: - """Get the data from the API.""" + async def _async_update_data(self) -> HeatPump: + """Fetch data from the API.""" + await self.session.async_ensure_token_valid() + try: - self._heat_pump_data.get_status(self.session.token[CONF_ACCESS_TOKEN]) + await self._heat_pump_data.async_get_status( + self.session.token[CONF_ACCESS_TOKEN] + ) except UnauthorizedException as error: raise ConfigEntryAuthFailed from error except EXCEPTIONS as error: raise UpdateFailed(error) from error return self._heat_pump_data - - async def _async_update_data(self) -> HeatPump: - """Fetch data from the API.""" - await self.session.async_ensure_token_valid() - - return await self.hass.async_add_executor_job(self.fetch_data) diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json index 6fdae84cfff..e7f54b478c6 100644 --- a/homeassistant/components/weheat/icons.json +++ b/homeassistant/components/weheat/icons.json @@ -1,5 +1,22 @@ { "entity": { + "binary_sensor": { + "indoor_unit_water_pump_state": { + "default": "mdi:pump" + }, + "indoor_unit_auxiliary_pump_state": { + "default": "mdi:pump" + }, + "indoor_unit_dhw_valve_or_pump_state": { + "default": "mdi:pump" + }, + "indoor_unit_gas_boiler_state": { + "default": "mdi:toggle-switch" + }, + "indoor_unit_electric_heater_state": { + "default": "mdi:heating-coil" + } + }, "sensor": { "power_output": { "default": "mdi:heat-wave" @@ -27,6 +44,12 @@ }, "electricity_used": { "default": "mdi:flash" + }, + "compressor_rpm": { + "default": "mdi:fan" + }, + "compressor_percentage": { + "default": "mdi:fan" } } } diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index ef89a2f1acb..1d60f66afba 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2024.11.02"] + "requirements": ["weheat==2025.1.15"] } diff --git a/homeassistant/components/weheat/quality_scale.yaml b/homeassistant/components/weheat/quality_scale.yaml new file mode 100644 index 00000000000..705efce4421 --- /dev/null +++ b/homeassistant/components/weheat/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions currently available + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: todo + comment: | + There are two servers that are used for this integration. + If the authentication server is unreachable, the user will not pass the configuration step. + If the backend is unreachable, an empty error message is displayed. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No service actions currently available + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No configuration parameters available. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and thus does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and thus does not support discovery. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: todo + comment: | + While unlikely to happen. Check if it is easily integrated. + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + There is no reconfiguration, as the only configuration step is authentication. + repair-issues: + status: exempt + comment: | + This is a cloud service and apart form reauthentication there are not user repairable issues. + stale-devices: + status: todo + comment: | + While unlikely to happen. Check if it is easily integrated. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index ef5be9030b9..2d840aec86a 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -11,7 +11,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -25,6 +31,9 @@ from .const import ( from .coordinator import WeheatDataUpdateCoordinator from .entity import WeheatEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class WeHeatSensorEntityDescription(SensorEntityDescription): @@ -142,6 +151,28 @@ SENSORS = [ state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda status: status.energy_total, ), + WeHeatSensorEntityDescription( + translation_key="energy_output", + key="energy_output", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_output, + ), + WeHeatSensorEntityDescription( + translation_key="compressor_rpm", + key="compressor_rpm", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + value_fn=lambda status: status.compressor_rpm, + ), + WeHeatSensorEntityDescription( + translation_key="compressor_percentage", + key="compressor_percentage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda status: status.compressor_percentage, + ), ] diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index 0733024cbed..2a208c2f8ca 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -32,6 +32,23 @@ } }, "entity": { + "binary_sensor": { + "indoor_unit_water_pump_state": { + "name": "Indoor unit water pump" + }, + "indoor_unit_auxiliary_pump_state": { + "name": "Indoor unit auxilary water pump" + }, + "indoor_unit_dhw_valve_or_pump_state": { + "name": "Indoor unit DHW valve or water pump" + }, + "indoor_unit_gas_boiler_state": { + "name": "Indoor unit gas boiler heating allowed" + }, + "indoor_unit_electric_heater_state": { + "name": "Indoor unit electric heater" + } + }, "sensor": { "power_output": { "name": "Output power" @@ -84,6 +101,15 @@ }, "electricity_used": { "name": "Electricity used" + }, + "energy_output": { + "name": "Total energy output" + }, + "compressor_rpm": { + "name": "Compressor speed" + }, + "compressor_percentage": { + "name": "Compressor usage" } } } diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index f9d3270aaa0..42dae679aa5 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -81,7 +81,6 @@ class WemoHumidifier(WemoBinaryStateEntity, FanEntity): ) wemo: Humidifier _last_fan_on_mode: FanMode - _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: DeviceCoordinator) -> None: """Initialize the WeMo switch.""" diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 26dec417631..6068cd3ff0b 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -8,9 +8,11 @@ from pywemo import Bridge, BridgeLight, Dimmer from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, ColorMode, LightEntity, LightEntityFeature, @@ -77,6 +79,8 @@ def async_setup_bridge( class WemoLight(WemoEntity, LightEntity): """Representation of a WeMo light.""" + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN _attr_supported_features = LightEntityFeature.TRANSITION def __init__(self, coordinator: DeviceCoordinator, light: BridgeLight) -> None: @@ -123,9 +127,11 @@ class WemoLight(WemoEntity, LightEntity): return self.light.state.get("color_xy") @property - def color_temp(self) -> int | None: - """Return the color temperature of this light in mireds.""" - return self.light.state.get("temperature_mireds") + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + if not (mireds := self.light.state.get("temperature_mireds")): + return None + return color_util.color_temperature_mired_to_kelvin(mireds) @property def color_mode(self) -> ColorMode: @@ -165,7 +171,7 @@ class WemoLight(WemoEntity, LightEntity): xy_color = None brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255) - color_temp = kwargs.get(ATTR_COLOR_TEMP) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) hs_color = kwargs.get(ATTR_HS_COLOR) transition_time = int(kwargs.get(ATTR_TRANSITION, 0)) @@ -182,9 +188,9 @@ class WemoLight(WemoEntity, LightEntity): if xy_color is not None: self.light.set_color(xy_color, transition=transition_time) - if color_temp is not None: + if color_temp_kelvin is not None: self.light.set_temperature( - mireds=color_temp, transition=transition_time + kelvin=color_temp_kelvin, transition=transition_time ) self.light.turn_on(**turn_on_kwargs) diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 36f8fbec59d..64adcda4742 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -20,8 +20,10 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +type WhirlpoolConfigEntry = ConfigEntry[WhirlpoolData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool: """Set up Whirlpool Sixth Sense from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -47,21 +49,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Cannot fetch appliances") return False - hass.data[DOMAIN][entry.entry_id] = WhirlpoolData( - appliances_manager, auth, backend_selector - ) + entry.runtime_data = WhirlpoolData(appliances_manager, auth, backend_selector) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @dataclass diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index aa399746006..943c5d1c956 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -23,7 +23,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -31,7 +30,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import WhirlpoolData +from . import WhirlpoolConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -70,11 +69,11 @@ SUPPORTED_TARGET_TEMPERATURE_STEP = 1 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WhirlpoolConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - whirlpool_data: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id] + whirlpool_data = config_entry.runtime_data aircons = [ AirConEntity( @@ -110,7 +109,6 @@ class AirConEntity(ClimateEntity): _attr_swing_modes = SUPPORTED_SWING_MODES _attr_target_temperature_step = SUPPORTED_TARGET_TEMPERATURE_STEP _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py index 9b1dd00e7bd..87d6ea827e2 100644 --- a/homeassistant/components/whirlpool/diagnostics.py +++ b/homeassistant/components/whirlpool/diagnostics.py @@ -5,11 +5,9 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import WhirlpoolData -from .const import DOMAIN +from . import WhirlpoolConfigEntry TO_REDACT = { "SERIAL_NUMBER", @@ -24,11 +22,11 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WhirlpoolConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - whirlpool: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id] + whirlpool = config_entry.runtime_data diagnostics_data = { "Washer_dryers": { wd["NAME"]: dict(wd.items()) diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index 5618a3f61cb..b463a1a76f8 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.8"] + "requirements": ["whirlpool-sixth-sense==0.18.11"] } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 8c74f01298e..9180164c272 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo @@ -23,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from . import WhirlpoolData +from . import WhirlpoolConfigEntry from .const import DOMAIN TANK_FILL = { @@ -132,12 +131,12 @@ SENSOR_TIMER: tuple[SensorEntityDescription] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WhirlpoolConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Config flow entry for Whrilpool Laundry.""" entities: list = [] - whirlpool_data: WhirlpoolData = hass.data[DOMAIN][config_entry.entry_id] + whirlpool_data = config_entry.runtime_data for appliance in whirlpool_data.appliances_manager.washer_dryers: _wd = WasherDryer( whirlpool_data.backend_selector, @@ -292,9 +291,8 @@ class WasherDryerTimeClass(RestoreSensor): seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) ) - if ( - self._attr_native_value is None - or isinstance(self._attr_native_value, datetime) + if self._attr_native_value is None or ( + isinstance(self._attr_native_value, datetime) and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60) ): self._attr_native_value = new_timestamp diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py index 74663d61d8f..1036e5b1ead 100644 --- a/homeassistant/components/wilight/config_flow.py +++ b/homeassistant/components/wilight/config_flow.py @@ -5,9 +5,15 @@ from urllib.parse import urlparse import pywilight -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from .const import DOMAIN @@ -53,25 +59,25 @@ class WiLightFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=self._title, data=data) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered WiLight.""" # Filter out basic information if ( not discovery_info.ssdp_location - or ssdp.ATTR_UPNP_MANUFACTURER not in discovery_info.upnp - or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp - or ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp - or ssdp.ATTR_UPNP_MODEL_NUMBER not in discovery_info.upnp + or ATTR_UPNP_MANUFACTURER not in discovery_info.upnp + or ATTR_UPNP_SERIAL not in discovery_info.upnp + or ATTR_UPNP_MODEL_NAME not in discovery_info.upnp + or ATTR_UPNP_MODEL_NUMBER not in discovery_info.upnp ): return self.async_abort(reason="not_wilight_device") # Filter out non-WiLight devices - if discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER: + if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER: return self.async_abort(reason="not_wilight_device") host = urlparse(discovery_info.ssdp_location).hostname - serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] - model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] + model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME] if not self._wilight_update(host, serial_number, model_name): return self.async_abort(reason="not_wilight_device") diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index 71f1098603b..a14198e3b5d 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -64,7 +64,6 @@ class WiLightFan(WiLightDevice, FanEntity): | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF ) - _enable_turn_on_off_backwards_compatibility = False def __init__(self, api_device: PyWiLightDevice, index: str, item_name: str) -> None: """Initialize the device.""" diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 1c196bd4b92..59c3ed8433f 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -16,6 +16,7 @@ from aiohttp import ClientError from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response from aiowithings import NotificationCategory, WithingsClient +from aiowithings.exceptions import WithingsError from aiowithings.util import to_enum from yarl import URL @@ -223,10 +224,13 @@ class WithingsWebhookManager: "Unregister Withings webhook (%s)", self.entry.data[CONF_WEBHOOK_ID] ) webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) - await async_unsubscribe_webhooks(self.withings_data.client) for coordinator in self.withings_data.coordinators: coordinator.webhook_subscription_listener(False) self._webhooks_registered = False + try: + await async_unsubscribe_webhooks(self.withings_data.client) + except WithingsError as ex: + LOGGER.warning("Failed to unsubscribe from Withings webhook: %s", ex) async def register_webhook( self, diff --git a/homeassistant/components/withings/icons.json b/homeassistant/components/withings/icons.json index 79ff7489bf8..8123337dc82 100644 --- a/homeassistant/components/withings/icons.json +++ b/homeassistant/components/withings/icons.json @@ -16,6 +16,9 @@ "heart_pulse": { "default": "mdi:heart-pulse" }, + "height": { + "default": "mdi:human-male-height-variant" + }, "hydration": { "default": "mdi:water" }, diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 57d4bafdc7b..4c78e077d21 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -5,8 +5,13 @@ "codeowners": ["@joostlek"], "config_flow": true, "dependencies": ["application_credentials", "http", "webhook"], + "dhcp": [ + { + "macaddress": "0024E4*" + } + ], "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_push", "loggers": ["aiowithings"], - "requirements": ["aiowithings==3.1.3"] + "requirements": ["aiowithings==3.1.5"] } diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 71bc0a9aaa8..92b25389450 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -10,10 +10,11 @@ from pywizlight.discovery import DiscoveredBulb from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError import voluptuous as vol -from homeassistant.components import dhcp, onboarding +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.network import is_ip_address from .const import DEFAULT_NAME, DISCOVER_SCAN_TIMEOUT, DOMAIN, WIZ_CONNECT_EXCEPTIONS @@ -38,7 +39,7 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_devices: dict[str, DiscoveredBulb] = {} async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = DiscoveredBulb( diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py index a3f36d580d2..9ef4cd57b3d 100644 --- a/homeassistant/components/wiz/light.py +++ b/homeassistant/components/wiz/light.py @@ -10,7 +10,7 @@ from pywizlight.scenes import get_id_from_scene_name from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -21,10 +21,6 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, -) from . import WizConfigEntry from .entity import WizToggleEntity @@ -43,10 +39,10 @@ def _async_pilot_builder(**kwargs: Any) -> PilotBuilder: if ATTR_RGBW_COLOR in kwargs: return PilotBuilder(brightness=brightness, rgbw=kwargs[ATTR_RGBW_COLOR]) - if ATTR_COLOR_TEMP in kwargs: + if ATTR_COLOR_TEMP_KELVIN in kwargs: return PilotBuilder( brightness=brightness, - colortemp=color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]), + colortemp=kwargs[ATTR_COLOR_TEMP_KELVIN], ) if ATTR_EFFECT in kwargs: @@ -93,8 +89,8 @@ class WizBulbEntity(WizToggleEntity, LightEntity): self._attr_effect_list = wiz_data.scenes if bulb_type.bulb_type != BulbClass.DW: kelvin = bulb_type.kelvin_range - self._attr_min_mireds = color_temperature_kelvin_to_mired(kelvin.max) - self._attr_max_mireds = color_temperature_kelvin_to_mired(kelvin.min) + self._attr_max_color_temp_kelvin = kelvin.max + self._attr_min_color_temp_kelvin = kelvin.min if bulb_type.features.effect: self._attr_supported_features = LightEntityFeature.EFFECT self._async_update_attrs() @@ -111,7 +107,7 @@ class WizBulbEntity(WizToggleEntity, LightEntity): color_temp := state.get_colortemp() ): self._attr_color_mode = ColorMode.COLOR_TEMP - self._attr_color_temp = color_temperature_kelvin_to_mired(color_temp) + self._attr_color_temp_kelvin = color_temp elif ( ColorMode.RGBWW in color_modes and (rgbww := state.get_rgbww()) is not None ): diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 812a0500d1a..2e0b7b1c793 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -7,7 +7,7 @@ from typing import Any import voluptuous as vol from wled import WLED, Device, WLEDConnectionError -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -17,6 +17,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN @@ -68,7 +69,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" # Abort quick if the mac address is provided by discovery info diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py index 69ff6ccb1fa..8d09867a46e 100644 --- a/homeassistant/components/wled/const.py +++ b/homeassistant/components/wled/const.py @@ -53,7 +53,9 @@ LIGHT_CAPABILITIES_COLOR_MODE_MAPPING: dict[LightCapability, list[ColorMode]] = ColorMode.COLOR_TEMP, ], LightCapability.RGB_COLOR | LightCapability.COLOR_TEMPERATURE: [ - ColorMode.RGBWW, + # Technically this is RGBWW but wled does not support RGBWW colors (with warm and cold white separately) + # but rather RGB + CCT which does not have a direct mapping in HA + ColorMode.RGB, ], LightCapability.WHITE_CHANNEL | LightCapability.COLOR_TEMPERATURE: [ ColorMode.COLOR_TEMP, diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index c731f8181af..326008ae1af 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/wled", "integration_type": "device", "iot_class": "local_push", - "requirements": ["wled==0.20.2"], + "requirements": ["wled==0.21.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/components/wmspro/config_flow.py b/homeassistant/components/wmspro/config_flow.py index 2ce58ec9eca..94deed11c08 100644 --- a/homeassistant/components/wmspro/config_flow.py +++ b/homeassistant/components/wmspro/config_flow.py @@ -10,12 +10,11 @@ import aiohttp import voluptuous as vol from wmspro.webcontrol import WebControlPro -from homeassistant.components import dhcp -from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_DHCP, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, SUGGESTED_HOST @@ -34,7 +33,7 @@ class WebControlProConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle the DHCP discovery step.""" unique_id = format_mac(discovery_info.macaddress) @@ -95,7 +94,7 @@ class WebControlProConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") return self.async_create_entry(title=host, data=user_input) - if self.source == dhcp.DOMAIN: + if self.source == SOURCE_DHCP: discovery_info: DhcpServiceInfo = self.init_data data_values = {CONF_HOST: discovery_info.ip} else: diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index f4a2541a1d7..3684208f102 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -94,7 +94,11 @@ def _get_obj_holidays( language=language, categories=set_categories, ) - if (supported_languages := obj_holidays.supported_languages) and language == "en": + if ( + (supported_languages := obj_holidays.supported_languages) + and language + and language.startswith("en") + ): for lang in supported_languages: if lang.startswith("en"): obj_holidays = country_holidays( diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 2036d685d31..895c7cd50e2 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -136,7 +136,7 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: year: int = dt_util.now().year if country := user_input.get(CONF_COUNTRY): - language = user_input.get(CONF_LANGUAGE) + language: str | None = user_input.get(CONF_LANGUAGE) province = user_input.get(CONF_PROVINCE) obj_holidays = country_holidays( country=country, @@ -145,8 +145,10 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: language=language, ) if ( - supported_languages := obj_holidays.supported_languages - ) and language == "en": + (supported_languages := obj_holidays.supported_languages) + and language + and language.startswith("en") + ): for lang in supported_languages: if lang.startswith("en"): obj_holidays = country_holidays( diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index ea08bfe1717..4b9d072f747 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.61"] + "requirements": ["holidays==0.65"] } diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index e74dc0160d9..87fa294dbba 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -14,9 +14,9 @@ "options": { "description": "Set additional options for {name} configured for country {country}", "data": { - "excludes": "Excludes", + "excludes": "Days to exclude", "days_offset": "Offset", - "workdays": "Workdays", + "workdays": "Days to include", "add_holidays": "Add holidays", "remove_holidays": "Remove Holidays", "province": "Subdivision of country", @@ -24,9 +24,9 @@ "category": "Additional category as holiday" }, "data_description": { - "excludes": "List of workdays to exclude, notice the keyword `holiday` and read the documentation on how to use it correctly", + "excludes": "Select which weekdays to exclude as workdays.\nThe key `holidays` adds those for the configured country, customizable by all the settings below. Read the documentation on how to use them correctly.", "days_offset": "Days offset from current day", - "workdays": "List of working days", + "workdays": "Select which weekdays to include as possible workdays.", "add_holidays": "Add custom holidays as YYYY-MM-DD or as range using `,` as separator", "remove_holidays": "Remove holidays as YYYY-MM-DD, as range using `,` as separator or by using partial of name", "province": "State, territory, province or region of country", diff --git a/homeassistant/components/worldclock/config_flow.py b/homeassistant/components/worldclock/config_flow.py index eebf0d59dcb..e91d2e40f63 100644 --- a/homeassistant/components/worldclock/config_flow.py +++ b/homeassistant/components/worldclock/config_flow.py @@ -83,10 +83,6 @@ CONFIG_FLOW = { schema=get_schema, validate_user_input=validate_duplicate, ), - "import": SchemaFlowFormStep( - schema=get_schema, - validate_user_input=validate_duplicate, - ), } OPTIONS_FLOW = { "init": SchemaFlowFormStep( diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index f4879ca08c4..89ea14bbbd0 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -4,62 +4,15 @@ from __future__ import annotations from datetime import tzinfo -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_TIME_ZONE -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from .const import CONF_TIME_FORMAT, DEFAULT_NAME, DEFAULT_TIME_STR_FORMAT, DOMAIN - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_TIME_ZONE): cv.time_zone, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_TIME_FORMAT, default=DEFAULT_TIME_STR_FORMAT): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the World clock sensor.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Worldclock", - }, - ) +from .const import CONF_TIME_FORMAT, DOMAIN async def async_setup_entry( diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index 5fdcb1a5484..41e7b9cf1e6 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -8,10 +8,10 @@ from urllib.parse import urlparse import voluptuous as vol -from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .data import WyomingService @@ -69,6 +69,19 @@ class WyomingConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(discovery_info.uuid) self._abort_if_unique_id_configured() + uri = urlparse(discovery_info.config["uri"]) + for entry in self._async_current_entries(include_ignore=True): + if ( + entry.data[CONF_HOST] == uri.hostname + and entry.data[CONF_PORT] == uri.port + ): + return self.async_update_reload_and_abort( + entry, + unique_id=discovery_info.uuid, + reload_even_if_entry_is_unchanged=False, + reason="already_configured", + ) + self._hassio_discovery = discovery_info self.context.update( { @@ -104,7 +117,7 @@ class WyomingConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" _LOGGER.debug("Zeroconf discovery info: %s", discovery_info) @@ -126,6 +139,19 @@ class WyomingConfigFlow(ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"name": self._name} + for entry in self._async_current_entries(include_ignore=True): + if ( + entry.data[CONF_HOST] == service.host + and entry.data[CONF_PORT] == service.port + and entry.source != SOURCE_HASSIO + ): + return self.async_update_reload_and_abort( + entry, + unique_id=unique_id, + reload_even_if_entry_is_unchanged=False, + reason="already_configured", + ) + self._service = service return await self.async_step_zeroconf_confirm() diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py index 9a17559c1f8..988d47925ac 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from .const import DOMAIN from .data import WyomingService @@ -97,7 +97,7 @@ class WyomingConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" - conversation_id = user_input.conversation_id or ulid.ulid_now() + conversation_id = user_input.conversation_id or ulid_util.ulid_now() intent_response = intent.IntentResponse(language=user_input.language) try: diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py index 23343cb0f8d..d98f1f51d54 100644 --- a/homeassistant/components/x10/light.py +++ b/homeassistant/components/x10/light.py @@ -81,9 +81,16 @@ class X10Light(LightEntity): @property def brightness(self): - """Return the brightness of the light.""" + """Return the brightness of the light, scaled to base class 0..255. + + This needs to be scaled from 0..x for use with X10 dimmers. + """ return self._brightness + def normalize_x10_brightness(self, brightness: float) -> float: + """Return calculated brightness values.""" + return int((brightness / 255) * 32) + @property def is_on(self): """Return true if light is on.""" @@ -91,11 +98,37 @@ class X10Light(LightEntity): def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - if self._is_cm11a: - x10_command(f"on {self._id}") - else: - x10_command(f"fon {self._id}") + old_brightness = self._brightness + if old_brightness == 0: + # Dim down from max if applicable, also avoids a "dim" command if an "on" is more appropriate + old_brightness = 255 self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + brightness_diff = self.normalize_x10_brightness( + self._brightness + ) - self.normalize_x10_brightness(old_brightness) + command_suffix = "" + # heyu has quite a messy command structure - we'll just deal with it here + if brightness_diff == 0: + if self._is_cm11a: + command_prefix = "on" + else: + command_prefix = "fon" + elif brightness_diff > 0: + if self._is_cm11a: + command_prefix = "bright" + else: + command_prefix = "fbright" + command_suffix = f" {brightness_diff}" + else: + if self._is_cm11a: + if self._state: + command_prefix = "dim" + else: + command_prefix = "dimb" + else: + command_prefix = "fdim" + command_suffix = f" {-brightness_diff}" + x10_command(f"{command_prefix} {self._id}{command_suffix}") self._state = True def turn_off(self, **kwargs: Any) -> None: @@ -104,6 +137,7 @@ class X10Light(LightEntity): x10_command(f"off {self._id}") else: x10_command(f"foff {self._id}") + self._brightness = 0 self._state = False def update(self) -> None: diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 6ab46cea069..5282a34903a 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -10,11 +10,7 @@ from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - aiohttp_client, - config_entry_oauth2_flow, - config_validation as cv, -) +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from . import api from .const import DOMAIN @@ -40,9 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth( - aiohttp_client.async_get_clientsession(hass), session - ) + auth = api.AsyncConfigEntryAuth(session) client = XboxLiveClient(auth) consoles: SmartglassConsoleList = await client.smartglass.get_console_list() diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index a0c2d4cfb16..d4c47e4cc39 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -1,24 +1,20 @@ """API for xbox bound to Home Assistant OAuth.""" -from aiohttp import ClientSession from xbox.webapi.authentication.manager import AuthenticationManager from xbox.webapi.authentication.models import OAuth2TokenResponse +from xbox.webapi.common.signed_session import SignedSession -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.util.dt import utc_from_timestamp class AsyncConfigEntryAuth(AuthenticationManager): """Provide xbox authentication tied to an OAuth2 based config entry.""" - def __init__( - self, - websession: ClientSession, - oauth_session: config_entry_oauth2_flow.OAuth2Session, - ) -> None: + def __init__(self, oauth_session: OAuth2Session) -> None: """Initialize xbox auth.""" # Leaving out client credentials as they are handled by Home Assistant - super().__init__(websession, "", "", "") + super().__init__(SignedSession(), "", "", "") self._oauth_session = oauth_session self.oauth = self._get_oauth_token() diff --git a/homeassistant/components/xbox/manifest.json b/homeassistant/components/xbox/manifest.json index 30a6c3bc700..3fc2071e66b 100644 --- a/homeassistant/components/xbox/manifest.json +++ b/homeassistant/components/xbox/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/xbox", "iot_class": "cloud_polling", - "requirements": ["xbox-webapi==2.0.11"] + "requirements": ["xbox-webapi==2.1.0"] } diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index 6252e6849d0..e0484b80b7e 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -7,11 +7,11 @@ from typing import Any import voluptuous as vol from xiaomi_gateway import MULTICAST_PORT, XiaomiGateway, XiaomiGatewayDiscovery -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_INTERFACE, @@ -153,7 +153,7 @@ class XiaomiAqaraFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" name = discovery_info.name diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json index a77b78c5a09..75b4ab1ecda 100644 --- a/homeassistant/components/xiaomi_aqara/strings.json +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -7,14 +7,14 @@ "data": { "interface": "The network interface to use", "host": "IP address (optional)", - "mac": "Mac Address (optional)" + "mac": "MAC address (optional)" } }, "settings": { "title": "Optional settings", "description": "The key (password) can be retrieved using this tutorial: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. If the key is not provided only sensors will be accessible", "data": { - "key": "The key of your gateway", + "key": "The key of your Gateway", "name": "Name of the Gateway" } }, @@ -28,9 +28,9 @@ "error": { "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", "invalid_interface": "Invalid network interface", - "invalid_key": "Invalid gateway key", + "invalid_key": "Invalid Gateway key", "invalid_host": "Invalid hostname or IP address, see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", - "invalid_mac": "Invalid Mac Address" + "invalid_mac": "Invalid MAC address" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index b068f4a1e61..c3ebc48d743 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -11,7 +11,6 @@ from micloud import MiCloud from micloud.micloudexception import MiCloudAccessDenied import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -21,6 +20,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_CLOUD_COUNTRY, @@ -145,7 +145,7 @@ class XiaomiMiioFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_cloud() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" name = discovery_info.name diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 81ca38eb053..e1de3f56252 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -300,7 +300,6 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Representation of a generic Xiaomi device.""" _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device, entry, unique_id, coordinator): """Initialize the generic Xiaomi device.""" diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 8ccc798a2e1..3f1f8b926b3 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -28,7 +28,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -45,7 +45,7 @@ from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import color, dt as dt_util +from homeassistant.util import color as color_util, dt as dt_util from .const import ( CONF_FLOW_TYPE, @@ -430,33 +430,54 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): self._color_temp = None @property - def color_temp(self): + def _current_mireds(self): """Return the color temperature.""" return self._color_temp @property - def min_mireds(self): + def _min_mireds(self): """Return the coldest color_temp that this light supports.""" return 175 @property - def max_mireds(self): + def _max_mireds(self): """Return the warmest color_temp that this light supports.""" return 333 + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + return ( + color_util.color_temperature_mired_to_kelvin(self._color_temp) + if self._color_temp + else None + ) + + @property + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin(self._max_mireds) + + @property + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin(self._min_mireds) + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - if ATTR_COLOR_TEMP in kwargs: - color_temp = kwargs[ATTR_COLOR_TEMP] + if ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) percent_color_temp = self.translate( - color_temp, self.max_mireds, self.min_mireds, CCT_MIN, CCT_MAX + color_temp, self._max_mireds, self._min_mireds, CCT_MIN, CCT_MAX ) if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] percent_brightness = ceil(100 * brightness / 255.0) - if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs: + if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( "Setting brightness and color temperature: %s %s%%, %s mireds, %s%% cct", brightness, @@ -476,7 +497,7 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): self._color_temp = color_temp self._brightness = brightness - elif ATTR_COLOR_TEMP in kwargs: + elif ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( "Setting color temperature: %s mireds, %s%% cct", color_temp, @@ -526,7 +547,11 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): self._state = state.is_on self._brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( - state.color_temperature, CCT_MIN, CCT_MAX, self.max_mireds, self.min_mireds + state.color_temperature, + CCT_MIN, + CCT_MAX, + self._max_mireds, + self._min_mireds, ) delayed_turn_off = self.delayed_turn_off_timestamp( @@ -560,12 +585,12 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): ) @property - def min_mireds(self): + def _min_mireds(self): """Return the coldest color_temp that this light supports.""" return 175 @property - def max_mireds(self): + def _max_mireds(self): """Return the warmest color_temp that this light supports.""" return 370 @@ -585,7 +610,11 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): self._state = state.is_on self._brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( - state.color_temperature, CCT_MIN, CCT_MAX, self.max_mireds, self.min_mireds + state.color_temperature, + CCT_MIN, + CCT_MAX, + self._max_mireds, + self._min_mireds, ) delayed_turn_off = self.delayed_turn_off_timestamp( @@ -797,12 +826,12 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): ) @property - def min_mireds(self): + def _min_mireds(self): """Return the coldest color_temp that this light supports.""" return 153 @property - def max_mireds(self): + def _max_mireds(self): """Return the warmest color_temp that this light supports.""" return 588 @@ -820,10 +849,12 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - if ATTR_COLOR_TEMP in kwargs: - color_temp = kwargs[ATTR_COLOR_TEMP] + if ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) percent_color_temp = self.translate( - color_temp, self.max_mireds, self.min_mireds, CCT_MIN, CCT_MAX + color_temp, self._max_mireds, self._min_mireds, CCT_MIN, CCT_MAX ) if ATTR_BRIGHTNESS in kwargs: @@ -832,7 +863,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): if ATTR_HS_COLOR in kwargs: hs_color = kwargs[ATTR_HS_COLOR] - rgb = color.color_hs_to_RGB(*hs_color) + rgb = color_util.color_hs_to_RGB(*hs_color) if ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR in kwargs: _LOGGER.debug( @@ -853,7 +884,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): self._hs_color = hs_color self._brightness = brightness - elif ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs: + elif ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( ( "Setting brightness and color temperature: " @@ -886,7 +917,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): if result: self._hs_color = hs_color - elif ATTR_COLOR_TEMP in kwargs: + elif ATTR_COLOR_TEMP_KELVIN in kwargs: _LOGGER.debug( "Setting color temperature: %s mireds, %s%% cct", color_temp, @@ -936,9 +967,13 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): self._state = state.is_on self._brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( - state.color_temperature, CCT_MIN, CCT_MAX, self.max_mireds, self.min_mireds + state.color_temperature, + CCT_MIN, + CCT_MAX, + self._max_mireds, + self._min_mireds, ) - self._hs_color = color.color_RGB_to_hs(*state.rgb) + self._hs_color = color_util.color_RGB_to_hs(*state.rgb) self._state_attrs.update( { @@ -1014,7 +1049,7 @@ class XiaomiGatewayLight(LightEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" if ATTR_HS_COLOR in kwargs: - rgb = color.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) + rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) else: rgb = self._rgb @@ -1052,7 +1087,7 @@ class XiaomiGatewayLight(LightEntity): if self._is_on: self._brightness_pct = state_dict["brightness"] self._rgb = state_dict["rgb"] - self._hs = color.color_RGB_to_hs(*self._rgb) + self._hs = color_util.color_RGB_to_hs(*self._rgb) class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): @@ -1067,7 +1102,7 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): return round((self._sub_device.status["brightness"] * 255) / 100) @property - def color_temp(self): + def _current_mireds(self): """Return current color temperature.""" return self._sub_device.status["color_temp"] @@ -1077,12 +1112,12 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): return self._sub_device.status["status"] == "on" @property - def min_mireds(self): + def _min_mireds(self): """Return min cct.""" return self._sub_device.status["cct_min"] @property - def max_mireds(self): + def _max_mireds(self): """Return max cct.""" return self._sub_device.status["cct_max"] @@ -1090,8 +1125,10 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): """Instruct the light to turn on.""" await self.hass.async_add_executor_job(self._sub_device.on) - if ATTR_COLOR_TEMP in kwargs: - color_temp = kwargs[ATTR_COLOR_TEMP] + if ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) await self.hass.async_add_executor_job( self._sub_device.set_color_temp, color_temp ) diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index eb0d6bca205..6729ce2e0f4 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -260,10 +260,10 @@ class XiaomiGenericSelector(XiaomiSelector): if description.options_map: self._options_map = {} - for key, val in enum_class._member_map_.items(): # noqa: SLF001 + for key, val in enum_class._member_map_.items(): self._options_map[description.options_map[key]] = val else: - self._options_map = enum_class._member_map_ # noqa: SLF001 + self._options_map = enum_class._member_map_ self._reverse_map = {val: key for key, val in self._options_map.items()} self._enum_class = enum_class diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index b720cc90d2c..532eb9581cd 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -10,13 +10,8 @@ from miio import DeviceException import voluptuous as vol from homeassistant.components.vacuum import ( - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -55,29 +50,29 @@ ATTR_ZONE_REPEATER = "repeats" ATTR_TIMERS = "timers" STATE_CODE_TO_STATE = { - 1: STATE_IDLE, # "Starting" - 2: STATE_IDLE, # "Charger disconnected" - 3: STATE_IDLE, # "Idle" - 4: STATE_CLEANING, # "Remote control active" - 5: STATE_CLEANING, # "Cleaning" - 6: STATE_RETURNING, # "Returning home" - 7: STATE_CLEANING, # "Manual mode" - 8: STATE_DOCKED, # "Charging" - 9: STATE_ERROR, # "Charging problem" - 10: STATE_PAUSED, # "Paused" - 11: STATE_CLEANING, # "Spot cleaning" - 12: STATE_ERROR, # "Error" - 13: STATE_IDLE, # "Shutting down" - 14: STATE_DOCKED, # "Updating" - 15: STATE_RETURNING, # "Docking" - 16: STATE_CLEANING, # "Going to target" - 17: STATE_CLEANING, # "Zoned cleaning" - 18: STATE_CLEANING, # "Segment cleaning" - 22: STATE_DOCKED, # "Emptying the bin" on s7+ - 23: STATE_DOCKED, # "Washing the mop" on s7maxV - 26: STATE_RETURNING, # "Going to wash the mop" on s7maxV - 100: STATE_DOCKED, # "Charging complete" - 101: STATE_ERROR, # "Device offline" + 1: VacuumActivity.IDLE, # "Starting" + 2: VacuumActivity.IDLE, # "Charger disconnected" + 3: VacuumActivity.IDLE, # "Idle" + 4: VacuumActivity.CLEANING, # "Remote control active" + 5: VacuumActivity.CLEANING, # "Cleaning" + 6: VacuumActivity.RETURNING, # "Returning home" + 7: VacuumActivity.CLEANING, # "Manual mode" + 8: VacuumActivity.DOCKED, # "Charging" + 9: VacuumActivity.ERROR, # "Charging problem" + 10: VacuumActivity.PAUSED, # "Paused" + 11: VacuumActivity.CLEANING, # "Spot cleaning" + 12: VacuumActivity.ERROR, # "Error" + 13: VacuumActivity.IDLE, # "Shutting down" + 14: VacuumActivity.DOCKED, # "Updating" + 15: VacuumActivity.RETURNING, # "Docking" + 16: VacuumActivity.CLEANING, # "Going to target" + 17: VacuumActivity.CLEANING, # "Zoned cleaning" + 18: VacuumActivity.CLEANING, # "Segment cleaning" + 22: VacuumActivity.DOCKED, # "Emptying the bin" on s7+ + 23: VacuumActivity.DOCKED, # "Washing the mop" on s7maxV + 26: VacuumActivity.RETURNING, # "Going to wash the mop" on s7maxV + 100: VacuumActivity.DOCKED, # "Charging complete" + 101: VacuumActivity.ERROR, # "Device offline" } @@ -211,7 +206,7 @@ class MiroboVacuum( ) -> None: """Initialize the Xiaomi vacuum cleaner robot handler.""" super().__init__(device, entry, unique_id, coordinator) - self._state: str | None = None + self._state: VacuumActivity | None = None async def async_added_to_hass(self) -> None: """Run when entity is about to be added to hass.""" @@ -219,12 +214,12 @@ class MiroboVacuum( self._handle_coordinator_update() @property - def state(self) -> str | None: + def activity(self) -> VacuumActivity | None: """Return the status of the vacuum cleaner.""" # The vacuum reverts back to an idle state after erroring out. # We want to keep returning an error until it has been cleared. if self.coordinator.data.status.got_error: - return STATE_ERROR + return VacuumActivity.ERROR return self._state diff --git a/homeassistant/components/xs1/climate.py b/homeassistant/components/xs1/climate.py index c7d580631d3..3bb80df25b2 100644 --- a/homeassistant/components/xs1/climate.py +++ b/homeassistant/components/xs1/climate.py @@ -56,7 +56,6 @@ class XS1ThermostatEntity(XS1DeviceEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - _enable_turn_on_off_backwards_compatibility = False def __init__(self, device, sensor): """Initialize the actuator.""" diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 50c2a0af457..f1cde31d066 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.1"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"] } diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index c543de89b84..d67e136be4a 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.lock import CONF_DEFAULT_CODE, DOMAIN as LOCK_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CODE +from homeassistant.const import CONF_CODE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -27,21 +27,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: YaleConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool: """Migrate old entry.""" LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: + new_options = entry.options.copy() if config_entry_default_code := entry.options.get(CONF_CODE): entity_reg = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id) @@ -52,12 +53,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOCK_DOMAIN, {CONF_DEFAULT_CODE: config_entry_default_code}, ) - new_options = entry.options.copy() del new_options[CONF_CODE] - hass.config_entries.async_update_entry(entry, options=new_options) + hass.config_entries.async_update_entry(entry, options=new_options, version=2) - hass.config_entries.async_update_entry(entry, version=2) + if entry.version == 2 and entry.minor_version == 1: + # Removes name from entry data + new_data = entry.data.copy() + del new_data[CONF_NAME] + hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2) LOGGER.debug("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 0f5b7d0b8e5..8244d96064a 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -15,7 +15,6 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -47,7 +46,7 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity): def __init__(self, coordinator: YaleDataUpdateCoordinator) -> None: """Initialize the Yale Alarm Device.""" super().__init__(coordinator) - self._attr_unique_id = coordinator.entry.entry_id + self._attr_unique_id = coordinator.config_entry.entry_id async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" @@ -84,7 +83,7 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity): translation_domain=DOMAIN, translation_key="set_alarm", translation_placeholders={ - "name": self.coordinator.entry.data[CONF_NAME], + "name": self.coordinator.config_entry.title, "error": str(error), }, ) from error diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py index 8e68b1f0cb4..fa9584505e2 100644 --- a/homeassistant/components/yale_smart_alarm/binary_sensor.py +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -86,7 +86,7 @@ class YaleDoorBatterySensor(YaleEntity, BinarySensorEntity): ) -> None: """Initiate Yale door battery Sensor.""" super().__init__(coordinator, data) - self._attr_unique_id = f"{data["address"]}-battery" + self._attr_unique_id = f"{data['address']}-battery" @property def is_on(self) -> bool: @@ -108,7 +108,9 @@ class YaleProblemSensor(YaleAlarmEntity, BinarySensorEntity): """Initiate Yale Problem Sensor.""" super().__init__(coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.entry.entry_id}-{entity_description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}-{entity_description.key}" + ) @property def is_on(self) -> bool: diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index c71b7b33a08..3ceee367284 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -23,7 +23,6 @@ from .const import ( CONF_AREA_ID, CONF_LOCK_CODE_DIGITS, DEFAULT_AREA_ID, - DEFAULT_NAME, DOMAIN, YALE_BASE_ERRORS, ) @@ -67,6 +66,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Yale integration.""" VERSION = 2 + MINOR_VERSION = 2 @staticmethod @callback @@ -146,7 +146,6 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] - name = DEFAULT_NAME area = user_input.get(CONF_AREA_ID, DEFAULT_AREA_ID) errors = await self.hass.async_add_executor_job( @@ -161,7 +160,6 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_USERNAME: username, CONF_PASSWORD: password, - CONF_NAME: name, CONF_AREA_ID: area, }, ) diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index 66bd71c9f1e..db63567fa92 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -9,12 +9,14 @@ from yalesmartalarmclient import YaleLock from yalesmartalarmclient.client import YaleSmartAlarmClient from yalesmartalarmclient.exceptions import AuthenticationError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import YaleConfigEntry + from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, YALE_BASE_ERRORS @@ -22,13 +24,14 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """A Yale Data Update Coordinator.""" yale: YaleSmartAlarmClient + config_entry: YaleConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: YaleConfigEntry) -> None: """Initialize the Yale hub.""" - self.entry = entry super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), always_update=False, @@ -40,8 +43,8 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: self.yale = await self.hass.async_add_executor_job( YaleSmartAlarmClient, - self.entry.data[CONF_USERNAME], - self.entry.data[CONF_PASSWORD], + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], ) self.locks = await self.hass.async_add_executor_job(self.yale.get_locks) except AuthenticationError as error: @@ -81,7 +84,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): contact["address"]: contact["_state"] for contact in door_windows } _sensor_battery_map = { - f"{contact["address"]}-battery": contact["_battery"] + f"{contact['address']}-battery": contact["_battery"] for contact in door_windows } _temp_map = {temp["address"]: temp["status_temp"] for temp in temp_sensors} diff --git a/homeassistant/components/yale_smart_alarm/entity.py b/homeassistant/components/yale_smart_alarm/entity.py index e37dc3562f5..2610f54f0a9 100644 --- a/homeassistant/components/yale_smart_alarm/entity.py +++ b/homeassistant/components/yale_smart_alarm/entity.py @@ -2,7 +2,7 @@ from yalesmartalarmclient import YaleLock -from homeassistant.const import CONF_NAME, CONF_USERNAME +from homeassistant.const import CONF_USERNAME from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,7 +25,7 @@ class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator]): manufacturer=MANUFACTURER, model=MODEL, identifiers={(DOMAIN, data["address"])}, - via_device=(DOMAIN, coordinator.entry.data[CONF_USERNAME]), + via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), ) @@ -43,7 +43,7 @@ class YaleLockEntity(CoordinatorEntity[YaleDataUpdateCoordinator]): manufacturer=MANUFACTURER, model=MODEL, identifiers={(DOMAIN, lock.sid())}, - via_device=(DOMAIN, coordinator.entry.data[CONF_USERNAME]), + via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), ) self.lock_data = lock @@ -58,10 +58,10 @@ class YaleAlarmEntity(CoordinatorEntity[YaleDataUpdateCoordinator], Entity): super().__init__(coordinator) panel_info = coordinator.data["panel_info"] self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.entry.data[CONF_USERNAME])}, + identifiers={(DOMAIN, coordinator.config_entry.data[CONF_USERNAME])}, manufacturer=MANUFACTURER, model=MODEL, - name=coordinator.entry.data[CONF_NAME], + name=coordinator.config_entry.title, connections={(CONNECTION_NETWORK_MAC, panel_info["mac"])}, sw_version=panel_info["version"], ) diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 243299658ed..7a93baf0827 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -9,7 +9,7 @@ from yalesmartalarmclient import YaleLock, YaleLockState from homeassistant.components.lock import LockEntity, LockState from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import YaleConfigEntry @@ -65,12 +65,6 @@ class YaleDoorlock(YaleLockEntity, LockEntity): async def async_set_lock(self, state: YaleLockState, code: str | None) -> None: """Set lock.""" - if state is YaleLockState.UNLOCKED and not code: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_code", - ) - lock_state = False try: if state is YaleLockState.LOCKED: diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index 7f940e1139e..ebcf0b3af63 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -88,14 +88,11 @@ "set_lock": { "message": "Could not set lock for {name}: {error}" }, - "no_code": { - "message": "Can not unlock without code" - }, "could_not_change_lock": { "message": "Could not set lock, check system ready for lock" }, "could_not_trigger_panic": { - "message": "Could not trigger panic button for entity id {entity_id}: {error}" + "message": "Could not trigger panic button for entity ID {entity_id}: {error}" } } } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index c3d1a3d97f1..15b11719fdb 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.5.1"] + "requirements": ["yalexs-ble==2.5.6"] } diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index a074f34c782..c43e547a71e 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -10,11 +10,14 @@ from aiohttp import ClientConnectorError from aiomusiccast import MusicCastConnectionException, MusicCastDevice import voluptuous as vol -from homeassistant import data_entry_flow -from homeassistant.components import ssdp -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from . import get_upnp_desc from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN @@ -33,7 +36,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.ConfigFlowResult: + ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" # Request user input, unless we are preparing discovery flow if user_input is None: @@ -73,9 +76,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): return self._show_setup_form(errors) - def _show_setup_form( - self, errors: dict | None = None - ) -> data_entry_flow.ConfigFlowResult: + def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult: """Show the setup form to the user.""" return self.async_show_form( step_id="user", @@ -84,15 +85,15 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo - ) -> data_entry_flow.ConfigFlowResult: + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: """Handle ssdp discoveries.""" if not await MusicCastDevice.check_yamaha_ssdp( discovery_info.ssdp_location, async_get_clientsession(self.hass) ): return self.async_abort(reason="yxc_control_url_missing") - self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + self.serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] self.upnp_description = discovery_info.ssdp_location # ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment @@ -108,18 +109,14 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - "name": discovery_info.upnp.get( - ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host - ) + "name": discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, self.host) } } ) return await self.async_step_confirm() - async def async_step_confirm( - self, user_input=None - ) -> data_entry_flow.ConfigFlowResult: + async def async_step_confirm(self, user_input=None) -> ConfigFlowResult: """Allow the user to confirm adding the device.""" if user_input is not None: return self.async_create_entry( diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 4384cc34836..cff14f2b67d 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import uuid +from homeassistant.util import uuid as uuid_util from .const import ( ATTR_MAIN_SYNC, @@ -735,7 +735,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): group = ( self.coordinator.data.group_id if self.is_server - else uuid.random_uuid_hex().upper() + else uuid_util.random_uuid_hex().upper() ) ip_addresses = set() diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 7a3a0a2f100..35892764bcb 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -11,7 +11,7 @@ import yeelight from yeelight.aio import AsyncBulb from yeelight.main import get_known_models -from homeassistant.components import dhcp, onboarding, ssdp, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -23,6 +23,9 @@ from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CON from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from .const import ( @@ -69,21 +72,21 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_devices: dict[str, Any] = {} async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery from homekit.""" self._discovered_ip = discovery_info.host return await self._async_handle_discovery() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery from dhcp.""" self._discovered_ip = discovery_info.ip return await self._async_handle_discovery() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery from zeroconf.""" self._discovered_ip = discovery_info.host @@ -91,7 +94,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): return await self._async_handle_discovery_with_unique_id() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle discovery from ssdp.""" self._discovered_ip = urlparse(discovery_info.ssdp_headers["location"]).hostname diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index d0d53510859..8cc3f2600e5 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -16,11 +16,10 @@ from yeelight.main import BulbException from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, FLASH_LONG, @@ -40,10 +39,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType import homeassistant.util.color as color_util -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_mired_to_kelvin as mired_to_kelvin, -) from . import YEELIGHT_FLOW_TRANSITION_SCHEMA from .const import ( @@ -71,6 +66,7 @@ from .entity import YeelightEntity _LOGGER = logging.getLogger(__name__) ATTR_MINUTES = "minutes" +ATTR_KELVIN = "kelvin" SERVICE_SET_MODE = "set_mode" SERVICE_SET_MUSIC_MODE = "set_music_mode" @@ -440,8 +436,8 @@ class YeelightBaseLight(YeelightEntity, LightEntity): self._effect = None model_specs = self._bulb.get_model_specs() - self._attr_min_mireds = kelvin_to_mired(model_specs["color_temp"]["max"]) - self._attr_max_mireds = kelvin_to_mired(model_specs["color_temp"]["min"]) + self._attr_max_color_temp_kelvin = model_specs["color_temp"]["max"] + self._attr_min_color_temp_kelvin = model_specs["color_temp"]["min"] self._light_type = LightType.Main @@ -476,10 +472,10 @@ class YeelightBaseLight(YeelightEntity, LightEntity): return self._predefined_effects + self.custom_effects_names @property - def color_temp(self) -> int | None: - """Return the color temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" if temp_in_k := self._get_property("ct"): - self._color_temp = kelvin_to_mired(int(temp_in_k)) + self._color_temp = int(temp_in_k) return self._color_temp @property @@ -678,20 +674,19 @@ class YeelightBaseLight(YeelightEntity, LightEntity): ) @_async_cmd - async def async_set_colortemp(self, colortemp, duration) -> None: + async def async_set_colortemp(self, temp_in_k, duration) -> None: """Set bulb's color temperature.""" if ( - not colortemp + not temp_in_k or not self.supported_color_modes or ColorMode.COLOR_TEMP not in self.supported_color_modes ): return - temp_in_k = mired_to_kelvin(colortemp) if ( not self.device.is_color_flow_enabled and self.color_mode == ColorMode.COLOR_TEMP - and self.color_temp == colortemp + and self.color_temp_kelvin == temp_in_k ): _LOGGER.debug("Color temp already set to: %s", temp_in_k) # Already set, and since we get pushed updates @@ -779,7 +774,7 @@ class YeelightBaseLight(YeelightEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the bulb on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - colortemp = kwargs.get(ATTR_COLOR_TEMP) + colortemp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) hs_color = kwargs.get(ATTR_HS_COLOR) rgb = kwargs.get(ATTR_RGB_COLOR) flash = kwargs.get(ATTR_FLASH) @@ -933,12 +928,12 @@ class YeelightWithoutNightlightSwitchMixIn(YeelightBaseLight): return super()._brightness_property @property - def color_temp(self) -> int | None: - """Return the color temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" if self.device.is_nightlight_enabled: # Enabling the nightlight locks the colortemp to max - return self.max_mireds - return super().color_temp + return self.min_color_temp_kelvin + return super().color_temp_kelvin class YeelightColorLightWithoutNightlightSwitch( @@ -1081,8 +1076,8 @@ class YeelightAmbientLight(YeelightColorLightWithoutNightlightSwitch): def __init__(self, *args, **kwargs): """Initialize the Yeelight Ambient light.""" super().__init__(*args, **kwargs) - self._attr_min_mireds = kelvin_to_mired(6500) - self._attr_max_mireds = kelvin_to_mired(1700) + self._attr_max_color_temp_kelvin = 6500 + self._attr_min_color_temp_kelvin = 1700 self._light_type = LightType.Ambient diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 4da2e0cfc3e..6efb66449ab 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.14", "async-upnp-client==0.41.0"], + "requirements": ["yeelight==0.7.14", "async-upnp-client==0.43.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index ac482504880..75156ab019b 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -9,17 +9,18 @@ from datetime import datetime from functools import partial from ipaddress import IPv4Address import logging -from typing import Self +from typing import ClassVar, Self from urllib.parse import urlparse from async_upnp_client.search import SsdpSearchListener from async_upnp_client.utils import CaseInsensitiveDict from homeassistant import config_entries -from homeassistant.components import network, ssdp +from homeassistant.components import network from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.util.async_ import create_eager_task from .const import ( @@ -44,11 +45,11 @@ def _set_future_if_not_done(future: asyncio.Future[None]) -> None: class YeelightScanner: """Scan for Yeelight devices.""" - _scanner: Self | None = None + _scanner: ClassVar[Self | None] = None @classmethod @callback - def async_get(cls, hass: HomeAssistant) -> YeelightScanner: + def async_get(cls, hass: HomeAssistant) -> Self: """Get scanner instance.""" if cls._scanner is None: cls._scanner = cls(hass) @@ -171,7 +172,7 @@ class YeelightScanner: self._hass, DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="", ssdp_st=SSDP_ST, ssdp_headers=response, diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index 72baec52c85..72e400b7cf3 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -59,7 +59,7 @@ "services": { "set_mode": { "name": "Set mode", - "description": "Sets a operation mode.", + "description": "Sets an operation mode.", "fields": { "mode": { "name": "[%key:common::config_flow::data::mode%]", @@ -129,7 +129,7 @@ }, "set_auto_delay_off_scene": { "name": "Set auto delay off scene", - "description": "Turns the light on to the specified brightness and sets a timer to turn it back off after the given number of minutes. If the light is off, Set a color scene, if light is off, it will be turned on.", + "description": "Turns the light on to the specified brightness and sets a timer to turn it back off after the given number of minutes. If the light is off, it will be turned on.", "fields": { "minutes": { "name": "Minutes", @@ -143,7 +143,7 @@ }, "start_flow": { "name": "Start flow", - "description": "Start a custom flow, using transitions from https://yeelight.readthedocs.io/en/stable/yeelight.html#flow-objects.", + "description": "Starts a custom flow, using transitions from https://yeelight.readthedocs.io/en/stable/yeelight.html#flow-objects.", "fields": { "count": { "name": "Count", diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 07a1fb07cc0..fa4c2202b03 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -12,6 +12,7 @@ from yolink.const import ( ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, + ATTR_DEVICE_WATER_METER_CONTROLLER, ) from yolink.device import YoLinkDevice @@ -44,6 +45,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_CO_SMOKE_SENSOR, + ATTR_DEVICE_WATER_METER_CONTROLLER, ] @@ -84,6 +86,15 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( value=lambda state: state.get("smokeAlarm"), exists_fn=lambda device: device.device_type == ATTR_DEVICE_CO_SMOKE_SENSOR, ), + YoLinkBinarySensorEntityDescription( + key="pipe_leak_detected", + state_key="alarm", + device_class=BinarySensorDeviceClass.MOISTURE, + value=lambda state: state.get("leak") if state is not None else None, + exists_fn=lambda device: ( + device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER + ), + ), ) diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py index 98f1b764498..ff3bbf0d93b 100644 --- a/homeassistant/components/yolink/climate.py +++ b/homeassistant/components/yolink/climate.py @@ -63,7 +63,6 @@ class YoLinkClimateEntity(YoLinkEntity, ClimateEntity): """YoLink Climate Entity.""" _attr_name = None - _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py index a011d493dc9..8d622de70e7 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -19,6 +19,11 @@ from .const import ( SERVICE_PLAY_ON_SPEAKER_HUB = "play_on_speaker_hub" +_SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS = ( + (ATTR_VOLUME, lambda x: x), + (ATTR_TONE, lambda x: x.capitalize()), +) + def async_register_services(hass: HomeAssistant) -> None: """Register services for YoLink integration.""" @@ -46,16 +51,16 @@ def async_register_services(hass: HomeAssistant) -> None: identifier[1] ) ) is not None: - tone_param = service_data[ATTR_TONE].capitalize() - play_request = ClientRequest( - "playAudio", - { - ATTR_TONE: tone_param, - ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE], - ATTR_VOLUME: service_data[ATTR_VOLUME], - ATTR_REPEAT: service_data[ATTR_REPEAT], - }, - ) + params = { + ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE], + ATTR_REPEAT: service_data[ATTR_REPEAT], + } + + for attr, transform in _SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS: + if attr in service_data: + params[attr] = transform(service_data[attr]) + + play_request = ClientRequest("playAudio", params) await device_coordinator.device.call_device(play_request) hass.services.async_register( @@ -64,9 +69,9 @@ def async_register_services(hass: HomeAssistant) -> None: schema=vol.Schema( { vol.Required(ATTR_TARGET_DEVICE): cv.string, - vol.Required(ATTR_TONE): cv.string, + vol.Optional(ATTR_TONE): cv.string, vol.Required(ATTR_TEXT_MESSAGE): cv.string, - vol.Required(ATTR_VOLUME): vol.All( + vol.Optional(ATTR_VOLUME): vol.All( vol.Coerce(int), vol.Range(min=0, max=15) ), vol.Optional(ATTR_REPEAT, default=0): vol.All( diff --git a/homeassistant/components/yolink/services.yaml b/homeassistant/components/yolink/services.yaml index 5f7a3ec3122..7375962070e 100644 --- a/homeassistant/components/yolink/services.yaml +++ b/homeassistant/components/yolink/services.yaml @@ -14,7 +14,6 @@ play_on_speaker_hub: selector: text: tone: - required: true default: "tip" selector: select: @@ -25,7 +24,6 @@ play_on_speaker_hub: - "tip" translation_key: speaker_tone volume: - required: true default: 8 selector: number: @@ -33,7 +31,6 @@ play_on_speaker_hub: max: 15 step: 1 repeat: - required: true default: 0 selector: number: diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 2f9a9454502..cbb092405d7 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -115,7 +115,7 @@ }, "volume": { "name": "Volume", - "description": "Speaker volume during playback." + "description": "Override the speaker volume during playback of this message only." }, "repeat": { "name": "Repeat", diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py index d475034cc9d..03a27b5a378 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -1,6 +1,5 @@ """The youless integration.""" -from datetime import timedelta import logging from urllib.error import URLError @@ -10,9 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import YouLessCoordinator PLATFORMS = [Platform.SENSOR] @@ -28,24 +27,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except URLError as exception: raise ConfigEntryNotReady from exception - async def async_update_data() -> YoulessAPI: - """Fetch data from the API.""" - await hass.async_add_executor_job(api.update) - return api - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="youless_gateway", - update_method=async_update_data, - update_interval=timedelta(seconds=10), - ) - - await coordinator.async_config_entry_first_refresh() + youless_coordinator = YouLessCoordinator(hass, api) + await youless_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data[DOMAIN][entry.entry_id] = youless_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/youless/coordinator.py b/homeassistant/components/youless/coordinator.py new file mode 100644 index 00000000000..0be5e463689 --- /dev/null +++ b/homeassistant/components/youless/coordinator.py @@ -0,0 +1,25 @@ +"""The coordinator for the Youless integration.""" + +from datetime import timedelta +import logging + +from youless_api import YoulessAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class YouLessCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching YouLess data.""" + + def __init__(self, hass: HomeAssistant, device: YoulessAPI) -> None: + """Initialize global YouLess data provider.""" + super().__init__( + hass, _LOGGER, name="youless_gateway", update_interval=timedelta(seconds=10) + ) + self.device = device + + async def _async_update_data(self) -> None: + await self.hass.async_add_executor_job(self.device.update) diff --git a/homeassistant/components/youless/entity.py b/homeassistant/components/youless/entity.py new file mode 100644 index 00000000000..9931768c267 --- /dev/null +++ b/homeassistant/components/youless/entity.py @@ -0,0 +1,25 @@ +"""The entity for the Youless integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import YouLessCoordinator + + +class YouLessEntity(CoordinatorEntity[YouLessCoordinator]): + """Base entity for YouLess.""" + + def __init__( + self, coordinator: YouLessCoordinator, device_group: str, device_name: str + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.device = coordinator.device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_group)}, + manufacturer="YouLess", + model=self.device.model, + name=device_name, + sw_version=self.device.firmware_version, + ) diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index 1ccc8cda0ff..9a51e0fe0d1 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/youless", "iot_class": "local_polling", "loggers": ["youless_api"], - "requirements": ["youless-api==2.1.2"] + "requirements": ["youless-api==2.2.0"] } diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index ed0fc703cc4..413f1ad6958 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -2,12 +2,15 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from youless_api import YoulessAPI -from youless_api.youless_sensor import YoulessSensor from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -20,346 +23,316 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from . import DOMAIN +from .coordinator import YouLessCoordinator +from .entity import YouLessEntity + + +@dataclass(frozen=True, kw_only=True) +class YouLessSensorEntityDescription(SensorEntityDescription): + """Describes a YouLess sensor entity.""" + + device_group: str + device_group_name: str + value_func: Callable[[YoulessAPI], float | None] + + +SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( + YouLessSensorEntityDescription( + key="water", + device_group="water", + device_group_name="Water meter", + name="Water usage", + icon="mdi:water", + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + value_func=( + lambda device: device.water_meter.value if device.water_meter else None + ), + ), + YouLessSensorEntityDescription( + key="gas", + device_group="gas", + device_group_name="Gas meter", + name="Gas usage", + icon="mdi:fire", + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + value_func=lambda device: device.gas_meter.value if device.gas_meter else None, + ), + YouLessSensorEntityDescription( + key="usage", + device_group="power", + device_group_name="Power usage", + name="Power Usage", + icon="mdi:meter-electric", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.current_power_usage.value + if device.current_power_usage + else None + ), + ), + YouLessSensorEntityDescription( + key="power_low", + device_group="power", + device_group_name="Power usage", + name="Energy low", + icon="mdi:transmission-tower-export", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.power_meter.low.value if device.power_meter else None + ), + ), + YouLessSensorEntityDescription( + key="power_high", + device_group="power", + device_group_name="Power usage", + name="Energy high", + icon="mdi:transmission-tower-export", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.power_meter.high.value if device.power_meter else None + ), + ), + YouLessSensorEntityDescription( + key="power_total", + device_group="power", + device_group_name="Power usage", + name="Energy total", + icon="mdi:transmission-tower-export", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.power_meter.total.value + if device.power_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="phase_1_power", + device_group="power", + device_group_name="Power usage", + name="Phase 1 power", + icon=None, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.phase1.power.value if device.phase1 else None, + ), + YouLessSensorEntityDescription( + key="phase_1_voltage", + device_group="power", + device_group_name="Power usage", + name="Phase 1 voltage", + icon=None, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=( + lambda device: device.phase1.voltage.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_1_current", + device_group="power", + device_group_name="Power usage", + name="Phase 1 current", + icon=None, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=( + lambda device: device.phase1.current.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_2_power", + device_group="power", + device_group_name="Power usage", + name="Phase 2 power", + icon=None, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.phase2.power.value if device.phase2 else None, + ), + YouLessSensorEntityDescription( + key="phase_2_voltage", + device_group="power", + device_group_name="Power usage", + name="Phase 2 voltage", + icon=None, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=( + lambda device: device.phase2.voltage.value if device.phase2 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_2_current", + device_group="power", + device_group_name="Power usage", + name="Phase 2 current", + icon=None, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=( + lambda device: device.phase2.current.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_3_power", + device_group="power", + device_group_name="Power usage", + name="Phase 3 power", + icon=None, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.phase3.power.value if device.phase3 else None, + ), + YouLessSensorEntityDescription( + key="phase_3_voltage", + device_group="power", + device_group_name="Power usage", + name="Phase 3 voltage", + icon=None, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=( + lambda device: device.phase3.voltage.value if device.phase3 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_3_current", + device_group="power", + device_group_name="Power usage", + name="Phase 3 current", + icon=None, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=( + lambda device: device.phase3.current.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="delivery_low", + device_group="delivery", + device_group_name="Energy delivery", + name="Energy delivery low", + icon="mdi:transmission-tower-import", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.delivery_meter.low.value + if device.delivery_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="delivery_high", + device_group="delivery", + device_group_name="Energy delivery", + name="Energy delivery high", + icon="mdi:transmission-tower-import", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.delivery_meter.high.value + if device.delivery_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="extra_total", + device_group="extra", + device_group_name="Extra meter", + name="Extra total", + icon="mdi:meter-electric", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.extra_meter.total.value + if device.extra_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="extra_usage", + device_group="extra", + device_group_name="Extra meter", + name="Extra usage", + icon="mdi:lightning-bolt", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.extra_meter.usage.value + if device.extra_meter + else None + ), + ), +) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Initialize the integration.""" - coordinator: DataUpdateCoordinator[YoulessAPI] = hass.data[DOMAIN][entry.entry_id] + coordinator: YouLessCoordinator = hass.data[DOMAIN][entry.entry_id] device = entry.data[CONF_DEVICE] if (device := entry.data[CONF_DEVICE]) is None: device = entry.entry_id async_add_entities( [ - WaterSensor(coordinator, device), - GasSensor(coordinator, device), - EnergyMeterSensor( - coordinator, device, "low", SensorStateClass.TOTAL_INCREASING - ), - EnergyMeterSensor( - coordinator, device, "high", SensorStateClass.TOTAL_INCREASING - ), - EnergyMeterSensor(coordinator, device, "total", SensorStateClass.TOTAL), - CurrentPowerSensor(coordinator, device), - DeliveryMeterSensor(coordinator, device, "low"), - DeliveryMeterSensor(coordinator, device, "high"), - ExtraMeterSensor(coordinator, device, "total"), - ExtraMeterPowerSensor(coordinator, device, "usage"), - PhasePowerSensor(coordinator, device, 1), - PhaseVoltageSensor(coordinator, device, 1), - PhaseCurrentSensor(coordinator, device, 1), - PhasePowerSensor(coordinator, device, 2), - PhaseVoltageSensor(coordinator, device, 2), - PhaseCurrentSensor(coordinator, device, 2), - PhasePowerSensor(coordinator, device, 3), - PhaseVoltageSensor(coordinator, device, 3), - PhaseCurrentSensor(coordinator, device, 3), + YouLessSensor(coordinator, description, device) + for description in SENSOR_TYPES ] ) -class YoulessBaseSensor( - CoordinatorEntity[DataUpdateCoordinator[YoulessAPI]], SensorEntity -): - """The base sensor for Youless.""" +class YouLessSensor(YouLessEntity, SensorEntity): + """Representation of a Sensor.""" + + entity_description: YouLessSensorEntityDescription def __init__( self, - coordinator: DataUpdateCoordinator[YoulessAPI], + coordinator: YouLessCoordinator, + description: YouLessSensorEntityDescription, device: str, - device_group: str, - friendly_name: str, - sensor_id: str, ) -> None: - """Create the sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{DOMAIN}_{device}_{sensor_id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{device}_{device_group}")}, - manufacturer="YouLess", - model=self.coordinator.data.model, - name=friendly_name, + """Initialize the sensor.""" + super().__init__( + coordinator, + f"{device}_{description.device_group}", + description.device_group_name, ) - - @property - def get_sensor(self) -> YoulessSensor | None: - """Property to get the underlying sensor object.""" - return None + self._attr_unique_id = f"{DOMAIN}_{device}_{description.key}" + self.entity_description = description @property def native_value(self) -> StateType: - """Determine the state value, only if a sensor is initialized.""" - if self.get_sensor is None: - return None - - return self.get_sensor.value - - @property - def available(self) -> bool: - """Return a flag to indicate the sensor not being available.""" - return super().available and self.get_sensor is not None - - -class WaterSensor(YoulessBaseSensor): - """The Youless Water sensor.""" - - _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS - _attr_device_class = SensorDeviceClass.WATER - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str - ) -> None: - """Instantiate a Water sensor.""" - super().__init__(coordinator, device, "water", "Water meter", "water") - self._attr_name = "Water usage" - self._attr_icon = "mdi:water" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - return self.coordinator.data.water_meter - - -class GasSensor(YoulessBaseSensor): - """The Youless gas sensor.""" - - _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS - _attr_device_class = SensorDeviceClass.GAS - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str - ) -> None: - """Instantiate a gas sensor.""" - super().__init__(coordinator, device, "gas", "Gas meter", "gas") - self._attr_name = "Gas usage" - self._attr_icon = "mdi:fire" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - return self.coordinator.data.gas_meter - - -class CurrentPowerSensor(YoulessBaseSensor): - """The current power usage sensor.""" - - _attr_native_unit_of_measurement = UnitOfPower.WATT - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str - ) -> None: - """Instantiate the usage meter.""" - super().__init__(coordinator, device, "power", "Power usage", "usage") - self._device = device - self._attr_name = "Power Usage" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - return self.coordinator.data.current_power_usage - - -class DeliveryMeterSensor(YoulessBaseSensor): - """The Youless delivery meter value sensor.""" - - _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str - ) -> None: - """Instantiate a delivery meter sensor.""" - super().__init__( - coordinator, device, "delivery", "Energy delivery", f"delivery_{dev_type}" - ) - self._type = dev_type - self._attr_name = f"Energy delivery {dev_type}" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.delivery_meter is None: - return None - - return getattr(self.coordinator.data.delivery_meter, f"_{self._type}", None) - - -class EnergyMeterSensor(YoulessBaseSensor): - """The Youless low meter value sensor.""" - - _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, - coordinator: DataUpdateCoordinator[YoulessAPI], - device: str, - dev_type: str, - state_class: SensorStateClass, - ) -> None: - """Instantiate a energy meter sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"power_{dev_type}" - ) - self._device = device - self._type = dev_type - self._attr_name = f"Energy {dev_type}" - self._attr_state_class = state_class - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.power_meter is None: - return None - - return getattr(self.coordinator.data.power_meter, f"_{self._type}", None) - - -class PhasePowerSensor(YoulessBaseSensor): - """The current power usage of a single phase.""" - - _attr_native_unit_of_measurement = UnitOfPower.WATT - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int - ) -> None: - """Initialize the power phase sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"phase_{phase}_power" - ) - self._attr_name = f"Phase {phase} power" - self._phase = phase - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor value from the coordinator.""" - phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) - if phase_sensor is None: - return None - - return phase_sensor.power - - -class PhaseVoltageSensor(YoulessBaseSensor): - """The current voltage of a single phase.""" - - _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT - _attr_device_class = SensorDeviceClass.VOLTAGE - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int - ) -> None: - """Initialize the voltage phase sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"phase_{phase}_voltage" - ) - self._attr_name = f"Phase {phase} voltage" - self._phase = phase - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor value from the coordinator for phase voltage.""" - phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) - if phase_sensor is None: - return None - - return phase_sensor.voltage - - -class PhaseCurrentSensor(YoulessBaseSensor): - """The current current of a single phase.""" - - _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE - _attr_device_class = SensorDeviceClass.CURRENT - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int - ) -> None: - """Initialize the current phase sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"phase_{phase}_current" - ) - self._attr_name = f"Phase {phase} current" - self._phase = phase - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor value from the coordinator for phase current.""" - phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) - if phase_sensor is None: - return None - - return phase_sensor.current - - -class ExtraMeterSensor(YoulessBaseSensor): - """The Youless extra meter value sensor (s0).""" - - _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str - ) -> None: - """Instantiate an extra meter sensor.""" - super().__init__( - coordinator, device, "extra", "Extra meter", f"extra_{dev_type}" - ) - self._type = dev_type - self._attr_name = f"Extra {dev_type}" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.extra_meter is None: - return None - - return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None) - - -class ExtraMeterPowerSensor(YoulessBaseSensor): - """The Youless extra meter power value sensor (s0).""" - - _attr_native_unit_of_measurement = UnitOfPower.WATT - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str - ) -> None: - """Instantiate an extra meter power sensor.""" - super().__init__( - coordinator, device, "extra", "Extra meter", f"extra_{dev_type}" - ) - self._type = dev_type - self._attr_name = f"Extra {dev_type}" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.extra_meter is None: - return None - - return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None) + """Return the state of the sensor.""" + return self.entity_description.value_func(self.device) diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index d9bab3e6fe4..05881d649cf 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -11,8 +11,9 @@ import time from urllib.error import HTTPError from urllib.parse import urljoin -from pyzabbix import ZabbixAPI, ZabbixAPIException, ZabbixMetric, ZabbixSender import voluptuous as vol +from zabbix_utils import ItemValue, Sender, ZabbixAPI +from zabbix_utils.exceptions import APIRequestError from homeassistant.const import ( CONF_HOST, @@ -42,6 +43,7 @@ CONF_PUBLISH_STATES_HOST = "publish_states_host" DEFAULT_SSL = False DEFAULT_PATH = "zabbix" +DEFAULT_SENDER_PORT = 10051 TIMEOUT = 5 RETRY_DELAY = 20 @@ -86,7 +88,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: try: zapi = ZabbixAPI(url=url, user=username, password=password) _LOGGER.debug("Connected to Zabbix API Version %s", zapi.api_version()) - except ZabbixAPIException as login_exception: + except APIRequestError as login_exception: _LOGGER.error("Unable to login to the Zabbix API: %s", login_exception) return False except HTTPError as http_error: @@ -104,7 +106,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def event_to_metrics( event: Event, float_keys: set[str], string_keys: set[str] - ) -> list[ZabbixMetric] | None: + ) -> list[ItemValue] | None: """Add an event to the outgoing Zabbix list.""" state = event.data.get("new_state") if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE): @@ -145,14 +147,14 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: float_keys.update(floats) if len(float_keys) != float_keys_count: floats_discovery = [{"{#KEY}": float_key} for float_key in float_keys] - metric = ZabbixMetric( + metric = ItemValue( publish_states_host, "homeassistant.floats_discovery", json.dumps(floats_discovery), ) metrics.append(metric) for key, value in floats.items(): - metric = ZabbixMetric( + metric = ItemValue( publish_states_host, f"homeassistant.float[{key}]", value ) metrics.append(metric) @@ -161,7 +163,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: return metrics if publish_states_host: - zabbix_sender = ZabbixSender(zabbix_server=conf[CONF_HOST]) + zabbix_sender = Sender(server=conf[CONF_HOST], port=DEFAULT_SENDER_PORT) instance = ZabbixThread(zabbix_sender, event_to_metrics) instance.setup(hass) @@ -175,10 +177,8 @@ class ZabbixThread(threading.Thread): def __init__( self, - zabbix_sender: ZabbixSender, - event_to_metrics: Callable[ - [Event, set[str], set[str]], list[ZabbixMetric] | None - ], + zabbix_sender: Sender, + event_to_metrics: Callable[[Event, set[str], set[str]], list[ItemValue] | None], ) -> None: """Initialize the listener.""" threading.Thread.__init__(self, name="Zabbix") @@ -208,12 +208,12 @@ class ZabbixThread(threading.Thread): item = (time.monotonic(), event) self.queue.put(item) - def get_metrics(self) -> tuple[int, list[ZabbixMetric]]: + def get_metrics(self) -> tuple[int, list[ItemValue]]: """Return a batch of events formatted for writing.""" queue_seconds = QUEUE_BACKLOG_SECONDS + self.MAX_TRIES * RETRY_DELAY count = 0 - metrics: list[ZabbixMetric] = [] + metrics: list[ItemValue] = [] dropped = 0 @@ -243,7 +243,7 @@ class ZabbixThread(threading.Thread): return count, metrics - def write_to_zabbix(self, metrics: list[ZabbixMetric]) -> None: + def write_to_zabbix(self, metrics: list[ItemValue]) -> None: """Write preprocessed events to zabbix, with retry.""" for retry in range(self.MAX_TRIES + 1): diff --git a/homeassistant/components/zabbix/manifest.json b/homeassistant/components/zabbix/manifest.json index 9c7171bea46..6707cb7ddb3 100644 --- a/homeassistant/components/zabbix/manifest.json +++ b/homeassistant/components/zabbix/manifest.json @@ -1,10 +1,10 @@ { "domain": "zabbix", "name": "Zabbix", - "codeowners": [], + "codeowners": ["@kruton"], "documentation": "https://www.home-assistant.io/integrations/zabbix", "iot_class": "local_polling", - "loggers": ["pyzabbix"], + "loggers": ["zabbix_utils"], "quality_scale": "legacy", - "requirements": ["py-zabbix==1.1.7"] + "requirements": ["zabbix-utils==2.0.2"] } diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index f5d96f106cb..7728233ebc0 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -6,8 +6,8 @@ from collections.abc import Mapping import logging from typing import Any -from pyzabbix import ZabbixAPI import voluptuous as vol +from zabbix_utils import ZabbixAPI from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 449c2ccef91..b748006336c 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -4,9 +4,8 @@ from __future__ import annotations import contextlib from contextlib import suppress -from dataclasses import dataclass from fnmatch import translate -from functools import lru_cache +from functools import lru_cache, partial from ipaddress import IPv4Address, IPv6Address import logging import re @@ -30,12 +29,20 @@ from homeassistant.const import ( __version__, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import discovery_flow, instance_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery_flow, instance_id +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID as _ATTR_PROPERTIES_ID, + ZeroconfServiceInfo as _ZeroconfServiceInfo, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import ( HomeKitDiscoveredIntegration, @@ -83,7 +90,11 @@ ATTR_NAME: Final = "name" ATTR_PROPERTIES: Final = "properties" # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] -ATTR_PROPERTIES_ID: Final = "id" +_DEPRECATED_ATTR_PROPERTIES_ID = DeprecatedConstant( + _ATTR_PROPERTIES_ID, + "homeassistant.helpers.service_info.zeroconf.ATTR_PROPERTIES_ID", + "2026.2", +) CONFIG_SCHEMA = vol.Schema( { @@ -101,60 +112,36 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) - -@dataclass(slots=True) -class ZeroconfServiceInfo(BaseServiceInfo): - """Prepared info from mDNS entries. - - The ip_address is the most recently updated address - that is not a link local or unspecified address. - - The ip_addresses are all addresses in order of most - recently updated to least recently updated. - - The host is the string representation of the ip_address. - - The addresses are the string representations of the - ip_addresses. - - It is recommended to use the ip_address to determine - the address to connect to as it will be the most - recently updated address that is not a link local - or unspecified address. - """ - - ip_address: IPv4Address | IPv6Address - ip_addresses: list[IPv4Address | IPv6Address] - port: int | None - hostname: str - type: str - name: str - properties: dict[str, Any] - - @property - def host(self) -> str: - """Return the host.""" - return str(self.ip_address) - - @property - def addresses(self) -> list[str]: - """Return the addresses.""" - return [str(ip_address) for ip_address in self.ip_addresses] +_DEPRECATED_ZeroconfServiceInfo = DeprecatedConstant( + _ZeroconfServiceInfo, + "homeassistant.helpers.service_info.zeroconf.ZeroconfServiceInfo", + "2026.2", +) @bind_hass async def async_get_instance(hass: HomeAssistant) -> HaZeroconf: - """Zeroconf instance to be shared with other integrations that use it.""" - return cast(HaZeroconf, (await _async_get_instance(hass)).zeroconf) + """Get or create the shared HaZeroconf instance.""" + return cast(HaZeroconf, (_async_get_instance(hass)).zeroconf) @bind_hass async def async_get_async_instance(hass: HomeAssistant) -> HaAsyncZeroconf: - """Zeroconf instance to be shared with other integrations that use it.""" - return await _async_get_instance(hass) + """Get or create the shared HaAsyncZeroconf instance.""" + return _async_get_instance(hass) -async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf: +@callback +def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf: + """Get or create the shared HaAsyncZeroconf instance. + + This method must be run in the event loop, and is an alternative + to the async_get_async_instance method when a coroutine cannot be used. + """ + return _async_get_instance(hass) + + +def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf: if DOMAIN in hass.data: return cast(HaAsyncZeroconf, hass.data[DOMAIN]) @@ -221,7 +208,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ] - aio_zc = await _async_get_instance(hass, **zc_args) + aio_zc = _async_get_instance(hass, **zc_args) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) zeroconf_types = await async_get_zeroconf(hass) homekit_models = await async_get_homekit(hass) @@ -409,7 +396,7 @@ class ZeroconfDiscovery: def _async_dismiss_discoveries(self, name: str) -> None: """Dismiss all discoveries for the given name.""" for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - ZeroconfServiceInfo, + _ZeroconfServiceInfo, lambda service_info: bool(service_info.name == name), ): self.hass.config_entries.flow.async_abort(flow["flow_id"]) @@ -585,7 +572,7 @@ def async_get_homekit_discovery( return None -def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: +def info_from_service(service: AsyncServiceInfo) -> _ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" # See https://ietf.org/rfc/rfc6763.html#section-6.4 and # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings @@ -605,10 +592,10 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: return None if TYPE_CHECKING: - assert ( - service.server is not None - ), "server cannot be none if there are addresses" - return ZeroconfServiceInfo( + assert service.server is not None, ( + "server cannot be none if there are addresses" + ) + return _ZeroconfServiceInfo( ip_address=ip_address, ip_addresses=ip_addresses, port=service.port, @@ -674,3 +661,11 @@ def _memorized_fnmatch(name: str, pattern: str) -> bool: since the devices will not change frequently """ return bool(_compile_fnmatch(pattern).match(name)) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 98b09f1a251..6fe2b5b1923 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.136.0"] + "requirements": ["zeroconf==0.141.0"] } diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index fcf5afb5ac5..af9f56cd7dc 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -88,7 +88,6 @@ class Thermostat(ZHAEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key: str = "thermostat" - _enable_turn_on_off_backwards_compatibility = False def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: """Initialize the ZHA thermostat entity.""" diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 9c515c315b7..d41ae7dbfee 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -14,7 +14,7 @@ from zha.application.const import RadioType import zigpy.backups from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH -from homeassistant.components import onboarding, usb, zeroconf +from homeassistant.components import onboarding, usb from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon @@ -35,6 +35,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.selector import FileSelector, FileSelectorConfig +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import dt as dt_util from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN @@ -102,7 +104,8 @@ def _format_backup_choice( async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: """List all serial ports, including the Yellow radio and the multi-PAN addon.""" - ports = await hass.async_add_executor_job(serial.tools.list_ports.comports) + ports: list[ListPortInfo] = [] + ports.extend(await hass.async_add_executor_job(serial.tools.list_ports.comports)) # Add useful info to the Yellow's serial port selection screen try: @@ -585,9 +588,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): description_placeholders={CONF_NAME: self._title}, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle usb discovery.""" vid = discovery_info.vid pid = discovery_info.pid @@ -622,7 +623,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" @@ -648,7 +649,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): fallback_title = name.split("._", 1)[0] title = discovery_info.properties.get("name", fallback_title) - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=discovery_info.ip_address, ip_addresses=discovery_info.ip_addresses, port=port, diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index fc374f6c44d..7bdfc54c986 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -61,7 +61,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZHAEntity): """ return self.entity_data.entity.battery_level - @property # type: ignore[explicit-override, misc] + @property # type: ignore[misc] def device_info(self) -> DeviceInfo: """Return device info.""" # We opt ZHA device tracker back into overriding this method because diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 3e3d0642ca2..499721722fa 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -8,7 +8,7 @@ from functools import partial import logging from typing import Any -from propcache import cached_property +from propcache.api import cached_property from zha.mixins import LogMixin from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, EntityCategory @@ -87,7 +87,7 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, zha_gateway.state.node_info.ieee), + via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)), ) @callback diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 767c0d4cfb7..73b23e97387 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -47,7 +47,6 @@ class ZhaFan(FanEntity, ZHAEntity): """Representation of a ZHA fan.""" _attr_translation_key: str = "fan" - _enable_turn_on_off_backwards_compatibility = False def __init__(self, entity_data: EntityData) -> None: """Initialize the ZHA fan.""" diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 2440e18cf53..c31627d3dc3 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -1170,7 +1170,7 @@ def async_add_entities( # broad exception to prevent a single entity from preventing an entire platform from loading # this can potentially be caused by a misbehaving device or a bad quirk. Not ideal but the # alternative is adding try/catch to each entity class __init__ method with a specific exception - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception( "Error while adding entity from entity data: %s", entity_data ) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 9a22dfb02e9..2f5d9e9e4c9 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -15,7 +15,7 @@ from zha.application.platforms.light.const import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_TRANSITION, @@ -29,6 +29,7 @@ from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import color as color_util from .entity import ZHAEntity from .helpers import ( @@ -128,14 +129,18 @@ class Light(LightEntity, ZHAEntity): return self.entity_data.entity.brightness @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return self.entity_data.entity.min_mireds + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin( + self.entity_data.entity.min_mireds + ) @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return self.entity_data.entity.max_mireds + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" + return color_util.color_temperature_mired_to_kelvin( + self.entity_data.entity.max_mireds + ) @property def xy_color(self) -> tuple[float, float] | None: @@ -143,9 +148,13 @@ class Light(LightEntity, ZHAEntity): return self.entity_data.entity.xy_color @property - def color_temp(self) -> int | None: - """Return the CT color value in mireds.""" - return self.entity_data.entity.color_temp + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" + return ( + color_util.color_temperature_mired_to_kelvin(mireds) + if (mireds := self.entity_data.entity.color_temp) + else None + ) @property def color_mode(self) -> ColorMode | None: @@ -167,12 +176,17 @@ class Light(LightEntity, ZHAEntity): @convert_zha_error_to_ha_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" + color_temp = ( + color_util.color_temperature_kelvin_to_mired(color_temp_k) + if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) + else None + ) await self.entity_data.entity.async_turn_on( transition=kwargs.get(ATTR_TRANSITION), brightness=kwargs.get(ATTR_BRIGHTNESS), effect=kwargs.get(ATTR_EFFECT), flash=kwargs.get(ATTR_FLASH), - color_temp=kwargs.get(ATTR_COLOR_TEMP), + color_temp=color_temp, xy_color=kwargs.get(ATTR_XY_COLOR), ) self.async_write_ha_state() @@ -188,12 +202,17 @@ class Light(LightEntity, ZHAEntity): @callback def restore_external_state_attributes(self, state: State) -> None: """Restore entity state.""" + color_temp = ( + color_util.color_temperature_kelvin_to_mired(color_temp_k) + if (color_temp_k := state.attributes.get(ATTR_COLOR_TEMP_KELVIN)) + else None + ) self.entity_data.entity.restore_external_state_attributes( state=(state.state == STATE_ON), off_with_transition=state.attributes.get(OFF_WITH_TRANSITION), off_brightness=state.attributes.get(OFF_BRIGHTNESS), brightness=state.attributes.get(ATTR_BRIGHTNESS), - color_temp=state.attributes.get(ATTR_COLOR_TEMP), + color_temp=color_temp, xy_color=state.attributes.get(ATTR_XY_COLOR), color_mode=( HA_TO_ZHA_COLOR_MODE[ColorMode(state.attributes[ATTR_COLOR_MODE])] diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index a2a285e6109..f9323fe99df 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["hassio", "onboarding", "usb"], "codeowners": ["@dmulcahey", "@adminiuga", "@puddly", "@TheJulianJES"], "config_flow": true, - "dependencies": ["file_upload"], + "dependencies": ["file_upload", "homeassistant_hardware"], "documentation": "https://www.home-assistant.io/integrations/zha", "iot_class": "local_polling", "loggers": [ @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.39"], + "requirements": ["zha==0.0.45"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 82c30b7678a..aaf156290a7 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -29,6 +29,7 @@ from zigpy.exceptions import NetworkNotFormed from homeassistant import config_entries from homeassistant.components import usb from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from . import repairs from .const import ( @@ -86,7 +87,7 @@ HARDWARE_MIGRATION_SCHEMA = vol.Schema( vol.Required("old_discovery_info"): vol.Schema( { vol.Exclusive("hw", "discovery"): HARDWARE_DISCOVERY_SCHEMA, - vol.Exclusive("usb", "discovery"): usb.UsbServiceInfo, + vol.Exclusive("usb", "discovery"): UsbServiceInfo, } ), } diff --git a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py index 4d6d1ae52d8..566158eff56 100644 --- a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py +++ b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py @@ -5,9 +5,10 @@ from __future__ import annotations import enum import logging -from universal_silabs_flasher.const import ApplicationType -from universal_silabs_flasher.flasher import Flasher - +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + probe_silabs_firmware_type, +) from homeassistant.components.homeassistant_sky_connect import ( hardware as skyconnect_hardware, ) @@ -74,23 +75,6 @@ def _detect_radio_hardware(hass: HomeAssistant, device: str) -> HardwareType: return HardwareType.OTHER -async def probe_silabs_firmware_type( - device: str, *, probe_methods: ApplicationType | None = None -) -> ApplicationType | None: - """Probe the running firmware on a Silabs device.""" - flasher = Flasher( - device=device, - **({"probe_methods": probe_methods} if probe_methods else {}), - ) - - try: - await flasher.probe_app_type() - except Exception: # noqa: BLE001 - _LOGGER.debug("Failed to probe application type", exc_info=True) - - return flasher.app_type - - async def warn_on_wrong_silabs_firmware(hass: HomeAssistant, device: str) -> bool: """Create a repair issue if the wrong type of SiLabs firmware is detected.""" # Only consider actual serial ports diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index dde000b24b5..670d6af3c52 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -43,6 +43,8 @@ _EXTRA_STATE_ATTRIBUTES: set[str] = { "measurement_type", "apparent_power_max", "rms_current_max", + "rms_current_max_ph_b", + "rms_current_max_ph_c", "rms_voltage_max", "ac_frequency_max", "power_factor_max", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index c462bef8fb0..35c9f35887d 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -586,6 +586,12 @@ }, "preheat_status": { "name": "Pre-heat status" + }, + "open_window_detection_status": { + "name": "Open window detection status" + }, + "window_detection": { + "name": "Open window detection" } }, "button": { @@ -600,6 +606,12 @@ }, "self_test": { "name": "Self-test" + }, + "reset_summation_delivered": { + "name": "Reset summation delivered" + }, + "restart_device": { + "name": "Restart device" } }, "climate": { @@ -798,6 +810,81 @@ }, "off_led_intensity": { "name": "Off LED intensity" + }, + "frost_protection_temperature": { + "name": "Frost protection temperature" + }, + "valve_opening_degree": { + "name": "Valve opening degree" + }, + "valve_closing_degree": { + "name": "Valve closing degree" + }, + "siren_time": { + "name": "Siren time" + }, + "timer_time_left": { + "name": "Timer time left" + }, + "approach_distance": { + "name": "Approach distance" + }, + "fixed_load_demand": { + "name": "Fixed load demand" + }, + "display_brightness": { + "name": "Display brightness" + }, + "display_inactive_brightness": { + "name": "Display inactive brightness" + }, + "display_activity_timeout": { + "name": "Display activity timeout" + }, + "open_window_detection_threshold": { + "name": "Open window detection threshold" + }, + "open_window_event_duration": { + "name": "Open window event duration" + }, + "open_window_detection_guard_period": { + "name": "Open window detection guard period" + }, + "fallback_timeout": { + "name": "Fallback timeout" + }, + "boost_amount": { + "name": "Boost amount" + }, + "ambient_sensor_correction": { + "name": "Ambient sensor correction" + }, + "external_sensor_correction": { + "name": "External sensor correction" + }, + "move_sensitivity": { + "name": "Motion sensitivity" + }, + "detection_distance_min": { + "name": "Minimum range" + }, + "detection_distance_max": { + "name": "Maximum range" + }, + "presence_sensitivity": { + "name": "Presence sensitivity" + }, + "presence_timeout": { + "name": "Fade time" + }, + "regulator_set_point": { + "name": "Regulator set point" + }, + "detection_delay": { + "name": "Detection delay" + }, + "fading_time": { + "name": "Fading time" } }, "select": { @@ -899,6 +986,48 @@ }, "off_led_color": { "name": "Off LED color" + }, + "external_trigger_mode": { + "name": "External trigger mode" + }, + "local_temperature_source": { + "name": "Local temperature source" + }, + "control_type": { + "name": "Control type" + }, + "thermostat_application": { + "name": "Thermostat application" + }, + "heating_fuel": { + "name": "Heating fuel" + }, + "heat_transfer_medium": { + "name": "Heat transfer medium" + }, + "heating_emitter_type": { + "name": "Heating emitter type" + }, + "external_temperature_sensor_type": { + "name": "External temperature sensor type" + }, + "preset_mode": { + "name": "Preset mode" + }, + "sensor_mode": { + "name": "Sensor mode" + }, + "thermostat_mode": { + "name": "Thermostat mode" + }, + "regulator_period": { + "name": "Regulator period" + }, + "click_mode": { + "name": "Click mode" + }, + "operation_mode": { + "name": "Operation mode" } }, "sensor": { @@ -917,6 +1046,12 @@ "instantaneous_demand": { "name": "Instantaneous demand" }, + "rms_current_ph_b": { + "name": "Current phase B" + }, + "rms_current_ph_c": { + "name": "Current phase C" + }, "summation_delivered": { "name": "Summation delivered" }, @@ -1096,6 +1231,27 @@ }, "valve_status_2": { "name": "Status 2" + }, + "timer_state": { + "name": "Timer state" + }, + "last_valve_open_duration": { + "name": "Last valve open duration" + }, + "motion_distance": { + "name": "Motion distance" + }, + "control_status": { + "name": "Control status" + }, + "distance": { + "name": "Target distance" + }, + "local_temperature_floor": { + "name": "Floor temperature" + }, + "self_test": { + "name": "Self test result" } }, "switch": { @@ -1209,6 +1365,21 @@ }, "double_up_full": { "name": "Double tap on - full" + }, + "open_window": { + "name": "Open window" + }, + "turbo_mode": { + "name": "Turbo mode" + }, + "detach_relay": { + "name": "Detach relay" + }, + "enable_siren": { + "name": "Enable siren" + }, + "find_switch": { + "name": "Distance switch" } } } diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index cb5c160e7b3..2f540da5ea7 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -124,7 +124,7 @@ class ZHAFirmwareUpdateEntity( return self.entity_data.entity.installed_version @property - def in_progress(self) -> bool | int | None: + def in_progress(self) -> bool | None: """Update installation progress. Should return a boolean (True if in progress, False if not). @@ -163,11 +163,7 @@ class ZHAFirmwareUpdateEntity( """ if self.entity_data.device_proxy.device.is_mains_powered: - header = ( - "" - f"{OTA_MESSAGE_RELIABILITY}" - "" - ) + header = f"{OTA_MESSAGE_RELIABILITY}" else: header = ( "" diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index eaf00b5432f..b5acc230472 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -135,7 +135,6 @@ class ZhongHongClimate(ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False def __init__(self, hub, addr_out, addr_in): """Set up the ZhongHong climate devices.""" diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index aa4aefe6d95..af4999e5438 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -85,11 +85,8 @@ async def async_attach_trigger( from_s = zone_event.data["old_state"] to_s = zone_event.data["new_state"] - if ( - from_s - and not location.has_location(from_s) - or to_s - and not location.has_location(to_s) + if (from_s and not location.has_location(from_s)) or ( + to_s and not location.has_location(to_s) ): return @@ -107,13 +104,8 @@ async def async_attach_trigger( from_match = condition.zone(hass, zone_state, from_s) if from_s else False to_match = condition.zone(hass, zone_state, to_s) if to_s else False - if ( - event == EVENT_ENTER - and not from_match - and to_match - or event == EVENT_LEAVE - and from_match - and not to_match + if (event == EVENT_ENTER and not from_match and to_match) or ( + event == EVENT_LEAVE and from_match and not to_match ): description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}" hass.async_run_hass_job( diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index ff0459ddbdd..1a1cd6ae9c1 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -395,6 +395,8 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_node_metadata) websocket_api.async_register_command(hass, websocket_node_alerts) websocket_api.async_register_command(hass, websocket_add_node) + websocket_api.async_register_command(hass, websocket_cancel_secure_bootstrap_s2) + websocket_api.async_register_command(hass, websocket_subscribe_s2_inclusion) websocket_api.async_register_command(hass, websocket_grant_security_classes) websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin) websocket_api.async_register_command(hass, websocket_provision_smart_start_node) @@ -839,6 +841,63 @@ async def websocket_add_node( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/cancel_secure_bootstrap_s2", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_cancel_secure_bootstrap_s2( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Cancel secure bootstrap S2.""" + await driver.controller.async_cancel_secure_bootstrap_s2() + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/subscribe_s2_inclusion", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_subscribe_s2_inclusion( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Subscribe to S2 inclusion initiated by the controller.""" + + @callback + def forward_dsk(event: dict) -> None: + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": event["event"], "dsk": event["dsk"]} + ) + ) + + unsub = driver.controller.on("validate dsk and enter pin", forward_dsk) + connection.subscriptions[msg["id"]] = unsub + msg[DATA_UNSUBSCRIBE] = [unsub] + connection.send_result(msg[ID]) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index c7ab579c2cb..580694cae11 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -128,7 +128,6 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): """Representation of a Z-Wave climate.""" _attr_precision = PRECISION_TENTHS - _enable_turn_on_off_backwards_compatibility = False def __init__( self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 36f208e18d5..44adf6a12ab 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -19,7 +19,6 @@ from homeassistant.components.hassio import ( AddonManager, AddonState, ) -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ( SOURCE_USB, ConfigEntriesFlowManager, @@ -39,6 +38,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from . import disconnect_client @@ -405,9 +406,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): }, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" if not is_hassio(self.hass): return self.async_abort(reason="discovery_requires_supervisor") @@ -671,7 +670,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): discovery_info = await self._async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" - if not self.unique_id or self.context["source"] == SOURCE_USB: + if not self.unique_id or self.source == SOURCE_USB: if not self.version_info: try: self.version_info = await async_get_version_info( diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 37d3fc57886..d83132e4b95 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -83,7 +83,6 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False def __init__( self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 5885527e01c..904a26acc78 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -154,16 +154,8 @@ async def async_enable_server_logging_if_needed( LOGGER.info("Enabling zwave-js-server logging") if (curr_server_log_level := driver.log_config.level) and ( LOG_LEVEL_MAP[curr_server_log_level] - ) > (lib_log_level := LIB_LOGGER.getEffectiveLevel()): + ) > LIB_LOGGER.getEffectiveLevel(): entry_data = entry.runtime_data - LOGGER.warning( - ( - "Server logging is set to %s and is currently less verbose " - "than library logging, setting server log level to %s to match" - ), - curr_server_log_level, - logging.getLevelName(lib_log_level), - ) entry_data[DATA_OLD_SERVER_LOG_LEVEL] = curr_server_log_level await driver.async_update_log_config(LogConfig(level=LogLevel.DEBUG)) await driver.client.enable_server_logging() diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 4a044ca3f52..639d2fbcd7a 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -29,7 +29,7 @@ from zwave_js_server.model.value import Value from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_RGBW_COLOR, ATTR_TRANSITION, @@ -60,6 +60,8 @@ MULTI_COLOR_MAP = { ColorComponent.CYAN: COLOR_SWITCH_COMBINED_CYAN, ColorComponent.PURPLE: COLOR_SWITCH_COMBINED_PURPLE, } +MIN_MIREDS = 153 # 6500K as a safe default +MAX_MIREDS = 370 # 2700K as a safe default async def async_setup_entry( @@ -103,6 +105,9 @@ def byte_to_zwave_brightness(value: int) -> int: class ZwaveLight(ZWaveBaseEntity, LightEntity): """Representation of a Z-Wave light.""" + _attr_min_color_temp_kelvin = 2700 # 370 mireds as a safe default + _attr_max_color_temp_kelvin = 6500 # 153 mireds as a safe default + def __init__( self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: @@ -116,8 +121,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._hs_color: tuple[float, float] | None = None self._rgbw_color: tuple[int, int, int, int] | None = None self._color_temp: int | None = None - self._min_mireds = 153 # 6500K as a safe default - self._max_mireds = 370 # 2700K as a safe default self._warm_white = self.get_zwave_value( TARGET_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, @@ -241,20 +244,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): return self._rgbw_color @property - def color_temp(self) -> int | None: - """Return the color temperature.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature value in Kelvin.""" return self._color_temp - @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return self._min_mireds - - @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return self._max_mireds - @property def supported_color_modes(self) -> set[ColorMode] | None: """Flag supported features.""" @@ -267,10 +260,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): brightness = kwargs.get(ATTR_BRIGHTNESS) hs_color = kwargs.get(ATTR_HS_COLOR) - color_temp = kwargs.get(ATTR_COLOR_TEMP) + color_temp_k = kwargs.get(ATTR_COLOR_TEMP_KELVIN) rgbw = kwargs.get(ATTR_RGBW_COLOR) - new_colors = self._get_new_colors(hs_color, color_temp, rgbw) + new_colors = self._get_new_colors(hs_color, color_temp_k, rgbw) if new_colors is not None: await self._async_set_colors(new_colors, transition) @@ -284,7 +277,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): def _get_new_colors( self, hs_color: tuple[float, float] | None, - color_temp: int | None, + color_temp_k: int | None, rgbw: tuple[int, int, int, int] | None, brightness_scale: float | None = None, ) -> dict[ColorComponent, int] | None: @@ -309,17 +302,14 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): return colors # Color temperature - if color_temp is not None and self._supports_color_temp: + if color_temp_k is not None and self._supports_color_temp: # Limit color temp to min/max values + color_temp = color_util.color_temperature_kelvin_to_mired(color_temp_k) cold = max( 0, min( 255, - round( - (self._max_mireds - color_temp) - / (self._max_mireds - self._min_mireds) - * 255 - ), + round((MAX_MIREDS - color_temp) / (MAX_MIREDS - MIN_MIREDS) * 255), ), ) warm = 255 - cold @@ -468,7 +458,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if warm_white and cool_white: self._supports_color_temp = True # only one white channel (warm white or cool white) = rgbw support - elif red and green and blue and warm_white or cool_white: + elif (red and green and blue and warm_white) or cool_white: self._supports_rgbw = True @callback @@ -505,9 +495,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): cold_white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value) # Calculate color temps based on whites if cold_white or warm_white: - self._color_temp = round( - self._max_mireds - - ((cold_white / 255) * (self._max_mireds - self._min_mireds)) + self._color_temp = color_util.color_temperature_mired_to_kelvin( + MAX_MIREDS - ((cold_white / 255) * (MAX_MIREDS - MIN_MIREDS)) ) # White channels turned on, set color mode to color_temp self._color_mode = ColorMode.COLOR_TEMP @@ -568,7 +557,7 @@ class ZwaveColorOnOffLight(ZwaveLight): if ( kwargs.get(ATTR_RGBW_COLOR) is not None - or kwargs.get(ATTR_COLOR_TEMP) is not None + or kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None ): # RGBW and color temp are not supported in this mode, # delegate to the parent class @@ -629,7 +618,7 @@ class ZwaveColorOnOffLight(ZwaveLight): if new_colors is None: new_colors = self._get_new_colors( - hs_color=hs_color, color_temp=None, rgbw=None, brightness_scale=scale + hs_color=hs_color, color_temp_k=None, rgbw=None, brightness_scale=scale ) if new_colors is not None: diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index ad435b97cbc..011776f4556 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index d1cb66ceafc..fe293fd178b 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -488,10 +488,7 @@ class ZWaveServices: ) if nodes_without_endpoints and _LOGGER.isEnabledFor(logging.WARNING): _LOGGER.warning( - ( - "The following nodes do not have endpoint %x and will be " - "skipped: %s" - ), + "The following nodes do not have endpoint %x and will be skipped: %s", endpoint, nodes_without_endpoints, ) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 28789bbf9f4..e2d7720189d 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,28 +1,28 @@ { "config": { "abort": { - "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", - "addon_info_failed": "Failed to get Z-Wave JS add-on info.", - "addon_install_failed": "Failed to install the Z-Wave JS add-on.", - "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", - "addon_start_failed": "Failed to start the Z-Wave JS add-on.", + "addon_get_discovery_info_failed": "Failed to get Z-Wave add-on discovery info.", + "addon_info_failed": "Failed to get Z-Wave add-on info.", + "addon_install_failed": "Failed to install the Z-Wave add-on.", + "addon_set_config_failed": "Failed to set Z-Wave configuration.", + "addon_start_failed": "Failed to start the Z-Wave add-on.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "discovery_requires_supervisor": "Discovery requires the supervisor.", "not_zwave_device": "Discovered device is not a Z-Wave device.", - "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave JS add-on." + "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on." }, "error": { - "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", + "addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_ws_url": "Invalid websocket URL", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name}", "progress": { - "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", - "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." + "install_addon": "Please wait while the Z-Wave add-on installation finishes. This can take several minutes.", + "start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds." }, "step": { "configure_addon": { @@ -34,13 +34,13 @@ "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "The add-on will generate security keys if those fields are left empty.", - "title": "Enter the Z-Wave JS add-on configuration" + "title": "Enter the Z-Wave add-on configuration" }, "hassio_confirm": { - "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" + "title": "Set up Z-Wave integration with the Z-Wave add-on" }, "install_addon": { - "title": "The Z-Wave JS add-on installation has started" + "title": "The Z-Wave add-on installation has started" }, "manual": { "data": { @@ -49,20 +49,20 @@ }, "on_supervisor": { "data": { - "use_addon": "Use the Z-Wave JS Supervisor add-on" + "use_addon": "Use the Z-Wave Supervisor add-on" }, - "description": "Do you want to use the Z-Wave JS Supervisor add-on?", + "description": "Do you want to use the Z-Wave Supervisor add-on?", "title": "Select connection method" }, "start_addon": { - "title": "The Z-Wave JS add-on is starting." + "title": "The Z-Wave add-on is starting." }, "usb_confirm": { - "description": "Do you want to set up {name} with the Z-Wave JS add-on?" + "description": "Do you want to set up {name} with the Z-Wave add-on?" }, "zeroconf_confirm": { - "description": "Do you want to add the Z-Wave JS Server with home ID {home_id} found at {url} to Home Assistant?", - "title": "Discovered Z-Wave JS Server" + "description": "Do you want to add the Z-Wave Server with home ID {home_id} found at {url} to Home Assistant?", + "title": "Discovered Z-Wave Server" } } }, @@ -89,7 +89,7 @@ "event.value_notification.scene_activation": "Scene Activation on {subtype}", "state.node_status": "Node status changed", "zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}", - "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value" + "zwave_js.value_updated.value": "Value change on a Z-Wave Value" }, "extra_fields": { "code_slot": "Code slot", @@ -191,7 +191,7 @@ }, "step": { "init": { - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", "menu_options": { "confirm": "Re-interview device", "ignore": "Ignore device config update" @@ -203,8 +203,8 @@ "title": "Device configuration file changed: {device_name}" }, "invalid_server_version": { - "description": "The version of Z-Wave JS Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave JS Server to the latest version to fix this issue.", - "title": "Newer version of Z-Wave JS Server needed" + "description": "The version of Z-Wave Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave Server to the latest version to fix this issue.", + "title": "Newer version of Z-Wave Server needed" } }, "options": { @@ -290,7 +290,7 @@ "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]" } }, - "name": "Bulk set partial configuration parameters (advanced)." + "name": "Bulk set partial configuration parameters (advanced)" }, "clear_lock_usercode": { "description": "Clears a user code from a lock.", @@ -306,7 +306,7 @@ "description": "Calls a Command Class API on a node. Some Command Classes can't be fully controlled via the `set_value` action and require direct calls to the Command Class API.", "fields": { "area_id": { - "description": "The area(s) to target for this service. If an area is specified, all zwave_js devices and entities in that area will be targeted for this service.", + "description": "The area(s) to target for this action. If an area is specified, all Z-Wave devices and entities in that area will be targeted for this action.", "name": "Area ID(s)" }, "command_class": { @@ -314,7 +314,7 @@ "name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]" }, "device_id": { - "description": "The device(s) to target for this service.", + "description": "The device(s) to target for this action.", "name": "Device ID(s)" }, "endpoint": { @@ -322,22 +322,22 @@ "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]" }, "entity_id": { - "description": "The entity ID(s) to target for this service.", + "description": "The entity ID(s) to target for this action.", "name": "Entity ID(s)" }, "method_name": { - "description": "The name of the API method to call. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods.", + "description": "The name of the API method to call. Refer to the Z-Wave Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods.", "name": "Method name" }, "parameters": { - "description": "A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters.", + "description": "A list of parameters to pass to the API method. Refer to the Z-Wave Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters.", "name": "Parameters" } }, "name": "Invoke a Command Class API on a node (advanced)" }, "multicast_set_value": { - "description": "Changes any value that Z-Wave JS recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This action has minimal validation so only use this action if you know what you are doing.", + "description": "Changes any value that Z-Wave recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This action has minimal validation so only use this action if you know what you are doing.", "fields": { "area_id": { "description": "[%key:component::zwave_js::services::set_value::fields::area_id::description%]", @@ -383,7 +383,7 @@ "name": "Set a value on multiple devices via multicast (advanced)" }, "ping": { - "description": "Forces Z-Wave JS to try to reach a node. This can be used to update the status of the node in Z-Wave JS when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep.", + "description": "Forces Z-Wave to try to reach a node. This can be used to update the status of the node in Z-Wave when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep.", "fields": { "area_id": { "description": "[%key:component::zwave_js::services::set_value::fields::area_id::description%]", @@ -474,7 +474,7 @@ "name": "[%key:component::zwave_js::services::set_value::fields::area_id::name%]" }, "bitmask": { - "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with value_size or value_format.", + "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with 'Value size' or 'Value format'.", "name": "Bitmask" }, "device_id": { @@ -498,11 +498,11 @@ "name": "Value" }, "value_format": { - "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with value_size when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.", + "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with 'Value size' when a config parameter is not defined in your device's configuration file. Cannot be combined with 'Bitmask'.", "name": "Value format" }, "value_size": { - "description": "Size of the value, either 1, 2, or 4. Used in combination with value_format when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.", + "description": "Size of the value, either 1, 2, or 4. Used in combination with 'Value format' when a config parameter is not defined in your device's configuration file. Cannot be combined with 'Bitmask'.", "name": "Value size" } }, @@ -553,10 +553,10 @@ "name": "Set lock user code" }, "set_value": { - "description": "Changes any value that Z-Wave JS recognizes on a Z-Wave device. This action has minimal validation so only use this action if you know what you are doing.", + "description": "Changes any value that Z-Wave recognizes on a Z-Wave device. This action has minimal validation so only use this action if you know what you are doing.", "fields": { "area_id": { - "description": "The area(s) to target for this service. If an area is specified, all zwave_js devices and entities in that area will be targeted for this service.", + "description": "The area(s) to target for this action. If an area is specified, all Z-Wave devices and entities in that area will be targeted for this action.", "name": "Area ID(s)" }, "command_class": { @@ -564,7 +564,7 @@ "name": "Command class" }, "device_id": { - "description": "The device(s) to target for this service.", + "description": "The device(s) to target for this action.", "name": "Device ID(s)" }, "endpoint": { @@ -572,11 +572,11 @@ "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]" }, "entity_id": { - "description": "The entity ID(s) to target for this service.", + "description": "The entity ID(s) to target for this action.", "name": "Entity ID(s)" }, "options": { - "description": "Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set.", + "description": "Set value options map. Refer to the Z-Wave documentation for more information on what options can be set.", "name": "Options" }, "property": { diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 9938d08408c..db52683c173 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable import functools -from pydantic import ValidationError +from pydantic.v1 import ValidationError import voluptuous as vol from zwave_js_server.client import Client from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP diff --git a/homeassistant/components/zwave_me/climate.py b/homeassistant/components/zwave_me/climate.py index de6f606745f..b8eed88b505 100644 --- a/homeassistant/components/zwave_me/climate.py +++ b/homeassistant/components/zwave_me/climate.py @@ -57,7 +57,6 @@ class ZWaveMeClimate(ZWaveMeEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - _enable_turn_on_off_backwards_compatibility = False def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/zwave_me/config_flow.py b/homeassistant/components/zwave_me/config_flow.py index 1444bfc1b95..d37d76a093b 100644 --- a/homeassistant/components/zwave_me/config_flow.py +++ b/homeassistant/components/zwave_me/config_flow.py @@ -7,9 +7,9 @@ import logging from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import helpers from .const import DOMAIN diff --git a/homeassistant/components/zwave_me/fan.py b/homeassistant/components/zwave_me/fan.py index 1016586ab55..bd0feba0dfb 100644 --- a/homeassistant/components/zwave_me/fan.py +++ b/homeassistant/components/zwave_me/fan.py @@ -49,7 +49,6 @@ class ZWaveMeFan(ZWaveMeEntity, FanEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - _enable_turn_on_off_backwards_compatibility = False @property def percentage(self) -> int: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ade4cd855ca..620e4bc8197 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -22,11 +22,10 @@ from functools import cache import logging from random import randint from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Generic, Self, cast +from typing import TYPE_CHECKING, Any, Self, cast from async_interrupt import interrupt -from propcache import cached_property -from typing_extensions import TypeVar +from propcache.api import cached_property import voluptuous as vol from . import data_entry_flow, loader @@ -88,12 +87,12 @@ from .util.enum import try_parse_enum if TYPE_CHECKING: from .components.bluetooth import BluetoothServiceInfoBleak - from .components.dhcp import DhcpServiceInfo - from .components.ssdp import SsdpServiceInfo - from .components.usb import UsbServiceInfo - from .components.zeroconf import ZeroconfServiceInfo + from .helpers.service_info.dhcp import DhcpServiceInfo from .helpers.service_info.hassio import HassioServiceInfo from .helpers.service_info.mqtt import MqttServiceInfo + from .helpers.service_info.ssdp import SsdpServiceInfo + from .helpers.service_info.usb import UsbServiceInfo + from .helpers.service_info.zeroconf import ZeroconfServiceInfo _LOGGER = logging.getLogger(__name__) @@ -137,8 +136,6 @@ DISCOVERY_COOLDOWN = 1 ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision" UNIQUE_ID_COLLISION_TITLE_LIMIT = 5 -_DataT = TypeVar("_DataT", default=Any) - class ConfigEntryState(Enum): """Config entry state.""" @@ -313,7 +310,7 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N ) -class ConfigEntry(Generic[_DataT]): +class ConfigEntry[_DataT = Any]: """Hold a configuration entry.""" entry_id: str @@ -691,10 +688,7 @@ class ConfigEntry(Generic[_DataT]): self._tries += 1 ready_message = f"ready yet: {message}" if message else "ready yet" _LOGGER.debug( - ( - "Config entry '%s' for %s integration not %s; Retrying in %d" - " seconds" - ), + "Config entry '%s' for %s integration not %s; Retrying in %d seconds", self.title, self.domain, ready_message, @@ -1890,7 +1884,7 @@ class ConfigEntries: def async_loaded_entries(self, domain: str) -> list[ConfigEntry]: """Return loaded entries for a specific domain. - This will exclude ignored or disabled config entruis. + This will exclude ignored or disabled config entries. """ entries = self._entries.get_entries_for_domain(domain) diff --git a/homeassistant/const.py b/homeassistant/const.py index 514c2154611..699aebcafdf 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,15 +23,15 @@ if TYPE_CHECKING: from .helpers.typing import NoEventData APPLICATION_NAME: Final = "HomeAssistant" -MAJOR_VERSION: Final = 2024 -MINOR_VERSION: Final = 12 +MAJOR_VERSION: Final = 2025 +MINOR_VERSION: Final = 2 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" -REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) +REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) # Truthy date string triggers showing related deprecation warning messages. -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2025.2" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" @@ -336,133 +336,6 @@ EVENT_RECORDER_HOURLY_STATISTICS_GENERATED: Final = ( ) EVENT_SHOPPING_LIST_UPDATED: Final = "shopping_list_updated" -# #### DEVICE CLASSES #### -# DEVICE_CLASS_* below are deprecated as of 2021.12 -# use the SensorDeviceClass enum instead. -_DEPRECATED_DEVICE_CLASS_AQI: Final = DeprecatedConstant( - "aqi", "SensorDeviceClass.AQI", "2025.1" -) -_DEPRECATED_DEVICE_CLASS_BATTERY: Final = DeprecatedConstant( - "battery", - "SensorDeviceClass.BATTERY", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_CO: Final = DeprecatedConstant( - "carbon_monoxide", - "SensorDeviceClass.CO", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_CO2: Final = DeprecatedConstant( - "carbon_dioxide", - "SensorDeviceClass.CO2", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_CURRENT: Final = DeprecatedConstant( - "current", - "SensorDeviceClass.CURRENT", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_DATE: Final = DeprecatedConstant( - "date", "SensorDeviceClass.DATE", "2025.1" -) -_DEPRECATED_DEVICE_CLASS_ENERGY: Final = DeprecatedConstant( - "energy", - "SensorDeviceClass.ENERGY", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_FREQUENCY: Final = DeprecatedConstant( - "frequency", - "SensorDeviceClass.FREQUENCY", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_GAS: Final = DeprecatedConstant( - "gas", "SensorDeviceClass.GAS", "2025.1" -) -_DEPRECATED_DEVICE_CLASS_HUMIDITY: Final = DeprecatedConstant( - "humidity", - "SensorDeviceClass.HUMIDITY", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_ILLUMINANCE: Final = DeprecatedConstant( - "illuminance", - "SensorDeviceClass.ILLUMINANCE", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_MONETARY: Final = DeprecatedConstant( - "monetary", - "SensorDeviceClass.MONETARY", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE: Final = DeprecatedConstant( - "nitrogen_dioxide", - "SensorDeviceClass.NITROGEN_DIOXIDE", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE: Final = DeprecatedConstant( - "nitrogen_monoxide", - "SensorDeviceClass.NITROGEN_MONOXIDE", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE: Final = DeprecatedConstant( - "nitrous_oxide", - "SensorDeviceClass.NITROUS_OXIDE", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_OZONE: Final = DeprecatedConstant( - "ozone", "SensorDeviceClass.OZONE", "2025.1" -) -_DEPRECATED_DEVICE_CLASS_PM1: Final = DeprecatedConstant( - "pm1", "SensorDeviceClass.PM1", "2025.1" -) -_DEPRECATED_DEVICE_CLASS_PM10: Final = DeprecatedConstant( - "pm10", "SensorDeviceClass.PM10", "2025.1" -) -_DEPRECATED_DEVICE_CLASS_PM25: Final = DeprecatedConstant( - "pm25", "SensorDeviceClass.PM25", "2025.1" -) -_DEPRECATED_DEVICE_CLASS_POWER_FACTOR: Final = DeprecatedConstant( - "power_factor", - "SensorDeviceClass.POWER_FACTOR", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_POWER: Final = DeprecatedConstant( - "power", "SensorDeviceClass.POWER", "2025.1" -) -_DEPRECATED_DEVICE_CLASS_PRESSURE: Final = DeprecatedConstant( - "pressure", - "SensorDeviceClass.PRESSURE", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH: Final = DeprecatedConstant( - "signal_strength", - "SensorDeviceClass.SIGNAL_STRENGTH", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE: Final = DeprecatedConstant( - "sulphur_dioxide", - "SensorDeviceClass.SULPHUR_DIOXIDE", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_TEMPERATURE: Final = DeprecatedConstant( - "temperature", - "SensorDeviceClass.TEMPERATURE", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_TIMESTAMP: Final = DeprecatedConstant( - "timestamp", - "SensorDeviceClass.TIMESTAMP", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: Final = DeprecatedConstant( - "volatile_organic_compounds", - "SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS", - "2025.1", -) -_DEPRECATED_DEVICE_CLASS_VOLTAGE: Final = DeprecatedConstant( - "voltage", - "SensorDeviceClass.VOLTAGE", - "2025.1", -) # #### STATES #### STATE_ON: Final = "on" @@ -712,17 +585,11 @@ class UnitOfApparentPower(StrEnum): VOLT_AMPERE = "VA" -_DEPRECATED_POWER_VOLT_AMPERE: Final = DeprecatedConstantEnum( - UnitOfApparentPower.VOLT_AMPERE, - "2025.1", -) -"""Deprecated: please use UnitOfApparentPower.VOLT_AMPERE.""" - - # Power units class UnitOfPower(StrEnum): """Power units.""" + MILLIWATT = "mW" WATT = "W" KILO_WATT = "kW" MEGA_WATT = "MW" @@ -731,23 +598,6 @@ class UnitOfPower(StrEnum): BTU_PER_HOUR = "BTU/h" -_DEPRECATED_POWER_WATT: Final = DeprecatedConstantEnum( - UnitOfPower.WATT, - "2025.1", -) -"""Deprecated: please use UnitOfPower.WATT.""" -_DEPRECATED_POWER_KILO_WATT: Final = DeprecatedConstantEnum( - UnitOfPower.KILO_WATT, - "2025.1", -) -"""Deprecated: please use UnitOfPower.KILO_WATT.""" -_DEPRECATED_POWER_BTU_PER_HOUR: Final = DeprecatedConstantEnum( - UnitOfPower.BTU_PER_HOUR, - "2025.1", -) -"""Deprecated: please use UnitOfPower.BTU_PER_HOUR.""" - - # Reactive power units class UnitOfReactivePower(StrEnum): """Reactive power units.""" @@ -770,6 +620,7 @@ class UnitOfEnergy(StrEnum): KILO_JOULE = "kJ" MEGA_JOULE = "MJ" GIGA_JOULE = "GJ" + MILLIWATT_HOUR = "mWh" WATT_HOUR = "Wh" KILO_WATT_HOUR = "kWh" MEGA_WATT_HOUR = "MWh" @@ -781,23 +632,6 @@ class UnitOfEnergy(StrEnum): GIGA_CALORIE = "Gcal" -_DEPRECATED_ENERGY_KILO_WATT_HOUR: Final = DeprecatedConstantEnum( - UnitOfEnergy.KILO_WATT_HOUR, - "2025.1", -) -"""Deprecated: please use UnitOfEnergy.KILO_WATT_HOUR.""" -_DEPRECATED_ENERGY_MEGA_WATT_HOUR: Final = DeprecatedConstantEnum( - UnitOfEnergy.MEGA_WATT_HOUR, - "2025.1", -) -"""Deprecated: please use UnitOfEnergy.MEGA_WATT_HOUR.""" -_DEPRECATED_ENERGY_WATT_HOUR: Final = DeprecatedConstantEnum( - UnitOfEnergy.WATT_HOUR, - "2025.1", -) -"""Deprecated: please use UnitOfEnergy.WATT_HOUR.""" - - # Electric_current units class UnitOfElectricCurrent(StrEnum): """Electric current units.""" @@ -806,18 +640,6 @@ class UnitOfElectricCurrent(StrEnum): AMPERE = "A" -_DEPRECATED_ELECTRIC_CURRENT_MILLIAMPERE: Final = DeprecatedConstantEnum( - UnitOfElectricCurrent.MILLIAMPERE, - "2025.1", -) -"""Deprecated: please use UnitOfElectricCurrent.MILLIAMPERE.""" -_DEPRECATED_ELECTRIC_CURRENT_AMPERE: Final = DeprecatedConstantEnum( - UnitOfElectricCurrent.AMPERE, - "2025.1", -) -"""Deprecated: please use UnitOfElectricCurrent.AMPERE.""" - - # Electric_potential units class UnitOfElectricPotential(StrEnum): """Electric potential units.""" @@ -825,19 +647,10 @@ class UnitOfElectricPotential(StrEnum): MICROVOLT = "µV" MILLIVOLT = "mV" VOLT = "V" + KILOVOLT = "kV" + MEGAVOLT = "MV" -_DEPRECATED_ELECTRIC_POTENTIAL_MILLIVOLT: Final = DeprecatedConstantEnum( - UnitOfElectricPotential.MILLIVOLT, - "2025.1", -) -"""Deprecated: please use UnitOfElectricPotential.MILLIVOLT.""" -_DEPRECATED_ELECTRIC_POTENTIAL_VOLT: Final = DeprecatedConstantEnum( - UnitOfElectricPotential.VOLT, - "2025.1", -) -"""Deprecated: please use UnitOfElectricPotential.VOLT.""" - # Degree units DEGREE: Final = "°" @@ -856,23 +669,6 @@ class UnitOfTemperature(StrEnum): KELVIN = "K" -_DEPRECATED_TEMP_CELSIUS: Final = DeprecatedConstantEnum( - UnitOfTemperature.CELSIUS, - "2025.1", -) -"""Deprecated: please use UnitOfTemperature.CELSIUS""" -_DEPRECATED_TEMP_FAHRENHEIT: Final = DeprecatedConstantEnum( - UnitOfTemperature.FAHRENHEIT, - "2025.1", -) -"""Deprecated: please use UnitOfTemperature.FAHRENHEIT""" -_DEPRECATED_TEMP_KELVIN: Final = DeprecatedConstantEnum( - UnitOfTemperature.KELVIN, - "2025.1", -) -"""Deprecated: please use UnitOfTemperature.KELVIN""" - - # Time units class UnitOfTime(StrEnum): """Time units.""" @@ -888,53 +684,6 @@ class UnitOfTime(StrEnum): YEARS = "y" -_DEPRECATED_TIME_MICROSECONDS: Final = DeprecatedConstantEnum( - UnitOfTime.MICROSECONDS, - "2025.1", -) -"""Deprecated: please use UnitOfTime.MICROSECONDS.""" -_DEPRECATED_TIME_MILLISECONDS: Final = DeprecatedConstantEnum( - UnitOfTime.MILLISECONDS, - "2025.1", -) -"""Deprecated: please use UnitOfTime.MILLISECONDS.""" -_DEPRECATED_TIME_SECONDS: Final = DeprecatedConstantEnum( - UnitOfTime.SECONDS, - "2025.1", -) -"""Deprecated: please use UnitOfTime.SECONDS.""" -_DEPRECATED_TIME_MINUTES: Final = DeprecatedConstantEnum( - UnitOfTime.MINUTES, - "2025.1", -) -"""Deprecated: please use UnitOfTime.MINUTES.""" -_DEPRECATED_TIME_HOURS: Final = DeprecatedConstantEnum( - UnitOfTime.HOURS, - "2025.1", -) -"""Deprecated: please use UnitOfTime.HOURS.""" -_DEPRECATED_TIME_DAYS: Final = DeprecatedConstantEnum( - UnitOfTime.DAYS, - "2025.1", -) -"""Deprecated: please use UnitOfTime.DAYS.""" -_DEPRECATED_TIME_WEEKS: Final = DeprecatedConstantEnum( - UnitOfTime.WEEKS, - "2025.1", -) -"""Deprecated: please use UnitOfTime.WEEKS.""" -_DEPRECATED_TIME_MONTHS: Final = DeprecatedConstantEnum( - UnitOfTime.MONTHS, - "2025.1", -) -"""Deprecated: please use UnitOfTime.MONTHS.""" -_DEPRECATED_TIME_YEARS: Final = DeprecatedConstantEnum( - UnitOfTime.YEARS, - "2025.1", -) -"""Deprecated: please use UnitOfTime.YEARS.""" - - # Length units class UnitOfLength(StrEnum): """Length units.""" @@ -950,48 +699,6 @@ class UnitOfLength(StrEnum): NAUTICAL_MILES = "nmi" -_DEPRECATED_LENGTH_MILLIMETERS: Final = DeprecatedConstantEnum( - UnitOfLength.MILLIMETERS, - "2025.1", -) -"""Deprecated: please use UnitOfLength.MILLIMETERS.""" -_DEPRECATED_LENGTH_CENTIMETERS: Final = DeprecatedConstantEnum( - UnitOfLength.CENTIMETERS, - "2025.1", -) -"""Deprecated: please use UnitOfLength.CENTIMETERS.""" -_DEPRECATED_LENGTH_METERS: Final = DeprecatedConstantEnum( - UnitOfLength.METERS, - "2025.1", -) -"""Deprecated: please use UnitOfLength.METERS.""" -_DEPRECATED_LENGTH_KILOMETERS: Final = DeprecatedConstantEnum( - UnitOfLength.KILOMETERS, - "2025.1", -) -"""Deprecated: please use UnitOfLength.KILOMETERS.""" -_DEPRECATED_LENGTH_INCHES: Final = DeprecatedConstantEnum( - UnitOfLength.INCHES, - "2025.1", -) -"""Deprecated: please use UnitOfLength.INCHES.""" -_DEPRECATED_LENGTH_FEET: Final = DeprecatedConstantEnum( - UnitOfLength.FEET, - "2025.1", -) -"""Deprecated: please use UnitOfLength.FEET.""" -_DEPRECATED_LENGTH_YARD: Final = DeprecatedConstantEnum( - UnitOfLength.YARDS, - "2025.1", -) -"""Deprecated: please use UnitOfLength.YARDS.""" -_DEPRECATED_LENGTH_MILES: Final = DeprecatedConstantEnum( - UnitOfLength.MILES, - "2025.1", -) -"""Deprecated: please use UnitOfLength.MILES.""" - - # Frequency units class UnitOfFrequency(StrEnum): """Frequency units.""" @@ -1002,28 +709,6 @@ class UnitOfFrequency(StrEnum): GIGAHERTZ = "GHz" -_DEPRECATED_FREQUENCY_HERTZ: Final = DeprecatedConstantEnum( - UnitOfFrequency.HERTZ, - "2025.1", -) -"""Deprecated: please use UnitOfFrequency.HERTZ""" -_DEPRECATED_FREQUENCY_KILOHERTZ: Final = DeprecatedConstantEnum( - UnitOfFrequency.KILOHERTZ, - "2025.1", -) -"""Deprecated: please use UnitOfFrequency.KILOHERTZ""" -_DEPRECATED_FREQUENCY_MEGAHERTZ: Final = DeprecatedConstantEnum( - UnitOfFrequency.MEGAHERTZ, - "2025.1", -) -"""Deprecated: please use UnitOfFrequency.MEGAHERTZ""" -_DEPRECATED_FREQUENCY_GIGAHERTZ: Final = DeprecatedConstantEnum( - UnitOfFrequency.GIGAHERTZ, - "2025.1", -) -"""Deprecated: please use UnitOfFrequency.GIGAHERTZ""" - - # Pressure units class UnitOfPressure(StrEnum): """Pressure units.""" @@ -1039,53 +724,6 @@ class UnitOfPressure(StrEnum): PSI = "psi" -_DEPRECATED_PRESSURE_PA: Final = DeprecatedConstantEnum( - UnitOfPressure.PA, - "2025.1", -) -"""Deprecated: please use UnitOfPressure.PA""" -_DEPRECATED_PRESSURE_HPA: Final = DeprecatedConstantEnum( - UnitOfPressure.HPA, - "2025.1", -) -"""Deprecated: please use UnitOfPressure.HPA""" -_DEPRECATED_PRESSURE_KPA: Final = DeprecatedConstantEnum( - UnitOfPressure.KPA, - "2025.1", -) -"""Deprecated: please use UnitOfPressure.KPA""" -_DEPRECATED_PRESSURE_BAR: Final = DeprecatedConstantEnum( - UnitOfPressure.BAR, - "2025.1", -) -"""Deprecated: please use UnitOfPressure.BAR""" -_DEPRECATED_PRESSURE_CBAR: Final = DeprecatedConstantEnum( - UnitOfPressure.CBAR, - "2025.1", -) -"""Deprecated: please use UnitOfPressure.CBAR""" -_DEPRECATED_PRESSURE_MBAR: Final = DeprecatedConstantEnum( - UnitOfPressure.MBAR, - "2025.1", -) -"""Deprecated: please use UnitOfPressure.MBAR""" -_DEPRECATED_PRESSURE_MMHG: Final = DeprecatedConstantEnum( - UnitOfPressure.MMHG, - "2025.1", -) -"""Deprecated: please use UnitOfPressure.MMHG""" -_DEPRECATED_PRESSURE_INHG: Final = DeprecatedConstantEnum( - UnitOfPressure.INHG, - "2025.1", -) -"""Deprecated: please use UnitOfPressure.INHG""" -_DEPRECATED_PRESSURE_PSI: Final = DeprecatedConstantEnum( - UnitOfPressure.PSI, - "2025.1", -) -"""Deprecated: please use UnitOfPressure.PSI""" - - # Sound pressure units class UnitOfSoundPressure(StrEnum): """Sound pressure units.""" @@ -1094,18 +732,6 @@ class UnitOfSoundPressure(StrEnum): WEIGHTED_DECIBEL_A = "dBA" -_DEPRECATED_SOUND_PRESSURE_DB: Final = DeprecatedConstantEnum( - UnitOfSoundPressure.DECIBEL, - "2025.1", -) -"""Deprecated: please use UnitOfSoundPressure.DECIBEL""" -_DEPRECATED_SOUND_PRESSURE_WEIGHTED_DBA: Final = DeprecatedConstantEnum( - UnitOfSoundPressure.WEIGHTED_DECIBEL_A, - "2025.1", -) -"""Deprecated: please use UnitOfSoundPressure.WEIGHTED_DECIBEL_A""" - - # Volume units class UnitOfVolume(StrEnum): """Volume units.""" @@ -1125,39 +751,6 @@ class UnitOfVolume(StrEnum): British/Imperial fluid ounces are not yet supported""" -_DEPRECATED_VOLUME_LITERS: Final = DeprecatedConstantEnum( - UnitOfVolume.LITERS, - "2025.1", -) -"""Deprecated: please use UnitOfVolume.LITERS""" -_DEPRECATED_VOLUME_MILLILITERS: Final = DeprecatedConstantEnum( - UnitOfVolume.MILLILITERS, - "2025.1", -) -"""Deprecated: please use UnitOfVolume.MILLILITERS""" -_DEPRECATED_VOLUME_CUBIC_METERS: Final = DeprecatedConstantEnum( - UnitOfVolume.CUBIC_METERS, - "2025.1", -) -"""Deprecated: please use UnitOfVolume.CUBIC_METERS""" -_DEPRECATED_VOLUME_CUBIC_FEET: Final = DeprecatedConstantEnum( - UnitOfVolume.CUBIC_FEET, - "2025.1", -) -"""Deprecated: please use UnitOfVolume.CUBIC_FEET""" - -_DEPRECATED_VOLUME_GALLONS: Final = DeprecatedConstantEnum( - UnitOfVolume.GALLONS, - "2025.1", -) -"""Deprecated: please use UnitOfVolume.GALLONS""" -_DEPRECATED_VOLUME_FLUID_OUNCE: Final = DeprecatedConstantEnum( - UnitOfVolume.FLUID_OUNCES, - "2025.1", -) -"""Deprecated: please use UnitOfVolume.FLUID_OUNCES""" - - # Volume Flow Rate units class UnitOfVolumeFlowRate(StrEnum): """Volume flow rate units.""" @@ -1169,18 +762,6 @@ class UnitOfVolumeFlowRate(StrEnum): MILLILITERS_PER_SECOND = "mL/s" -_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = DeprecatedConstantEnum( - UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - "2025.1", -) -"""Deprecated: please use UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR""" -_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = DeprecatedConstantEnum( - UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, - "2025.1", -) -"""Deprecated: please use UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE""" - - class UnitOfArea(StrEnum): """Area units.""" @@ -1216,38 +797,6 @@ class UnitOfMass(StrEnum): STONES = "st" -_DEPRECATED_MASS_GRAMS: Final = DeprecatedConstantEnum( - UnitOfMass.GRAMS, - "2025.1", -) -"""Deprecated: please use UnitOfMass.GRAMS""" -_DEPRECATED_MASS_KILOGRAMS: Final = DeprecatedConstantEnum( - UnitOfMass.KILOGRAMS, - "2025.1", -) -"""Deprecated: please use UnitOfMass.KILOGRAMS""" -_DEPRECATED_MASS_MILLIGRAMS: Final = DeprecatedConstantEnum( - UnitOfMass.MILLIGRAMS, - "2025.1", -) -"""Deprecated: please use UnitOfMass.MILLIGRAMS""" -_DEPRECATED_MASS_MICROGRAMS: Final = DeprecatedConstantEnum( - UnitOfMass.MICROGRAMS, - "2025.1", -) -"""Deprecated: please use UnitOfMass.MICROGRAMS""" -_DEPRECATED_MASS_OUNCES: Final = DeprecatedConstantEnum( - UnitOfMass.OUNCES, - "2025.1", -) -"""Deprecated: please use UnitOfMass.OUNCES""" -_DEPRECATED_MASS_POUNDS: Final = DeprecatedConstantEnum( - UnitOfMass.POUNDS, - "2025.1", -) -"""Deprecated: please use UnitOfMass.POUNDS""" - - class UnitOfConductivity( StrEnum, metaclass=EnumWithDeprecatedMembers, @@ -1299,19 +848,6 @@ class UnitOfIrradiance(StrEnum): BTUS_PER_HOUR_SQUARE_FOOT = "BTU/(h⋅ft²)" -# Irradiation units -_DEPRECATED_IRRADIATION_WATTS_PER_SQUARE_METER: Final = DeprecatedConstantEnum( - UnitOfIrradiance.WATTS_PER_SQUARE_METER, - "2025.1", -) -"""Deprecated: please use UnitOfIrradiance.WATTS_PER_SQUARE_METER""" -_DEPRECATED_IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = DeprecatedConstantEnum( - UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT, - "2025.1", -) -"""Deprecated: please use UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT""" - - class UnitOfVolumetricFlux(StrEnum): """Volumetric flux, commonly used for precipitation intensity. @@ -1349,27 +885,6 @@ class UnitOfPrecipitationDepth(StrEnum): """Derived from cm³/cm²""" -# Precipitation units -_DEPRECATED_PRECIPITATION_INCHES: Final = DeprecatedConstantEnum( - UnitOfPrecipitationDepth.INCHES, "2025.1" -) -"""Deprecated: please use UnitOfPrecipitationDepth.INCHES""" -_DEPRECATED_PRECIPITATION_MILLIMETERS: Final = DeprecatedConstantEnum( - UnitOfPrecipitationDepth.MILLIMETERS, - "2025.1", -) -"""Deprecated: please use UnitOfPrecipitationDepth.MILLIMETERS""" -_DEPRECATED_PRECIPITATION_MILLIMETERS_PER_HOUR: Final = DeprecatedConstantEnum( - UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - "2025.1", -) -"""Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR""" -_DEPRECATED_PRECIPITATION_INCHES_PER_HOUR: Final = DeprecatedConstantEnum( - UnitOfVolumetricFlux.INCHES_PER_HOUR, - "2025.1", -) -"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" - # Concentration units CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³" CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³" @@ -1400,45 +915,6 @@ class UnitOfSpeed(StrEnum): MILLIMETERS_PER_SECOND = "mm/s" -_DEPRECATED_SPEED_FEET_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfSpeed.FEET_PER_SECOND, "2025.1" -) -"""Deprecated: please use UnitOfSpeed.FEET_PER_SECOND""" -_DEPRECATED_SPEED_METERS_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfSpeed.METERS_PER_SECOND, "2025.1" -) -"""Deprecated: please use UnitOfSpeed.METERS_PER_SECOND""" -_DEPRECATED_SPEED_KILOMETERS_PER_HOUR: Final = DeprecatedConstantEnum( - UnitOfSpeed.KILOMETERS_PER_HOUR, - "2025.1", -) -"""Deprecated: please use UnitOfSpeed.KILOMETERS_PER_HOUR""" -_DEPRECATED_SPEED_KNOTS: Final = DeprecatedConstantEnum(UnitOfSpeed.KNOTS, "2025.1") -"""Deprecated: please use UnitOfSpeed.KNOTS""" -_DEPRECATED_SPEED_MILES_PER_HOUR: Final = DeprecatedConstantEnum( - UnitOfSpeed.MILES_PER_HOUR, "2025.1" -) -"""Deprecated: please use UnitOfSpeed.MILES_PER_HOUR""" - -_DEPRECATED_SPEED_MILLIMETERS_PER_DAY: Final = DeprecatedConstantEnum( - UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, - "2025.1", -) -"""Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_DAY""" - -_DEPRECATED_SPEED_INCHES_PER_DAY: Final = DeprecatedConstantEnum( - UnitOfVolumetricFlux.INCHES_PER_DAY, - "2025.1", -) -"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_DAY""" - -_DEPRECATED_SPEED_INCHES_PER_HOUR: Final = DeprecatedConstantEnum( - UnitOfVolumetricFlux.INCHES_PER_HOUR, - "2025.1", -) -"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" - - # Signal_strength units SIGNAL_STRENGTH_DECIBELS: Final = "dB" SIGNAL_STRENGTH_DECIBELS_MILLIWATT: Final = "dBm" @@ -1471,90 +947,6 @@ class UnitOfInformation(StrEnum): YOBIBYTES = "YiB" -_DEPRECATED_DATA_BITS: Final = DeprecatedConstantEnum(UnitOfInformation.BITS, "2025.1") -"""Deprecated: please use UnitOfInformation.BITS""" -_DEPRECATED_DATA_KILOBITS: Final = DeprecatedConstantEnum( - UnitOfInformation.KILOBITS, "2025.1" -) -"""Deprecated: please use UnitOfInformation.KILOBITS""" -_DEPRECATED_DATA_MEGABITS: Final = DeprecatedConstantEnum( - UnitOfInformation.MEGABITS, "2025.1" -) -"""Deprecated: please use UnitOfInformation.MEGABITS""" -_DEPRECATED_DATA_GIGABITS: Final = DeprecatedConstantEnum( - UnitOfInformation.GIGABITS, "2025.1" -) -"""Deprecated: please use UnitOfInformation.GIGABITS""" -_DEPRECATED_DATA_BYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.BYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.BYTES""" -_DEPRECATED_DATA_KILOBYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.KILOBYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.KILOBYTES""" -_DEPRECATED_DATA_MEGABYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.MEGABYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.MEGABYTES""" -_DEPRECATED_DATA_GIGABYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.GIGABYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.GIGABYTES""" -_DEPRECATED_DATA_TERABYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.TERABYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.TERABYTES""" -_DEPRECATED_DATA_PETABYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.PETABYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.PETABYTES""" -_DEPRECATED_DATA_EXABYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.EXABYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.EXABYTES""" -_DEPRECATED_DATA_ZETTABYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.ZETTABYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.ZETTABYTES""" -_DEPRECATED_DATA_YOTTABYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.YOTTABYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.YOTTABYTES""" -_DEPRECATED_DATA_KIBIBYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.KIBIBYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.KIBIBYTES""" -_DEPRECATED_DATA_MEBIBYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.MEBIBYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.MEBIBYTES""" -_DEPRECATED_DATA_GIBIBYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.GIBIBYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.GIBIBYTES""" -_DEPRECATED_DATA_TEBIBYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.TEBIBYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.TEBIBYTES""" -_DEPRECATED_DATA_PEBIBYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.PEBIBYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.PEBIBYTES""" -_DEPRECATED_DATA_EXBIBYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.EXBIBYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.EXBIBYTES""" -_DEPRECATED_DATA_ZEBIBYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.ZEBIBYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.ZEBIBYTES""" -_DEPRECATED_DATA_YOBIBYTES: Final = DeprecatedConstantEnum( - UnitOfInformation.YOBIBYTES, "2025.1" -) -"""Deprecated: please use UnitOfInformation.YOBIBYTES""" - - # Data_rate units class UnitOfDataRate(StrEnum): """Data rate units.""" @@ -1572,63 +964,6 @@ class UnitOfDataRate(StrEnum): GIBIBYTES_PER_SECOND = "GiB/s" -_DEPRECATED_DATA_RATE_BITS_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfDataRate.BITS_PER_SECOND, - "2025.1", -) -"""Deprecated: please use UnitOfDataRate.BITS_PER_SECOND""" -_DEPRECATED_DATA_RATE_KILOBITS_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfDataRate.KILOBITS_PER_SECOND, - "2025.1", -) -"""Deprecated: please use UnitOfDataRate.KILOBITS_PER_SECOND""" -_DEPRECATED_DATA_RATE_MEGABITS_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfDataRate.MEGABITS_PER_SECOND, - "2025.1", -) -"""Deprecated: please use UnitOfDataRate.MEGABITS_PER_SECOND""" -_DEPRECATED_DATA_RATE_GIGABITS_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfDataRate.GIGABITS_PER_SECOND, - "2025.1", -) -"""Deprecated: please use UnitOfDataRate.GIGABITS_PER_SECOND""" -_DEPRECATED_DATA_RATE_BYTES_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfDataRate.BYTES_PER_SECOND, - "2025.1", -) -"""Deprecated: please use UnitOfDataRate.BYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_KILOBYTES_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfDataRate.KILOBYTES_PER_SECOND, - "2025.1", -) -"""Deprecated: please use UnitOfDataRate.KILOBYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_MEGABYTES_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfDataRate.MEGABYTES_PER_SECOND, - "2025.1", -) -"""Deprecated: please use UnitOfDataRate.MEGABYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_GIGABYTES_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfDataRate.GIGABYTES_PER_SECOND, - "2025.1", -) -"""Deprecated: please use UnitOfDataRate.GIGABYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_KIBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfDataRate.KIBIBYTES_PER_SECOND, - "2025.1", -) -"""Deprecated: please use UnitOfDataRate.KIBIBYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_MEBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfDataRate.MEBIBYTES_PER_SECOND, - "2025.1", -) -"""Deprecated: please use UnitOfDataRate.MEBIBYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_GIBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum( - UnitOfDataRate.GIBIBYTES_PER_SECOND, - "2025.1", -) -"""Deprecated: please use UnitOfDataRate.GIBIBYTES_PER_SECOND""" - - # States COMPRESSED_STATE_STATE: Final = "s" COMPRESSED_STATE_ATTRIBUTES: Final = "a" @@ -1762,14 +1097,6 @@ class EntityCategory(StrEnum): DIAGNOSTIC = "diagnostic" -# ENTITY_CATEGOR* below are deprecated as of 2021.12 -# use the EntityCategory enum instead. -_DEPRECATED_ENTITY_CATEGORY_CONFIG: Final = DeprecatedConstantEnum( - EntityCategory.CONFIG, "2025.1" -) -_DEPRECATED_ENTITY_CATEGORY_DIAGNOSTIC: Final = DeprecatedConstantEnum( - EntityCategory.DIAGNOSTIC, "2025.1" -) ENTITY_CATEGORIES: Final[list[str]] = [cls.value for cls in EntityCategory] # The ID of the Home Assistant Media Player Cast App diff --git a/homeassistant/core.py b/homeassistant/core.py index f4c819c1262..46ae499e2ca 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -36,12 +36,12 @@ from typing import ( NotRequired, Self, TypedDict, + TypeVar, cast, overload, ) -from propcache import cached_property, under_cached_property -from typing_extensions import TypeVar +from propcache.api import cached_property, under_cached_property import voluptuous as vol from . import util @@ -84,7 +84,6 @@ from .exceptions import ( ) from .helpers.deprecation import ( DeferredDeprecatedAlias, - DeprecatedConstantEnum, EnumWithDeprecatedMembers, all_with_deprecated_constants, check_if_deprecated_constant, @@ -177,14 +176,6 @@ class EventStateReportedData(EventStateEventData): old_last_reported: datetime.datetime -# SOURCE_* are deprecated as of Home Assistant 2022.2, use ConfigSource instead -_DEPRECATED_SOURCE_DISCOVERED = DeprecatedConstantEnum( - ConfigSource.DISCOVERED, "2025.1" -) -_DEPRECATED_SOURCE_STORAGE = DeprecatedConstantEnum(ConfigSource.STORAGE, "2025.1") -_DEPRECATED_SOURCE_YAML = DeprecatedConstantEnum(ConfigSource.YAML, "2025.1") - - def _deprecated_core_config() -> Any: # pylint: disable-next=import-outside-toplevel from . import core_config @@ -341,7 +332,7 @@ class HassJob[**_P, _R_co]: we run the job. """ - __slots__ = ("target", "name", "_cancel_on_shutdown", "_cache") + __slots__ = ("_cache", "_cancel_on_shutdown", "name", "target") def __init__( self, @@ -1162,8 +1153,7 @@ class HomeAssistant: await self.async_block_till_done() except TimeoutError: _LOGGER.warning( - "Timed out waiting for integrations to stop, the shutdown will" - " continue" + "Timed out waiting for integrations to stop, the shutdown will continue" ) self._async_log_running_tasks("stop integrations") @@ -1256,7 +1246,7 @@ class HomeAssistant: class Context: """The context that triggered something.""" - __slots__ = ("id", "user_id", "parent_id", "origin_event", "_cache") + __slots__ = ("_cache", "id", "origin_event", "parent_id", "user_id") def __init__( self, @@ -1331,12 +1321,12 @@ class Event(Generic[_DataT]): """Representation of an event within the bus.""" __slots__ = ( - "event_type", + "_cache", + "context", "data", + "event_type", "origin", "time_fired_timestamp", - "context", - "_cache", ) def __init__( @@ -1777,18 +1767,18 @@ class State: """ __slots__ = ( - "entity_id", - "state", + "_cache", "attributes", + "context", + "domain", + "entity_id", "last_changed", "last_reported", "last_updated", - "context", - "state_info", - "domain", - "object_id", "last_updated_timestamp", - "_cache", + "object_id", + "state", + "state_info", ) def __init__( @@ -2076,7 +2066,7 @@ class States(UserDict[str, State]): class StateMachine: """Helper class that tracks the state of different entities.""" - __slots__ = ("_states", "_states_data", "_reservations", "_bus", "_loop") + __slots__ = ("_bus", "_loop", "_reservations", "_states", "_states_data") def __init__(self, bus: EventBus, loop: asyncio.events.AbstractEventLoop) -> None: """Initialize state machine.""" @@ -2414,7 +2404,7 @@ class SupportsResponse(enum.StrEnum): class Service: """Representation of a callable service.""" - __slots__ = ["job", "schema", "domain", "service", "supports_response"] + __slots__ = ["domain", "job", "schema", "service", "supports_response"] def __init__( self, @@ -2441,10 +2431,11 @@ class Service: class ServiceCall: """Representation of a call to a service.""" - __slots__ = ("domain", "service", "data", "context", "return_response") + __slots__ = ("context", "data", "domain", "hass", "return_response", "service") def __init__( self, + hass: HomeAssistant, domain: str, service: str, data: dict[str, Any] | None = None, @@ -2452,6 +2443,7 @@ class ServiceCall: return_response: bool = False, ) -> None: """Initialize a service call.""" + self.hass = hass self.domain = domain self.service = service self.data = ReadOnlyDict(data or {}) @@ -2472,7 +2464,7 @@ class ServiceCall: class ServiceRegistry: """Offer the services over the eventbus.""" - __slots__ = ("_services", "_hass") + __slots__ = ("_hass", "_services") def __init__(self, hass: HomeAssistant) -> None: """Initialize a service registry.""" @@ -2777,7 +2769,7 @@ class ServiceRegistry: processed_data = service_data service_call = ServiceCall( - domain, service, processed_data, context, return_response + self._hass, domain, service, processed_data, context, return_response ) self._hass.bus.async_fire_internal( diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index 430a882ecb9..f080705fced 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -68,11 +68,11 @@ from .util.hass_dict import HassKey from .util.package import is_docker_env from .util.unit_system import ( _CONF_UNIT_SYSTEM_IMPERIAL, + _CONF_UNIT_SYSTEM_METRIC, _CONF_UNIT_SYSTEM_US_CUSTOMARY, METRIC_SYSTEM, UnitSystem, get_unit_system, - validate_unit_system, ) # Typing imports that create a circular dependency @@ -188,6 +188,26 @@ _CUSTOMIZE_CONFIG_SCHEMA = vol.Schema( ) +def _raise_issue_if_imperial_unit_system( + hass: HomeAssistant, config: dict[str, Any] +) -> dict[str, Any]: + if config.get(CONF_UNIT_SYSTEM) == _CONF_UNIT_SYSTEM_IMPERIAL: + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + "imperial_unit_system", + is_fixable=False, + learn_more_url="homeassistant://config/general", + severity=ir.IssueSeverity.WARNING, + translation_key="imperial_unit_system", + ) + config[CONF_UNIT_SYSTEM] = _CONF_UNIT_SYSTEM_US_CUSTOMARY + else: + ir.async_delete_issue(hass, HOMEASSISTANT_DOMAIN, "imperial_unit_system") + + return config + + def _raise_issue_if_historic_currency(hass: HomeAssistant, currency: str) -> None: if currency not in HISTORIC_CURRENCIES: ir.async_delete_issue(hass, HOMEASSISTANT_DOMAIN, "historic_currency") @@ -249,7 +269,11 @@ CORE_CONFIG_SCHEMA = vol.All( CONF_ELEVATION: vol.Coerce(int), CONF_RADIUS: cv.positive_int, vol.Remove(CONF_TEMPERATURE_UNIT): cv.temperature_unit, - CONF_UNIT_SYSTEM: validate_unit_system, + CONF_UNIT_SYSTEM: vol.Any( + _CONF_UNIT_SYSTEM_METRIC, + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + _CONF_UNIT_SYSTEM_IMPERIAL, + ), CONF_TIME_ZONE: cv.time_zone, vol.Optional(CONF_INTERNAL_URL): cv.url, vol.Optional(CONF_EXTERNAL_URL): cv.url, @@ -333,6 +357,9 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non # so we need to run it in an executor job. config = await hass.async_add_executor_job(CORE_CONFIG_SCHEMA, config) + # Check if we need to raise an issue for imperial unit system + config = _raise_issue_if_imperial_unit_system(hass, config) + # Only load auth during startup. if not hasattr(hass, "auth"): if (auth_conf := config.get(CONF_AUTH_PROVIDERS)) is None: @@ -482,25 +509,25 @@ class _ComponentSet(set[str]): self._top_level_components = top_level_components self._all_components = all_components - def add(self, component: str) -> None: + def add(self, value: str) -> None: """Add a component to the store.""" - if "." not in component: - self._top_level_components.add(component) - self._all_components.add(component) + if "." not in value: + self._top_level_components.add(value) + self._all_components.add(value) else: - platform, _, domain = component.partition(".") + platform, _, domain = value.partition(".") if domain in BASE_PLATFORMS: self._all_components.add(platform) - return super().add(component) + return super().add(value) - def remove(self, component: str) -> None: + def remove(self, value: str) -> None: """Remove a component from the store.""" - if "." in component: + if "." in value: raise ValueError("_ComponentSet does not support removing sub-components") - self._top_level_components.remove(component) - return super().remove(component) + self._top_level_components.remove(value) + return super().remove(value) - def discard(self, component: str) -> None: + def discard(self, value: str) -> None: """Remove a component from the store.""" raise NotImplementedError("_ComponentSet does not support discard, use remove") diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 338b5f3992f..e5ee5a79922 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -10,22 +10,14 @@ from contextlib import suppress import copy from dataclasses import dataclass from enum import StrEnum -from functools import partial import logging from types import MappingProxyType -from typing import Any, Generic, Required, TypedDict, cast +from typing import Any, Generic, Required, TypedDict, TypeVar, cast -from typing_extensions import TypeVar import voluptuous as vol from .core import HomeAssistant, callback from .exceptions import HomeAssistantError -from .helpers.deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) from .helpers.frame import ReportBehavior, report_usage from .loader import async_suggest_report_issue from .util import uuid as uuid_util @@ -46,26 +38,6 @@ class FlowResultType(StrEnum): MENU = "menu" -# RESULT_TYPE_* is deprecated, to be removed in 2025.1 -_DEPRECATED_RESULT_TYPE_FORM = DeprecatedConstantEnum(FlowResultType.FORM, "2025.1") -_DEPRECATED_RESULT_TYPE_CREATE_ENTRY = DeprecatedConstantEnum( - FlowResultType.CREATE_ENTRY, "2025.1" -) -_DEPRECATED_RESULT_TYPE_ABORT = DeprecatedConstantEnum(FlowResultType.ABORT, "2025.1") -_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP = DeprecatedConstantEnum( - FlowResultType.EXTERNAL_STEP, "2025.1" -) -_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP_DONE = DeprecatedConstantEnum( - FlowResultType.EXTERNAL_STEP_DONE, "2025.1" -) -_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS = DeprecatedConstantEnum( - FlowResultType.SHOW_PROGRESS, "2025.1" -) -_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS_DONE = DeprecatedConstantEnum( - FlowResultType.SHOW_PROGRESS_DONE, "2025.1" -) -_DEPRECATED_RESULT_TYPE_MENU = DeprecatedConstantEnum(FlowResultType.MENU, "2025.1") - # Event that is fired when a flow is progressed via external or progress source. EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" @@ -126,6 +98,7 @@ class InvalidData(vol.Invalid): schema_errors: dict[str, Any], **kwargs: Any, ) -> None: + """Initialize an invalid data exception.""" super().__init__(message, path, error_message, **kwargs) self.schema_errors = schema_errors @@ -929,11 +902,3 @@ class section: def __call__(self, value: Any) -> Any: """Validate input.""" return self.schema(value) - - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index f308cbc5cd8..0b2d2c071c5 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -15,9 +15,9 @@ if TYPE_CHECKING: _function_cache: dict[str, Callable[[str, str, dict[str, str] | None], str]] = {} -def import_async_get_exception_message() -> ( - Callable[[str, str, dict[str, str] | None], str] -): +def import_async_get_exception_message() -> Callable[ + [str, str, dict[str, str] | None], str +]: """Return a method that can fetch a translated exception message. Defaults to English, requires translations to already be cached. @@ -174,7 +174,7 @@ class ConditionErrorIndex(ConditionError): """Yield an indented representation.""" if self.total > 1: yield self._indent( - indent, f"In '{self.type}' (item {self.index+1} of {self.total}):" + indent, f"In '{self.type}' (item {self.index + 1} of {self.total}):" ) else: yield self._indent(indent, f"In '{self.type}':") @@ -270,6 +270,25 @@ class ServiceNotFound(ServiceValidationError): self.generate_message = True +class ServiceNotSupported(ServiceValidationError): + """Raised when an entity action is not supported.""" + + def __init__(self, domain: str, service: str, entity_id: str) -> None: + """Initialize ServiceNotSupported exception.""" + super().__init__( + translation_domain="homeassistant", + translation_key="service_not_supported", + translation_placeholders={ + "domain": domain, + "service": service, + "entity_id": entity_id, + }, + ) + self.domain = domain + self.service = service + self.generate_message = True + + class MaxLengthExceeded(HomeAssistantError): """Raised when a property value has exceeded the max character length.""" diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index a105efc2685..8a5880dcde9 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -192,6 +192,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "govee_ble", "local_name": "GVH5127*", }, + { + "connectable": False, + "domain": "govee_ble", + "local_name": "GVH5130*", + }, { "connectable": False, "domain": "govee_ble", @@ -431,7 +436,7 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ }, { "domain": "led_ble", - "local_name": "MELK-*", + "local_name": "LD-0003", }, { "domain": "medcom_ble", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ffe61b915c6..b393e5c8851 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -113,6 +113,7 @@ FLOWS = { "color_extractor", "comelit", "control4", + "cookidoo", "coolmaster", "cpuspeed", "crownstone", @@ -154,6 +155,7 @@ FLOWS = { "ecowitt", "edl21", "efergy", + "eheimdigital", "electrasmart", "electric_kiwi", "elevenlabs", @@ -253,6 +255,7 @@ FLOWS = { "holiday", "home_connect", "homeassistant_sky_connect", + "homee", "homekit", "homekit_controller", "homematicip_cloud", @@ -276,6 +279,7 @@ FLOWS = { "icloud", "idasen_desk", "ifttt", + "igloohome", "imap", "imgw_pib", "improv_ble", @@ -296,6 +300,7 @@ FLOWS = { "iss", "ista_ecotrend", "isy994", + "ituran", "izone", "jellyfin", "jewish_calendar", @@ -326,6 +331,7 @@ FLOWS = { "leaone", "led_ble", "lektrico", + "letpot", "lg_netcast", "lg_soundbar", "lg_thinq", @@ -352,6 +358,7 @@ FLOWS = { "mailgun", "mastodon", "matter", + "mcp_server", "mealie", "meater", "medcom_ble", @@ -406,8 +413,10 @@ FLOWS = { "nibe_heatpump", "nice_go", "nightscout", + "niko_home_control", "nina", "nmap_tracker", + "nmbs", "nobo_hub", "nordpool", "notion", @@ -419,6 +428,7 @@ FLOWS = { "nzbget", "obihai", "octoprint", + "ohme", "ollama", "omnilogic", "oncue", @@ -442,11 +452,13 @@ FLOWS = { "otp", "ourgroceries", "overkiz", + "overseerr", "ovo_energy", "owntracks", "p1_monitor", "palazzetti", "panasonic_viera", + "peblar", "peco", "pegel_online", "permobil", @@ -460,6 +472,7 @@ FLOWS = { "plum_lightpad", "point", "poolsense", + "powerfox", "powerwall", "private_ble_device", "profiler", @@ -476,6 +489,7 @@ FLOWS = { "pvpc_hourly_pricing", "pyload", "qbittorrent", + "qbus", "qingping", "qnap", "qnap_qsw", @@ -542,6 +556,7 @@ FLOWS = { "skybell", "slack", "sleepiq", + "slide_local", "slimproto", "sma", "smappee", @@ -572,7 +587,6 @@ FLOWS = { "starlink", "steam_online", "steamist", - "stookalert", "stookwijzer", "streamlabswater", "subaru", @@ -664,6 +678,7 @@ FLOWS = { "wake_on_lan", "wallbox", "waqi", + "watergate", "watttime", "waze_travel_time", "weatherflow", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 1ef91841db8..7d14ab0f444 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -209,6 +209,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "fully_kiosk", "registered_devices": True, }, + { + "domain": "fyta", + "hostname": "fyta*", + }, { "domain": "goalzero", "registered_devices": True, @@ -236,6 +240,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "guardian*", "macaddress": "30AEA4*", }, + { + "domain": "homewizard", + "registered_devices": True, + }, { "domain": "hunterdouglas_powerview", "registered_devices": True, @@ -245,6 +253,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "hunter*", "macaddress": "002674*", }, + { + "domain": "incomfort", + "hostname": "rfgateway", + "macaddress": "0004A3*", + }, + { + "domain": "incomfort", + "registered_devices": True, + }, { "domain": "insteon", "macaddress": "000EF3*", @@ -1103,6 +1120,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "unifiprotect", "macaddress": "74ACB9*", }, + { + "domain": "velux", + "hostname": "velux_klf*", + "macaddress": "646184*", + }, { "domain": "verisure", "macaddress": "0023C1*", @@ -1111,6 +1133,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "vicare", "macaddress": "B87424*", }, + { + "domain": "withings", + "macaddress": "0024E4*", + }, { "domain": "wiz", "registered_devices": True, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8238a09072b..9a7167f5367 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -606,12 +606,6 @@ "config_flow": true, "iot_class": "local_push" }, - "azure_data_explorer": { - "name": "Azure Data Explorer", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_push" - }, "baf": { "name": "Big Ass Fans", "integration_type": "hub", @@ -1044,6 +1038,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "cookidoo": { + "name": "Cookidoo", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "coolmaster": { "name": "CoolMasterNet", "integration_type": "hub", @@ -1144,6 +1144,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "decorquip": { + "name": "Decorquip Dream", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "delijn": { "name": "De Lijn", "integration_type": "hub", @@ -1374,12 +1379,6 @@ "config_flow": true, "iot_class": "local_push" }, - "dte_energy_bridge": { - "name": "DTE Energy Bridge", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "dublin_bus_transport": { "name": "Dublin Bus", "integration_type": "hub", @@ -1441,9 +1440,10 @@ }, "easyenergy": { "name": "easyEnergy", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "single_config_entry": true }, "ebox": { "name": "EBox", @@ -1524,6 +1524,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "eheimdigital": { + "name": "EHEIM Digital", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "electrasmart": { "name": "Electra Smart", "integration_type": "hub", @@ -1647,9 +1653,10 @@ }, "energyzero": { "name": "EnergyZero", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "single_config_entry": true }, "enigma2": { "name": "Enigma2 (OpenWebif)", @@ -2467,6 +2474,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "harvey": { + "name": "Harvey", + "integration_type": "virtual", + "supported_by": "aquacell" + }, "hassio": { "name": "Home Assistant Supervisor", "integration_type": "hub", @@ -2593,6 +2605,12 @@ "integration_type": "virtual", "supported_by": "netatmo" }, + "homee": { + "name": "Homee", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "homematic": { "name": "Homematic", "integrations": { @@ -2782,6 +2800,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "igloohome": { + "name": "igloohome", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ign_sismologia": { "name": "IGN Sismolog\u00eda", "integration_type": "service", @@ -2810,7 +2834,7 @@ "name": "IKEA TR\u00c5DFRI" }, "idasen_desk": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "name": "IKEA Idasen Desk" @@ -2989,6 +3013,12 @@ "config_flow": true, "iot_class": "local_push" }, + "ituran": { + "name": "Ituran", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "izone": { "name": "iZone", "integration_type": "hub", @@ -3273,6 +3303,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "letpot": { + "name": "LetPot", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "leviton": { "name": "Leviton", "iot_standards": [ @@ -3304,7 +3340,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_push", - "name": "LG webOS Smart TV" + "name": "LG webOS TV" } } }, @@ -3571,6 +3607,13 @@ "config_flow": true, "iot_class": "local_push" }, + "mcp_server": { + "name": "Model Context Protocol Server", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_push", + "single_config_entry": true + }, "mealie": { "name": "Mealie", "integration_type": "service", @@ -3699,6 +3742,12 @@ "microsoft": { "name": "Microsoft", "integrations": { + "azure_data_explorer": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push", + "name": "Azure Data Explorer" + }, "azure_devops": { "integration_type": "hub", "config_flow": true, @@ -4145,8 +4194,8 @@ "niko_home_control": { "name": "Niko Home Control", "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" + "config_flow": true, + "iot_class": "local_push" }, "nilu": { "name": "Norwegian Institute for Air Research (NILU)", @@ -4175,7 +4224,7 @@ "nmbs": { "name": "NMBS", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "no_ip": { @@ -4317,6 +4366,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "ohme": { + "name": "Ohme", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ollama": { "name": "Ollama", "integration_type": "service", @@ -4545,6 +4600,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "overseerr": { + "name": "Overseerr", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_push" + }, "ovo_energy": { "name": "OVO Energy", "integration_type": "service", @@ -4598,6 +4659,12 @@ "integration_type": "virtual", "supported_by": "upb" }, + "peblar": { + "name": "Peblar", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "peco": { "name": "PECO Outage Counter", "integration_type": "hub", @@ -4763,6 +4830,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "powerfox": { + "name": "Powerfox", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "private_ble_device": { "name": "Private BLE Device", "integration_type": "hub", @@ -4908,6 +4981,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "qbus": { + "name": "Qbus", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "qingping": { "name": "Qingping", "integration_type": "hub", @@ -5093,7 +5172,8 @@ "name": "Refoss", "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_polling", + "single_config_entry": true }, "rejseplanen": { "name": "Rejseplanen", @@ -5120,7 +5200,7 @@ "iot_class": "local_polling" }, "reolink": { - "name": "Reolink IP NVR/camera", + "name": "Reolink", "integration_type": "hub", "config_flow": true, "iot_class": "local_push" @@ -5599,12 +5679,6 @@ "integration_type": "virtual", "supported_by": "overkiz" }, - "simulated": { - "name": "Simulated", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "sinch": { "name": "Sinch SMS", "integration_type": "hub", @@ -5660,9 +5734,20 @@ }, "slide": { "name": "Slide", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" + "integrations": { + "slide": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "cloud_polling", + "name": "Slide" + }, + "slide_local": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "Slide Local" + } + } }, "slimproto": { "name": "SlimProto (Squeezebox players)", @@ -5957,12 +6042,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "stookalert": { - "name": "RIVM Stookalert", - "integration_type": "service", - "config_flow": true, - "iot_class": "cloud_polling" - }, "stookwijzer": { "name": "Stookwijzer", "integration_type": "service", @@ -6121,7 +6200,8 @@ "name": "System Monitor", "integration_type": "hub", "config_flow": true, - "iot_class": "local_push" + "iot_class": "local_push", + "single_config_entry": true }, "tado": { "name": "Tado", @@ -6904,6 +6984,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "watergate": { + "name": "Watergate", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "watttime": { "name": "WattTime", "integration_type": "service", @@ -7331,7 +7417,6 @@ "iot_class": "calculated" }, "filter": { - "name": "Filter", "integration_type": "helper", "config_flow": false, "iot_class": "local_push" @@ -7462,6 +7547,7 @@ "emulated_roku", "energenie_power_sockets", "filesize", + "filter", "garages_amsterdam", "generic", "generic_hygrostat", diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index f73388b203c..72f160ee2ec 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -16,6 +16,11 @@ MQTT = { "fully_kiosk": [ "fully/deviceInfo/+", ], + "qbus": [ + "cloudapp/QBUSMQTTGW/state", + "cloudapp/QBUSMQTTGW/config", + "cloudapp/QBUSMQTTGW/+/state", + ], "tasmota": [ "tasmota/discovery/#", ], diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 9ed65bab868..89d1aa30cb8 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -224,6 +224,44 @@ SSDP = { "manufacturer": "The OctoPrint Project", }, ], + "onkyo": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "ONKYO", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "ONKYO", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3", + "manufacturer": "ONKYO", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "Onkyo & Pioneer Corporation", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Onkyo & Pioneer Corporation", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3", + "manufacturer": "Onkyo & Pioneer Corporation", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "Pioneer", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Pioneer", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3", + "manufacturer": "Pioneer", + }, + ], "openhome": [ { "st": "urn:av-openhome-org:service:Product:1", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 5f7161a8245..203f01e7d68 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -92,6 +92,10 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, + "LIFX Colour": { + "always_discover": True, + "domain": "lifx", + }, "LIFX DLCOL": { "always_discover": True, "domain": "lifx", @@ -140,6 +144,10 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, + "LIFX Permanent Outdoor": { + "always_discover": True, + "domain": "lifx", + }, "LIFX Pls": { "always_discover": True, "domain": "lifx", @@ -164,6 +172,10 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, + "LIFX Tube": { + "always_discover": True, + "domain": "lifx", + }, "LIFX White": { "always_discover": True, "domain": "lifx", @@ -524,6 +536,10 @@ ZEROCONF = { "domain": "bosch_shc", "name": "bosch shc*", }, + { + "domain": "eheimdigital", + "name": "eheimdigital._http._tcp.local.", + }, { "domain": "lektrico", "name": "lektrico*", @@ -542,6 +558,14 @@ ZEROCONF = { "manufacturer": "nettigo", }, }, + { + "domain": "peblar", + "name": "pblr-*", + }, + { + "domain": "powerfox", + "name": "powerfox*", + }, { "domain": "pure_energie", "name": "smartbridge*", @@ -558,6 +582,10 @@ ZEROCONF = { "domain": "shelly", "name": "shelly*", }, + { + "domain": "slide_local", + "name": "slide*", + }, { "domain": "synology_dsm", "properties": { @@ -701,6 +729,11 @@ ZEROCONF = { "domain": "octoprint", }, ], + "_owserver._tcp.local.": [ + { + "domain": "onewire", + }, + ], "_plexmediasvr._tcp.local.": [ { "domain": "plex", @@ -747,6 +780,11 @@ ZEROCONF = { }, }, ], + "_rio._tcp.local.": [ + { + "domain": "russound_rio", + }, + ], "_sideplay._tcp.local.": [ { "domain": "ecobee", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index f01ae325875..b5f5ee9a961 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -9,15 +9,16 @@ import socket from ssl import SSLContext import sys from types import MappingProxyType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self import aiohttp from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT -from aiohttp.resolver import AsyncResolver from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout +from aiohttp_asyncmdnsresolver.api import AsyncMDNSResolver from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass @@ -82,6 +83,31 @@ class HassClientResponse(aiohttp.ClientResponse): return await super().json(*args, loads=loads, **kwargs) +class ChunkAsyncStreamIterator: + """Async iterator for chunked streams. + + Based on aiohttp.streams.ChunkTupleAsyncStreamIterator, but yields + bytes instead of tuple[bytes, bool]. + """ + + __slots__ = ("_stream",) + + def __init__(self, stream: aiohttp.StreamReader) -> None: + """Initialize.""" + self._stream = stream + + def __aiter__(self) -> Self: + """Iterate.""" + return self + + async def __anext__(self) -> bytes: + """Yield next chunk.""" + rv = await self._stream.readchunk() + if rv == (b"", False): + raise StopAsyncIteration + return rv[0] + + @callback @bind_hass def async_get_clientsession( @@ -337,7 +363,7 @@ def _async_get_connector( ssl=ssl_context, limit=MAXIMUM_CONNECTIONS, limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, - resolver=AsyncResolver(), + resolver=_async_make_resolver(hass), ) connectors[connector_key] = connector @@ -348,3 +374,8 @@ def _async_get_connector( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_connector) return connector + + +@callback +def _async_make_resolver(hass: HomeAssistant) -> AsyncMDNSResolver: + return AsyncMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index f74296a9fb1..5601ce4032d 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, field from datetime import datetime from typing import TYPE_CHECKING, Any, Literal, TypedDict +from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType @@ -27,9 +28,9 @@ from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: # mypy cannot workout _cache Protocol with dataclasses - from propcache import cached_property as under_cached_property + from propcache.api import cached_property as under_cached_property else: - from propcache import under_cached_property + from propcache.api import under_cached_property DATA_REGISTRY: HassKey[AreaRegistry] = HassKey("area_registry") @@ -38,7 +39,7 @@ EVENT_AREA_REGISTRY_UPDATED: EventType[EventAreaRegistryUpdatedData] = EventType ) STORAGE_KEY = "core.area_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 7 +STORAGE_VERSION_MINOR = 8 class _AreaStoreData(TypedDict): @@ -46,11 +47,13 @@ class _AreaStoreData(TypedDict): aliases: list[str] floor_id: str | None + humidity_entity_id: str | None icon: str | None id: str labels: list[str] name: str picture: str | None + temperature_entity_id: str | None created_at: str modified_at: str @@ -74,10 +77,12 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): aliases: set[str] floor_id: str | None + humidity_entity_id: str | None icon: str | None id: str labels: set[str] = field(default_factory=set) picture: str | None + temperature_entity_id: str | None _cache: dict[str, Any] = field(default_factory=dict, compare=False, init=False) @under_cached_property @@ -89,10 +94,12 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): "aliases": list(self.aliases), "area_id": self.id, "floor_id": self.floor_id, + "humidity_entity_id": self.humidity_entity_id, "icon": self.icon, "labels": list(self.labels), "name": self.name, "picture": self.picture, + "temperature_entity_id": self.temperature_entity_id, "created_at": self.created_at.timestamp(), "modified_at": self.modified_at.timestamp(), } @@ -138,11 +145,17 @@ class AreaRegistryStore(Store[AreasRegistryStoreData]): area["labels"] = [] if old_minor_version < 7: - # Version 1.7 adds created_at and modiefied_at + # Version 1.7 adds created_at and modified_at created_at = utc_from_timestamp(0).isoformat() for area in old_data["areas"]: area["created_at"] = area["modified_at"] = created_at + if old_minor_version < 8: + # Version 1.8 adds humidity_entity_id and temperature_entity_id + for area in old_data["areas"]: + area["humidity_entity_id"] = None + area["temperature_entity_id"] = None + if old_major_version > 1: raise NotImplementedError return old_data # type: ignore[return-value] @@ -242,11 +255,14 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): *, aliases: set[str] | None = None, floor_id: str | None = None, + humidity_entity_id: str | None = None, icon: str | None = None, labels: set[str] | None = None, picture: str | None = None, + temperature_entity_id: str | None = None, ) -> AreaEntry: """Create a new area.""" + self.hass.verify_event_loop_thread("area_registry.async_create") if area := self.async_get_area_by_name(name): @@ -254,14 +270,22 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): f"The name {name} ({area.normalized_name}) is already in use" ) + if humidity_entity_id is not None: + _validate_humidity_entity(self.hass, humidity_entity_id) + + if temperature_entity_id is not None: + _validate_temperature_entity(self.hass, temperature_entity_id) + area = AreaEntry( aliases=aliases or set(), floor_id=floor_id, + humidity_entity_id=humidity_entity_id, icon=icon, id=self._generate_id(name), labels=labels or set(), name=name, picture=picture, + temperature_entity_id=temperature_entity_id, ) area_id = area.id self.areas[area_id] = area @@ -298,20 +322,24 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): *, aliases: set[str] | UndefinedType = UNDEFINED, floor_id: str | None | UndefinedType = UNDEFINED, + humidity_entity_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, + temperature_entity_id: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" updated = self._async_update( area_id, aliases=aliases, floor_id=floor_id, + humidity_entity_id=humidity_entity_id, icon=icon, labels=labels, name=name, picture=picture, + temperature_entity_id=temperature_entity_id, ) # Since updated may be the old or the new and we always fire # an event even if nothing has changed we cannot use async_fire_internal @@ -330,10 +358,12 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): *, aliases: set[str] | UndefinedType = UNDEFINED, floor_id: str | None | UndefinedType = UNDEFINED, + humidity_entity_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, + temperature_entity_id: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" old = self.areas[area_id] @@ -342,14 +372,22 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): attr_name: value for attr_name, value in ( ("aliases", aliases), + ("floor_id", floor_id), + ("humidity_entity_id", humidity_entity_id), ("icon", icon), ("labels", labels), ("picture", picture), - ("floor_id", floor_id), + ("temperature_entity_id", temperature_entity_id), ) if value is not UNDEFINED and value != getattr(old, attr_name) } + if "humidity_entity_id" in new_values and humidity_entity_id is not None: + _validate_humidity_entity(self.hass, new_values["humidity_entity_id"]) + + if "temperature_entity_id" in new_values and temperature_entity_id is not None: + _validate_temperature_entity(self.hass, new_values["temperature_entity_id"]) + if name is not UNDEFINED and name != old.name: new_values["name"] = name @@ -378,11 +416,13 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): areas[area["id"]] = AreaEntry( aliases=set(area["aliases"]), floor_id=area["floor_id"], + humidity_entity_id=area["humidity_entity_id"], icon=area["icon"], id=area["id"], labels=set(area["labels"]), name=area["name"], picture=area["picture"], + temperature_entity_id=area["temperature_entity_id"], created_at=datetime.fromisoformat(area["created_at"]), modified_at=datetime.fromisoformat(area["modified_at"]), ) @@ -398,11 +438,13 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): { "aliases": list(entry.aliases), "floor_id": entry.floor_id, + "humidity_entity_id": entry.humidity_entity_id, "icon": entry.icon, "id": entry.id, "labels": list(entry.labels), "name": entry.name, "picture": entry.picture, + "temperature_entity_id": entry.temperature_entity_id, "created_at": entry.created_at.isoformat(), "modified_at": entry.modified_at.isoformat(), } @@ -477,3 +519,33 @@ def async_entries_for_floor(registry: AreaRegistry, floor_id: str) -> list[AreaE def async_entries_for_label(registry: AreaRegistry, label_id: str) -> list[AreaEntry]: """Return entries that match a label.""" return registry.areas.get_areas_for_label(label_id) + + +def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None: + """Validate temperature entity.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.sensor import SensorDeviceClass + + if not (state := hass.states.get(entity_id)): + raise ValueError(f"Entity {entity_id} does not exist") + + if ( + state.domain != "sensor" + or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.TEMPERATURE + ): + raise ValueError(f"Entity {entity_id} is not a temperature sensor") + + +def _validate_humidity_entity(hass: HomeAssistant, entity_id: str) -> None: + """Validate humidity entity.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.sensor import SensorDeviceClass + + if not (state := hass.states.get(entity_id)): + raise ValueError(f"Entity {entity_id} does not exist") + + if ( + state.domain != "sensor" + or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.HUMIDITY + ): + raise ValueError(f"Entity {entity_id} is not a humidity sensor") diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 4b5e2f277a0..a8e8fa4160d 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -220,7 +220,7 @@ async def async_check_ha_config_file( # noqa: C901 except (vol.Invalid, HomeAssistantError) as ex: _comp_error(ex, domain, config, config[domain]) continue - except Exception as err: # noqa: BLE001 + except Exception as err: logging.getLogger(__name__).exception( "Unexpected error validating config" ) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 86d3450c3a0..aef673cb500 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -2,7 +2,7 @@ from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass @@ -11,9 +11,8 @@ from hashlib import md5 from itertools import groupby import logging from operator import attrgetter -from typing import Any, Generic, TypedDict +from typing import Any, TypedDict -from typing_extensions import TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -37,8 +36,6 @@ CHANGE_ADDED = "added" CHANGE_UPDATED = "updated" CHANGE_REMOVED = "removed" -_EntityT = TypeVar("_EntityT", bound=Entity, default=Entity) - @dataclass(slots=True) class CollectionChange: @@ -129,7 +126,7 @@ class CollectionEntity(Entity): """Handle updated configuration.""" -class ObservableCollection[_ItemT](ABC): +class ObservableCollection[_ItemT]: """Base collection type that can be observed.""" def __init__(self, id_manager: IDManager | None) -> None: @@ -448,7 +445,7 @@ _GROUP_BY_KEY = attrgetter("change_type") @dataclass(slots=True, frozen=True) -class _CollectionLifeCycle(Generic[_EntityT]): +class _CollectionLifeCycle[_EntityT: Entity = Entity]: """Life cycle for a collection of entities.""" domain: str @@ -523,7 +520,7 @@ class _CollectionLifeCycle(Generic[_EntityT]): @callback -def sync_entity_lifecycle( +def sync_entity_lifecycle[_EntityT: Entity = Entity]( hass: HomeAssistant, domain: str, platform: str, diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5952e28a1eb..695af80bc1c 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -884,10 +884,8 @@ def time( condition_trace_update_result(weekday=weekday, now_weekday=now_weekday) if ( - isinstance(weekday, str) - and weekday != now_weekday - or now_weekday not in weekday - ): + isinstance(weekday, str) and weekday != now_weekday + ) or now_weekday not in weekday: return False return True diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index b047e1aef81..45e2e7cf35f 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -16,11 +16,11 @@ if TYPE_CHECKING: import asyncio from homeassistant.components.bluetooth import BluetoothServiceInfoBleak - from homeassistant.components.dhcp import DhcpServiceInfo - from homeassistant.components.ssdp import SsdpServiceInfo - from homeassistant.components.zeroconf import ZeroconfServiceInfo + from .service_info.dhcp import DhcpServiceInfo from .service_info.mqtt import MqttServiceInfo + from .service_info.ssdp import SsdpServiceInfo + from .service_info.zeroconf import ZeroconfServiceInfo type DiscoveryFunctionType[_R] = Callable[[HomeAssistant], _R] @@ -67,9 +67,11 @@ class DiscoveryFlowHandler[_R: Awaitable[bool] | bool](config_entries.ConfigFlow in_progress = self._async_in_progress() if not (has_devices := bool(in_progress)): - has_devices = await cast( - "asyncio.Future[bool]", self._discovery_function(self.hass) - ) + discovery_result = self._discovery_function(self.hass) + if isinstance(discovery_result, bool): + has_devices = discovery_result + else: + has_devices = await cast("asyncio.Future[bool]", discovery_result) if not has_devices: return self.async_abort(reason="no_devices_found") diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3681e941eee..2c8dbe69c22 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,8 +1,6 @@ """Helpers for config validation using voluptuous.""" -# PEP 563 seems to break typing.get_type_hints when used -# with PEP 695 syntax. Fixed in Python 3.13. -# from __future__ import annotations +from __future__ import annotations from collections.abc import Callable, Hashable, Mapping import contextlib @@ -354,7 +352,7 @@ def ensure_list[_T](value: _T | None) -> list[_T] | list[Any]: """Wrap value in list if it is not one.""" if value is None: return [] - return cast("list[_T]", value) if isinstance(value, list) else [value] + return cast(list[_T], value) if isinstance(value, list) else [value] def entity_id(value: Any) -> str: @@ -676,11 +674,7 @@ def string(value: Any) -> str: raise vol.Invalid("string value is None") # This is expected to be the most common case, so check it first. - if ( - type(value) is str # noqa: E721 - or type(value) is NodeStrClass - or isinstance(value, str) - ): + if type(value) is str or type(value) is NodeStrClass or isinstance(value, str): return value if isinstance(value, template_helper.ResultWrapper): diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index adb2062a8ea..b15d8b9e607 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -3,10 +3,9 @@ from __future__ import annotations from http import HTTPStatus -from typing import Any, Generic +from typing import Any, Generic, TypeVar from aiohttp import web -from typing_extensions import TypeVar import voluptuous as vol import voluptuous_serialize diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 81f7821ec79..f02c6507d02 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from contextlib import suppress -from enum import Enum, EnumType, _EnumDict +from enum import EnumType, IntEnum, IntFlag, StrEnum, _EnumDict import functools import inspect import logging @@ -255,7 +255,7 @@ class DeprecatedConstant(NamedTuple): class DeprecatedConstantEnum(NamedTuple): """Deprecated constant.""" - enum: Enum + enum: StrEnum | IntEnum | IntFlag breaks_in_ha_version: str | None @@ -306,7 +306,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A replacement = deprecated_const.replacement breaks_in_ha_version = deprecated_const.breaks_in_ha_version elif isinstance(deprecated_const, DeprecatedConstantEnum): - value = deprecated_const.enum.value + value = deprecated_const.enum replacement = ( f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}" ) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 0e56adc7377..685509cb29d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -6,7 +6,7 @@ from collections import defaultdict from collections.abc import Mapping from datetime import datetime from enum import StrEnum -from functools import lru_cache, partial +from functools import lru_cache import logging import time from typing import TYPE_CHECKING, Any, Literal, TypedDict @@ -32,12 +32,7 @@ import homeassistant.util.uuid as uuid_util from . import storage, translation from .debounce import Debouncer -from .deprecation import ( - DeprecatedConstantEnum, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) +from .frame import ReportBehavior, report_usage from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType from .singleton import singleton @@ -45,13 +40,13 @@ from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: # mypy cannot workout _cache Protocol with attrs - from propcache import cached_property as under_cached_property + from propcache.api import cached_property as under_cached_property from homeassistant.config_entries import ConfigEntry from . import entity_registry else: - from propcache import under_cached_property + from propcache.api import under_cached_property _LOGGER = logging.getLogger(__name__) @@ -85,16 +80,6 @@ class DeviceEntryDisabler(StrEnum): USER = "user" -# DISABLED_* are deprecated, to be removed in 2022.3 -_DEPRECATED_DISABLED_CONFIG_ENTRY = DeprecatedConstantEnum( - DeviceEntryDisabler.CONFIG_ENTRY, "2025.1" -) -_DEPRECATED_DISABLED_INTEGRATION = DeprecatedConstantEnum( - DeviceEntryDisabler.INTEGRATION, "2025.1" -) -_DEPRECATED_DISABLED_USER = DeprecatedConstantEnum(DeviceEntryDisabler.USER, "2025.1") - - class DeviceInfo(TypedDict, total=False): """Entity device information for device registry.""" @@ -821,7 +806,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): name = default_name if via_device is not None and via_device is not UNDEFINED: - via = self.async_get_device(identifiers={via_device}) + if (via := self.async_get_device(identifiers={via_device})) is None: + report_usage( + "calls `device_registry.async_get_or_create` referencing a " + f"non existing `via_device` {via_device}, " + f"with device info: {device_info}", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2025.12.0", + ) + via_device_id: str | UndefinedType = via.id if via else UNDEFINED else: via_device_id = UNDEFINED @@ -965,16 +958,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values["config_entries"] = config_entries old_values["config_entries"] = old.config_entries - for attr_name, setvalue in ( - ("connections", merge_connections), - ("identifiers", merge_identifiers), - ): - old_value = getattr(old, attr_name) - # If not undefined, check if `value` contains new items. - if setvalue is not UNDEFINED and not setvalue.issubset(old_value): - new_values[attr_name] = old_value | setvalue - old_values[attr_name] = old_value - if merge_connections is not UNDEFINED: normalized_connections = self._validate_connections( device_id, @@ -1471,11 +1454,3 @@ def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str, (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value) for key, value in connections } - - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index a5a790b7ce5..350ae6dbd6a 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -154,7 +154,7 @@ def _format_err[*_Ts]( return ( # Functions wrapped in partial do not have a __name__ - f"Exception in {getattr(target, "__name__", None) or target} " + f"Exception in {getattr(target, '__name__', None) or target} " f"when dispatching '{signal}': {args}" ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 1f77dd3f95c..2b9f2d7069e 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -18,7 +18,7 @@ import time from types import FunctionType from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypedDict, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.const import ( @@ -647,6 +647,22 @@ class Entity( f".{self.translation_key}.name" ) + @cached_property + def _unit_of_measurement_translation_key(self) -> str | None: + """Return translation key for unit of measurement.""" + if self.translation_key is None: + return None + if self.platform is None: + raise ValueError( + f"Entity {type(self)} cannot have a translation key for " + "unit of measurement before being added to the entity platform" + ) + platform = self.platform + return ( + f"component.{platform.platform_name}.entity.{platform.domain}" + f".{self.translation_key}.unit_of_measurement" + ) + def _substitute_name_placeholders(self, name: str) -> str: """Substitute placeholders in entity name.""" try: @@ -1012,7 +1028,7 @@ class Entity( return STATE_UNAVAILABLE if (state := self.state) is None: return STATE_UNKNOWN - if type(state) is str: # noqa: E721 + if type(state) is str: # fast path for strings return state if isinstance(state, float): @@ -1464,9 +1480,9 @@ class Entity( if self.registry_entry is not None: # This is an assert as it should never happen, but helps in tests - assert ( - not self.registry_entry.disabled_by - ), f"Entity '{self.entity_id}' is being added while it's disabled" + assert not self.registry_entry.disabled_by, ( + f"Entity '{self.entity_id}' is being added while it's disabled" + ) self.async_on_remove( async_track_entity_registry_updated_event( diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 1be7289401c..02508e9ee9e 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -7,9 +7,7 @@ from collections.abc import Callable, Iterable from datetime import timedelta import logging from types import ModuleType -from typing import Any, Generic - -from typing_extensions import TypeVar +from typing import Any from homeassistant import config as conf_util from homeassistant.config_entries import ConfigEntry @@ -39,8 +37,6 @@ from .typing import ConfigType, DiscoveryInfoType, VolDictType, VolSchemaType DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) DATA_INSTANCES = "entity_components" -_EntityT = TypeVar("_EntityT", bound=entity.Entity, default=entity.Entity) - @bind_hass async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: @@ -64,7 +60,7 @@ async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: await entity_obj.async_update_ha_state(True) -class EntityComponent(Generic[_EntityT]): +class EntityComponent[_EntityT: entity.Entity = entity.Entity]: """The EntityComponent manages platforms that manage entities. An example of an entity component is 'light', which manages platforms such diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 62eed213b2a..0d7614c569c 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -145,6 +145,7 @@ class EntityPlatform: self.platform_translations: dict[str, str] = {} self.object_id_component_translations: dict[str, str] = {} self.object_id_platform_translations: dict[str, str] = {} + self.default_language_platform_translations: dict[str, str] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -480,6 +481,14 @@ class EntityPlatform: self.object_id_platform_translations = await self._async_get_translations( object_id_language, "entity", self.platform_name ) + if config_language == languages.DEFAULT_LANGUAGE: + self.default_language_platform_translations = self.platform_translations + else: + self.default_language_platform_translations = ( + await self._async_get_translations( + languages.DEFAULT_LANGUAGE, "entity", self.platform_name + ) + ) def _schedule_add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 9d50b7ae83b..7300b148c77 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -65,11 +65,11 @@ from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: # mypy cannot workout _cache Protocol with attrs - from propcache import cached_property as under_cached_property + from propcache.api import cached_property as under_cached_property from homeassistant.config_entries import ConfigEntry else: - from propcache import under_cached_property + from propcache.api import under_cached_property DATA_REGISTRY: HassKey[EntityRegistry] = HassKey("entity_registry") EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = EventType( @@ -648,6 +648,8 @@ def _validate_item( domain: str, platform: str, *, + config_entry_id: str | None | UndefinedType = None, + device_id: str | None | UndefinedType = None, disabled_by: RegistryEntryDisabler | None | UndefinedType = None, entity_category: EntityCategory | None | UndefinedType = None, hidden_by: RegistryEntryHider | None | UndefinedType = None, @@ -665,12 +667,21 @@ def _validate_item( # In HA Core 2025.10, we should fail if unique_id is not a string report_issue = async_suggest_report_issue(hass, integration_domain=platform) _LOGGER.error( - ("'%s' from integration %s has a non string unique_id" " '%s', please %s"), + "'%s' from integration %s has a non string unique_id '%s', please %s", domain, platform, unique_id, report_issue, ) + if config_entry_id and config_entry_id is not UNDEFINED: + if not hass.config_entries.async_get_entry(config_entry_id): + raise ValueError( + f"Can't link entity to unknown config entry {config_entry_id}" + ) + if device_id and device_id is not UNDEFINED: + device_registry = dr.async_get(hass) + if not device_registry.async_get(device_id): + raise ValueError(f"Device {device_id} does not exist") if ( disabled_by and disabled_by is not UNDEFINED @@ -794,7 +805,7 @@ class EntityRegistry(BaseRegistry): tries += 1 len_suffix = len(str(tries)) + 1 test_string = ( - f"{preferred_string[:MAX_LENGTH_STATE_ENTITY_ID-len_suffix]}_{tries}" + f"{preferred_string[: MAX_LENGTH_STATE_ENTITY_ID - len_suffix]}_{tries}" ) return test_string @@ -859,6 +870,8 @@ class EntityRegistry(BaseRegistry): self.hass, domain, platform, + config_entry_id=config_entry_id, + device_id=device_id, disabled_by=disabled_by, entity_category=entity_category, hidden_by=hidden_by, @@ -1090,6 +1103,8 @@ class EntityRegistry(BaseRegistry): self.hass, old.domain, old.platform, + config_entry_id=config_entry_id, + device_id=device_id, disabled_by=disabled_by, entity_category=entity_category, hidden_by=hidden_by, diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 578132f358f..b363bc21e86 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -90,7 +90,6 @@ RANDOM_MICROSECOND_MIN = 50000 RANDOM_MICROSECOND_MAX = 500000 _TypedDictT = TypeVar("_TypedDictT", bound=Mapping[str, Any]) -_StateEventDataT = TypeVar("_StateEventDataT", bound=EventStateEventData) @dataclass(slots=True, frozen=True) @@ -333,7 +332,7 @@ def async_track_state_change_event( @callback -def _async_dispatch_entity_id_event_soon( +def _async_dispatch_entity_id_event_soon[_StateEventDataT: EventStateEventData]( hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]], event: Event[_StateEventDataT], @@ -343,7 +342,7 @@ def _async_dispatch_entity_id_event_soon( @callback -def _async_dispatch_entity_id_event( +def _async_dispatch_entity_id_event[_StateEventDataT: EventStateEventData]( hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]], event: Event[_StateEventDataT], @@ -363,7 +362,7 @@ def _async_dispatch_entity_id_event( @callback -def _async_state_filter( +def _async_state_filter[_StateEventDataT: EventStateEventData]( hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]], event_data: _StateEventDataT, @@ -952,8 +951,7 @@ def async_track_template( if ( not isinstance(last_result, TemplateError) and result_as_boolean(last_result) - or not result_as_boolean(result) - ): + ) or not result_as_boolean(result): return hass.async_run_hass_job( diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 6d03ae4ffd2..f33f8407e47 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -13,7 +13,7 @@ import sys from types import FrameType from typing import Any, cast -from propcache import cached_property +from propcache.api import cached_property from homeassistant.core import HomeAssistant, async_get_hass_or_none from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index 22f8e2acbeb..68daf5c7939 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -46,9 +46,9 @@ def request_handler_factory( ) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: """Wrap the handler classes.""" is_coroutinefunction = asyncio.iscoroutinefunction(handler) - assert is_coroutinefunction or is_callback( - handler - ), "Handler should be a coroutine or a callback." + assert is_coroutinefunction or is_callback(handler), ( + "Handler should be a coroutine or a callback." + ) async def handle(request: web.Request) -> web.StreamResponse: """Handle incoming request.""" diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index c3a65943cb5..ade2ce747d5 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine import sys +from types import TracebackType from typing import Any, Self import httpx @@ -58,7 +59,12 @@ class HassHttpXAsyncClient(httpx.AsyncClient): """Prevent an integration from reopen of the client via context manager.""" return self - async def __aexit__(self, *args: object) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None = None, + exc_value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: """Prevent an integration from close of the client via context manager.""" diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index ce8205eb915..a8c1b0b2186 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -78,7 +78,7 @@ async def _async_get_component_icons( class _IconsCache: """Cache for icons.""" - __slots__ = ("_hass", "_loaded", "_cache", "_lock") + __slots__ = ("_cache", "_hass", "_loaded", "_lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index a3eb19657e8..4ded7444989 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -175,6 +175,9 @@ async def async_process_integration_platforms( else: integration_platforms = hass.data[DATA_INTEGRATION_PLATFORMS] + # Tell the loader that it should try to pre-load the integration + # for any future components that are loaded so we can reduce the + # amount of import executor usage. async_register_preload_platform(hass, platform_name) top_level_components = hass.config.top_level_components.copy() process_job = HassJob( @@ -187,10 +190,6 @@ async def async_process_integration_platforms( integration_platform = IntegrationPlatform( platform_name, process_job, top_level_components ) - # Tell the loader that it should try to pre-load the integration - # for any future components that are loaded so we can reduce the - # amount of import executor usage. - async_register_preload_platform(hass, platform_name) integration_platforms.append(integration_platform) if not top_level_components: return diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index b38f769b302..649819a5f06 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -12,7 +12,7 @@ from itertools import groupby import logging from typing import Any -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components.homeassistant.exposed_entities import async_should_expose @@ -49,6 +49,7 @@ INTENT_NEVERMIND = "HassNevermind" INTENT_SET_POSITION = "HassSetPosition" INTENT_START_TIMER = "HassStartTimer" INTENT_CANCEL_TIMER = "HassCancelTimer" +INTENT_CANCEL_ALL_TIMERS = "HassCancelAllTimers" INTENT_INCREASE_TIMER = "HassIncreaseTimer" INTENT_DECREASE_TIMER = "HassDecreaseTimer" INTENT_PAUSE_TIMER = "HassPauseTimer" @@ -57,6 +58,7 @@ INTENT_TIMER_STATUS = "HassTimerStatus" INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" INTENT_RESPOND = "HassRespond" +INTENT_BROADCAST = "HassBroadcast" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) @@ -1200,17 +1202,17 @@ class Intent: """Hold the intent.""" __slots__ = [ + "assistant", + "category", + "context", + "conversation_agent_id", + "device_id", "hass", - "platform", "intent_type", + "language", + "platform", "slots", "text_input", - "context", - "language", - "category", - "assistant", - "device_id", - "conversation_agent_id", ] def __init__( diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index ebb74856429..a97dd48bf61 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any, Final import orjson from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic -from homeassistant.util.json import ( # noqa: F401 +from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS as _JSON_DECODE_EXCEPTIONS, JSON_ENCODE_EXCEPTIONS as _JSON_ENCODE_EXCEPTIONS, SerializationError, diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index d322810b0ef..ea376923f9d 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -5,36 +5,35 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass +from datetime import timedelta from decimal import Decimal from enum import Enum from functools import cache, partial -from typing import Any +from typing import Any, cast import slugify as unicode_slug import voluptuous as vol from voluptuous_openapi import UNSUPPORTED, convert -from homeassistant.components.climate import INTENT_GET_TEMPERATURE -from homeassistant.components.conversation import ( - ConversationTraceEventType, - async_conversation_trace_append, +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, ) +from homeassistant.components.climate import INTENT_GET_TEMPERATURE from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.intent import async_device_supports_timers -from homeassistant.components.script import ATTR_VARIABLES, DOMAIN as SCRIPT_DOMAIN +from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.components.weather import INTENT_GET_WEATHER from homeassistant.const import ( ATTR_DOMAIN, - ATTR_ENTITY_ID, ATTR_SERVICE, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, - SERVICE_TURN_ON, ) from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util import dt as dt_util, yaml as yaml_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType @@ -87,7 +86,7 @@ def _async_get_apis(hass: HomeAssistant) -> dict[str, API]: @callback -def async_register_api(hass: HomeAssistant, api: API) -> None: +def async_register_api(hass: HomeAssistant, api: API) -> Callable[[], None]: """Register an API to be exposed to LLMs.""" apis = _async_get_apis(hass) @@ -96,6 +95,13 @@ def async_register_api(hass: HomeAssistant, api: API) -> None: apis[api.id] = api + @callback + def unregister() -> None: + """Unregister the API.""" + apis.pop(api.id) + + return unregister + async def async_get_api( hass: HomeAssistant, api_id: str, llm_context: LLMContext @@ -166,6 +172,12 @@ class APIInstance: async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType: """Call a LLM tool, validate args and return the response.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.conversation import ( + ConversationTraceEventType, + async_conversation_trace_append, + ) + async_conversation_trace_append( ConversationTraceEventType.TOOL_CALL, {"tool_name": tool_input.tool_name, "tool_args": tool_input.tool_args}, @@ -363,7 +375,7 @@ class AssistAPI(API): prompt.append( "An overview of the areas and the devices in this smart home:" ) - prompt.append(yaml.dump(list(exposed_entities.values()))) + prompt.append(yaml_util.dump(list(exposed_entities.values()))) return "\n".join(prompt) @@ -408,6 +420,8 @@ class AssistAPI(API): IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler) for intent_handler in intent_handlers ] + if exposed_domains and CALENDAR_DOMAIN in exposed_domains: + tools.append(CalendarGetEventsTool()) if llm_context.assistant is not None: for state in self.hass.states.async_all(SCRIPT_DOMAIN): @@ -416,9 +430,7 @@ class AssistAPI(API): ): continue - script_tool = ScriptTool(self.hass, state.entity_id) - if script_tool.parameters.schema: - tools.append(script_tool) + tools.append(ScriptTool(self.hass, state.entity_id)) return tools @@ -449,17 +461,13 @@ def _get_exposed_entities( entities = {} for state in hass.states.async_all(): - if not async_should_expose(hass, assistant, state.entity_id): + if ( + not async_should_expose(hass, assistant, state.entity_id) + or state.domain == SCRIPT_DOMAIN + ): continue description: str | None = None - if state.domain == SCRIPT_DOMAIN: - description, parameters = _get_cached_script_parameters( - hass, state.entity_id - ) - if parameters.schema: # Only list scripts without input fields here - continue - entity_entry = entity_registry.async_get(state.entity_id) names = [state.name] area_names = [] @@ -702,10 +710,9 @@ class ScriptTool(Tool): script_entity_id: str, ) -> None: """Init the class.""" - self.name = split_entity_id(script_entity_id)[1] + self._object_id = self.name = split_entity_id(script_entity_id)[1] if self.name[0].isdigit(): self.name = "_" + self.name - self._entity_id = script_entity_id self.description, self.parameters = _get_cached_script_parameters( hass, script_entity_id @@ -745,14 +752,76 @@ class ScriptTool(Tool): floor = list(intent.find_floors(floor, floor_reg))[0].floor_id tool_input.tool_args[field] = floor - await hass.services.async_call( + result = await hass.services.async_call( SCRIPT_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: self._entity_id, - ATTR_VARIABLES: tool_input.tool_args, - }, + self._object_id, + tool_input.tool_args, context=llm_context.context, + blocking=True, + return_response=True, ) - return {"success": True} + return {"success": True, "result": result} + + +class CalendarGetEventsTool(Tool): + """LLM Tool allowing querying a calendar.""" + + name = "calendar_get_events" + description = ( + "Get events from a calendar. " + "When asked when something happens, search the whole week. " + "Results are RFC 5545 which means 'end' is exclusive." + ) + parameters = vol.Schema( + { + vol.Required("calendar"): cv.string, + vol.Required("range"): vol.In(["today", "week"]), + } + ) + + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a calendar.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["calendar"], + domains=[CALENDAR_DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "Calendar not found"} + + entity_id = result.states[0].entity_id + if data["range"] == "today": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=1) + elif data["range"] == "week": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=7) + + service_data = { + "entity_id": entity_id, + "start_date_time": start.isoformat(), + "end_date_time": end.isoformat(), + } + + service_result = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + + events = [ + event if "T" in event["start"] else {**event, "all_day": True} + for event in cast(dict, service_result)[entity_id]["events"] + ] + + return {"success": True, "result": events} diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py index a12de4f9029..5264869d037 100644 --- a/homeassistant/helpers/location.py +++ b/homeassistant/helpers/location.py @@ -7,7 +7,7 @@ import logging from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant, State -from homeassistant.util import location as loc_util +from homeassistant.util import location as location_util _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,7 @@ def closest(latitude: float, longitude: float, states: Iterable[State]) -> State return min( with_location, - key=lambda state: loc_util.distance( + key=lambda state: location_util.distance( state.attributes.get(ATTR_LATITUDE), state.attributes.get(ATTR_LONGITUDE), latitude, diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 86dcd858c1b..bd3babc8793 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -16,7 +16,7 @@ from types import MappingProxyType from typing import Any, Literal, TypedDict, cast, overload import async_interrupt -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant import exceptions @@ -473,13 +473,13 @@ class _ScriptRun: script_execution_set("aborted") except _StopScript as err: script_execution_set("finished", err.response) - response = err.response # Let the _StopScript bubble up if this is a sub-script if not self._script.top_level: - # We already consumed the response, do not pass it on - err.response = None raise + + response = err.response + except Exception: script_execution_set("error") raise @@ -756,10 +756,8 @@ class _ScriptRun: ) running_script = ( - params[CONF_DOMAIN] == "automation" - and params[CONF_SERVICE] == "trigger" - or params[CONF_DOMAIN] in ("python_script", "script") - ) + params[CONF_DOMAIN] == "automation" and params[CONF_SERVICE] == "trigger" + ) or params[CONF_DOMAIN] in ("python_script", "script") trace_set_result(params=params, running_script=running_script) response_data = await self._async_run_long_action( self._hass.async_create_task_internal( @@ -1589,6 +1587,9 @@ class Script: target, referenced, script[CONF_SEQUENCE] ) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_target(target, referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" @@ -1636,6 +1637,9 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_devices(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_devices(referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" @@ -1684,6 +1688,9 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_entities(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_entities(referenced, step[CONF_SEQUENCE]) + def run( self, variables: _VarsType | None = None, context: Context | None = None ) -> None: @@ -1770,7 +1777,7 @@ class Script: f"{self.domain}.{self.name} which is already running " "in the current execution path; " "Traceback (most recent call last):\n" - f"{"\n".join(formatted_stack)}", + f"{'\n'.join(formatted_stack)}", level=logging.WARNING, ) return None @@ -1834,7 +1841,7 @@ class Script: def _prep_repeat_script(self, step: int) -> Script: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Repeat at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Repeat at step {step + 1}") sub_script = Script( self._hass, action[CONF_REPEAT][CONF_SEQUENCE], @@ -1857,7 +1864,7 @@ class Script: async def _async_prep_choose_data(self, step: int) -> _ChooseData: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Choose at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Choose at step {step + 1}") choices = [] for idx, choice in enumerate(action[CONF_CHOOSE], start=1): conditions = [ @@ -1911,7 +1918,7 @@ class Script: async def _async_prep_if_data(self, step: int) -> _IfData: """Prepare data for an if statement.""" action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"If at step {step+1}") + step_name = action.get(CONF_ALIAS, f"If at step {step + 1}") conditions = [ await self._async_get_condition(config) for config in action[CONF_IF] @@ -1962,7 +1969,7 @@ class Script: async def _async_prep_parallel_scripts(self, step: int) -> list[Script]: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Parallel action at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Parallel action at step {step + 1}") parallel_scripts: list[Script] = [] for idx, parallel_script in enumerate(action[CONF_PARALLEL], start=1): parallel_name = parallel_script.get(CONF_ALIAS, f"parallel {idx}") @@ -1994,7 +2001,7 @@ class Script: async def _async_prep_sequence_script(self, step: int) -> Script: """Prepare a sequence script.""" action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Sequence action at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Sequence action at step {step + 1}") sequence_script = Script( self._hass, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 31b2e8e8ac8..255739c0059 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -42,6 +42,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import ( HomeAssistantError, + ServiceNotSupported, TemplateError, Unauthorized, UnknownUser, @@ -132,8 +133,7 @@ def _validate_option_or_feature(option_or_feature: str, label: str) -> Any: domain, enum, option = option_or_feature.split(".", 2) except ValueError as exc: raise vol.Invalid( - f"Invalid {label} '{option_or_feature}', expected " - ".." + f"Invalid {label} '{option_or_feature}', expected .." ) from exc base_components = _base_components() @@ -225,7 +225,7 @@ class ServiceParams(TypedDict): class ServiceTargetSelector: """Class to hold a target selector for a service.""" - __slots__ = ("entity_ids", "device_ids", "area_ids", "floor_ids", "label_ids") + __slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids") def __init__(self, service_call: ServiceCall) -> None: """Extract ids from service call data.""" @@ -502,7 +502,7 @@ def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: @bind_hass -def async_extract_referenced_entity_ids( # noqa: C901 +def async_extract_referenced_entity_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True ) -> SelectedEntities: """Extract referenced entity IDs from a service call.""" @@ -986,9 +986,7 @@ async def entity_service_call( ): # If entity explicitly referenced, raise an error if referenced is not None and entity.entity_id in referenced.referenced: - raise HomeAssistantError( - f"Entity {entity.entity_id} does not support this service." - ) + raise ServiceNotSupported(call.domain, call.service, entity.entity_id) continue diff --git a/homeassistant/helpers/service_info/dhcp.py b/homeassistant/helpers/service_info/dhcp.py new file mode 100644 index 00000000000..47479a53a8a --- /dev/null +++ b/homeassistant/helpers/service_info/dhcp.py @@ -0,0 +1,14 @@ +"""DHCP discovery data.""" + +from dataclasses import dataclass + +from homeassistant.data_entry_flow import BaseServiceInfo + + +@dataclass(slots=True) +class DhcpServiceInfo(BaseServiceInfo): + """Prepared info from dhcp entries.""" + + ip: str + hostname: str + macaddress: str diff --git a/homeassistant/helpers/service_info/mqtt.py b/homeassistant/helpers/service_info/mqtt.py index 6ffc981ced1..a5284807617 100644 --- a/homeassistant/helpers/service_info/mqtt.py +++ b/homeassistant/helpers/service_info/mqtt.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from homeassistant.data_entry_flow import BaseServiceInfo -type ReceivePayloadType = str | bytes +type ReceivePayloadType = str | bytes | bytearray @dataclass(slots=True) diff --git a/homeassistant/helpers/service_info/ssdp.py b/homeassistant/helpers/service_info/ssdp.py new file mode 100644 index 00000000000..4a3a5a24474 --- /dev/null +++ b/homeassistant/helpers/service_info/ssdp.py @@ -0,0 +1,41 @@ +"""SSDP discovery data.""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, Final + +from homeassistant.data_entry_flow import BaseServiceInfo + +# Attributes for accessing info from retrieved UPnP device description +ATTR_ST: Final = "st" +ATTR_NT: Final = "nt" +ATTR_UPNP_DEVICE_TYPE: Final = "deviceType" +ATTR_UPNP_FRIENDLY_NAME: Final = "friendlyName" +ATTR_UPNP_MANUFACTURER: Final = "manufacturer" +ATTR_UPNP_MANUFACTURER_URL: Final = "manufacturerURL" +ATTR_UPNP_MODEL_DESCRIPTION: Final = "modelDescription" +ATTR_UPNP_MODEL_NAME: Final = "modelName" +ATTR_UPNP_MODEL_NUMBER: Final = "modelNumber" +ATTR_UPNP_MODEL_URL: Final = "modelURL" +ATTR_UPNP_SERIAL: Final = "serialNumber" +ATTR_UPNP_SERVICE_LIST: Final = "serviceList" +ATTR_UPNP_UDN: Final = "UDN" +ATTR_UPNP_UPC: Final = "UPC" +ATTR_UPNP_PRESENTATION_URL: Final = "presentationURL" + + +@dataclass(slots=True) +class SsdpServiceInfo(BaseServiceInfo): + """Prepared info from ssdp/upnp entries.""" + + ssdp_usn: str + ssdp_st: str + upnp: Mapping[str, Any] + ssdp_location: str | None = None + ssdp_nt: str | None = None + ssdp_udn: str | None = None + ssdp_ext: str | None = None + ssdp_server: str | None = None + ssdp_headers: Mapping[str, Any] = field(default_factory=dict) + ssdp_all_locations: set[str] = field(default_factory=set) + x_homeassistant_matching_domains: set[str] = field(default_factory=set) diff --git a/homeassistant/helpers/service_info/usb.py b/homeassistant/helpers/service_info/usb.py new file mode 100644 index 00000000000..c7d6f6ea143 --- /dev/null +++ b/homeassistant/helpers/service_info/usb.py @@ -0,0 +1,17 @@ +"""USB discovery data.""" + +from dataclasses import dataclass + +from homeassistant.data_entry_flow import BaseServiceInfo + + +@dataclass(slots=True) +class UsbServiceInfo(BaseServiceInfo): + """Prepared info from usb entries.""" + + device: str + vid: str + pid: str + serial_number: str | None + manufacturer: str | None + description: str | None diff --git a/homeassistant/helpers/service_info/zeroconf.py b/homeassistant/helpers/service_info/zeroconf.py new file mode 100644 index 00000000000..a91bc5e77d9 --- /dev/null +++ b/homeassistant/helpers/service_info/zeroconf.py @@ -0,0 +1,50 @@ +"""Zeroconf discovery data.""" + +from dataclasses import dataclass +from ipaddress import IPv4Address, IPv6Address +from typing import Any, Final + +from homeassistant.data_entry_flow import BaseServiceInfo + +# Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] +ATTR_PROPERTIES_ID: Final = "id" + + +@dataclass(slots=True) +class ZeroconfServiceInfo(BaseServiceInfo): + """Prepared info from mDNS entries. + + The ip_address is the most recently updated address + that is not a link local or unspecified address. + + The ip_addresses are all addresses in order of most + recently updated to least recently updated. + + The host is the string representation of the ip_address. + + The addresses are the string representations of the + ip_addresses. + + It is recommended to use the ip_address to determine + the address to connect to as it will be the most + recently updated address that is not a link local + or unspecified address. + """ + + ip_address: IPv4Address | IPv6Address + ip_addresses: list[IPv4Address | IPv6Address] + port: int | None + hostname: str + type: str + name: str + properties: dict[str, Any] + + @property + def host(self) -> str: + """Return the host.""" + return str(self.ip_address) + + @property + def addresses(self) -> list[str]: + """Return the addresses.""" + return [str(ip_address) for ip_address in self.ip_addresses] diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index 20e4ee82162..075fc50b49a 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -3,15 +3,22 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Coroutine import functools -from typing import Any, cast, overload +from typing import Any, Literal, assert_type, cast, overload from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey type _FuncType[_T] = Callable[[HomeAssistant], _T] +type _Coro[_T] = Coroutine[Any, Any, _T] + + +@overload +def singleton[_T]( + data_key: HassKey[_T], *, async_: Literal[True] +) -> Callable[[_FuncType[_Coro[_T]]], _FuncType[_Coro[_T]]]: ... @overload @@ -24,29 +31,37 @@ def singleton[_T]( def singleton[_T](data_key: str) -> Callable[[_FuncType[_T]], _FuncType[_T]]: ... -def singleton[_T](data_key: Any) -> Callable[[_FuncType[_T]], _FuncType[_T]]: +def singleton[_S, _T, _U]( + data_key: Any, *, async_: bool = False +) -> Callable[[_FuncType[_S]], _FuncType[_S]]: """Decorate a function that should be called once per instance. Result will be cached and simultaneous calls will be handled. """ - def wrapper(func: _FuncType[_T]) -> _FuncType[_T]: + @overload + def wrapper(func: _FuncType[_Coro[_T]]) -> _FuncType[_Coro[_T]]: ... + + @overload + def wrapper(func: _FuncType[_U]) -> _FuncType[_U]: ... + + def wrapper(func: _FuncType[_Coro[_T] | _U]) -> _FuncType[_Coro[_T] | _U]: """Wrap a function with caching logic.""" if not asyncio.iscoroutinefunction(func): @functools.lru_cache(maxsize=1) @bind_hass @functools.wraps(func) - def wrapped(hass: HomeAssistant) -> _T: + def wrapped(hass: HomeAssistant) -> _U: if data_key not in hass.data: hass.data[data_key] = func(hass) - return cast(_T, hass.data[data_key]) + return cast(_U, hass.data[data_key]) return wrapped @bind_hass @functools.wraps(func) - async def async_wrapped(hass: HomeAssistant) -> Any: + async def async_wrapped(hass: HomeAssistant) -> _T: if data_key not in hass.data: evt = hass.data[data_key] = asyncio.Event() result = await func(hass) @@ -62,6 +77,45 @@ def singleton[_T](data_key: Any) -> Callable[[_FuncType[_T]], _FuncType[_T]]: return cast(_T, obj_or_evt) - return async_wrapped # type: ignore[return-value] + return async_wrapped return wrapper + + +async def _test_singleton_typing(hass: HomeAssistant) -> None: + """Test singleton overloads work as intended. + + This is tested during the mypy run. Do not move it to 'tests'! + """ + # Test HassKey + key = HassKey[int]("key") + + @singleton(key) + def func(hass: HomeAssistant) -> int: + return 2 + + @singleton(key, async_=True) + async def async_func(hass: HomeAssistant) -> int: + return 2 + + assert_type(func(hass), int) + assert_type(await async_func(hass), int) + + # Test invalid use of 'async_' with sync function + @singleton(key, async_=True) # type: ignore[arg-type] + def func_error(hass: HomeAssistant) -> int: + return 2 + + # Test string key + other_key = "key" + + @singleton(other_key) + def func2(hass: HomeAssistant) -> str: + return "" + + @singleton(other_key) + async def async_func2(hass: HomeAssistant) -> str: + return "" + + assert_type(func2(hass), str) + assert_type(await async_func2(hass), str) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 080599f54d8..ac1fe3bb29d 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -13,7 +13,7 @@ import os from pathlib import Path from typing import Any -from propcache import cached_property +from propcache.api import cached_property from homeassistant.const import ( EVENT_HOMEASSISTANT_FINAL_WRITE, diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index df4c45cd5ed..df9679dcb08 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -5,7 +5,6 @@ from __future__ import annotations from functools import cache from getpass import getuser import logging -import os import platform from typing import TYPE_CHECKING, Any @@ -13,6 +12,7 @@ from homeassistant.const import __version__ as current_version from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass from homeassistant.util.package import is_docker_env, is_virtual_env +from homeassistant.util.system_info import is_official_image from .hassio import is_hassio from .importlib import async_import_module @@ -23,12 +23,6 @@ _LOGGER = logging.getLogger(__name__) _DATA_MAC_VER = "system_info_mac_ver" -@cache -def is_official_image() -> bool: - """Return True if Home Assistant is running in an official container.""" - return os.path.isfile("/OFFICIAL_IMAGE") - - @singleton(_DATA_MAC_VER) async def async_get_mac_ver(hass: HomeAssistant) -> str: """Return the macOS version.""" @@ -71,7 +65,10 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: try: info_object["user"] = cached_get_user() - except KeyError: + except (KeyError, OSError): + # OSError on python >= 3.13, KeyError on python < 3.13 + # KeyError can be removed when 3.12 support is dropped + # see https://docs.python.org/3/whatsnew/3.13.html info_object["user"] = None if platform.system() == "Darwin": diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 57587dc21d6..7866250d658 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -23,7 +23,16 @@ import statistics from struct import error as StructError, pack, unpack_from import sys from types import CodeType, TracebackType -from typing import Any, Concatenate, Literal, NoReturn, Self, cast, overload +from typing import ( + TYPE_CHECKING, + Any, + Concatenate, + Literal, + NoReturn, + Self, + cast, + overload, +) from urllib.parse import urlencode as urllib_urlencode import weakref @@ -35,7 +44,7 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace from lru import LRU import orjson -from propcache import under_cached_property +from propcache.api import under_cached_property import voluptuous as vol from homeassistant.const import ( @@ -65,7 +74,7 @@ from homeassistant.loader import bind_hass from homeassistant.util import ( convert, dt as dt_util, - location as loc_util, + location as location_util, slugify as slugify_util, ) from homeassistant.util.async_ import run_callback_threadsafe @@ -88,6 +97,9 @@ from .singleton import singleton from .translation import async_translate_state from .typing import TemplateVarsType +if TYPE_CHECKING: + from _typeshed import OptExcInfo + # mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) @@ -374,19 +386,19 @@ class RenderInfo: """Holds information about a template render.""" __slots__ = ( - "template", - "filter_lifecycle", - "filter", "_result", - "is_static", - "exception", "all_states", "all_states_lifecycle", "domains", "domains_lifecycle", "entities", - "rate_limit", + "exception", + "filter", + "filter_lifecycle", "has_time", + "is_static", + "rate_limit", + "template", ) def __init__(self, template: Template) -> None: @@ -495,17 +507,17 @@ class Template: __slots__ = ( "__weakref__", - "template", + "_compiled", + "_compiled_code", + "_exc_info", + "_hash_cache", + "_limited", + "_log_fn", + "_renders", + "_strict", "hass", "is_static", - "_compiled_code", - "_compiled", - "_exc_info", - "_limited", - "_strict", - "_log_fn", - "_hash_cache", - "_renders", + "template", ) def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: @@ -532,7 +544,7 @@ class Template: self._compiled: jinja2.Template | None = None self.hass = hass self.is_static = not is_template_string(template) - self._exc_info: sys._OptExcInfo | None = None + self._exc_info: OptExcInfo | None = None self._limited: bool | None = None self._strict: bool | None = None self._log_fn: Callable[[int, str], None] | None = None @@ -589,7 +601,7 @@ class Template: or filter depending on hass or the state machine. """ if self.is_static: - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return self.template return self._parse_result(self.template) assert self.hass is not None, "hass variable not set on template" @@ -618,7 +630,7 @@ class Template: self._renders += 1 if self.is_static: - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return self.template return self._parse_result(self.template) @@ -639,7 +651,7 @@ class Template: render_result = render_result.strip() - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return render_result return self._parse_result(render_result) @@ -814,7 +826,7 @@ class Template: ) return value if error_value is _SENTINEL else error_value - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return render_result return self._parse_result(render_result) @@ -829,16 +841,16 @@ class Template: self.ensure_valid() assert self.hass is not None, "hass variable not set on template" - assert ( - self._limited is None or self._limited == limited - ), "can't change between limited and non limited template" - assert ( - self._strict is None or self._strict == strict - ), "can't change between strict and non strict template" + assert self._limited is None or self._limited == limited, ( + "can't change between limited and non limited template" + ) + assert self._strict is None or self._strict == strict, ( + "can't change between strict and non strict template" + ) assert not (strict and limited), "can't combine strict and limited template" - assert ( - self._log_fn is None or self._log_fn == log_fn - ), "can't change custom log function" + assert self._log_fn is None or self._log_fn == log_fn, ( + "can't change custom log function" + ) assert self._compiled_code is not None, "template code was not compiled" self._limited = limited @@ -979,7 +991,7 @@ class StateTranslated: class DomainStates: """Class to expose a specific HA domain as attributes.""" - __slots__ = ("_hass", "_domain") + __slots__ = ("_domain", "_hass") __setitem__ = _readonly __delitem__ = _readonly @@ -1023,7 +1035,7 @@ class DomainStates: class TemplateStateBase(State): """Class to represent a state object in a template.""" - __slots__ = ("_hass", "_collect", "_entity_id", "_state") + __slots__ = ("_collect", "_entity_id", "_hass", "_state") _state: State @@ -1723,7 +1735,7 @@ def label_entities(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: return [entry.entity_id for entry in entries] -def closest(hass, *args): +def closest(hass: HomeAssistant, *args: Any) -> State | None: """Find closest entity. Closest to home: @@ -1763,21 +1775,24 @@ def closest(hass, *args): ) return None - latitude = point_state.attributes.get(ATTR_LATITUDE) - longitude = point_state.attributes.get(ATTR_LONGITUDE) + latitude = point_state.attributes[ATTR_LATITUDE] + longitude = point_state.attributes[ATTR_LONGITUDE] entities = args[1] else: - latitude = convert(args[0], float) - longitude = convert(args[1], float) + latitude_arg = convert(args[0], float) + longitude_arg = convert(args[1], float) - if latitude is None or longitude is None: + if latitude_arg is None or longitude_arg is None: _LOGGER.warning( "Closest:Received invalid coordinates: %s, %s", args[0], args[1] ) return None + latitude = latitude_arg + longitude = longitude_arg + entities = args[2] states = expand(hass, entities) @@ -1786,20 +1801,20 @@ def closest(hass, *args): return loc_helper.closest(latitude, longitude, states) -def closest_filter(hass, *args): +def closest_filter(hass: HomeAssistant, *args: Any) -> State | None: """Call closest as a filter. Need to reorder arguments.""" new_args = list(args[1:]) new_args.append(args[0]) return closest(hass, *new_args) -def distance(hass, *args): +def distance(hass: HomeAssistant, *args: Any) -> float | None: """Calculate distance. Will calculate distance from home to a point or between points. Points can be passed in using state objects or lat/lng coordinates. """ - locations = [] + locations: list[tuple[float, float]] = [] to_process = list(args) @@ -1819,10 +1834,10 @@ def distance(hass, *args): return None value_2 = to_process.pop(0) - latitude = convert(value, float) - longitude = convert(value_2, float) + latitude_to_process = convert(value, float) + longitude_to_process = convert(value_2, float) - if latitude is None or longitude is None: + if latitude_to_process is None or longitude_to_process is None: _LOGGER.warning( "Distance:Unable to process latitude and longitude: %s, %s", value, @@ -1830,6 +1845,9 @@ def distance(hass, *args): ) return None + latitude = latitude_to_process + longitude = longitude_to_process + else: if not loc_helper.has_location(point_state): _LOGGER.warning( @@ -1837,8 +1855,8 @@ def distance(hass, *args): ) return None - latitude = point_state.attributes.get(ATTR_LATITUDE) - longitude = point_state.attributes.get(ATTR_LONGITUDE) + latitude = point_state.attributes[ATTR_LATITUDE] + longitude = point_state.attributes[ATTR_LONGITUDE] locations.append((latitude, longitude)) @@ -1846,7 +1864,7 @@ def distance(hass, *args): return hass.config.distance(*locations[0]) return hass.config.units.length( - loc_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS + location_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS ) @@ -1861,14 +1879,19 @@ def is_state(hass: HomeAssistant, entity_id: str, state: str | list[str]) -> boo """Test if a state is a specific value.""" state_obj = _get_state(hass, entity_id) return state_obj is not None and ( - state_obj.state == state or isinstance(state, list) and state_obj.state in state + state_obj.state == state + or (isinstance(state, list) and state_obj.state in state) ) def is_state_attr(hass: HomeAssistant, entity_id: str, name: str, value: Any) -> bool: """Test if a state's attribute is a specific value.""" - attr = state_attr(hass, entity_id, name) - return attr is not None and attr == value + if (state_obj := _get_state(hass, entity_id)) is not None: + attr = state_obj.attributes.get(name, _SENTINEL) + if attr is _SENTINEL: + return False + return bool(attr == value) + return False def state_attr(hass: HomeAssistant, entity_id: str, name: str) -> Any: diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 431a7a7d1f8..d191d474480 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -23,11 +23,11 @@ class TraceElement: "_child_run_id", "_error", "_last_variables", - "path", "_result", - "reuse_by_child", "_timestamp", "_variables", + "path", + "reuse_by_child", ) def __init__(self, variables: TemplateVarsType, path: str) -> None: diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 01c47aa8d0d..fdfefc9bff4 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -147,7 +147,7 @@ class _TranslationsCacheData: class _TranslationCache: """Cache for flattened translations.""" - __slots__ = ("hass", "cache_data", "lock") + __slots__ = ("cache_data", "hass", "lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 6cc4584935e..943eadff19a 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -9,13 +9,12 @@ from datetime import datetime, timedelta import logging from random import randint from time import monotonic -from typing import Any, Generic, Protocol +from typing import Any, Generic, Protocol, TypeVar import urllib.error import aiohttp -from propcache import cached_property +from propcache.api import cached_property import requests -from typing_extensions import TypeVar from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -37,11 +36,6 @@ REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 REQUEST_REFRESH_DEFAULT_IMMEDIATE = True _DataT = TypeVar("_DataT", default=dict[str, Any]) -_DataUpdateCoordinatorT = TypeVar( - "_DataUpdateCoordinatorT", - bound="DataUpdateCoordinator[Any]", - default="DataUpdateCoordinator[dict[str, Any]]", -) class UpdateFailed(HomeAssistantError): @@ -365,7 +359,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self._async_unsub_refresh() self._debounced_refresh.async_cancel() - if self._shutdown_requested or scheduled and self.hass.is_stopping: + if self._shutdown_requested or (scheduled and self.hass.is_stopping): return if log_timing := self.logger.isEnabledFor(logging.DEBUG): @@ -459,7 +453,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.logger.debug( "Finished fetching %s data in %.3f seconds (success: %s)", self.name, - monotonic() - start, # pylint: disable=possibly-used-before-assignment + monotonic() - start, self.last_update_success, ) if not auth_failed and self._listeners and not self.hass.is_stopping: @@ -565,7 +559,11 @@ class BaseCoordinatorEntity[ """ -class CoordinatorEntity(BaseCoordinatorEntity[_DataUpdateCoordinatorT]): +class CoordinatorEntity[ + _DataUpdateCoordinatorT: DataUpdateCoordinator[Any] = DataUpdateCoordinator[ + dict[str, Any] + ] +](BaseCoordinatorEntity[_DataUpdateCoordinatorT]): """A class for entities using DataUpdateCoordinator.""" def __init__( diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 4313cd2d6e0..92b588dbe15 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -25,7 +25,7 @@ from awesomeversion import ( AwesomeVersionException, AwesomeVersionStrategy, ) -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from . import generated @@ -65,14 +65,15 @@ _LOGGER = logging.getLogger(__name__) # This list can be extended by calling async_register_preload_platform # BASE_PRELOAD_PLATFORMS = [ + "backup", "config", "config_flow", "diagnostics", "energy", "group", - "logbook", "hardware", "intent", + "logbook", "media_source", "recorder", "repairs", @@ -830,6 +831,9 @@ class Integration: @cached_property def quality_scale(self) -> str | None: """Return Integration Quality Scale.""" + # Custom integrations default to "custom" quality scale. + if not self.is_built_in or self.overwrites_built_in: + return "custom" return self.manifest.get("quality_scale") @cached_property @@ -1761,8 +1765,7 @@ def async_suggest_report_issue( if not integration_domain: return "report it to the custom integration author" return ( - f"report it to the author of the '{integration_domain}' " - "custom integration" + f"report it to the author of the '{integration_domain}' custom integration" ) return f"create a bug report at {issue_tracker}" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 19bfee3c80a..cb29214390b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,54 +3,56 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 -aiohasupervisor==0.2.1 +aiohasupervisor==0.2.2b5 +aiohttp-asyncmdnsresolver==0.0.1 aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.7 +aiohttp==3.11.11 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 -async-upnp-client==0.41.0 +async-upnp-client==0.43.0 atomicwrites-homeassistant==1.4.1 attrs==24.2.0 audioop-lts==0.2.1;python_version>='3.13' av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.8.0 bleak==0.22.3 -bluetooth-adapters==0.20.2 +bluetooth-adapters==0.21.1 bluetooth-auto-recovery==1.4.2 -bluetooth-data-tools==1.20.0 +bluetooth-data-tools==1.22.0 cached-ipaddress==0.8.0 certifi>=2021.5.30 -ciso8601==2.3.1 -cryptography==43.0.1 -dbus-fast==2.24.3 -fnv-hash-fast==1.0.2 -go2rtc-client==0.1.1 +ciso8601==2.3.2 +cronsim==2.6 +cryptography==44.0.0 +dbus-fast==2.30.2 +fnv-hash-fast==1.2.2 +go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.6.0 -hass-nabucasa==0.85.0 -hassil==2.0.4 +habluetooth==3.12.0 +hass-nabucasa==0.88.1 +hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241106.2 -home-assistant-intents==2024.11.13 -httpx==0.27.2 +home-assistant-frontend==20250109.2 +home-assistant-intents==2025.1.1 +httpx==0.28.1 ifaddr==0.2.0 -Jinja2==3.1.4 +Jinja2==3.1.5 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.11 +orjson==3.10.12 packaging>=23.1 paho-mqtt==1.6.1 -Pillow==11.0.0 -propcache==0.2.0 +Pillow==11.1.0 +propcache==0.2.1 psutil-home-assistant==0.0.1 -PyJWT==2.10.0 +PyJWT==2.10.1 pymicro-vad==1.0.1 PyNaCl==1.5.0 -pyOpenSSL==24.2.1 +pyOpenSSL==24.3.0 pyserial==3.5 pyspeex-noise==1.0.2 python-slugify==8.0.4 @@ -58,20 +60,20 @@ PyTurboJPEG==1.7.5 pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 -securetar==2024.11.0 -SQLAlchemy==2.0.31 +securetar==2025.1.3 +SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 -ulid-transform==1.0.2 +ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.4 -voluptuous-openapi==0.0.5 +uv==0.5.21 +voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.18.0 -zeroconf==0.136.0 +yarl==1.18.3 +zeroconf==0.141.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -106,16 +108,16 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.6.2.post1 +anyio==4.8.0 h11==0.14.0 -httpcore==1.0.5 +httpcore==1.0.7 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.1.3 +numpy==2.2.2 pandas~=2.2.3 # Constrain multidict to avoid typing issues @@ -125,9 +127,8 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Required to avoid breaking (#101042). -# v2 has breaking changes (#99218). -pydantic==1.10.19 +# ensure pydantic version does not float since it might have breaking changes +pydantic==2.10.6 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -146,7 +147,7 @@ pyOpenSSL>=24.0.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==5.28.3 +protobuf==5.29.2 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder @@ -197,3 +198,11 @@ tenacity!=8.4.0 # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 + +# aiofiles keeps getting downgraded by custom components +# causing newer methods to not be available and breaking +# some integrations at startup +# https://github.com/home-assistant/core/issues/127529 +# https://github.com/home-assistant/core/issues/122508 +# https://github.com/home-assistant/core/issues/118004 +aiofiles>=24.1.0 diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index b769d385a4f..c16269a2a8b 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -58,7 +58,7 @@ def benchmark[_CallableT: Callable](func: _CallableT) -> _CallableT: @benchmark -async def fire_events(hass): +async def fire_events(hass: core.HomeAssistant) -> float: """Fire a million events.""" count = 0 event_name = "benchmark_event" @@ -85,7 +85,7 @@ async def fire_events(hass): @benchmark -async def fire_events_with_filter(hass): +async def fire_events_with_filter(hass: core.HomeAssistant) -> float: """Fire a million events with a filter that rejects them.""" count = 0 event_name = "benchmark_event" @@ -117,7 +117,7 @@ async def fire_events_with_filter(hass): @benchmark -async def state_changed_helper(hass): +async def state_changed_helper(hass: core.HomeAssistant) -> float: """Run a million events through state changed helper with 1000 entities.""" count = 0 entity_id = "light.kitchen" @@ -141,7 +141,7 @@ async def state_changed_helper(hass): } for _ in range(10**6): - hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc] start = timer() @@ -151,7 +151,7 @@ async def state_changed_helper(hass): @benchmark -async def state_changed_event_helper(hass): +async def state_changed_event_helper(hass: core.HomeAssistant) -> float: """Run a million events through state changed event helper with 1000 entities.""" count = 0 entity_id = "light.kitchen" @@ -174,7 +174,7 @@ async def state_changed_event_helper(hass): } for _ in range(events_to_fire): - hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc] start = timer() @@ -186,7 +186,7 @@ async def state_changed_event_helper(hass): @benchmark -async def state_changed_event_filter_helper(hass): +async def state_changed_event_filter_helper(hass: core.HomeAssistant) -> float: """Run a million events through state changed event helper. With 1000 entities that all get filtered. @@ -212,7 +212,7 @@ async def state_changed_event_filter_helper(hass): } for _ in range(events_to_fire): - hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc] start = timer() @@ -224,7 +224,7 @@ async def state_changed_event_filter_helper(hass): @benchmark -async def filtering_entity_id(hass): +async def filtering_entity_id(hass: core.HomeAssistant) -> float: """Run a 100k state changes through entity filter.""" config = { "include": { @@ -289,7 +289,7 @@ async def filtering_entity_id(hass): @benchmark -async def valid_entity_id(hass): +async def valid_entity_id(hass: core.HomeAssistant) -> float: """Run valid entity ID a million times.""" start = timer() for _ in range(10**6): @@ -298,7 +298,7 @@ async def valid_entity_id(hass): @benchmark -async def json_serialize_states(hass): +async def json_serialize_states(hass: core.HomeAssistant) -> float: """Serialize million states with websocket default encoder.""" states = [ core.State("light.kitchen", "on", {"friendly_name": "Kitchen Lights"}) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index ee2b6c762d8..eb898e4b544 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -13,6 +13,8 @@ import zoneinfo from aiozoneinfo import async_get_time_zone as _async_get_time_zone import ciso8601 +from homeassistant.helpers.deprecation import deprecated_function + DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.UTC DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC @@ -170,6 +172,7 @@ utc_from_timestamp = partial(dt.datetime.fromtimestamp, tz=UTC) """Return a UTC time from a timestamp.""" +@deprecated_function("datetime.timestamp", breaks_in_ha_version="2026.1") def utc_to_timestamp(utc_dt: dt.datetime) -> float: """Fast conversion of a datetime in UTC to a timestamp.""" # Taken from diff --git a/homeassistant/util/event_type.py b/homeassistant/util/event_type.py index 509a35d33ae..e0083057272 100644 --- a/homeassistant/util/event_type.py +++ b/homeassistant/util/event_type.py @@ -6,14 +6,10 @@ Custom for type checking. See stub file. from __future__ import annotations from collections.abc import Mapping -from typing import Any, Generic - -from typing_extensions import TypeVar - -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any]) +from typing import Any -class EventType(str, Generic[_DataT]): +class EventType[_DataT: Mapping[str, Any] = Mapping[str, Any]](str): """Custom type for Event.event_type. At runtime this is a generic subclass of str. diff --git a/homeassistant/util/event_type.pyi b/homeassistant/util/event_type.pyi index 4285e54e8c9..f9cb140440f 100644 --- a/homeassistant/util/event_type.pyi +++ b/homeassistant/util/event_type.pyi @@ -2,15 +2,15 @@ # ruff: noqa: PYI021 # Allow docstrings from collections.abc import Mapping -from typing import Any, Generic - -from typing_extensions import TypeVar +from typing import Any, Generic, TypeVar __all__ = [ "EventType", ] -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any]) +_DataT = TypeVar( # needs to be invariant + "_DataT", bound=Mapping[str, Any], default=Mapping[str, Any] +) class EventType(Generic[_DataT]): """Custom type for Event.event_type. At runtime delegated to str. diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 968567ae0c9..a935d44d585 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -46,7 +46,7 @@ def json_loads_array(obj: bytes | bytearray | memoryview | str, /) -> JsonArrayT """Parse JSON data and ensure result is a list.""" value: JsonValueType = json_loads(obj) # Avoid isinstance overhead as we are not interested in list subclasses - if type(value) is list: # noqa: E721 + if type(value) is list: return value raise ValueError(f"Expected JSON to be parsed as a list got {type(value)}") @@ -55,7 +55,7 @@ def json_loads_object(obj: bytes | bytearray | memoryview | str, /) -> JsonObjec """Parse JSON data and ensure result is a dictionary.""" value: JsonValueType = json_loads(obj) # Avoid isinstance overhead as we are not interested in dict subclasses - if type(value) is dict: # noqa: E721 + if type(value) is dict: return value raise ValueError(f"Expected JSON to be parsed as a dict got {type(value)}") @@ -95,7 +95,7 @@ def load_json_array( default = [] value: JsonValueType = load_json(filename, default=default) # Avoid isinstance overhead as we are not interested in list subclasses - if type(value) is list: # noqa: E721 + if type(value) is list: return value _LOGGER.exception( "Expected JSON to be parsed as a list got %s in: %s", {type(value)}, filename @@ -115,7 +115,7 @@ def load_json_object( default = {} value: JsonValueType = load_json(filename, default=default) # Avoid isinstance overhead as we are not interested in dict subclasses - if type(value) is dict: # noqa: E721 + if type(value) is dict: return value _LOGGER.exception( "Expected JSON to be parsed as a dict got %s in: %s", {type(value)}, filename diff --git a/homeassistant/util/loop.py b/homeassistant/util/loop.py index d7593013046..bebd399a5cd 100644 --- a/homeassistant/util/loop.py +++ b/homeassistant/util/loop.py @@ -93,8 +93,9 @@ def raise_for_blocking_call( return if found_frame is None: - raise RuntimeError( # noqa: TRY200 - f"Caught blocking call to {func.__name__} with args {mapped_args.get("args")} " + raise RuntimeError( # noqa: B904 + f"Caught blocking call to {func.__name__} " + f"with args {mapped_args.get('args')} " f"in {offender_filename}, line {offender_lineno}: {offender_line} " "inside the event loop; " "This is causing stability issues. " diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index 08a2c2a3967..70d7dc80505 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -98,8 +98,7 @@ def is_host_valid(host: str) -> bool: return False if re.match(r"^[0-9\.]+$", host): # reject invalid IPv4 return False - if host.endswith("."): # dot at the end is correct - host = host[:-1] + host = host.removesuffix(".") allowed = re.compile(r"(?!-)[A-Z\d\-]{1,63}(? bool: @cache def is_docker_env() -> bool: - """Return True if we run in a docker env.""" - return Path("/.dockerenv").exists() + """Return True if we run in a container env.""" + return ( + Path("/.dockerenv").exists() + or Path("/run/.containerenv").exists() + or "KUBERNETES_SERVICE_HOST" in os.environ + or is_official_image() + ) def get_installed_versions(specifiers: set[str]) -> set[str]: diff --git a/homeassistant/util/system_info.py b/homeassistant/util/system_info.py new file mode 100644 index 00000000000..80621bd16a5 --- /dev/null +++ b/homeassistant/util/system_info.py @@ -0,0 +1,12 @@ +"""Util to gather system info.""" + +from __future__ import annotations + +from functools import cache +import os + + +@cache +def is_official_image() -> bool: + """Return True if Home Assistant is running in an official container.""" + return os.path.isfile("/OFFICIAL_IMAGE") diff --git a/homeassistant/util/ulid.py b/homeassistant/util/ulid.py index f4895f9d963..ba0c466969c 100644 --- a/homeassistant/util/ulid.py +++ b/homeassistant/util/ulid.py @@ -13,14 +13,14 @@ from ulid_transform import ( ) __all__ = [ - "ulid", - "ulid_hex", - "ulid_at_time", - "ulid_to_bytes", "bytes_to_ulid", - "ulid_now", - "ulid_to_bytes_or_none", "bytes_to_ulid_or_none", + "ulid", + "ulid_at_time", + "ulid_hex", + "ulid_now", + "ulid_to_bytes", + "ulid_to_bytes_or_none", ] diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 3cffcb5768e..ad320cdb9ae 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -249,11 +249,15 @@ class ElectricPotentialConverter(BaseUnitConverter): UnitOfElectricPotential.VOLT: 1, UnitOfElectricPotential.MILLIVOLT: 1e3, UnitOfElectricPotential.MICROVOLT: 1e6, + UnitOfElectricPotential.KILOVOLT: 1 / 1e3, + UnitOfElectricPotential.MEGAVOLT: 1 / 1e6, } VALID_UNITS = { UnitOfElectricPotential.VOLT, UnitOfElectricPotential.MILLIVOLT, UnitOfElectricPotential.MICROVOLT, + UnitOfElectricPotential.KILOVOLT, + UnitOfElectricPotential.MEGAVOLT, } @@ -266,6 +270,7 @@ class EnergyConverter(BaseUnitConverter): UnitOfEnergy.KILO_JOULE: _WH_TO_J, UnitOfEnergy.MEGA_JOULE: _WH_TO_J / 1e3, UnitOfEnergy.GIGA_JOULE: _WH_TO_J / 1e6, + UnitOfEnergy.MILLIWATT_HOUR: 1e6, UnitOfEnergy.WATT_HOUR: 1e3, UnitOfEnergy.KILO_WATT_HOUR: 1, UnitOfEnergy.MEGA_WATT_HOUR: 1 / 1e3, @@ -339,6 +344,7 @@ class PowerConverter(BaseUnitConverter): UNIT_CLASS = "power" _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfPower.MILLIWATT: 1 * 1000, UnitOfPower.WATT: 1, UnitOfPower.KILO_WATT: 1 / 1000, UnitOfPower.MEGA_WATT: 1 / 1e6, @@ -346,6 +352,7 @@ class PowerConverter(BaseUnitConverter): UnitOfPower.TERA_WATT: 1 / 1e12, } VALID_UNITS = { + UnitOfPower.MILLIWATT, UnitOfPower.WATT, UnitOfPower.KILO_WATT, UnitOfPower.MEGA_WATT, diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index c812dd38230..15993cbae47 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -233,7 +233,6 @@ def _deprecated_unit_system(value: str) -> str: """Convert deprecated unit system.""" if value == _CONF_UNIT_SYSTEM_IMPERIAL: - # need to add warning in 2023.1 return _CONF_UNIT_SYSTEM_US_CUSTOMARY return value diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index cf90b223cb6..3b1f5c4cc0a 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -16,15 +16,15 @@ from .objects import Input __all__ = [ "SECRET_YAML", "Input", - "dump", - "save_yaml", "Secrets", + "UndefinedSubstitution", "YamlTypeError", + "dump", + "extract_inputs", "load_yaml", "load_yaml_dict", - "secret_yaml", "parse_yaml", - "UndefinedSubstitution", - "extract_inputs", + "save_yaml", + "secret_yaml", "substitute", ] diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 39d38a8f47d..3911d62040b 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -22,7 +22,7 @@ except ImportError: SafeLoader as FastestAvailableSafeLoader, ) -from propcache import cached_property +from propcache.api import cached_property from homeassistant.exceptions import HomeAssistantError diff --git a/mypy.ini b/mypy.ini index a71f980dac9..7f7b66e238f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3,20 +3,20 @@ # To update, run python3 -m script.hassfest -p mypy_config [mypy] -python_version = 3.12 +python_version = 3.13 platform = linux -plugins = pydantic.mypy +plugins = pydantic.mypy, pydantic.v1.mypy show_error_codes = true follow_imports = normal local_partial_types = true strict_equality = true +strict_bytes = true no_implicit_optional = true -report_deprecated_as_error = true warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true warn_unused_ignores = true -enable_error_code = ignore-without-code, redundant-self, truthy-iterable +enable_error_code = deprecated, ignore-without-code, redundant-self, truthy-iterable disable_error_code = annotation-unchecked, import-not-found, import-untyped extra_checks = false check_untyped_defs = true @@ -165,6 +165,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.acaia.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.accuweather.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1115,6 +1125,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.cookidoo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.counter.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1436,6 +1456,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.eheimdigital.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.electrasmart.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1966,6 +1996,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.habitica.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.hardkernel.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2086,6 +2126,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homee.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.homekit.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2336,6 +2386,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.incomfort.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.input_button.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2436,6 +2496,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.iron_os.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.islamic_prayer_times.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2626,6 +2696,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.letpot.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lidarr.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2766,6 +2846,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lovelace.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.luftdaten.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2826,6 +2916,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.mcp_server.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.mealie.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.media_extractor.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3326,6 +3436,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.overseerr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.p1_monitor.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3336,6 +3456,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pandora.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.panel_custom.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3346,6 +3476,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.peblar.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.peco.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3366,6 +3506,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.person.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.pi_hole.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3396,6 +3546,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.powerfox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.powerwall.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3486,6 +3646,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.python_script.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.qbus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.qnap_qsw.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3756,6 +3936,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.russound_rio.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ruuvi_gateway.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3806,6 +3996,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.schlage.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.scrape.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -4127,16 +4327,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.stookalert.*] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - [mypy-homeassistant.components.stookwijzer.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a837650f3b5..f76e0b43c10 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -55,10 +55,14 @@ class TypeHintMatch: """Confirm if function should be checked.""" return ( self.function_name == node.name - or self.has_async_counterpart - and node.name == f"async_{self.function_name}" - or self.function_name.endswith("*") - and node.name.startswith(self.function_name[:-1]) + or ( + self.has_async_counterpart + and node.name == f"async_{self.function_name}" + ) + or ( + self.function_name.endswith("*") + and node.name.startswith(self.function_name[:-1]) + ) ) @@ -102,7 +106,8 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "aiohttp_client": "ClientSessionGenerator", "aiohttp_server": "Callable[[], TestServer]", "area_registry": "AreaRegistry", - "async_test_recorder": "RecorderInstanceGenerator", + "async_test_recorder": "RecorderInstanceContextManager", + "async_setup_recorder_instance": "RecorderInstanceGenerator", "caplog": "pytest.LogCaptureFixture", "capsys": "pytest.CaptureFixture[str]", "current_request_with_host": "None", @@ -1017,6 +1022,34 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { return_type=None, has_async_counterpart=True, ), + TypeHintMatch( + function_name="async_handle_async_webrtc_offer", + arg_types={ + 1: "str", + 2: "str", + 3: "WebRTCSendMessage", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_on_webrtc_candidate", + arg_types={ + 1: "str", + 2: "RTCIceCandidateInit", + }, + return_type=None, + ), + TypeHintMatch( + function_name="close_webrtc_session", + arg_types={ + 1: "str", + }, + return_type=None, + ), + TypeHintMatch( + function_name="_async_get_webrtc_client_configuration", + return_type="WebRTCClientConfiguration", + ), ], ), ], @@ -2970,8 +3003,8 @@ def _is_valid_type( isinstance(node, nodes.Subscript) and isinstance(node.value, nodes.Name) and node.value.name in _KNOWN_GENERIC_TYPES - or isinstance(node, nodes.Name) - and node.name.endswith(_KNOWN_GENERIC_TYPES_TUPLE) + ) or ( + isinstance(node, nodes.Name) and node.name.endswith(_KNOWN_GENERIC_TYPES_TUPLE) ): return True diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index c6a869dd7fc..0d6582535f7 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -21,7 +21,7 @@ class ObsoleteImportMatch: _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { "functools": [ ObsoleteImportMatch( - reason="replaced by propcache.cached_property", + reason="replaced by propcache.api.cached_property", constant=re.compile(r"^cached_property$"), ), ], @@ -33,144 +33,10 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { ], "homeassistant.backports.functools": [ ObsoleteImportMatch( - reason="replaced by propcache.cached_property", + reason="replaced by propcache.api.cached_property", constant=re.compile(r"^cached_property$"), ), ], - "homeassistant.components.alarm_control_panel": [ - ObsoleteImportMatch( - reason="replaced by AlarmControlPanelEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ObsoleteImportMatch( - reason="replaced by CodeFormat enum", - constant=re.compile(r"^FORMAT_(\w*)$"), - ), - ], - "homeassistant.components.alarm_control_panel.const": [ - ObsoleteImportMatch( - reason="replaced by AlarmControlPanelEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ObsoleteImportMatch( - reason="replaced by CodeFormat enum", - constant=re.compile(r"^FORMAT_(\w*)$"), - ), - ], - "homeassistant.components.automation": [ - ObsoleteImportMatch( - reason="replaced by TriggerActionType from helpers.trigger", - constant=re.compile(r"^AutomationActionType$"), - ), - ObsoleteImportMatch( - reason="replaced by TriggerData from helpers.trigger", - constant=re.compile(r"^AutomationTriggerData$"), - ), - ObsoleteImportMatch( - reason="replaced by TriggerInfo from helpers.trigger", - constant=re.compile(r"^AutomationTriggerInfo$"), - ), - ], - "homeassistant.components.binary_sensor": [ - ObsoleteImportMatch( - reason="replaced by BinarySensorDeviceClass enum", - constant=re.compile(r"^DEVICE_CLASS_(\w*)$"), - ), - ], - "homeassistant.components.camera": [ - ObsoleteImportMatch( - reason="replaced by CameraEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ObsoleteImportMatch( - reason="replaced by StreamType enum", - constant=re.compile(r"^STREAM_TYPE_(\w*)$"), - ), - ], - "homeassistant.components.camera.const": [ - ObsoleteImportMatch( - reason="replaced by StreamType enum", - constant=re.compile(r"^STREAM_TYPE_(\w*)$"), - ), - ], - "homeassistant.components.climate": [ - ObsoleteImportMatch( - reason="replaced by HVACMode enum", - constant=re.compile(r"^HVAC_MODE_(\w*)$"), - ), - ObsoleteImportMatch( - reason="replaced by ClimateEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ], - "homeassistant.components.climate.const": [ - ObsoleteImportMatch( - reason="replaced by HVACAction enum", - constant=re.compile(r"^CURRENT_HVAC_(\w*)$"), - ), - ObsoleteImportMatch( - reason="replaced by HVACMode enum", - constant=re.compile(r"^HVAC_MODE_(\w*)$"), - ), - ObsoleteImportMatch( - reason="replaced by ClimateEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ], - "homeassistant.components.cover": [ - ObsoleteImportMatch( - reason="replaced by CoverDeviceClass enum", - constant=re.compile(r"^DEVICE_CLASS_(\w*)$"), - ), - ObsoleteImportMatch( - reason="replaced by CoverEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ], - "homeassistant.components.device_tracker": [ - ObsoleteImportMatch( - reason="replaced by SourceType enum", - constant=re.compile(r"^SOURCE_TYPE_\w+$"), - ), - ], - "homeassistant.components.device_tracker.const": [ - ObsoleteImportMatch( - reason="replaced by SourceType enum", - constant=re.compile(r"^SOURCE_TYPE_\w+$"), - ), - ], - "homeassistant.components.fan": [ - ObsoleteImportMatch( - reason="replaced by FanEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ], - "homeassistant.components.humidifier": [ - ObsoleteImportMatch( - reason="replaced by HumidifierDeviceClass enum", - constant=re.compile(r"^DEVICE_CLASS_(\w*)$"), - ), - ObsoleteImportMatch( - reason="replaced by HumidifierEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ], - "homeassistant.components.humidifier.const": [ - ObsoleteImportMatch( - reason="replaced by HumidifierDeviceClass enum", - constant=re.compile(r"^DEVICE_CLASS_(\w*)$"), - ), - ObsoleteImportMatch( - reason="replaced by HumidifierEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ], - "homeassistant.components.lock": [ - ObsoleteImportMatch( - reason="replaced by LockEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ], "homeassistant.components.light": [ ObsoleteImportMatch( reason="replaced by ColorMode enum", @@ -225,52 +91,12 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { constant=re.compile(r"^REPEAT_MODE(\w*)$"), ), ], - "homeassistant.components.remote": [ - ObsoleteImportMatch( - reason="replaced by RemoteEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ], - "homeassistant.components.sensor": [ - ObsoleteImportMatch( - reason="replaced by SensorDeviceClass enum", - constant=re.compile(r"^DEVICE_CLASS_(?!STATE_CLASSES)$"), - ), - ObsoleteImportMatch( - reason="replaced by SensorStateClass enum", - constant=re.compile(r"^STATE_CLASS_(\w*)$"), - ), - ], - "homeassistant.components.siren": [ - ObsoleteImportMatch( - reason="replaced by SirenEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ], - "homeassistant.components.siren.const": [ - ObsoleteImportMatch( - reason="replaced by SirenEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ], - "homeassistant.components.switch": [ - ObsoleteImportMatch( - reason="replaced by SwitchDeviceClass enum", - constant=re.compile(r"^DEVICE_CLASS_(\w*)$"), - ), - ], "homeassistant.components.vacuum": [ ObsoleteImportMatch( reason="replaced by VacuumEntityFeature enum", constant=re.compile(r"^SUPPORT_(\w*)$"), ), ], - "homeassistant.components.water_heater": [ - ObsoleteImportMatch( - reason="replaced by WaterHeaterEntityFeature enum", - constant=re.compile(r"^SUPPORT_(\w*)$"), - ), - ], "homeassistant.config_entries": [ ObsoleteImportMatch( reason="replaced by ConfigEntryDisabler enum", @@ -282,86 +108,6 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { reason="replaced by local constants", constant=re.compile(r"^CONF_UNIT_SYSTEM_(\w+)$"), ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^DATA_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by ***DeviceClass enum", - constant=re.compile(r"^DEVICE_CLASS_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^ELECTRIC_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^ENERGY_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by EntityCategory enum", - constant=re.compile(r"^(ENTITY_CATEGORY_(\w+))|(ENTITY_CATEGORIES)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^FREQUENCY_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^IRRADIATION_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^LENGTH_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^MASS_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^POWER_(?!VOLT_AMPERE_REACTIVE)(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^PRECIPITATION_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^PRESSURE_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^SOUND_PRESSURE_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^SPEED_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^TEMP_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^TIME_(\w+)$"), - ), - ObsoleteImportMatch( - reason="replaced by unit enums", - constant=re.compile(r"^VOLUME_(\w+)$"), - ), - ], - "homeassistant.core": [ - ObsoleteImportMatch( - reason="replaced by ConfigSource enum", - constant=re.compile(r"^SOURCE_(\w*)$"), - ), - ], - "homeassistant.data_entry_flow": [ - ObsoleteImportMatch( - reason="replaced by FlowResultType enum", - constant=re.compile(r"^RESULT_TYPE_(\w*)$"), - ), ], "homeassistant.helpers.config_validation": [ ObsoleteImportMatch( @@ -369,12 +115,6 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { constant=re.compile(r"^PLATFORM_SCHEMA(_BASE)?$"), ), ], - "homeassistant.helpers.device_registry": [ - ObsoleteImportMatch( - reason="replaced by DeviceEntryDisabler enum", - constant=re.compile(r"^DISABLED_(\w*)$"), - ), - ], "homeassistant.helpers.json": [ ObsoleteImportMatch( reason="moved to homeassistant.util.json", @@ -383,18 +123,18 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { ), ), ], - "homeassistant.util": [ - ObsoleteImportMatch( - reason="replaced by unit_conversion.***Converter", - constant=re.compile(r"^(distance|pressure|speed|temperature|volume)$"), - ), - ], "homeassistant.util.unit_system": [ ObsoleteImportMatch( reason="replaced by US_CUSTOMARY_SYSTEM", constant=re.compile(r"^IMPERIAL_SYSTEM$"), ), ], + "propcache": [ + ObsoleteImportMatch( + reason="importing from propcache.api recommended", + constant=re.compile(r"^(under_)?cached_property$"), + ), + ], } _IGNORE_ROOT_IMPORT = ( @@ -534,9 +274,8 @@ class HassImportsFormatChecker(BaseChecker): self, current_package: str, node: nodes.ImportFrom ) -> None: """Check for improper 'from ._ import _' invocations.""" - if ( - node.level <= 1 - or not current_package.startswith("homeassistant.components.") + if node.level <= 1 or ( + not current_package.startswith("homeassistant.components.") and not current_package.startswith("tests.components.") ): return diff --git a/pyproject.toml b/pyproject.toml index e281a2429d0..56f2533840a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.12.0.dev0" +version = "2025.2.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -18,20 +18,20 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Home Automation", ] -requires-python = ">=3.12.0" +requires-python = ">=3.13.0" dependencies = [ "aiodns==3.2.0", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.1", - "aiohttp==3.11.7", + "aiohasupervisor==0.2.2b5", + "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", + "aiohttp-asyncmdnsresolver==0.0.1", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", @@ -41,46 +41,48 @@ dependencies = [ "awesomeversion==24.6.0", "bcrypt==4.2.0", "certifi>=2021.5.30", - "ciso8601==2.3.1", - "fnv-hash-fast==1.0.2", + "ciso8601==2.3.2", + "cronsim==2.6", + "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.85.0", + "hass-nabucasa==0.88.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.27.2", + "httpx==0.28.1", "home-assistant-bluetooth==1.13.0", "ifaddr==0.2.0", - "Jinja2==3.1.4", + "Jinja2==3.1.5", "lru-dict==1.3.0", - "PyJWT==2.10.0", + "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==43.0.1", - "Pillow==11.0.0", - "propcache==0.2.0", - "pyOpenSSL==24.2.1", - "orjson==3.10.11", + "cryptography==44.0.0", + "Pillow==11.1.0", + "propcache==0.2.1", + "pyOpenSSL==24.3.0", + "orjson==3.10.12", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2024.11.0", - "SQLAlchemy==2.0.31", + "securetar==2025.1.3", + "SQLAlchemy==2.0.36", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", "typing-extensions>=4.12.2,<5.0", - "ulid-transform==1.0.2", + "ulid-transform==1.2.0", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.5.4", + "uv==0.5.21", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", - "voluptuous-openapi==0.0.5", - "yarl==1.18.0", + "voluptuous-openapi==0.0.6", + "yarl==1.18.3", "webrtc-models==0.3.0", + "zeroconf==0.141.0" ] [project.urls] @@ -101,7 +103,7 @@ include-package-data = true include = ["homeassistant*"] [tool.pylint.MAIN] -py-version = "3.12" +py-version = "3.13" # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs = 2 @@ -527,8 +529,6 @@ filterwarnings = [ # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", - # https://github.com/hunterjm/python-onvif-zeep-async/pull/51 - >3.1.12 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client", # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", @@ -700,7 +700,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.8.0" +required-version = ">=0.9.1" [tool.ruff.lint] select = [ @@ -719,8 +719,10 @@ select = [ "B017", # pytest.raises(BaseException) should be considered evil "B018", # Found useless attribute access. Either assign it to a variable or remove it. "B023", # Function definition does not bind loop variable {name} + "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties "B026", # Star-arg unpacking after a keyword argument is strongly discouraged "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? + "B035", # Dictionary comprehension uses static key "B904", # Use raise from to specify exception cause "B905", # zip() without an explicit strict= parameter "BLE", @@ -754,12 +756,27 @@ select = [ "RSE", # flake8-raise "RUF005", # Consider iterable unpacking instead of concatenation "RUF006", # Store a reference to the return value of asyncio.create_task + "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs + "RUF008", # Do not use mutable default values for dataclass attributes "RUF010", # Use explicit conversion flag "RUF013", # PEP 484 prohibits implicit Optional + "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer "RUF017", # Avoid quadratic list summation "RUF018", # Avoid assignment expressions in assert statements "RUF019", # Unnecessary key check before dictionary access - # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up + "RUF020", # {never_like} | T is equivalent to T + "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear + "RUF022", # Sort __all__ + "RUF023", # Sort __slots__ + "RUF024", # Do not pass mutable objects as values to dict.fromkeys + "RUF026", # default_factory is a positional-only argument to defaultdict + "RUF030", # print() call in assert statement is likely unintentional + "RUF032", # Decimal() called with float literal argument + "RUF033", # __post_init__ method with argument defaults + "RUF034", # Useless if-else condition + "RUF100", # Unused `noqa` directive + "RUF101", # noqa directives that use redirected rule codes + "RUF200", # Failed to parse pyproject.toml: {message} "S102", # Use of exec detected "S103", # bad-file-permissions "S108", # hardcoded-temp-file @@ -838,7 +855,6 @@ ignore = [ "Q", "COM812", "COM819", - "ISC001", # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605" @@ -896,7 +912,15 @@ voluptuous = "vol" "homeassistant.helpers.floor_registry" = "fr" "homeassistant.helpers.issue_registry" = "ir" "homeassistant.helpers.label_registry" = "lr" +"homeassistant.util.color" = "color_util" "homeassistant.util.dt" = "dt_util" +"homeassistant.util.json" = "json_util" +"homeassistant.util.location" = "location_util" +"homeassistant.util.logging" = "logging_util" +"homeassistant.util.network" = "network_util" +"homeassistant.util.ulid" = "ulid_util" +"homeassistant.util.uuid" = "uuid_util" +"homeassistant.util.yaml" = "yaml_util" [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false @@ -905,6 +929,7 @@ mark-parentheses = false [tool.ruff.lint.flake8-tidy-imports.banned-api] "async_timeout".msg = "use asyncio.timeout instead" "pytz".msg = "use zoneinfo instead" +"tests".msg = "You should not import tests" [tool.ruff.lint.isort] force-sort-within-sections = true @@ -934,4 +959,4 @@ split-on-trailing-comma = false max-complexity = 25 [tool.ruff.lint.pydocstyle] -property-decorators = ["propcache.cached_property"] +property-decorators = ["propcache.api.cached_property"] diff --git a/requirements.txt b/requirements.txt index 5ca03592107..f1eb8dac825 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,10 +4,11 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.1 -aiohttp==3.11.7 +aiohasupervisor==0.2.2b5 +aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 +aiohttp-asyncmdnsresolver==0.0.1 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 @@ -17,35 +18,37 @@ audioop-lts==0.2.1;python_version>='3.13' awesomeversion==24.6.0 bcrypt==4.2.0 certifi>=2021.5.30 -ciso8601==2.3.1 -fnv-hash-fast==1.0.2 -hass-nabucasa==0.85.0 -httpx==0.27.2 +ciso8601==2.3.2 +cronsim==2.6 +fnv-hash-fast==1.2.2 +hass-nabucasa==0.88.1 +httpx==0.28.1 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 -Jinja2==3.1.4 +Jinja2==3.1.5 lru-dict==1.3.0 -PyJWT==2.10.0 -cryptography==43.0.1 -Pillow==11.0.0 -propcache==0.2.0 -pyOpenSSL==24.2.1 -orjson==3.10.11 +PyJWT==2.10.1 +cryptography==44.0.0 +Pillow==11.1.0 +propcache==0.2.1 +pyOpenSSL==24.3.0 +orjson==3.10.12 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2024.11.0 -SQLAlchemy==2.0.31 +securetar==2025.1.3 +SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 -ulid-transform==1.0.2 +ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.4 +uv==0.5.21 voluptuous==0.15.2 voluptuous-serialize==2.6.0 -voluptuous-openapi==0.0.5 -yarl==1.18.0 +voluptuous-openapi==0.0.6 +yarl==1.18.3 webrtc-models==0.3.0 +zeroconf==0.141.0 diff --git a/requirements_all.txt b/requirements_all.txt index f758f2d9cbb..2a92d18f57d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,10 +4,10 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.6.3 +AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.25 +AIOSomecomfort==0.0.30 # homeassistant.components.adax Adax-local==0.1.5 @@ -33,7 +33,7 @@ Mastodon.py==1.8.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.0.0 +Pillow==11.1.0 # homeassistant.components.plex PlexAPI==4.15.16 @@ -48,7 +48,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==0.0.2 +PyFlick==1.1.3 # homeassistant.components.flume PyFlume==0.6.5 @@ -60,7 +60,7 @@ PyFronius==0.7.3 PyLoadAPI==1.3.2 # homeassistant.components.met_eireann -PyMetEireann==2021.8.0 +PyMetEireann==2024.11.0 # homeassistant.components.met # homeassistant.components.norway_air @@ -84,13 +84,13 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.53.2 +PySwitchbot==0.55.4 # homeassistant.components.switchmate PySwitchmate==0.5.1 # homeassistant.components.syncthru -PySyncThru==0.7.10 +PySyncThru==0.8.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.35.0 +PyViCare==2.41.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.31 +SQLAlchemy==2.0.36 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 @@ -128,7 +128,7 @@ TravisPy==0.3.5 TwitterAPI==2.7.12 # homeassistant.components.onvif -WSDiscovery==2.0.0 +WSDiscovery==2.1.2 # homeassistant.components.accuweather accuweather==4.0.0 @@ -173,7 +173,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.9 +aioacaia==0.1.14 # homeassistant.components.airq aioairq==0.4.3 @@ -182,7 +182,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.10.3 +aioautomower==2025.1.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -213,7 +213,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.9.1 +aiocomelit==0.10.1 # homeassistant.components.dhcp aiodhcpwatcher==1.0.2 @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.2 +aioesphomeapi==28.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -252,26 +252,28 @@ aioflo==2021.11.0 aioftp==0.21.3 # homeassistant.components.github -# homeassistant.components.iron_os aiogithubapi==24.6.0 # homeassistant.components.guardian aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.10 +aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.1 +aiohasupervisor==0.2.2b5 # homeassistant.components.homekit_controller -aiohomekit==3.2.6 +aiohomekit==3.2.7 + +# homeassistant.components.mcp_server +aiohttp_sse==2.2.0 # homeassistant.components.hue aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==1.1.0 +aioimaplib==2.0.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -283,13 +285,10 @@ aiokef==0.2.16 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.5.5 +aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.1 - -# homeassistant.components.livisi -aiolivisi==0.0.19 +aiolifx==1.1.2 # homeassistant.components.lookin aiolookin==1.0.0 @@ -298,7 +297,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.3 +aiomealie==0.9.5 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -322,16 +321,16 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.9 # homeassistant.components.pegel_online -aiopegelonline==0.1.0 +aiopegelonline==0.1.1 # homeassistant.components.acmeda aiopulse==0.4.6 # homeassistant.components.purpleair -aiopurpleair==2022.12.1 +aiopurpleair==2023.12.0 # homeassistant.components.hunterdouglas_powerview aiopvapi==3.1.1 @@ -348,7 +347,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 @@ -360,7 +359,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.1.0 +aiorussound==4.4.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -369,7 +368,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.0.1 +aioshelly==12.3.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -387,7 +386,7 @@ aiosteamist==1.0.0 aiostreammagic==2.10.0 # homeassistant.components.switcher_kis -aioswitcher==5.0.0 +aioswitcher==6.0.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -402,7 +401,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==80 +aiounifi==81 # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -417,10 +416,10 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.4.2 +aiowebostv==0.6.0 # homeassistant.components.withings -aiowithings==3.1.3 +aiowithings==3.1.5 # homeassistant.components.yandex_transport aioymaps==1.2.5 @@ -453,7 +452,7 @@ amberelectric==2.0.12 amcrest==1.9.8 # homeassistant.components.androidtv -androidtv[async]==0.0.73 +androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote androidtvremote2==0.1.2 @@ -468,13 +467,16 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.31.2 +anthropic==0.44.0 + +# homeassistant.components.mcp_server +anyio==4.8.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 # homeassistant.components.apprise -apprise==1.9.0 +apprise==1.9.1 # homeassistant.components.aprs aprslib==0.7.2 @@ -486,7 +488,7 @@ apsystems-ez1==2.4.0 aqualogic==2.6 # homeassistant.components.aranet -aranet4==2.4.0 +aranet4==2.5.0 # homeassistant.components.arcam_fmj arcam-fmj==1.5.2 @@ -503,7 +505,7 @@ asmog==0.0.6 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.41.0 +async-upnp-client==0.43.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -543,10 +545,10 @@ av==13.1.0 # avion==0.10 # homeassistant.components.axis -axis==63 +axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.4 +ayla-iot-unofficial==1.4.5 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -582,17 +584,17 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.4 +bimmer-connected[china]==0.17.2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==1.1.0 +bleak-esphome==2.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.8.0 # homeassistant.components.bluetooth bleak==0.22.3 @@ -617,7 +619,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.2 +bluetooth-adapters==0.21.1 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -626,7 +628,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.20.0 +bluetooth-data-tools==1.22.0 # homeassistant.components.bond bond-async==0.2.1 @@ -707,6 +709,10 @@ connect-box==0.3.1 # homeassistant.components.xiaomi_miio construct==2.10.68 +# homeassistant.components.cookidoo +cookidoo-api==0.12.2 + +# homeassistant.components.backup # homeassistant.components.utility_meter cronsim==2.6 @@ -726,10 +732,10 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.24.3 +dbus-fast==2.30.2 # homeassistant.components.debugpy -debugpy==1.8.6 +debugpy==1.8.11 # homeassistant.components.decora_wifi # decora-wifi==1.4 @@ -738,7 +744,7 @@ debugpy==1.8.6 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==9.0.0 +deebot-client==11.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -749,13 +755,13 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==0.4.0 +demetriek==1.2.0 # homeassistant.components.denonavr -denonavr==1.0.0 +denonavr==1.0.1 # homeassistant.components.devialet -devialet==1.4.5 +devialet==1.5.7 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.3 @@ -808,11 +814,14 @@ ebusdpy==0.0.17 # homeassistant.components.ecoal_boiler ecoaliface==0.4.0 +# homeassistant.components.eheimdigital +eheimdigital==1.0.5 + # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 # homeassistant.components.elevenlabs -elevenlabs==1.6.1 +elevenlabs==1.9.0 # homeassistant.components.elgato elgato==5.1.2 @@ -821,10 +830,10 @@ elgato==5.1.2 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax -elmax-api==0.0.6.1 +elmax-api==0.0.6.4rc0 # homeassistant.components.elvia elvia==0.1.0 @@ -878,7 +887,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.20 +evohome-async==0.4.21 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 @@ -912,7 +921,7 @@ fivem-api==0.1.2 fixerio==1.0.0a0 # homeassistant.components.fjaraskupan -fjaraskupan==2.3.0 +fjaraskupan==2.3.2 # homeassistant.components.flexit_bacnet flexit_bacnet==2.2.1 @@ -921,11 +930,11 @@ flexit_bacnet==2.2.1 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.0.4 +flux-led==1.1.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.0.2 +fnv-hash-fast==1.2.2 # homeassistant.components.foobot foobot_async==1.0.0 @@ -937,7 +946,7 @@ forecast-solar==4.0.0 fortiosapi==1.0.5 # homeassistant.components.freebox -freebox-api==1.1.0 +freebox-api==1.2.2 # homeassistant.components.free_mobile freesms==0.2.0 @@ -950,16 +959,16 @@ fritzconnection[qr]==1.14.0 fyta_cli==0.7.0 # homeassistant.components.google_translate -gTTS==2.2.4 +gTTS==2.5.3 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.4 +gardena-bluetooth==1.5.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 # homeassistant.components.google -gcal-sync==6.2.0 +gcal-sync==7.0.0 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -984,7 +993,7 @@ georss-qld-bushfire-alert-client==0.8 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.9.4 +getmac==0.9.5 # homeassistant.components.gios gios==5.0.0 @@ -996,7 +1005,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.1 +go2rtc-client==0.1.2 # homeassistant.components.goalzero goalzero==0.2.2 @@ -1021,7 +1030,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.5 +google-nest-sdm==7.0.0 # homeassistant.components.google_photos google-photos-library-api==0.12.1 @@ -1030,13 +1039,14 @@ google-photos-library-api==0.12.1 googlemaps==2.5.1 # homeassistant.components.slide +# homeassistant.components.slide_local goslide-api==0.7.0 # homeassistant.components.tailwind -gotailwind==0.2.4 +gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.40.0 +govee-ble==0.42.0 # homeassistant.components.govee_light_local govee-local-api==1.5.3 @@ -1069,7 +1079,7 @@ gspread==5.5.0 gstreamer-player==1.1.2 # homeassistant.components.profiler -guppy3==3.1.4.post1;python_version<'3.13' +guppy3==3.1.5 # homeassistant.components.iaqualink h2==4.1.0 @@ -1084,19 +1094,19 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habitipy==0.3.3 +habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.6.0 +habluetooth==3.12.0 # homeassistant.components.cloud -hass-nabucasa==0.85.0 +hass-nabucasa==0.88.1 # homeassistant.components.splunk hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.0.4 +hassil==2.1.0 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -1127,19 +1137,19 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.61 +holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20241106.2 +home-assistant-frontend==20250109.2 # homeassistant.components.conversation -home-assistant-intents==2024.11.13 +home-assistant-intents==2025.1.1 # homeassistant.components.home_connect homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.3 +homematicip==1.1.6 # homeassistant.components.horizon horimote==0.4.1 @@ -1171,13 +1181,16 @@ ibmiotf==0.3.4 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==8.2.0 +ical==8.3.0 + +# homeassistant.components.caldav +icalendar==6.1.0 # homeassistant.components.ping icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.6.2 +idasen-ha==2.6.3 # homeassistant.components.network ifaddr==0.2.0 @@ -1185,14 +1198,17 @@ ifaddr==0.2.0 # homeassistant.components.iglo iglo==1.2.7 +# homeassistant.components.igloohome +igloohome-api==0.0.6 + # homeassistant.components.ihc ihcsdk==2.8.5 # homeassistant.components.imgw_pib -imgw_pib==1.0.6 +imgw_pib==1.0.9 # homeassistant.components.incomfort -incomfort-client==0.6.3-1 +incomfort-client==0.6.7 # homeassistant.components.influxdb influxdb-client==1.24.0 @@ -1210,7 +1226,7 @@ insteon-frontend-home-assistant==0.5.0 intellifire4py==4.1.9 # homeassistant.components.iotty -iottycloud==0.2.1 +iottycloud==0.3.0 # homeassistant.components.iperf3 iperf3==0.1.11 @@ -1241,7 +1257,7 @@ justnimbus==0.7.4 kaiterra-async-client==1.0.0 # homeassistant.components.keba -keba-kecontact==1.1.0 +keba-kecontact==1.3.0 # homeassistant.components.kegtron kegtron-ble==0.4.0 @@ -1250,10 +1266,10 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knocki -knocki==0.3.5 +knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.11.16.205004 +knx-frontend==2025.1.18.164225 # homeassistant.components.konnected konnected==1.2.0 @@ -1271,7 +1287,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.2 +lcn-frontend==0.2.3 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1280,11 +1296,14 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.0.2 +led-ble==1.1.4 # homeassistant.components.lektrico lektricowifi==0.0.43 +# homeassistant.components.letpot +letpot==0.3.0 + # homeassistant.components.foscam libpyfoscam==1.2.2 @@ -1312,6 +1331,9 @@ linear-garage-door==0.2.9 # homeassistant.components.linode linode-api==4.1.9b1 +# homeassistant.components.livisi +livisi==0.0.24 + # homeassistant.components.google_maps locationsharinglib==5.0.1 @@ -1342,6 +1364,9 @@ maxcube-api==0.4.3 # homeassistant.components.mythicbeastsdns mbddns==0.1.2 +# homeassistant.components.mcp_server +mcp==1.1.2 + # homeassistant.components.minecraft_server mcstatus==11.1.1 @@ -1403,13 +1428,13 @@ motionblindsble==0.1.3 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==4.1.1.116.3 +mozart-api==4.1.1.116.4 # homeassistant.components.mullvad mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.5 +music-assistant-client==1.0.8 # homeassistant.components.tts mutagen==1.47.0 @@ -1421,7 +1446,7 @@ mutesync==0.0.1 mypermobil==0.1.8 # homeassistant.components.myuplink -myuplink==0.6.0 +myuplink==0.7.0 # homeassistant.components.nad nad-receiver==0.3.0 @@ -1433,13 +1458,13 @@ ndms2-client==0.1.2 nessclient==1.1.2 # homeassistant.components.netdata -netdata==1.1.0 +netdata==1.3.0 # homeassistant.components.nmap_tracker netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==3.3.0 +nettigo-air-monitor==4.0.0 # homeassistant.components.neurio_energy neurio==0.3.1 @@ -1456,14 +1481,14 @@ nextcord==2.6.0 # homeassistant.components.nextdns nextdns==4.0.0 +# homeassistant.components.niko_home_control +nhc==0.3.9 + # homeassistant.components.nibe_heatpump -nibe==2.13.0 +nibe==2.14.0 # homeassistant.components.nice_go -nice-go==0.3.10 - -# homeassistant.components.niko_home_control -niko-home-control==0.2.1 +nice-go==1.0.0 # homeassistant.components.nilu niluclient==0.1.2 @@ -1478,7 +1503,7 @@ notifications-android-tv==0.1.5 notify-events==1.0.4 # homeassistant.components.nederlandse_spoorwegen -nsapi==3.0.5 +nsapi==3.1.2 # homeassistant.components.nsw_fuel_station nsw-fuel-api-client==1.1.0 @@ -1494,7 +1519,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.1.3 +numpy==2.2.2 # homeassistant.components.nyt_games nyt_games==0.4.4 @@ -1514,8 +1539,11 @@ odp-amsterdam==6.0.2 # homeassistant.components.oem oemthermostat==1.1.1 +# homeassistant.components.ohme +ohme==1.2.5 + # homeassistant.components.ollama -ollama==0.3.3 +ollama==0.4.7 # homeassistant.components.omnilogic omnilogic==0.4.5 @@ -1524,16 +1552,16 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.12 +onvif-zeep-async==3.2.5 # homeassistant.components.opengarage open-garage==0.2.0 # homeassistant.components.open_meteo -open-meteo==0.3.1 +open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.35.7 +openai==1.59.9 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1548,7 +1576,7 @@ openhomedevice==2.2.0 opensensemap-api==0.2.0 # homeassistant.components.enigma2 -openwebifpy==4.2.7 +openwebifpy==4.3.1 # homeassistant.components.luci openwrt-luci-rpc==1.1.17 @@ -1557,7 +1585,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.6 +opower==0.8.7 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1589,6 +1617,9 @@ panasonic-viera==0.4.2 # homeassistant.components.dunehd pdunehd==1.3.2 +# homeassistant.components.peblar +peblar==0.4.0 + # homeassistant.components.peco peco==0.0.30 @@ -1601,7 +1632,7 @@ pescea==1.0.12 # homeassistant.components.aruba # homeassistant.components.cisco_ios # homeassistant.components.pandora -pexpect==4.6.0 +pexpect==4.9.0 # homeassistant.components.modem_callerid phone-modem==0.1.1 @@ -1622,7 +1653,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.5.2 +plugwise==1.6.4 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1633,6 +1664,9 @@ pmsensor==0.4 # homeassistant.components.poolsense poolsense==0.0.8 +# homeassistant.components.powerfox +powerfox==1.2.1 + # homeassistant.components.reddit praw==7.5.0 @@ -1654,14 +1688,11 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.1.0 +psutil==6.1.1 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 -# homeassistant.components.androidtv -pure-python-adb[async]==0.3.0.dev0 - # homeassistant.components.pushbullet pushbullet.py==0.11.0 @@ -1669,10 +1700,10 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.1.1 +pvo==2.2.0 # homeassistant.components.aosmith -py-aosmith==1.0.11 +py-aosmith==1.0.12 # homeassistant.components.canary py-canary==0.5.4 @@ -1708,10 +1739,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.5.3 - -# homeassistant.components.zabbix -py-zabbix==1.1.7 +py-synologydsm-api==2.6.0 # homeassistant.components.atome pyAtome==0.1.1 @@ -1734,6 +1762,9 @@ pyEmby==1.10 # homeassistant.components.hikvision pyHik==0.3.2 +# homeassistant.components.homee +pyHomee==1.2.3 + # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 @@ -1766,7 +1797,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.4 +pyaprilaire==0.7.7 # homeassistant.components.asuswrt pyasuswrt==0.1.21 @@ -1778,10 +1809,10 @@ pyatag==0.3.5.3 pyatmo==8.1.0 # homeassistant.components.apple_tv -pyatv==0.15.1 +pyatv==0.16.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.0.15 +pyaussiebb==0.1.5 # homeassistant.components.balboa pybalboa==1.0.2 @@ -1793,7 +1824,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==1.0.4 +pyblu==2.0.0 # homeassistant.components.neato pybotvac==0.0.25 @@ -1832,16 +1863,16 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.cups -# pycups==1.9.73 +# pycups==2.0.4 # homeassistant.components.daikin -pydaikin==2.13.7 +pydaikin==2.13.8 # homeassistant.components.danfoss_air pydanfossair==0.1.0 # homeassistant.components.deako -pydeako==0.5.4 +pydeako==0.6.0 # homeassistant.components.deconz pydeconz==118 @@ -1859,7 +1890,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2024.9.0 +pydrawise==2025.1.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1892,7 +1923,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.0 +pyenphase==1.23.1 # homeassistant.components.envisalink pyenvisalink==4.7 @@ -1949,10 +1980,10 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.7.2 +pyheos==1.0.0 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.1 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -1973,7 +2004,7 @@ pyinsteon==1.6.3 pyintesishome==1.8.0 # homeassistant.components.ipma -pyipma==3.0.7 +pyipma==3.0.8 # homeassistant.components.ipp pyipp==0.17.0 @@ -1996,6 +2027,9 @@ pyisy==3.1.14 # homeassistant.components.itach pyitachip2ir==0.0.7 +# homeassistant.components.ituran +pyituran==0.1.4 + # homeassistant.components.jvc_projector pyjvcprojector==1.1.2 @@ -2027,7 +2061,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.2.11 +pylamarzocco==1.4.6 # homeassistant.components.lastfm pylast==5.1.0 @@ -2045,10 +2079,10 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2023.5.0 +pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.22.0 +pylutron-caseta==0.23.0 # homeassistant.components.lutron pylutron==0.2.16 @@ -2078,7 +2112,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.6.9 +pymodbus==3.8.3 # homeassistant.components.monoprice pymonoprice==0.4 @@ -2090,7 +2124,7 @@ pymsteams==0.1.12 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==0.2.1 +pynecil==4.0.1 # homeassistant.components.netgear pynetgear==0.10.10 @@ -2102,7 +2136,7 @@ pynetio==0.1.9.1 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.2 +pynordpool==0.2.4 # homeassistant.components.nuki pynuki==1.6.3 @@ -2149,19 +2183,19 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.15.0 +pyoverkiz==1.15.5 # homeassistant.components.onewire pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.14 +pypalazzetti==0.1.19 # homeassistant.components.elv pypca==0.0.7 # homeassistant.components.lcn -pypck==0.7.24 +pypck==0.8.3 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -2203,7 +2237,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.4 +pyrisco==0.6.5 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -2227,7 +2261,7 @@ pyschlage==2024.11.0 pysensibo==1.1.0 # homeassistant.components.serial -pyserial-asyncio-fast==0.13 +pyserial-asyncio-fast==0.14 # homeassistant.components.acer_projector # homeassistant.components.crownstone @@ -2251,7 +2285,7 @@ pysignalclirestapi==0.3.24 pyskyqhub==0.1.4 # homeassistant.components.sma -pysma==0.7.3 +pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 @@ -2269,7 +2303,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.3 +pysmlight==0.1.6 # homeassistant.components.snmp pysnmp==6.2.6 @@ -2287,13 +2321,13 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.10.0 +pysqueezebox==0.11.1 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==1.3.2 +pysuezV2==2.0.3 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -2344,10 +2378,10 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.8.0 +python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v7.0.0 +python-homewizard-energy==v8.1.1 # homeassistant.components.hp_ilo python-hpilo==4.4.3 @@ -2362,16 +2396,16 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.7 +python-kasa[speedups]==0.9.1 # homeassistant.components.linkplay -python-linkplay==0.0.20 +python-linkplay==0.1.3 # homeassistant.components.lirc # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==6.6.0 +python-matter-server==7.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2390,7 +2424,10 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.6.0 +python-otbr-api==2.7.0 + +# homeassistant.components.overseerr +python-overseerr==0.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -2402,7 +2439,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.7.2 +python-roborock==2.8.4 # homeassistant.components.smarttub python-smarttub==0.0.38 @@ -2411,7 +2448,7 @@ python-smarttub==0.0.38 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.17.7 +python-tado==0.18.5 # homeassistant.components.technove python-technove==1.3.1 @@ -2426,7 +2463,7 @@ python-vlc==3.0.18122 pythonegardia==1.0.52 # homeassistant.components.tile -pytile==2023.12.0 +pytile==2024.12.0 # homeassistant.components.tomorrowio pytomorrowio==0.3.6 @@ -2435,7 +2472,7 @@ pytomorrowio==0.3.6 pytouchline==0.7 # homeassistant.components.touchline_sl -pytouchlinesl==0.2.0 +pytouchlinesl==0.3.0 # homeassistant.components.traccar # homeassistant.components.traccar_server @@ -2469,13 +2506,13 @@ pyvera==0.3.15 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.12 +pyvesync==2.1.16 # homeassistant.components.vizio pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.21 +pyvlx==0.2.26 # homeassistant.components.volumio pyvolumio==0.1.5 @@ -2519,6 +2556,9 @@ pyzerproc==0.4.8 # homeassistant.components.qbittorrent qbittorrent-api==2024.2.59 +# homeassistant.components.qbus +qbusmqttapi==1.2.3 + # homeassistant.components.qingping qingping-ble==0.10.0 @@ -2544,19 +2584,19 @@ rapt-ble==0.1.2 raspyrfm-client==1.2.8 # homeassistant.components.refoss -refoss-ha==1.2.4 +refoss-ha==1.2.5 # homeassistant.components.rainmachine regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.7 +renault-api==0.2.9 # homeassistant.components.renson -renson-endura-delta==1.7.1 +renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.3 +reolink-aio==0.11.8 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2610,7 +2650,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.6.0 +samsungtvws[async,encrypted]==2.7.2 # homeassistant.components.sanix sanix==1.0.6 @@ -2625,7 +2665,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2024.11.0 +securetar==2025.1.3 # homeassistant.components.sendgrid sendgrid==6.8.2 @@ -2680,7 +2720,7 @@ sisyphus-control==3.1.4 skyboxremote==0.0.6 # homeassistant.components.slack -slackclient==2.5.0 +slack_sdk==3.33.4 # homeassistant.components.xmpp slixmpp==1.8.5 @@ -2689,7 +2729,7 @@ slixmpp==1.8.5 smart-meter-texas==0.5.5 # homeassistant.components.smhi -smhi-pkg==1.0.18 +smhi-pkg==1.0.19 # homeassistant.components.snapcast snapcast==2.3.6 @@ -2704,7 +2744,7 @@ solaredge-local==0.2.3 solarlog_cli==0.4.0 # homeassistant.components.solax -solax==3.2.1 +solax==3.2.3 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 @@ -2719,7 +2759,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.8 +spotifyaio==0.8.11 # homeassistant.components.sql sqlparse==0.5.0 @@ -2734,7 +2774,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.2.0 +starlink-grpc-core==1.2.2 # homeassistant.components.statsd statsd==3.2.1 @@ -2742,9 +2782,6 @@ statsd==3.2.1 # homeassistant.components.steam_online steamodd==4.21 -# homeassistant.components.stookalert -stookalert==0.1.4 - # homeassistant.components.stookwijzer stookwijzer==1.5.1 @@ -2757,7 +2794,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.11 +subarulink==0.7.13 # homeassistant.components.sunweg sunweg==3.0.2 @@ -2769,7 +2806,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.synology_srm synology-srm==0.2.0 @@ -2810,7 +2847,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.8.4 +tesla-fleet-api==0.9.2 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2819,7 +2856,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.4.2 +teslemetry-stream==0.6.6 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2831,13 +2868,13 @@ tessie-api==0.1.1 thermobeacon-ble==0.7.0 # homeassistant.components.thermopro -thermopro-ble==0.10.0 +thermopro-ble==0.10.1 # homeassistant.components.thingspeak thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.1 +thinqconnect==1.0.2 # homeassistant.components.tikteck tikteck==0.4 @@ -2858,7 +2895,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.5 +total-connect-client==2024.12 # homeassistant.components.tplink_lte tp-connected==0.0.4 @@ -2882,7 +2919,7 @@ ttn_client==1.2.0 tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu -twentemilieu==2.1.0 +twentemilieu==2.2.1 # homeassistant.components.twilio twilio==6.32.0 @@ -2897,7 +2934,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.2 +uiprotect==7.5.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2906,16 +2943,16 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.unifi_direct -unifi_ap==0.0.1 +unifi_ap==0.0.2 # homeassistant.components.unifiled unifiled==0.11 -# homeassistant.components.zha +# homeassistant.components.homeassistant_hardware universal-silabs-flasher==0.0.25 # homeassistant.components.upb -upb-lib==0.5.8 +upb-lib==0.5.9 # homeassistant.components.upcloud upcloud-api==2.6.0 @@ -2938,7 +2975,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.11.1 +velbus-aio==2025.1.1 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2947,7 +2984,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.1.0 +voip-utils==0.2.2 # homeassistant.components.volkszaehler volkszaehler==0.4.0 @@ -2975,11 +3012,14 @@ wakeonlan==2.1.0 wallbox==0.7.0 # homeassistant.components.folder_watcher -watchdog==2.3.1 +watchdog==6.0.0 # homeassistant.components.waterfurnace waterfurnace==1.1.0 +# homeassistant.components.watergate +watergate-local-api==2024.4.1 + # homeassistant.components.weatherflow_cloud weatherflow4py==1.0.6 @@ -2987,16 +3027,16 @@ weatherflow4py==1.0.6 webexpythonsdk==2.0.1 # homeassistant.components.nasweb -webio-api==0.1.8 +webio-api==0.1.11 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.11.02 +weheat==2025.1.15 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.8 +whirlpool-sixth-sense==0.18.11 # homeassistant.components.whois whois==0.9.27 @@ -3008,7 +3048,7 @@ wiffi==1.1.2 wirelesstagpy==0.8.1 # homeassistant.components.wled -wled==0.20.2 +wled==0.21.0 # homeassistant.components.wolflink wolf-comm==0.0.15 @@ -3017,13 +3057,13 @@ wolf-comm==0.0.15 wyoming==1.5.4 # homeassistant.components.xbox -xbox-webapi==2.0.11 +xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.3.0 +xknx==3.4.0 # homeassistant.components.knx xknxproject==3.8.1 @@ -3044,7 +3084,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.1 +yalexs-ble==2.5.6 # homeassistant.components.august # homeassistant.components.yale @@ -3060,13 +3100,16 @@ yeelightsunflower==0.0.10 yolink-api==0.4.7 # homeassistant.components.youless -youless-api==2.1.2 +youless-api==2.2.0 # homeassistant.components.youtube youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.11.04 +yt-dlp[default]==2025.01.15 + +# homeassistant.components.zabbix +zabbix-utils==2.0.2 # homeassistant.components.zamg zamg==0.3.6 @@ -3075,13 +3118,13 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.136.0 +zeroconf==0.141.0 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.39 +zha==0.0.45 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 @@ -3093,7 +3136,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.59.1 +zwave-js-server-python==0.60.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index f9763630767..68945852298 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,15 +7,15 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.3.5 +astroid==3.3.8 coverage==7.6.8 freezegun==1.5.1 license-expression==30.4.0 mock-open==1.4.0 -mypy-dev==1.14.0a3 +mypy-dev==1.15.0a2 pre-commit==4.0.0 -pydantic==1.10.19 -pylint==3.3.1 +pydantic==2.10.6 +pylint==3.3.3 pylint-per-file-ignores==1.3.2 pipdeptree==2.23.4 pytest-asyncio==0.24.0 @@ -29,25 +29,27 @@ pytest-timeout==2.3.1 pytest-unordered==0.6.1 pytest-picked==0.5.0 pytest-xdist==3.6.1 -pytest==8.3.3 +pytest==8.3.4 requests-mock==1.12.1 -respx==0.21.1 -syrupy==4.7.2 +respx==0.22.0 +syrupy==4.8.0 tqdm==4.66.5 -types-aiofiles==24.1.0.20240626 +types-aiofiles==24.1.0.20241221 types-atomicwrites==1.4.5.1 -types-croniter==4.0.0.20241030 +types-croniter==5.0.1.20241205 types-beautifulsoup4==4.12.0.20241020 types-caldav==1.3.0.20241107 types-chardet==0.1.5 types-decorator==5.1.8.20240310 types-paho-mqtt==1.6.0.20240321 +types-pexpect==4.9.0.20241208 types-pillow==10.2.0.20240822 -types-protobuf==5.28.3.20241030 -types-psutil==6.1.0.20241102 -types-python-dateutil==2.9.0.20241003 +types-protobuf==5.29.1.20241207 +types-psutil==6.1.0.20241221 +types-pyserial==3.5.0.20241221 +types-python-dateutil==2.9.0.20241206 types-python-slugify==8.0.2.20240310 -types-pytz==2024.2.0.20241003 -types-PyYAML==6.0.12.20240917 +types-pytz==2024.2.0.20241221 +types-PyYAML==6.0.12.20241230 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13ab6ecf718..dde87698b6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,10 +4,10 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.6.3 +AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.25 +AIOSomecomfort==0.0.30 # homeassistant.components.adax Adax-local==0.1.5 @@ -33,7 +33,7 @@ Mastodon.py==1.8.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.0.0 +Pillow==11.1.0 # homeassistant.components.plex PlexAPI==4.15.16 @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==0.0.2 +PyFlick==1.1.3 # homeassistant.components.flume PyFlume==0.6.5 @@ -57,7 +57,7 @@ PyFronius==0.7.3 PyLoadAPI==1.3.2 # homeassistant.components.met_eireann -PyMetEireann==2021.8.0 +PyMetEireann==2024.11.0 # homeassistant.components.met # homeassistant.components.norway_air @@ -81,10 +81,10 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.53.2 +PySwitchbot==0.55.4 # homeassistant.components.syncthru -PySyncThru==0.7.10 +PySyncThru==0.8.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.35.0 +PyViCare==2.41.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -110,13 +110,13 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.31 +SQLAlchemy==2.0.36 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 # homeassistant.components.onvif -WSDiscovery==2.0.0 +WSDiscovery==2.1.2 # homeassistant.components.accuweather accuweather==4.0.0 @@ -161,7 +161,7 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.9 +aioacaia==0.1.14 # homeassistant.components.airq aioairq==0.4.3 @@ -170,7 +170,7 @@ aioairq==0.4.3 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.10.3 +aioautomower==2025.1.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -201,7 +201,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.9.1 +aiocomelit==0.10.1 # homeassistant.components.dhcp aiodhcpwatcher==1.0.2 @@ -231,32 +231,34 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.2 +aioesphomeapi==28.0.1 # homeassistant.components.flo aioflo==2021.11.0 # homeassistant.components.github -# homeassistant.components.iron_os aiogithubapi==24.6.0 # homeassistant.components.guardian aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.10 +aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.1 +aiohasupervisor==0.2.2b5 # homeassistant.components.homekit_controller -aiohomekit==3.2.6 +aiohomekit==3.2.7 + +# homeassistant.components.mcp_server +aiohttp_sse==2.2.0 # homeassistant.components.hue aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==1.1.0 +aioimaplib==2.0.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -265,13 +267,10 @@ aiokafka==0.10.0 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.5.5 +aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.1 - -# homeassistant.components.livisi -aiolivisi==0.0.19 +aiolifx==1.1.2 # homeassistant.components.lookin aiolookin==1.0.0 @@ -280,7 +279,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.3 +aiomealie==0.9.5 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -304,16 +303,16 @@ aiooncue==0.3.7 aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.9 # homeassistant.components.pegel_online -aiopegelonline==0.1.0 +aiopegelonline==0.1.1 # homeassistant.components.acmeda aiopulse==0.4.6 # homeassistant.components.purpleair -aiopurpleair==2022.12.1 +aiopurpleair==2023.12.0 # homeassistant.components.hunterdouglas_powerview aiopvapi==3.1.1 @@ -330,7 +329,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 @@ -342,7 +341,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.1.0 +aiorussound==4.4.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -351,7 +350,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.0.1 +aioshelly==12.3.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -369,7 +368,7 @@ aiosteamist==1.0.0 aiostreammagic==2.10.0 # homeassistant.components.switcher_kis -aioswitcher==5.0.0 +aioswitcher==6.0.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -384,7 +383,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==80 +aiounifi==81 # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -399,10 +398,10 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.4.2 +aiowebostv==0.6.0 # homeassistant.components.withings -aiowithings==3.1.3 +aiowithings==3.1.5 # homeassistant.components.yandex_transport aioymaps==1.2.5 @@ -429,7 +428,7 @@ airtouch5py==0.2.11 amberelectric==2.0.12 # homeassistant.components.androidtv -androidtv[async]==0.0.73 +androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote androidtvremote2==0.1.2 @@ -441,13 +440,16 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.31.2 +anthropic==0.44.0 + +# homeassistant.components.mcp_server +anyio==4.8.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 # homeassistant.components.apprise -apprise==1.9.0 +apprise==1.9.1 # homeassistant.components.aprs aprslib==0.7.2 @@ -456,7 +458,7 @@ aprslib==0.7.2 apsystems-ez1==2.4.0 # homeassistant.components.aranet -aranet4==2.4.0 +aranet4==2.5.0 # homeassistant.components.arcam_fmj arcam-fmj==1.5.2 @@ -467,7 +469,7 @@ arcam-fmj==1.5.2 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.41.0 +async-upnp-client==0.43.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -492,10 +494,10 @@ automower-ble==0.2.0 av==13.1.0 # homeassistant.components.axis -axis==63 +axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.4 +ayla-iot-unofficial==1.4.5 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -516,14 +518,14 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.4 +bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==1.1.0 +bleak-esphome==2.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.8.0 # homeassistant.components.bluetooth bleak==0.22.3 @@ -541,7 +543,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.2 +bluetooth-adapters==0.21.1 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -550,7 +552,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.20.0 +bluetooth-data-tools==1.22.0 # homeassistant.components.bond bond-async==0.2.1 @@ -603,6 +605,10 @@ colorthief==0.2.1 # homeassistant.components.xiaomi_miio construct==2.10.68 +# homeassistant.components.cookidoo +cookidoo-api==0.12.2 + +# homeassistant.components.backup # homeassistant.components.utility_meter cronsim==2.6 @@ -622,13 +628,13 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.24.3 +dbus-fast==2.30.2 # homeassistant.components.debugpy -debugpy==1.8.6 +debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==9.0.0 +deebot-client==11.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -639,13 +645,13 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==0.4.0 +demetriek==1.2.0 # homeassistant.components.denonavr -denonavr==1.0.0 +denonavr==1.0.1 # homeassistant.components.devialet -devialet==1.4.5 +devialet==1.5.7 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.3 @@ -686,20 +692,23 @@ eagle100==0.1.1 # homeassistant.components.easyenergy easyenergy==2.1.2 +# homeassistant.components.eheimdigital +eheimdigital==1.0.5 + # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 # homeassistant.components.elevenlabs -elevenlabs==1.6.1 +elevenlabs==1.9.0 # homeassistant.components.elgato elgato==5.1.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax -elmax-api==0.0.6.1 +elmax-api==0.0.6.4rc0 # homeassistant.components.elvia elvia==0.1.0 @@ -744,7 +753,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==0.4.20 +evohome-async==0.4.21 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 @@ -771,7 +780,7 @@ fitbit==0.3.1 fivem-api==0.1.2 # homeassistant.components.fjaraskupan -fjaraskupan==2.3.0 +fjaraskupan==2.3.2 # homeassistant.components.flexit_bacnet flexit_bacnet==2.2.1 @@ -780,11 +789,11 @@ flexit_bacnet==2.2.1 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.0.4 +flux-led==1.1.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.0.2 +fnv-hash-fast==1.2.2 # homeassistant.components.foobot foobot_async==1.0.0 @@ -793,7 +802,7 @@ foobot_async==1.0.0 forecast-solar==4.0.0 # homeassistant.components.freebox -freebox-api==1.1.0 +freebox-api==1.2.2 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor @@ -803,16 +812,16 @@ fritzconnection[qr]==1.14.0 fyta_cli==0.7.0 # homeassistant.components.google_translate -gTTS==2.2.4 +gTTS==2.5.3 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.4 +gardena-bluetooth==1.5.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 # homeassistant.components.google -gcal-sync==6.2.0 +gcal-sync==7.0.0 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -837,7 +846,7 @@ georss-qld-bushfire-alert-client==0.8 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.9.4 +getmac==0.9.5 # homeassistant.components.gios gios==5.0.0 @@ -846,7 +855,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.1 +go2rtc-client==0.1.2 # homeassistant.components.goalzero goalzero==0.2.2 @@ -871,7 +880,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.5 +google-nest-sdm==7.0.0 # homeassistant.components.google_photos google-photos-library-api==0.12.1 @@ -879,11 +888,15 @@ google-photos-library-api==0.12.1 # homeassistant.components.google_travel_time googlemaps==2.5.1 +# homeassistant.components.slide +# homeassistant.components.slide_local +goslide-api==0.7.0 + # homeassistant.components.tailwind -gotailwind==0.2.4 +gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.40.0 +govee-ble==0.42.0 # homeassistant.components.govee_light_local govee-local-api==1.5.3 @@ -907,7 +920,7 @@ growattServer==1.5.0 gspread==5.5.0 # homeassistant.components.profiler -guppy3==3.1.4.post1;python_version<'3.13' +guppy3==3.1.5 # homeassistant.components.iaqualink h2==4.1.0 @@ -922,16 +935,16 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habitipy==0.3.3 +habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.6.0 +habluetooth==3.12.0 # homeassistant.components.cloud -hass-nabucasa==0.85.0 +hass-nabucasa==0.88.1 # homeassistant.components.conversation -hassil==2.0.4 +hassil==2.1.0 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -953,19 +966,19 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.61 +holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20241106.2 +home-assistant-frontend==20250109.2 # homeassistant.components.conversation -home-assistant-intents==2024.11.13 +home-assistant-intents==2025.1.1 # homeassistant.components.home_connect homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.3 +homematicip==1.1.6 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -988,22 +1001,28 @@ ibeacon-ble==1.2.0 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==8.2.0 +ical==8.3.0 + +# homeassistant.components.caldav +icalendar==6.1.0 # homeassistant.components.ping icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.6.2 +idasen-ha==2.6.3 # homeassistant.components.network ifaddr==0.2.0 +# homeassistant.components.igloohome +igloohome-api==0.0.6 + # homeassistant.components.imgw_pib -imgw_pib==1.0.6 +imgw_pib==1.0.9 # homeassistant.components.incomfort -incomfort-client==0.6.3-1 +incomfort-client==0.6.7 # homeassistant.components.influxdb influxdb-client==1.24.0 @@ -1021,7 +1040,7 @@ insteon-frontend-home-assistant==0.5.0 intellifire4py==4.1.9 # homeassistant.components.iotty -iottycloud==0.2.1 +iottycloud==0.3.0 # homeassistant.components.isal isal==1.7.1 @@ -1049,10 +1068,10 @@ justnimbus==0.7.4 kegtron-ble==0.4.0 # homeassistant.components.knocki -knocki==0.3.5 +knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.11.16.205004 +knx-frontend==2025.1.18.164225 # homeassistant.components.konnected konnected==1.2.0 @@ -1067,7 +1086,7 @@ lacrosse-view==1.0.3 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.2 +lcn-frontend==0.2.3 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1076,11 +1095,14 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.0.2 +led-ble==1.1.4 # homeassistant.components.lektrico lektricowifi==0.0.43 +# homeassistant.components.letpot +letpot==0.3.0 + # homeassistant.components.foscam libpyfoscam==1.2.2 @@ -1093,6 +1115,9 @@ libsoundtouch==0.8 # homeassistant.components.linear_garage_door linear-garage-door==0.2.9 +# homeassistant.components.livisi +livisi==0.0.24 + # homeassistant.components.london_underground london-tube-status==0.5 @@ -1117,6 +1142,9 @@ maxcube-api==0.4.3 # homeassistant.components.mythicbeastsdns mbddns==0.1.2 +# homeassistant.components.mcp_server +mcp==1.1.2 + # homeassistant.components.minecraft_server mcstatus==11.1.1 @@ -1172,13 +1200,13 @@ motionblindsble==0.1.3 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==4.1.1.116.3 +mozart-api==4.1.1.116.4 # homeassistant.components.mullvad mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.5 +music-assistant-client==1.0.8 # homeassistant.components.tts mutagen==1.47.0 @@ -1190,7 +1218,7 @@ mutesync==0.0.1 mypermobil==0.1.8 # homeassistant.components.myuplink -myuplink==0.6.0 +myuplink==0.7.0 # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 @@ -1202,7 +1230,7 @@ nessclient==1.1.2 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==3.3.0 +nettigo-air-monitor==4.0.0 # homeassistant.components.nexia nexia==2.0.8 @@ -1216,11 +1244,14 @@ nextcord==2.6.0 # homeassistant.components.nextdns nextdns==4.0.0 +# homeassistant.components.niko_home_control +nhc==0.3.9 + # homeassistant.components.nibe_heatpump -nibe==2.13.0 +nibe==2.14.0 # homeassistant.components.nice_go -nice-go==0.3.10 +nice-go==1.0.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 @@ -1242,7 +1273,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.1.3 +numpy==2.2.2 # homeassistant.components.nyt_games nyt_games==0.4.4 @@ -1256,8 +1287,11 @@ objgraph==3.5.0 # homeassistant.components.garages_amsterdam odp-amsterdam==6.0.2 +# homeassistant.components.ohme +ohme==1.2.5 + # homeassistant.components.ollama -ollama==0.3.3 +ollama==0.4.7 # homeassistant.components.omnilogic omnilogic==0.4.5 @@ -1266,16 +1300,16 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.12 +onvif-zeep-async==3.2.5 # homeassistant.components.opengarage open-garage==0.2.0 # homeassistant.components.open_meteo -open-meteo==0.3.1 +open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.35.7 +openai==1.59.9 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1284,10 +1318,10 @@ openerz-api==0.3.0 openhomedevice==2.2.0 # homeassistant.components.enigma2 -openwebifpy==4.2.7 +openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.6 +opower==0.8.7 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1310,6 +1344,9 @@ panasonic-viera==0.4.2 # homeassistant.components.dunehd pdunehd==1.3.2 +# homeassistant.components.peblar +peblar==0.4.0 + # homeassistant.components.peco peco==0.0.30 @@ -1329,7 +1366,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.5.2 +plugwise==1.6.4 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1337,6 +1374,9 @@ plumlightpad==0.0.11 # homeassistant.components.poolsense poolsense==0.0.8 +# homeassistant.components.powerfox +powerfox==1.2.1 + # homeassistant.components.reddit praw==7.5.0 @@ -1352,10 +1392,7 @@ prometheus-client==0.21.0 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.1.0 - -# homeassistant.components.androidtv -pure-python-adb[async]==0.3.0.dev0 +psutil==6.1.1 # homeassistant.components.pushbullet pushbullet.py==0.11.0 @@ -1364,10 +1401,10 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==2.1.1 +pvo==2.2.0 # homeassistant.components.aosmith -py-aosmith==1.0.11 +py-aosmith==1.0.12 # homeassistant.components.canary py-canary==0.5.4 @@ -1400,7 +1437,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.5.3 +py-synologydsm-api==2.6.0 # homeassistant.components.hdmi_cec pyCEC==0.5.2 @@ -1414,6 +1451,9 @@ pyDuotecno==2024.10.1 # homeassistant.components.electrasmart pyElectra==1.2.4 +# homeassistant.components.homee +pyHomee==1.2.3 + # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 @@ -1437,7 +1477,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.4 +pyaprilaire==0.7.7 # homeassistant.components.asuswrt pyasuswrt==0.1.21 @@ -1449,10 +1489,10 @@ pyatag==0.3.5.3 pyatmo==8.1.0 # homeassistant.components.apple_tv -pyatv==0.15.1 +pyatv==0.16.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.0.15 +pyaussiebb==0.1.5 # homeassistant.components.balboa pybalboa==1.0.2 @@ -1461,7 +1501,7 @@ pybalboa==1.0.2 pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==1.0.4 +pyblu==2.0.0 # homeassistant.components.neato pybotvac==0.0.25 @@ -1485,10 +1525,10 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.13.7 +pydaikin==2.13.8 # homeassistant.components.deako -pydeako==0.5.4 +pydeako==0.6.0 # homeassistant.components.deconz pydeconz==118 @@ -1500,7 +1540,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2024.9.0 +pydrawise==2025.1.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1527,7 +1567,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.0 +pyenphase==1.23.1 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1569,10 +1609,10 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.7.2 +pyheos==1.0.0 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.1 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -1590,7 +1630,7 @@ pyicloud==1.0.0 pyinsteon==1.6.3 # homeassistant.components.ipma -pyipma==3.0.7 +pyipma==3.0.8 # homeassistant.components.ipp pyipp==0.17.0 @@ -1607,6 +1647,9 @@ pyiss==1.0.1 # homeassistant.components.isy994 pyisy==3.1.14 +# homeassistant.components.ituran +pyituran==0.1.4 + # homeassistant.components.jvc_projector pyjvcprojector==1.1.2 @@ -1632,7 +1675,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.2.11 +pylamarzocco==1.4.6 # homeassistant.components.lastfm pylast==5.1.0 @@ -1650,10 +1693,10 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2023.5.0 +pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.22.0 +pylutron-caseta==0.23.0 # homeassistant.components.lutron pylutron==0.2.16 @@ -1677,7 +1720,7 @@ pymicro-vad==1.0.1 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.6.9 +pymodbus==3.8.3 # homeassistant.components.monoprice pymonoprice==0.4 @@ -1686,7 +1729,7 @@ pymonoprice==0.4 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==0.2.1 +pynecil==4.0.1 # homeassistant.components.netgear pynetgear==0.10.10 @@ -1695,7 +1738,7 @@ pynetgear==0.10.10 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.2 +pynordpool==0.2.4 # homeassistant.components.nuki pynuki==1.6.3 @@ -1736,16 +1779,16 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.15.0 +pyoverkiz==1.15.5 # homeassistant.components.onewire pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.14 +pypalazzetti==0.1.19 # homeassistant.components.lcn -pypck==0.7.24 +pypck==0.8.3 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -1771,11 +1814,14 @@ pyps4-2ndscreen==1.3.1 # homeassistant.components.qwikswitch pyqwikswitch==0.93 +# homeassistant.components.nmbs +pyrail==0.0.3 + # homeassistant.components.rainbird pyrainbird==6.0.1 # homeassistant.components.risco -pyrisco==0.6.4 +pyrisco==0.6.5 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -1811,7 +1857,7 @@ pysiaalarm==3.1.1 pysignalclirestapi==0.3.24 # homeassistant.components.sma -pysma==0.7.3 +pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 @@ -1829,7 +1875,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.3 +pysmlight==0.1.6 # homeassistant.components.snmp pysnmp==6.2.6 @@ -1847,10 +1893,10 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.10.0 +pysqueezebox==0.11.1 # homeassistant.components.suez_water -pysuezV2==1.3.2 +pysuezV2==2.0.3 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -1877,10 +1923,10 @@ python-fullykiosk==0.0.14 # python-gammu==3.2.4 # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.8.0 +python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v7.0.0 +python-homewizard-energy==v8.1.1 # homeassistant.components.izone python-izone==1.2.9 @@ -1889,13 +1935,13 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.7 +python-kasa[speedups]==0.9.1 # homeassistant.components.linkplay -python-linkplay==0.0.20 +python-linkplay==0.1.3 # homeassistant.components.matter -python-matter-server==6.6.0 +python-matter-server==7.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -1914,7 +1960,10 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.6.0 +python-otbr-api==2.7.0 + +# homeassistant.components.overseerr +python-overseerr==0.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -1923,7 +1972,7 @@ python-picnic-api==1.1.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.7.2 +python-roborock==2.8.4 # homeassistant.components.smarttub python-smarttub==0.0.38 @@ -1932,7 +1981,7 @@ python-smarttub==0.0.38 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.17.7 +python-tado==0.18.5 # homeassistant.components.technove python-technove==1.3.1 @@ -1941,13 +1990,13 @@ python-technove==1.3.1 python-telegram-bot[socks]==21.5 # homeassistant.components.tile -pytile==2023.12.0 +pytile==2024.12.0 # homeassistant.components.tomorrowio pytomorrowio==0.3.6 # homeassistant.components.touchline_sl -pytouchlinesl==0.2.0 +pytouchlinesl==0.3.0 # homeassistant.components.traccar # homeassistant.components.traccar_server @@ -1975,13 +2024,13 @@ pyuptimerobot==22.2.0 pyvera==0.3.15 # homeassistant.components.vesync -pyvesync==2.1.12 +pyvesync==2.1.16 # homeassistant.components.vizio pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.21 +pyvlx==0.2.26 # homeassistant.components.volumio pyvolumio==0.1.5 @@ -2019,6 +2068,9 @@ pyzerproc==0.4.8 # homeassistant.components.qbittorrent qbittorrent-api==2024.2.59 +# homeassistant.components.qbus +qbusmqttapi==1.2.3 + # homeassistant.components.qingping qingping-ble==0.10.0 @@ -2035,19 +2087,19 @@ radiotherm==2.1.0 rapt-ble==0.1.2 # homeassistant.components.refoss -refoss-ha==1.2.4 +refoss-ha==1.2.5 # homeassistant.components.rainmachine regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.7 +renault-api==0.2.9 # homeassistant.components.renson -renson-endura-delta==1.7.1 +renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.3 +reolink-aio==0.11.8 # homeassistant.components.rflink rflink==0.0.66 @@ -2086,7 +2138,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.6.0 +samsungtvws[async,encrypted]==2.7.2 # homeassistant.components.sanix sanix==1.0.6 @@ -2095,7 +2147,7 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2024.11.0 +securetar==2025.1.3 # homeassistant.components.emulated_kasa # homeassistant.components.sense @@ -2138,13 +2190,13 @@ simplisafe-python==2024.01.0 skyboxremote==0.0.6 # homeassistant.components.slack -slackclient==2.5.0 +slack_sdk==3.33.4 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 # homeassistant.components.smhi -smhi-pkg==1.0.18 +smhi-pkg==1.0.19 # homeassistant.components.snapcast snapcast==2.3.6 @@ -2156,7 +2208,7 @@ soco==0.30.6 solarlog_cli==0.4.0 # homeassistant.components.solax -solax==3.2.1 +solax==3.2.3 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 @@ -2171,7 +2223,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.8 +spotifyaio==0.8.11 # homeassistant.components.sql sqlparse==0.5.0 @@ -2183,7 +2235,7 @@ srpenergy==1.3.6 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.2.0 +starlink-grpc-core==1.2.2 # homeassistant.components.statsd statsd==3.2.1 @@ -2191,9 +2243,6 @@ statsd==3.2.1 # homeassistant.components.steam_online steamodd==4.21 -# homeassistant.components.stookalert -stookalert==0.1.4 - # homeassistant.components.stookwijzer stookwijzer==1.5.1 @@ -2206,7 +2255,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.11 +subarulink==0.7.13 # homeassistant.components.sunweg sunweg==3.0.2 @@ -2215,7 +2264,7 @@ sunweg==3.0.2 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.system_bridge systembridgeconnector==4.1.5 @@ -2238,7 +2287,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.8.4 +tesla-fleet-api==0.9.2 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2247,7 +2296,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.4.2 +teslemetry-stream==0.6.6 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2256,10 +2305,10 @@ tessie-api==0.1.1 thermobeacon-ble==0.7.0 # homeassistant.components.thermopro -thermopro-ble==0.10.0 +thermopro-ble==0.10.1 # homeassistant.components.lg_thinq -thinqconnect==1.0.1 +thinqconnect==1.0.2 # homeassistant.components.tilt_ble tilt-ble==0.2.3 @@ -2274,7 +2323,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.5 +total-connect-client==2024.12 # homeassistant.components.tplink_omada tplink-omada-client==1.4.3 @@ -2295,7 +2344,7 @@ ttn_client==1.2.0 tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu -twentemilieu==2.1.0 +twentemilieu==2.2.1 # homeassistant.components.twilio twilio==6.32.0 @@ -2310,7 +2359,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.6.2 +uiprotect==7.5.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2318,11 +2367,8 @@ ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.2.0 -# homeassistant.components.zha -universal-silabs-flasher==0.0.25 - # homeassistant.components.upb -upb-lib==0.5.8 +upb-lib==0.5.9 # homeassistant.components.upcloud upcloud-api==2.6.0 @@ -2345,7 +2391,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.11.1 +velbus-aio==2025.1.1 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2354,7 +2400,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.1.0 +voip-utils==0.2.2 # homeassistant.components.volvooncall volvooncall==0.10.3 @@ -2376,22 +2422,25 @@ wakeonlan==2.1.0 wallbox==0.7.0 # homeassistant.components.folder_watcher -watchdog==2.3.1 +watchdog==6.0.0 + +# homeassistant.components.watergate +watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud weatherflow4py==1.0.6 # homeassistant.components.nasweb -webio-api==0.1.8 +webio-api==0.1.11 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.11.02 +weheat==2025.1.15 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.8 +whirlpool-sixth-sense==0.18.11 # homeassistant.components.whois whois==0.9.27 @@ -2400,7 +2449,7 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wled -wled==0.20.2 +wled==0.21.0 # homeassistant.components.wolflink wolf-comm==0.0.15 @@ -2409,13 +2458,13 @@ wolf-comm==0.0.15 wyoming==1.5.4 # homeassistant.components.xbox -xbox-webapi==2.0.11 +xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.3.0 +xknx==3.4.0 # homeassistant.components.knx xknxproject==3.8.1 @@ -2433,7 +2482,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.1 +yalexs-ble==2.5.6 # homeassistant.components.august # homeassistant.components.yale @@ -2446,28 +2495,28 @@ yeelight==0.7.14 yolink-api==0.4.7 # homeassistant.components.youless -youless-api==2.1.2 +youless-api==2.2.0 # homeassistant.components.youtube youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.11.04 +yt-dlp[default]==2025.01.15 # homeassistant.components.zamg zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.136.0 +zeroconf==0.141.0 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.39 +zha==0.0.45 # homeassistant.components.zwave_js -zwave-js-server-python==0.59.1 +zwave-js-server-python==0.60.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 6523c4d0e43..4dd3bc46010 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.8.0 +ruff==0.9.1 yamllint==1.35.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e9e4cf5bbb3..ef57b9140ce 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -139,16 +139,16 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.6.2.post1 +anyio==4.8.0 h11==0.14.0 -httpcore==1.0.5 +httpcore==1.0.7 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.1.3 +numpy==2.2.2 pandas~=2.2.3 # Constrain multidict to avoid typing issues @@ -158,9 +158,8 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Required to avoid breaking (#101042). -# v2 has breaking changes (#99218). -pydantic==1.10.19 +# ensure pydantic version does not float since it might have breaking changes +pydantic==2.10.6 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -179,7 +178,7 @@ pyOpenSSL>=24.0.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==5.28.3 +protobuf==5.29.2 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder @@ -230,6 +229,14 @@ tenacity!=8.4.0 # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 + +# aiofiles keeps getting downgraded by custom components +# causing newer methods to not be available and breaking +# some integrations at startup +# https://github.com/home-assistant/core/issues/127529 +# https://github.com/home-assistant/core/issues/122508 +# https://github.com/home-assistant/core/issues/118004 +aiofiles>=24.1.0 """ GENERATED_MESSAGE = ( @@ -621,7 +628,6 @@ def _get_hassfest_config() -> Config: specific_integrations=None, action="validate", requirements=True, - core_integrations_path=Path("homeassistant/components"), ) diff --git a/script/hassfest/__init__.py b/script/hassfest/__init__.py index 2fa7997162f..c8c9aa9ef39 100644 --- a/script/hassfest/__init__.py +++ b/script/hassfest/__init__.py @@ -1 +1,14 @@ """Manifest validator.""" + +import ast +from functools import lru_cache +from pathlib import Path + + +@lru_cache +def ast_parse_module(file_path: Path) -> ast.Module: + """Parse a module. + + Cached to avoid parsing the same file for each plugin. + """ + return ast.parse(file_path.read_text()) diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 81670de5afd..c93d8fd4499 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -110,10 +110,10 @@ def get_config() -> Config: help="Comma-separate list of plugins to run. Valid plugin names: %(default)s", ) parser.add_argument( - "--core-integrations-path", + "--core-path", type=Path, - default=Path("homeassistant/components"), - help="Path to core integrations", + default=Path(), + help="Path to core", ) parsed = parser.parse_args() @@ -125,16 +125,18 @@ def get_config() -> Config: "Generate is not allowed when limiting to specific integrations" ) - if not parsed.integration_path and not Path("requirements_all.txt").is_file(): + if ( + not parsed.integration_path + and not (parsed.core_path / "requirements_all.txt").is_file() + ): raise RuntimeError("Run from Home Assistant root") return Config( - root=Path().absolute(), + root=parsed.core_path.absolute(), specific_integrations=parsed.integration_path, action=parsed.action, requirements=parsed.requirements, plugins=set(parsed.plugins), - core_integrations_path=parsed.core_integrations_path, ) diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 83d406a0036..f842ec61b97 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -231,8 +231,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if integrations_path.read_text() != content + "\n": config.add_error( "config_flow", - "File integrations.json is not up to date. " - "Run python3 -m script.hassfest", + "File integrations.json is not up to date. Run python3 -m script.hassfest", fixable=True, ) diff --git a/script/hassfest/config_schema.py b/script/hassfest/config_schema.py index 6b863ab9ecd..70dff1194bc 100644 --- a/script/hassfest/config_schema.py +++ b/script/hassfest/config_schema.py @@ -6,6 +6,7 @@ import ast from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from . import ast_parse_module from .model import Config, Integration CONFIG_SCHEMA_IGNORE = { @@ -60,7 +61,7 @@ def _validate_integration(config: Config, integration: Integration) -> None: # Virtual integrations don't have any implementation return - init = ast.parse(init_file.read_text()) + init = ast_parse_module(init_file) # No YAML Support if not _has_function( @@ -81,7 +82,7 @@ def _validate_integration(config: Config, integration: Integration) -> None: config_file = integration.path / "config.py" if config_file.is_file(): - config_module = ast.parse(config_file.read_text()) + config_module = ast_parse_module(config_file) if _has_function(config_module, ast.AsyncFunctionDef, "async_validate_config"): return diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 0c7f4f11a8c..d29571eaa83 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -10,6 +10,7 @@ from pathlib import Path from homeassistant.const import Platform from homeassistant.requirements import DISCOVERY_INTEGRATIONS +from . import ast_parse_module from .model import Config, Integration @@ -33,7 +34,7 @@ class ImportCollector(ast.NodeVisitor): self._cur_fil_dir = fil.relative_to(self.integration.path) self.referenced[self._cur_fil_dir] = set() try: - self.visit(ast.parse(fil.read_text())) + self.visit(ast_parse_module(fil)) except SyntaxError as e: e.add_note(f"File: {fil}") raise @@ -167,6 +168,7 @@ IGNORE_VIOLATIONS = { ("zha", "homeassistant_sky_connect"), ("zha", "homeassistant_yellow"), ("homeassistant_sky_connect", "zha"), + ("homeassistant_hardware", "zha"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 57d86bc4def..edc47e2f9d7 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -94,6 +94,8 @@ COPY . /usr/src/homeassistant # Uv is only needed during build RUN --mount=from=ghcr.io/astral-sh/uv:{uv},source=/uv,target=/bin/uv \ + # Uv creates a lock file in /tmp + --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ @@ -185,12 +187,12 @@ def _generate_files(config: Config) -> list[File]: + 10 ) * 1000 - package_versions = _get_package_versions(Path("requirements.txt"), {"uv"}) + package_versions = _get_package_versions(config.root / "requirements.txt", {"uv"}) package_versions |= _get_package_versions( - Path("requirements_test.txt"), {"pipdeptree", "tqdm"} + config.root / "requirements_test.txt", {"pipdeptree", "tqdm"} ) package_versions |= _get_package_versions( - Path("requirements_test_pre_commit.txt"), {"ruff"} + config.root / "requirements_test_pre_commit.txt", {"ruff"} ) return [ diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index e6ab27de9b0..21b98d30f1e 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,9 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.5.4,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ + # Uv creates a lock file in /tmp + --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ @@ -22,8 +24,8 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.4,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.4 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.1.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/script/hassfest/docker/entrypoint.sh b/script/hassfest/docker/entrypoint.sh index 7b75eb186d2..eabc08a9499 100755 --- a/script/hassfest/docker/entrypoint.sh +++ b/script/hassfest/docker/entrypoint.sh @@ -2,16 +2,28 @@ integrations="" integration_path="" +core_path_provided=false -# Enable recursive globbing using find -for manifest in $(find . -name "manifest.json"); do - manifest_path=$(realpath "${manifest}") - integrations="$integrations --integration-path ${manifest_path%/*}" +for arg in "$@"; do + case "$arg" in + --core-path=*) + core_path_provided=true + break + ;; + esac done -if [ -z "$integrations" ]; then - echo "Error: No integrations found!" - exit 1 +if [ "$core_path_provided" = false ]; then + # Enable recursive globbing using find + for manifest in $(find . -name "manifest.json"); do + manifest_path=$(realpath "${manifest}") + integrations="$integrations --integration-path ${manifest_path%/*}" + done + + if [ -z "$integrations" ]; then + echo "Error: No integrations found!" + exit 1 + fi fi cd /usr/src/homeassistant || exit 1 diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index fdbcf5bcb78..6e9cd8bdedc 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -27,6 +27,8 @@ DOCUMENTATION_URL_HOST = "www.home-assistant.io" DOCUMENTATION_URL_PATH_PREFIX = "/integrations/" DOCUMENTATION_URL_EXCEPTIONS = {"https://www.home-assistant.io/hassio"} +_CORE_DOCUMENTATION_BASE = "https://www.home-assistant.io/integrations" + class NonScaledQualityScaleTiers(StrEnum): """Supported manifest quality scales.""" @@ -117,19 +119,26 @@ NO_IOT_CLASS = [ ] -def documentation_url(value: str) -> str: +def core_documentation_url(value: str) -> str: """Validate that a documentation url has the correct path and domain.""" if value in DOCUMENTATION_URL_EXCEPTIONS: return value + if not value.startswith(_CORE_DOCUMENTATION_BASE): + raise vol.Invalid( + f"Documentation URL does not begin with {_CORE_DOCUMENTATION_BASE}" + ) + return value + + +def custom_documentation_url(value: str) -> str: + """Validate that a custom integration documentation url is correct.""" parsed_url = urlparse(value) if parsed_url.scheme != DOCUMENTATION_URL_SCHEMA: raise vol.Invalid("Documentation url is not prefixed with https") - if parsed_url.netloc == DOCUMENTATION_URL_HOST and not parsed_url.path.startswith( - DOCUMENTATION_URL_PATH_PREFIX - ): + if value.startswith(_CORE_DOCUMENTATION_BASE): raise vol.Invalid( - "Documentation url does not begin with www.home-assistant.io/integrations" + "Documentation URL should point to the custom integration documentation" ) return value @@ -258,7 +267,7 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema( } ) ], - vol.Required("documentation"): vol.All(vol.Url(), documentation_url), + vol.Required("documentation"): vol.All(vol.Url(), core_documentation_url), vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES), vol.Optional("requirements"): [str], vol.Optional("dependencies"): [str], @@ -293,6 +302,7 @@ def manifest_schema(value: dict[str, Any]) -> vol.Schema: CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend( { + vol.Required("documentation"): vol.All(vol.Url(), custom_documentation_url), vol.Optional("version"): vol.All(str, verify_version), vol.Optional("issue_tracker"): vol.Url(), vol.Optional("import_executor"): bool, diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 377f82b0d5c..08ded687096 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -30,11 +30,15 @@ class Config: root: pathlib.Path action: Literal["validate", "generate"] requirements: bool - core_integrations_path: pathlib.Path + core_integrations_path: pathlib.Path = field(init=False) errors: list[Error] = field(default_factory=list) cache: dict[str, Any] = field(default_factory=dict) plugins: set[str] = field(default_factory=set) + def __post_init__(self) -> None: + """Post init.""" + self.core_integrations_path = self.root / "homeassistant/components" + def add_error(self, *args: Any, **kwargs: Any) -> None: """Add an error.""" self.errors.append(Error(*args, **kwargs)) diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 25fe875e437..ac27df85ccc 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -33,23 +33,29 @@ HEADER: Final = """ GENERAL_SETTINGS: Final[dict[str, str]] = { "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), "platform": "linux", - "plugins": "pydantic.mypy", + "plugins": ", ".join( # noqa: FLY002 + [ + "pydantic.mypy", + "pydantic.v1.mypy", + ] + ), "show_error_codes": "true", "follow_imports": "normal", - # "enable_incomplete_feature": ", ".join( # noqa: FLY002 + # "enable_incomplete_feature": ", ".join( # [] # ), # Enable some checks globally. "local_partial_types": "true", "strict_equality": "true", + "strict_bytes": "true", "no_implicit_optional": "true", - "report_deprecated_as_error": "true", "warn_incomplete_stub": "true", "warn_redundant_casts": "true", "warn_unused_configs": "true", "warn_unused_ignores": "true", "enable_error_code": ", ".join( # noqa: FLY002 [ + "deprecated", "ignore-without-code", "redundant-self", "truthy-iterable", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index bbb2d3e4d0a..3732101913c 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import dataclass + import voluptuous as vol from voluptuous.humanize import humanize_error @@ -10,75 +12,111 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util.yaml import load_yaml_dict from .model import Config, Integration, ScaledQualityScaleTiers +from .quality_scale_validation import ( + RuleValidationProtocol, + config_entry_unloading, + config_flow, + diagnostics, + discovery, + parallel_updates, + reauthentication_flow, + reconfiguration_flow, + runtime_data, + strict_typing, + test_before_setup, + unique_config_entry, +) QUALITY_SCALE_TIERS = {value.name.lower(): value for value in ScaledQualityScaleTiers} -RULES = { - ScaledQualityScaleTiers.BRONZE: [ - "action-setup", - "appropriate-polling", - "brands", - "common-modules", - "config-flow", - "config-flow-test-coverage", - "dependency-transparency", - "docs-actions", - "docs-high-level-description", - "docs-installation-instructions", - "docs-removal-instructions", - "entity-event-setup", - "entity-unique-id", - "has-entity-name", - "runtime-data", - "test-before-configure", - "test-before-setup", - "unique-config-entry", - ], - ScaledQualityScaleTiers.SILVER: [ - "action-exceptions", - "config-entry-unloading", - "docs-configuration-parameters", - "docs-installation-parameters", - "entity-unavailable", - "integration-owner", - "log-when-unavailable", - "parallel-updates", - "reauthentication-flow", - "test-coverage", - ], - ScaledQualityScaleTiers.GOLD: [ - "devices", - "diagnostics", - "discovery", - "discovery-update-info", - "docs-data-update", - "docs-examples", - "docs-known-limitations", - "docs-supported-devices", - "docs-supported-functions", - "docs-troubleshooting", - "docs-use-cases", - "dynamic-devices", - "entity-category", - "entity-device-class", - "entity-disabled-by-default", - "entity-translations", - "exception-translations", - "icon-translations", - "reconfiguration-flow", - "repair-issues", - "stale-devices", - ], - ScaledQualityScaleTiers.PLATINUM: [ - "async-dependency", - "inject-websession", - "strict-typing", - ], + +@dataclass +class Rule: + """Quality scale rules.""" + + name: str + tier: ScaledQualityScaleTiers + validator: RuleValidationProtocol | None = None + + +ALL_RULES = [ + # BRONZE + Rule("action-setup", ScaledQualityScaleTiers.BRONZE), + Rule("appropriate-polling", ScaledQualityScaleTiers.BRONZE), + Rule("brands", ScaledQualityScaleTiers.BRONZE), + Rule("common-modules", ScaledQualityScaleTiers.BRONZE), + Rule("config-flow", ScaledQualityScaleTiers.BRONZE, config_flow), + Rule("config-flow-test-coverage", ScaledQualityScaleTiers.BRONZE), + Rule("dependency-transparency", ScaledQualityScaleTiers.BRONZE), + Rule("docs-actions", ScaledQualityScaleTiers.BRONZE), + Rule("docs-high-level-description", ScaledQualityScaleTiers.BRONZE), + Rule("docs-installation-instructions", ScaledQualityScaleTiers.BRONZE), + Rule("docs-removal-instructions", ScaledQualityScaleTiers.BRONZE), + Rule("entity-event-setup", ScaledQualityScaleTiers.BRONZE), + Rule("entity-unique-id", ScaledQualityScaleTiers.BRONZE), + Rule("has-entity-name", ScaledQualityScaleTiers.BRONZE), + Rule("runtime-data", ScaledQualityScaleTiers.BRONZE, runtime_data), + Rule("test-before-configure", ScaledQualityScaleTiers.BRONZE), + Rule("test-before-setup", ScaledQualityScaleTiers.BRONZE, test_before_setup), + Rule("unique-config-entry", ScaledQualityScaleTiers.BRONZE, unique_config_entry), + # SILVER + Rule("action-exceptions", ScaledQualityScaleTiers.SILVER), + Rule( + "config-entry-unloading", ScaledQualityScaleTiers.SILVER, config_entry_unloading + ), + Rule("docs-configuration-parameters", ScaledQualityScaleTiers.SILVER), + Rule("docs-installation-parameters", ScaledQualityScaleTiers.SILVER), + Rule("entity-unavailable", ScaledQualityScaleTiers.SILVER), + Rule("integration-owner", ScaledQualityScaleTiers.SILVER), + Rule("log-when-unavailable", ScaledQualityScaleTiers.SILVER), + Rule("parallel-updates", ScaledQualityScaleTiers.SILVER, parallel_updates), + Rule( + "reauthentication-flow", ScaledQualityScaleTiers.SILVER, reauthentication_flow + ), + Rule("test-coverage", ScaledQualityScaleTiers.SILVER), + # GOLD: [ + Rule("devices", ScaledQualityScaleTiers.GOLD), + Rule("diagnostics", ScaledQualityScaleTiers.GOLD, diagnostics), + Rule("discovery", ScaledQualityScaleTiers.GOLD, discovery), + Rule("discovery-update-info", ScaledQualityScaleTiers.GOLD), + Rule("docs-data-update", ScaledQualityScaleTiers.GOLD), + Rule("docs-examples", ScaledQualityScaleTiers.GOLD), + Rule("docs-known-limitations", ScaledQualityScaleTiers.GOLD), + Rule("docs-supported-devices", ScaledQualityScaleTiers.GOLD), + Rule("docs-supported-functions", ScaledQualityScaleTiers.GOLD), + Rule("docs-troubleshooting", ScaledQualityScaleTiers.GOLD), + Rule("docs-use-cases", ScaledQualityScaleTiers.GOLD), + Rule("dynamic-devices", ScaledQualityScaleTiers.GOLD), + Rule("entity-category", ScaledQualityScaleTiers.GOLD), + Rule("entity-device-class", ScaledQualityScaleTiers.GOLD), + Rule("entity-disabled-by-default", ScaledQualityScaleTiers.GOLD), + Rule("entity-translations", ScaledQualityScaleTiers.GOLD), + Rule("exception-translations", ScaledQualityScaleTiers.GOLD), + Rule("icon-translations", ScaledQualityScaleTiers.GOLD), + Rule("reconfiguration-flow", ScaledQualityScaleTiers.GOLD, reconfiguration_flow), + Rule("repair-issues", ScaledQualityScaleTiers.GOLD), + Rule("stale-devices", ScaledQualityScaleTiers.GOLD), + # PLATINUM + Rule("async-dependency", ScaledQualityScaleTiers.PLATINUM), + Rule("inject-websession", ScaledQualityScaleTiers.PLATINUM), + Rule("strict-typing", ScaledQualityScaleTiers.PLATINUM, strict_typing), +] + +SCALE_RULES = { + tier: [rule.name for rule in ALL_RULES if rule.tier == tier] + for tier in ScaledQualityScaleTiers } +VALIDATORS = {rule.name: rule.validator for rule in ALL_RULES if rule.validator} + +RULE_URL = ( + "Please check the documentation at " + "https://developers.home-assistant.io/docs/core/" + "integration-quality-scale/rules/{rule_name}/" +) + INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "abode", - "acaia", "accuweather", "acer_projector", "acmeda", @@ -113,7 +151,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "amcrest", "ampio", "analytics", - "analytics_insights", "android_ip_webcam", "androidtv", "androidtv_remote", @@ -181,11 +218,9 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "bluetooth_adapters", "bluetooth_le_tracker", "bluetooth_tracker", - "bmw_connected_drive", "bond", "bosch_shc", "braviatv", - "bring", "broadlink", "brother", "brottsplatskartan", @@ -198,7 +233,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "bthome", "buienradar", "caldav", - "cambridge_audio", "canary", "cast", "ccm15", @@ -276,7 +310,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "drop_connect", "dsmr", "dsmr_reader", - "dte_energy_bridge", "dublin_bus_transport", "duckdns", "duke_energy", @@ -303,7 +336,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "eight_sleep", "electrasmart", "electric_kiwi", - "elevenlabs", "eliqonline", "elkm1", "elmax", @@ -321,7 +353,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "energyzero", "enigma2", "enocean", - "enphase_envoy", "entur_public_transport", "environment_canada", "envisalink", @@ -355,7 +386,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "fints", "fireservicerota", "firmata", - "fitbit", "fivem", "fixer", "fjaraskupan", @@ -384,12 +414,1077 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "freedompro", "fritzbox", "fritzbox_callmonitor", - "fronius", "frontier_silicon", "fujitsu_fglair", "fujitsu_hvac", "futurenow", - "fyta", + "garadget", + "garages_amsterdam", + "gardena_bluetooth", + "gc100", + "gdacs", + "generic", + "generic_hygrostat", + "generic_thermostat", + "geniushub", + "geo_json_events", + "geo_rss_events", + "geocaching", + "geofency", + "geonetnz_quakes", + "geonetnz_volcano", + "gios", + "github", + "gitlab_ci", + "gitter", + "glances", + "go2rtc", + "goalzero", + "gogogate2", + "goodwe", + "google", + "google_assistant", + "google_assistant_sdk", + "google_cloud", + "google_domains", + "google_generative_ai_conversation", + "google_mail", + "google_maps", + "google_pubsub", + "google_sheets", + "google_translate", + "google_travel_time", + "google_wifi", + "govee_ble", + "govee_light_local", + "gpsd", + "gpslogger", + "graphite", + "gree", + "greeneye_monitor", + "greenwave", + "group", + "growatt_server", + "gstreamer", + "gtfs", + "guardian", + "harman_kardon_avr", + "harmony", + "hassio", + "haveibeenpwned", + "hddtemp", + "hdmi_cec", + "heatmiser", + "here_travel_time", + "hikvision", + "hikvisioncam", + "hisense_aehw4a1", + "history_stats", + "hitron_coda", + "hive", + "hko", + "hlk_sw16", + "holiday", + "home_connect", + "homekit", + "homekit_controller", + "homematic", + "homematicip_cloud", + "homeworks", + "honeywell", + "horizon", + "hp_ilo", + "html5", + "http", + "huawei_lte", + "hue", + "huisbaasje", + "hunterdouglas_powerview", + "husqvarna_automower_ble", + "huum", + "hvv_departures", + "hydrawise", + "hyperion", + "ialarm", + "iammeter", + "iaqualink", + "ibeacon", + "icloud", + "idteck_prox", + "ifttt", + "iglo", + "ign_sismologia", + "ihc", + "imgw_pib", + "improv_ble", + "incomfort", + "influxdb", + "inkbird", + "insteon", + "integration", + "intellifire", + "intesishome", + "ios", + "iotawatt", + "iotty", + "iperf3", + "ipma", + "ipp", + "iqvia", + "irish_rail_transport", + "isal", + "iskra", + "islamic_prayer_times", + "israel_rail", + "iss", + "isy994", + "itach", + "itunes", + "izone", + "jellyfin", + "jewish_calendar", + "joaoapps_join", + "juicenet", + "justnimbus", + "jvc_projector", + "kaiterra", + "kaleidescape", + "kankun", + "keba", + "keenetic_ndms2", + "kef", + "kegtron", + "keyboard", + "keyboard_remote", + "keymitt_ble", + "kira", + "kitchen_sink", + "kiwi", + "kmtronic", + "kodi", + "konnected", + "kostal_plenticore", + "kraken", + "kulersky", + "kwb", + "lacrosse", + "lacrosse_view", + "landisgyr_heat_meter", + "lannouncer", + "lastfm", + "launch_library", + "laundrify", + "lcn", + "ld2410_ble", + "leaone", + "led_ble", + "lektrico", + "lg_netcast", + "lg_soundbar", + "lg_thinq", + "lidarr", + "life360", + "lifx", + "lifx_cloud", + "lightwave", + "limitlessled", + "linear_garage_door", + "linkplay", + "linksys_smart", + "linode", + "linux_battery", + "lirc", + "litejet", + "livisi", + "llamalab_automate", + "local_calendar", + "local_file", + "local_ip", + "local_todo", + "location", + "locative", + "logentries", + "logi_circle", + "london_air", + "london_underground", + "lookin", + "loqed", + "luci", + "luftdaten", + "lupusec", + "lutron", + "lutron_caseta", + "lw12wifi", + "lyric", + "madvr", + "mailbox", + "mailgun", + "manual", + "manual_mqtt", + "map", + "marytts", + "matrix", + "matter", + "maxcube", + "mazda", + "meater", + "medcom_ble", + "media_extractor", + "mediaroom", + "melcloud", + "melissa", + "melnor", + "meraki", + "message_bird", + "met", + "met_eireann", + "meteo_france", + "meteoalarm", + "meteoclimatic", + "metoffice", + "mfi", + "microbees", + "microsoft", + "microsoft_face", + "microsoft_face_detect", + "microsoft_face_identify", + "mikrotik", + "mill", + "min_max", + "minio", + "mjpeg", + "moat", + "mobile_app", + "mochad", + "modbus", + "modem_callerid", + "modern_forms", + "moehlenhoff_alpha2", + "mold_indicator", + "monarch_money", + "monoprice", + "monzo", + "moon", + "mopeka", + "motion_blinds", + "motionblinds_ble", + "motioneye", + "motionmount", + "mpd", + "mqtt_eventstream", + "mqtt_json", + "mqtt_room", + "mqtt_statestream", + "msteams", + "mullvad", + "music_assistant", + "mutesync", + "mvglive", + "mycroft", + "myq", + "mysensors", + "mystrom", + "mythicbeastsdns", + "nad", + "nam", + "namecheapdns", + "nanoleaf", + "nasweb", + "neato", + "nederlandse_spoorwegen", + "ness_alarm", + "netatmo", + "netdata", + "netgear", + "netgear_lte", + "netio", + "network", + "neurio_energy", + "nexia", + "nextbus", + "nextcloud", + "nextdns", + "nfandroidtv", + "nibe_heatpump", + "nice_go", + "nightscout", + "niko_home_control", + "nilu", + "nina", + "nissan_leaf", + "nmap_tracker", + "nmbs", + "no_ip", + "noaa_tides", + "nobo_hub", + "norway_air", + "notify_events", + "notion", + "nsw_fuel_station", + "nsw_rural_fire_service_feed", + "nuheat", + "nuki", + "numato", + "nut", + "nws", + "nx584", + "nzbget", + "oasa_telematics", + "obihai", + "octoprint", + "oem", + "ohmconnect", + "ollama", + "ombi", + "omnilogic", + "oncue", + "ondilo_ico", + "onvif", + "open_meteo", + "openai_conversation", + "openalpr_cloud", + "openerz", + "openevse", + "openexchangerates", + "opengarage", + "openhardwaremonitor", + "openhome", + "opensensemap", + "opensky", + "opentherm_gw", + "openuv", + "openweathermap", + "opnsense", + "opower", + "opple", + "oralb", + "oru", + "orvibo", + "osoenergy", + "osramlightify", + "otbr", + "otp", + "ourgroceries", + "overkiz", + "ovo_energy", + "owntracks", + "p1_monitor", + "panasonic_bluray", + "panasonic_viera", + "pandora", + "panel_iframe", + "peco", + "pegel_online", + "pencom", + "permobil", + "persistent_notification", + "person", + "philips_js", + "pi_hole", + "picnic", + "picotts", + "pilight", + "ping", + "pioneer", + "pjlink", + "plaato", + "plant", + "plex", + "plum_lightpad", + "pocketcasts", + "point", + "poolsense", + "powerwall", + "private_ble_device", + "profiler", + "progettihwsw", + "proliphix", + "prometheus", + "prosegur", + "prowl", + "proximity", + "proxmoxve", + "prusalink", + "ps4", + "pulseaudio_loopback", + "pure_energie", + "purpleair", + "push", + "pushbullet", + "pushover", + "pushsafer", + "pvoutput", + "pvpc_hourly_pricing", + "pyload", + "qbittorrent", + "qingping", + "qld_bushfire", + "qnap", + "qnap_qsw", + "qrcode", + "quantum_gateway", + "qvr_pro", + "qwikswitch", + "rabbitair", + "rachio", + "radarr", + "radio_browser", + "radiotherm", + "raincloud", + "rainforest_eagle", + "rainforest_raven", + "rainmachine", + "random", + "rapt_ble", + "raspyrfm", + "rdw", + "recollect_waste", + "recorder", + "recswitch", + "reddit", + "refoss", + "rejseplanen", + "remember_the_milk", + "remote_rpi_gpio", + "renson", + "repetier", + "rest", + "rest_command", + "rflink", + "rfxtrx", + "rhasspy", + "ridwell", + "ring", + "ripple", + "risco", + "rituals_perfume_genie", + "rmvtransport", + "roborock", + "rocketchat", + "roku", + "romy", + "roomba", + "roon", + "route53", + "rova", + "rpi_camera", + "rpi_power", + "rss_feed_template", + "rtorrent", + "rtsp_to_webrtc", + "ruckus_unleashed", + "ruuvi_gateway", + "ruuvitag_ble", + "rympro", + "saj", + "samsungtv", + "sanix", + "satel_integra", + "schlage", + "schluter", + "scrape", + "screenlogic", + "scsgate", + "season", + "sendgrid", + "sense", + "sensirion_ble", + "sensorpro", + "sensorpush", + "sensoterra", + "sentry", + "senz", + "serial", + "serial_pm", + "sesame", + "seven_segments", + "seventeentrack", + "sfr_box", + "sharkiq", + "shell_command", + "shelly", + "shodan", + "shopping_list", + "sia", + "sigfox", + "sighthound", + "signal_messenger", + "simplefin", + "simplepush", + "simplisafe", + "simulated", + "sinch", + "sisyphus", + "sky_hub", + "sky_remote", + "skybeacon", + "skybell", + "slack", + "sleepiq", + "slide", + "slimproto", + "sma", + "smappee", + "smart_meter_texas", + "smartthings", + "smarttub", + "smarty", + "smhi", + "smlight", + "sms", + "smtp", + "snapcast", + "snips", + "snmp", + "snooz", + "solaredge", + "solaredge_local", + "solax", + "soma", + "somfy_mylink", + "sonarr", + "songpal", + "sonos", + "sony_projector", + "soundtouch", + "spaceapi", + "spc", + "speedtestdotnet", + "spider", + "splunk", + "spotify", + "sql", + "squeezebox", + "srp_energy", + "ssdp", + "starline", + "starlingbank", + "starlink", + "startca", + "statistics", + "statsd", + "steam_online", + "steamist", + "stiebel_eltron", + "stream", + "streamlabswater", + "subaru", + "sun", + "sunweg", + "supervisord", + "supla", + "surepetcare", + "swiss_hydrological_data", + "swisscom", + "switch_as_x", + "switchbee", + "switchbot_cloud", + "switcher_kis", + "switchmate", + "syncthing", + "syncthru", + "synology_chat", + "synology_dsm", + "synology_srm", + "syslog", + "system_bridge", + "systemmonitor", + "tado", + "tailscale", + "tami4", + "tank_utility", + "tankerkoenig", + "tapsaff", + "tasmota", + "tautulli", + "tcp", + "technove", + "ted5000", + "telegram", + "telegram_bot", + "tellduslive", + "tellstick", + "telnet", + "temper", + "template", + "tensorflow", + "tesla_fleet", + "tesla_wall_connector", + "teslemetry", + "tessie", + "tfiac", + "thermobeacon", + "thermopro", + "thermoworks_smoke", + "thethingsnetwork", + "thingspeak", + "thinkingcleaner", + "thomson", + "thread", + "threshold", + "tibber", + "tikteck", + "tile", + "tilt_ble", + "time_date", + "tmb", + "tod", + "todoist", + "tolo", + "tomato", + "tomorrowio", + "toon", + "torque", + "touchline", + "touchline_sl", + "tplink_lte", + "tplink_omada", + "traccar", + "traccar_server", + "tractive", + "tradfri", + "trafikverket_camera", + "trafikverket_ferry", + "trafikverket_train", + "trafikverket_weatherstation", + "transmission", + "transport_nsw", + "travisci", + "trend", + "triggercmd", + "tuya", + "twilio", + "twilio_call", + "twilio_sms", + "twinkly", + "twitch", + "twitter", + "ubus", + "uk_transport", + "ukraine_alarm", + "unifi", + "unifi_direct", + "unifiled", + "unifiprotect", + "universal", + "upb", + "upc_connect", + "upcloud", + "upnp", + "uptime", + "uptimerobot", + "usb", + "usgs_earthquakes_feed", + "utility_meter", + "uvc", + "v2c", + "vallox", + "vasttrafik", + "velux", + "venstar", + "vera", + "verisure", + "versasense", + "version", + "vesync", + "viaggiatreno", + "vilfo", + "vivotek", + "vizio", + "vlc", + "vlc_telnet", + "vodafone_station", + "voicerss", + "voip", + "volkszaehler", + "volumio", + "volvooncall", + "vulcan", + "vultr", + "w800rf32", + "wake_on_lan", + "wallbox", + "waqi", + "waterfurnace", + "watson_iot", + "watson_tts", + "watttime", + "waze_travel_time", + "weatherflow", + "weatherflow_cloud", + "weatherkit", + "webmin", + "wemo", + "whirlpool", + "whois", + "wiffi", + "wilight", + "wirelesstag", + "withings", + "wiz", + "wled", + "wmspro", + "wolflink", + "workday", + "worldclock", + "worldtidesinfo", + "worxlandroid", + "ws66i", + "wsdot", + "wyoming", + "x10", + "xbox", + "xeoma", + "xiaomi", + "xiaomi_aqara", + "xiaomi_ble", + "xiaomi_miio", + "xiaomi_tv", + "xmpp", + "xs1", + "yale", + "yale_smart_alarm", + "yalexs_ble", + "yamaha", + "yamaha_musiccast", + "yandex_transport", + "yandextts", + "yardian", + "yeelight", + "yeelightsunflower", + "yi", + "yolink", + "youless", + "youtube", + "zabbix", + "zamg", + "zengge", + "zeroconf", + "zerproc", + "zestimate", + "zeversolar", + "zha", + "zhong_hong", + "ziggo_mediabox_xl", + "zodiac", + "zoneminder", + "zwave_js", + "zwave_me", +] + +INTEGRATIONS_WITHOUT_SCALE = [ + "abode", + "accuweather", + "acer_projector", + "acmeda", + "actiontec", + "adax", + "adguard", + "ads", + "advantage_air", + "aemet", + "aftership", + "agent_dvr", + "airly", + "airgradient", + "airnow", + "airq", + "airthings", + "airthings_ble", + "airtouch4", + "airtouch5", + "airvisual", + "airvisual_pro", + "airzone", + "airzone_cloud", + "aladdin_connect", + "alarmdecoder", + "alert", + "alexa", + "alpha_vantage", + "amazon_polly", + "amberelectric", + "ambient_network", + "ambient_station", + "amcrest", + "ampio", + "analytics", + "analytics_insights", + "android_ip_webcam", + "androidtv", + "androidtv_remote", + "anel_pwrctrl", + "anova", + "anthemav", + "anthropic", + "aosmith", + "apache_kafka", + "apcupsd", + "apple_tv", + "apprise", + "aprilaire", + "aprs", + "apsystems", + "aquacell", + "aqualogic", + "aquostv", + "aranet", + "arcam_fmj", + "arest", + "arris_tg2492lg", + "aruba", + "arve", + "arwn", + "aseko_pool_live", + "assist_pipeline", + "asterisk_mbox", + "asuswrt", + "atag", + "aten_pe", + "atome", + "august", + "autarco", + "aurora", + "aurora_abb_powerone", + "aussie_broadband", + "avea", + "avion", + "awair", + "aws", + "axis", + "azure_data_explorer", + "azure_devops", + "azure_event_hub", + "azure_service_bus", + "backup", + "baf", + "baidu", + "balboa", + "bang_olufsen", + "bayesian", + "bbox", + "beewi_smartclim", + "bitcoin", + "bizkaibus", + "blackbird", + "blebox", + "blink", + "blinksticklight", + "blockchain", + "blue_current", + "bluemaestro", + "bluesound", + "bluetooth", + "bluetooth_adapters", + "bluetooth_le_tracker", + "bluetooth_tracker", + "bmw_connected_drive", + "bond", + "bosch_shc", + "braviatv", + "broadlink", + "brother", + "brottsplatskartan", + "browser", + "brunt", + "bring", + "bryant_evolution", + "bsblan", + "bt_home_hub_5", + "bt_smarthub", + "bthome", + "buienradar", + "caldav", + "canary", + "cast", + "ccm15", + "cert_expiry", + "chacon_dio", + "channels", + "circuit", + "cisco_ios", + "cisco_mobility_express", + "cisco_webex_teams", + "citybikes", + "clementine", + "clickatell", + "clicksend", + "clicksend_tts", + "climacell", + "cloud", + "cloudflare", + "cmus", + "co2signal", + "coinbase", + "color_extractor", + "comed_hourly_pricing", + "comelit", + "comfoconnect", + "command_line", + "compensation", + "concord232", + "control4", + "coolmaster", + "cppm_tracker", + "cpuspeed", + "crownstone", + "cups", + "currencylayer", + "daikin", + "danfoss_air", + "datadog", + "ddwrt", + "deako", + "debugpy", + "deconz", + "decora", + "decora_wifi", + "delijn", + "deluge", + "demo", + "denon", + "denonavr", + "derivative", + "devialet", + "device_sun_light_trigger", + "devolo_home_control", + "devolo_home_network", + "dexcom", + "dhcp", + "dialogflow", + "digital_ocean", + "directv", + "discogs", + "discord", + "dlib_face_detect", + "dlib_face_identify", + "dlink", + "dlna_dmr", + "dlna_dms", + "dnsip", + "dominos", + "doods", + "doorbird", + "dormakaba_dkey", + "dovado", + "downloader", + "dremel_3d_printer", + "drop_connect", + "dsmr", + "dsmr_reader", + "dublin_bus_transport", + "duckdns", + "duke_energy", + "dunehd", + "duotecno", + "dwd_weather_warnings", + "dweet", + "dynalite", + "eafm", + "easyenergy", + "ebox", + "ebusd", + "ecoal_boiler", + "ecobee", + "ecoforest", + "econet", + "ecovacs", + "ecowitt", + "eddystone_temperature", + "edimax", + "edl21", + "efergy", + "egardia", + "eight_sleep", + "electrasmart", + "electric_kiwi", + "elevenlabs", + "eliqonline", + "elkm1", + "elmax", + "elgato", + "elv", + "elvia", + "emby", + "emoncms", + "emoncms_history", + "emonitor", + "emulated_hue", + "emulated_kasa", + "emulated_roku", + "energenie_power_sockets", + "energy", + "energyzero", + "enigma2", + "enphase_envoy", + "enocean", + "entur_public_transport", + "environment_canada", + "envisalink", + "ephember", + "epic_games_store", + "epion", + "epson", + "eq3btsmart", + "escea", + "esphome", + "etherscan", + "eufy", + "eufylife_ble", + "everlights", + "evil_genius_labs", + "evohome", + "ezviz", + "faa_delays", + "facebook", + "fail2ban", + "familyhub", + "fastdotcom", + "feedreader", + "ffmpeg_motion", + "ffmpeg_noise", + "fibaro", + "fido", + "file", + "filesize", + "filter", + "fitbit", + "fints", + "fireservicerota", + "firmata", + "fivem", + "fixer", + "fjaraskupan", + "fleetgo", + "flexit", + "flexit_bacnet", + "flic", + "flick_electric", + "flipr", + "flo", + "flock", + "flume", + "flux", + "flux_led", + "folder", + "folder_watcher", + "foobot", + "forecast_solar", + "forked_daapd", + "fortios", + "foscam", + "foursquare", + "free_mobile", + "freebox", + "freedns", + "freedompro", + "fritz", + "fritzbox", + "fritzbox_callmonitor", + "frontier_silicon", + "fujitsu_fglair", + "fujitsu_hvac", + "futurenow", "garadget", "garages_amsterdam", "gardena_bluetooth", @@ -449,8 +1544,8 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "haveibeenpwned", "hddtemp", "hdmi_cec", - "heatmiser", "heos", + "heatmiser", "here_travel_time", "hikvision", "hikvisioncam", @@ -476,7 +1571,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "hue", "huisbaasje", "hunterdouglas_powerview", - "husqvarna_automower", "husqvarna_automower_ble", "huum", "hvv_departures", @@ -487,12 +1581,12 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "iaqualink", "ibeacon", "icloud", - "idasen_desk", "idteck_prox", "ifttt", "iglo", "ign_sismologia", "ihc", + "imap", "imgw_pib", "improv_ble", "incomfort", @@ -503,6 +1597,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "intellifire", "intesishome", "ios", + "iron_os", "iotawatt", "iotty", "iperf3", @@ -510,13 +1605,12 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "ipp", "iqvia", "irish_rail_transport", - "iron_os", "isal", + "ista_ecotrend", "iskra", "islamic_prayer_times", "israel_rail", "iss", - "ista_ecotrend", "isy994", "itach", "itunes", @@ -542,19 +1636,19 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "kiwi", "kmtronic", "knocki", - "knx", "kodi", "konnected", "kostal_plenticore", "kraken", + "knx", "kulersky", "kwb", "lacrosse", "lacrosse_view", - "lametric", "landisgyr_heat_meter", "lannouncer", "lastfm", + "lametric", "launch_library", "laundrify", "lcn", @@ -606,8 +1700,8 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "manual", "manual_mqtt", "map", - "marytts", "mastodon", + "marytts", "matrix", "matter", "maxcube", @@ -672,7 +1766,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "mysensors", "mystrom", "mythicbeastsdns", - "myuplink", "nad", "nam", "namecheapdns", @@ -680,8 +1773,8 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "nasweb", "neato", "nederlandse_spoorwegen", - "ness_alarm", "nest", + "ness_alarm", "netatmo", "netdata", "netgear", @@ -693,6 +1786,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "nextbus", "nextcloud", "nextdns", + "nyt_games", "nfandroidtv", "nibe_heatpump", "nice_go", @@ -706,7 +1800,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "no_ip", "noaa_tides", "nobo_hub", - "nordpool", "norway_air", "notify_events", "notion", @@ -718,7 +1811,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "nut", "nws", "nx584", - "nyt_games", "nzbget", "oasa_telematics", "obihai", @@ -729,9 +1821,9 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "ombi", "omnilogic", "oncue", + "onkyo", "ondilo_ico", "onewire", - "onkyo", "onvif", "open_meteo", "openai_conversation", @@ -765,6 +1857,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "panasonic_bluray", "panasonic_viera", "pandora", + "palazzetti", "panel_iframe", "peco", "pegel_online", @@ -781,9 +1874,9 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "pioneer", "pjlink", "plaato", + "plugwise", "plant", "plex", - "plugwise", "plum_lightpad", "pocketcasts", "point", @@ -819,6 +1912,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "quantum_gateway", "qvr_pro", "qwikswitch", + "rainbird", "rabbitair", "rachio", "radarr", @@ -840,9 +1934,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "rejseplanen", "remember_the_milk", "remote_rpi_gpio", - "renault", "renson", - "reolink", "repetier", "rest", "rest_command", @@ -869,12 +1961,10 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "rtorrent", "rtsp_to_webrtc", "ruckus_unleashed", - "russound_rio", "russound_rnet", "ruuvi_gateway", "ruuvitag_ble", "rympro", - "sabnzbd", "saj", "samsungtv", "sanix", @@ -939,7 +2029,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "snooz", "solaredge", "solaredge_local", - "solarlog", "solax", "soma", "somfy_mylink", @@ -967,19 +2056,17 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "steam_online", "steamist", "stiebel_eltron", - "stookalert", - "stookwijzer", "stream", "streamlabswater", + "stookwijzer", "subaru", - "suez_water", "sun", "sunweg", "supervisord", "supla", "surepetcare", - "swiss_hydrological_data", "swiss_public_transport", + "swiss_hydrological_data", "swisscom", "switch_as_x", "switchbee", @@ -1041,8 +2128,8 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "tomato", "tomorrowio", "toon", - "torque", "totalconnect", + "torque", "touchline", "touchline_sl", "tplink", @@ -1089,14 +2176,15 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "v2c", "vallox", "vasttrafik", - "velbus", "velux", "venstar", "vera", + "velbus", "verisure", "versasense", "version", "vesync", + "vicare", "viaggiatreno", "vilfo", "vivotek", @@ -1123,8 +2211,8 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "weatherflow", "weatherflow_cloud", "weatherkit", - "webmin", "webostv", + "webmin", "weheat", "wemo", "whirlpool", @@ -1248,7 +2336,7 @@ SCHEMA = vol.Schema( { vol.Required("rules"): vol.Schema( { - vol.Optional(rule): vol.Any( + vol.Optional(rule.name): vol.Any( vol.In(["todo", "done"]), vol.Schema( { @@ -1263,8 +2351,7 @@ SCHEMA = vol.Schema( } ), ) - for tier_list in RULES.values() - for rule in tier_list + for rule in ALL_RULES } ) } @@ -1313,7 +2400,27 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: if integration.domain in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE: integration.add_error( "quality_scale", - "Quality scale file found! Please remove from script/hassfest/quality_scale.py", + "Quality scale file found! Please remove from `INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE`" + " in script/hassfest/quality_scale.py", + ) + return + if ( + integration.domain in INTEGRATIONS_WITHOUT_SCALE + and declared_quality_scale is not None + ): + integration.add_error( + "quality_scale", + "This integration is graded and should be removed from `INTEGRATIONS_WITHOUT_SCALE`" + " in script/hassfest/quality_scale.py", + ) + return + if ( + integration.domain not in INTEGRATIONS_WITHOUT_SCALE + and declared_quality_scale is None + ): + integration.add_error( + "quality_scale", + "New integrations are required to at least reach the Bronze tier.", ) return name = str(iqs_file) @@ -1331,21 +2438,33 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: "quality_scale", f"Invalid {name}: {humanize_error(data, err)}" ) - if declared_quality_scale is None: - return - - rules_met = set() + rules_done = set[str]() + rules_met = set[str]() for rule_name, rule_value in data.get("rules", {}).items(): status = rule_value["status"] if isinstance(rule_value, dict) else rule_value - if status in {"done", "exempt"}: - rules_met.add(rule_name) + if status not in {"done", "exempt"}: + continue + rules_met.add(rule_name) + if status == "done": + rules_done.add(rule_name) + + for rule_name in rules_done: + if (validator := VALIDATORS.get(rule_name)) and ( + errors := validator.validate(config, integration, rules_done=rules_done) + ): + for error in errors: + integration.add_error("quality_scale", f"[{rule_name}] {error}") + integration.add_error("quality_scale", RULE_URL.format(rule_name=rule_name)) # An integration must have all the necessary rules for the declared # quality scale, and all the rules below. + if declared_quality_scale is None: + return + for scale in ScaledQualityScaleTiers: if scale > declared_quality_scale: break - required_rules = set(RULES[scale]) + required_rules = set(SCALE_RULES[scale]) if missing_rules := (required_rules - rules_met): friendly_rule_str = "\n".join( f" {rule}: todo" for rule in sorted(missing_rules) diff --git a/script/hassfest/quality_scale_validation/__init__.py b/script/hassfest/quality_scale_validation/__init__.py new file mode 100644 index 00000000000..7c41a58b601 --- /dev/null +++ b/script/hassfest/quality_scale_validation/__init__.py @@ -0,0 +1,17 @@ +"""Integration quality scale rules.""" + +from typing import Protocol + +from script.hassfest.model import Config, Integration + + +class RuleValidationProtocol(Protocol): + """Protocol for rule validation.""" + + def validate( + self, config: Config, integration: Integration, *, rules_done: set[str] + ) -> list[str] | None: + """Validate a quality scale rule. + + Returns error (if any). + """ diff --git a/script/hassfest/quality_scale_validation/config_entry_unloading.py b/script/hassfest/quality_scale_validation/config_entry_unloading.py new file mode 100644 index 00000000000..4874ddc4625 --- /dev/null +++ b/script/hassfest/quality_scale_validation/config_entry_unloading.py @@ -0,0 +1,33 @@ +"""Enforce that the integration implements entry unloading. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-entry-unloading/ +""" + +import ast + +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + + +def _has_unload_entry_function(module: ast.Module) -> bool: + """Test if the module defines `async_unload_entry` function.""" + return any( + type(item) is ast.AsyncFunctionDef and item.name == "async_unload_entry" + for item in module.body + ) + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate that the integration has a config flow.""" + + init_file = integration.path / "__init__.py" + init = ast_parse_module(init_file) + + if not _has_unload_entry_function(init): + return [ + "Integration does not support config entry unloading " + "(is missing `async_unload_entry` in __init__.py)" + ] + return None diff --git a/script/hassfest/quality_scale_validation/config_flow.py b/script/hassfest/quality_scale_validation/config_flow.py new file mode 100644 index 00000000000..d1ac70ab469 --- /dev/null +++ b/script/hassfest/quality_scale_validation/config_flow.py @@ -0,0 +1,26 @@ +"""Enforce that the integration implements config flow. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow/ +""" + +from script.hassfest.model import Config, Integration + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate that the integration implements config flow.""" + + if not integration.config_flow: + return [ + "Integration does not set config_flow in its manifest " + f"homeassistant/components/{integration.domain}/manifest.json", + ] + + config_flow_file = integration.path / "config_flow.py" + if not config_flow_file.exists(): + return [ + "Integration does not implement config flow (is missing config_flow.py)", + ] + + return None diff --git a/script/hassfest/quality_scale_validation/diagnostics.py b/script/hassfest/quality_scale_validation/diagnostics.py new file mode 100644 index 00000000000..ea143002b09 --- /dev/null +++ b/script/hassfest/quality_scale_validation/diagnostics.py @@ -0,0 +1,45 @@ +"""Enforce that the integration implements diagnostics. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/diagnostics/ +""" + +import ast + +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + +DIAGNOSTICS_FUNCTIONS = { + "async_get_config_entry_diagnostics", + "async_get_device_diagnostics", +} + + +def _has_diagnostics_function(module: ast.Module) -> bool: + """Test if the module defines at least one of diagnostic functions.""" + return any( + type(item) is ast.AsyncFunctionDef and item.name in DIAGNOSTICS_FUNCTIONS + for item in ast.walk(module) + ) + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate that the integration implements diagnostics.""" + + diagnostics_file = integration.path / "diagnostics.py" + if not diagnostics_file.exists(): + return [ + "Integration does implement diagnostics platform " + "(is missing diagnostics.py)", + ] + + diagnostics = ast_parse_module(diagnostics_file) + + if not _has_diagnostics_function(diagnostics): + return [ + f"Integration is missing one of {DIAGNOSTICS_FUNCTIONS} " + f"in {diagnostics_file}" + ] + + return None diff --git a/script/hassfest/quality_scale_validation/discovery.py b/script/hassfest/quality_scale_validation/discovery.py new file mode 100644 index 00000000000..45eaafde0b5 --- /dev/null +++ b/script/hassfest/quality_scale_validation/discovery.py @@ -0,0 +1,61 @@ +"""Enforce that the integration supports discovery. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/discovery/ +""" + +import ast + +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + +MANIFEST_KEYS = [ + "bluetooth", + "dhcp", + "homekit", + "mqtt", + "ssdp", + "usb", + "zeroconf", +] +CONFIG_FLOW_STEPS = { + "async_step_bluetooth", + "async_step_discovery", + "async_step_dhcp", + "async_step_hassio", + "async_step_homekit", + "async_step_mqtt", + "async_step_ssdp", + "async_step_usb", + "async_step_zeroconf", +} + + +def _has_discovery_function(module: ast.Module) -> bool: + """Test if the module defines at least one of the discovery functions.""" + return any( + type(item) is ast.AsyncFunctionDef and item.name in CONFIG_FLOW_STEPS + for item in ast.walk(module) + ) + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate that the integration implements diagnostics.""" + + config_flow_file = integration.path / "config_flow.py" + if not config_flow_file.exists(): + return ["Integration is missing config_flow.py"] + + # Check manifest + if any(key in integration.manifest for key in MANIFEST_KEYS): + return None + + # Fallback => check config_flow step + config_flow = ast_parse_module(config_flow_file) + if not (_has_discovery_function(config_flow)): + return [ + f"Integration is missing one of {CONFIG_FLOW_STEPS} in {config_flow_file}" + ] + + return None diff --git a/script/hassfest/quality_scale_validation/parallel_updates.py b/script/hassfest/quality_scale_validation/parallel_updates.py new file mode 100644 index 00000000000..00ad891774d --- /dev/null +++ b/script/hassfest/quality_scale_validation/parallel_updates.py @@ -0,0 +1,38 @@ +"""Enforce that the integration sets PARALLEL_UPDATES constant. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/parallel-updates +""" + +import ast + +from homeassistant.const import Platform +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + + +def _has_parallel_updates_defined(module: ast.Module) -> bool: + """Test if the module defines `PARALLEL_UPDATES` constant.""" + return any( + type(item) is ast.Assign and item.targets[0].id == "PARALLEL_UPDATES" + for item in module.body + ) + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate that the integration sets PARALLEL_UPDATES constant.""" + + errors = [] + for platform in Platform: + module_file = integration.path / f"{platform}.py" + if not module_file.exists(): + continue + module = ast_parse_module(module_file) + + if not _has_parallel_updates_defined(module): + errors.append( + f"Integration does not set `PARALLEL_UPDATES` in {module_file}" + ) + + return errors diff --git a/script/hassfest/quality_scale_validation/reauthentication_flow.py b/script/hassfest/quality_scale_validation/reauthentication_flow.py new file mode 100644 index 00000000000..3db9700af98 --- /dev/null +++ b/script/hassfest/quality_scale_validation/reauthentication_flow.py @@ -0,0 +1,33 @@ +"""Enforce that the integration implements reauthentication flow. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reauthentication-flow/ +""" + +import ast + +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + + +def _has_step_reauth_function(module: ast.Module) -> bool: + """Test if the module defines `async_step_reauth` function.""" + return any( + type(item) is ast.AsyncFunctionDef and item.name == "async_step_reauth" + for item in ast.walk(module) + ) + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate that the integration has a reauthentication flow.""" + + config_flow_file = integration.path / "config_flow.py" + config_flow = ast_parse_module(config_flow_file) + + if not _has_step_reauth_function(config_flow): + return [ + "Integration does not support a reauthentication flow " + f"(is missing `async_step_reauth` in {config_flow_file})" + ] + return None diff --git a/script/hassfest/quality_scale_validation/reconfiguration_flow.py b/script/hassfest/quality_scale_validation/reconfiguration_flow.py new file mode 100644 index 00000000000..28cc0ef6d43 --- /dev/null +++ b/script/hassfest/quality_scale_validation/reconfiguration_flow.py @@ -0,0 +1,33 @@ +"""Enforce that the integration implements reconfiguration flow. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow/ +""" + +import ast + +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + + +def _has_step_reconfigure_function(module: ast.Module) -> bool: + """Test if the module defines a function.""" + return any( + type(item) is ast.AsyncFunctionDef and item.name == "async_step_reconfigure" + for item in ast.walk(module) + ) + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate that the integration has a reconfiguration flow.""" + + config_flow_file = integration.path / "config_flow.py" + config_flow = ast_parse_module(config_flow_file) + + if not _has_step_reconfigure_function(config_flow): + return [ + "Integration does not support a reconfiguration flow " + f"(is missing `async_step_reconfigure` in {config_flow_file})" + ] + return None diff --git a/script/hassfest/quality_scale_validation/runtime_data.py b/script/hassfest/quality_scale_validation/runtime_data.py new file mode 100644 index 00000000000..cfc4c5224de --- /dev/null +++ b/script/hassfest/quality_scale_validation/runtime_data.py @@ -0,0 +1,130 @@ +"""Enforce that the integration uses ConfigEntry.runtime_data to store runtime data. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/runtime-data +""" + +import ast +import re + +from homeassistant.const import Platform +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + +_ANNOTATION_MATCH = re.compile(r"^[A-Za-z]+ConfigEntry$") +_FUNCTIONS: dict[str, dict[str, int]] = { + "__init__": { # based on ComponentProtocol + "async_migrate_entry": 2, + "async_remove_config_entry_device": 2, + "async_remove_entry": 2, + "async_setup_entry": 2, + "async_unload_entry": 2, + }, + "diagnostics": { # based on DiagnosticsProtocol + "async_get_config_entry_diagnostics": 2, + "async_get_device_diagnostics": 2, + }, +} +for platform in Platform: # based on EntityPlatformModule + _FUNCTIONS[platform.value] = { + "async_setup_entry": 2, + } + + +def _sets_runtime_data( + async_setup_entry_function: ast.AsyncFunctionDef, config_entry_argument: ast.arg +) -> bool: + """Check that `entry.runtime` gets set within `async_setup_entry`.""" + for node in ast.walk(async_setup_entry_function): + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == config_entry_argument.arg + and node.attr == "runtime_data" + and isinstance(node.ctx, ast.Store) + ): + return True + return False + + +def _get_async_function(module: ast.Module, name: str) -> ast.AsyncFunctionDef | None: + """Get async function.""" + for item in module.body: + if isinstance(item, ast.AsyncFunctionDef) and item.name == name: + return item + return None + + +def _check_function_annotation( + function: ast.AsyncFunctionDef, position: int +) -> str | None: + """Ensure function uses CustomConfigEntry type annotation.""" + if len(function.args.args) < position: + return f"{function.name} has incorrect signature" + argument = function.args.args[position - 1] + if not ( + (annotation := argument.annotation) + and isinstance(annotation, ast.Name) + and _ANNOTATION_MATCH.match(annotation.id) + ): + return f"([+ strict-typing]) {function.name} does not use typed ConfigEntry" + return None + + +def _check_typed_config_entry(integration: Integration) -> list[str]: + """Ensure integration uses CustomConfigEntry type annotation.""" + errors: list[str] = [] + # Check body level function annotations + for file, functions in _FUNCTIONS.items(): + module_file = integration.path / f"{file}.py" + if not module_file.exists(): + continue + module = ast_parse_module(module_file) + for function, position in functions.items(): + if not (async_function := _get_async_function(module, function)): + continue + if error := _check_function_annotation(async_function, position): + errors.append(f"{error} in {module_file}") + + # Check config_flow annotations + config_flow_file = integration.path / "config_flow.py" + config_flow = ast_parse_module(config_flow_file) + for node in config_flow.body: + if not isinstance(node, ast.ClassDef): + continue + if any( + isinstance(async_function, ast.FunctionDef) + and async_function.name == "async_get_options_flow" + and (error := _check_function_annotation(async_function, 1)) + for async_function in node.body + ): + errors.append(f"{error} in {config_flow_file}") + + return errors + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate correct use of ConfigEntry.runtime_data.""" + init_file = integration.path / "__init__.py" + init = ast_parse_module(init_file) + + # Should not happen, but better to be safe + if not (async_setup_entry := _get_async_function(init, "async_setup_entry")): + return [f"Could not find `async_setup_entry` in {init_file}"] + if len(async_setup_entry.args.args) != 2: + return [f"async_setup_entry has incorrect signature in {init_file}"] + config_entry_argument = async_setup_entry.args.args[1] + + errors: list[str] = [] + if not _sets_runtime_data(async_setup_entry, config_entry_argument): + errors.append( + "Integration does not set entry.runtime_data in async_setup_entry" + f"({init_file})" + ) + + # Extra checks, if strict-typing is marked as done + if "strict-typing" in rules_done: + errors.extend(_check_typed_config_entry(integration)) + + return errors diff --git a/script/hassfest/quality_scale_validation/strict_typing.py b/script/hassfest/quality_scale_validation/strict_typing.py new file mode 100644 index 00000000000..1f5a5665835 --- /dev/null +++ b/script/hassfest/quality_scale_validation/strict_typing.py @@ -0,0 +1,71 @@ +"""Enforce that the integration has strict typing enabled. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/strict-typing/ +""" + +from functools import lru_cache +from importlib import metadata +from pathlib import Path +import re + +from script.hassfest.model import Config, Integration + +_STRICT_TYPING_FILE = Path(".strict-typing") +_COMPONENT_REGEX = r"homeassistant.components.([^.]+).*" + + +@lru_cache +def _strict_typing_components(strict_typing_file: Path) -> set[str]: + return set( + { + match.group(1) + for line in strict_typing_file.read_text(encoding="utf-8").splitlines() + if (match := re.match(_COMPONENT_REGEX, line)) is not None + } + ) + + +def _check_requirements_are_typed(integration: Integration) -> list[str]: + """Check if all requirements are typed.""" + invalid_requirements = [] + for requirement in integration.requirements: + requirement_name, requirement_version = requirement.split("==") + # Remove any extras + requirement_name = requirement_name.split("[")[0] + try: + distribution = metadata.distribution(requirement_name) + except metadata.PackageNotFoundError: + # Package not installed locally + continue + if distribution.version != requirement_version: + # Version out of date locally + continue + + if not any(file for file in distribution.files if file.name == "py.typed"): + # no py.typed file + try: + metadata.distribution(f"types-{requirement_name}") + except metadata.PackageNotFoundError: + # also no stubs-only package + invalid_requirements.append(requirement) + + return invalid_requirements + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate that the integration has strict typing enabled.""" + strict_typing_file = config.root / _STRICT_TYPING_FILE + + if integration.domain not in _strict_typing_components(strict_typing_file): + return [ + "Integration does not have strict typing enabled " + "(is missing from .strict-typing)" + ] + if untyped_requirements := _check_requirements_are_typed(integration): + return [ + f"Requirements {untyped_requirements} do not conform PEP 561 (https://peps.python.org/pep-0561/)", + "They should be typed and have a 'py.typed' file", + ] + return None diff --git a/script/hassfest/quality_scale_validation/test_before_setup.py b/script/hassfest/quality_scale_validation/test_before_setup.py new file mode 100644 index 00000000000..1ac0d3d8e0b --- /dev/null +++ b/script/hassfest/quality_scale_validation/test_before_setup.py @@ -0,0 +1,89 @@ +"""Enforce that the integration raises correctly during initialisation. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup/ +""" + +import ast + +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + +_VALID_EXCEPTIONS = { + "ConfigEntryNotReady", + "ConfigEntryAuthFailed", + "ConfigEntryError", +} + + +def _get_exception_name(expression: ast.expr) -> str: + """Get the name of the exception being raised.""" + if expression is None: + # Bare raise + return None + + if isinstance(expression, ast.Name): + # Raise Exception + return expression.id + + if isinstance(expression, ast.Call): + # Raise Exception() + return _get_exception_name(expression.func) + + if isinstance(expression, ast.Attribute): + # Raise namespace.??? + return _get_exception_name(expression.value) + + raise AssertionError( + f"Raise is neither Attribute nor Call nor Name: {type(expression)}" + ) + + +def _raises_exception(integration: Integration) -> bool: + """Check that a valid exception is raised.""" + for module_file in integration.path.rglob("*.py"): + module = ast_parse_module(module_file) + for node in ast.walk(module): + if ( + isinstance(node, ast.Raise) + and _get_exception_name(node.exc) in _VALID_EXCEPTIONS + ): + return True + + return False + + +def _calls_first_refresh(async_setup_entry_function: ast.AsyncFunctionDef) -> bool: + """Check that a async_config_entry_first_refresh within `async_setup_entry`.""" + for node in ast.walk(async_setup_entry_function): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "async_config_entry_first_refresh" + ): + return True + + return False + + +def _get_setup_entry_function(module: ast.Module) -> ast.AsyncFunctionDef | None: + """Get async_setup_entry function.""" + for item in module.body: + if isinstance(item, ast.AsyncFunctionDef) and item.name == "async_setup_entry": + return item + return None + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate correct use of ConfigEntry.runtime_data.""" + init_file = integration.path / "__init__.py" + init = ast_parse_module(init_file) + + # Should not happen, but better to be safe + if not (async_setup_entry := _get_setup_entry_function(init)): + return [f"Could not find `async_setup_entry` in {init_file}"] + + if not (_calls_first_refresh(async_setup_entry) or _raises_exception(integration)): + return [f"Integration does not raise one of {_VALID_EXCEPTIONS}"] + return None diff --git a/script/hassfest/quality_scale_validation/unique_config_entry.py b/script/hassfest/quality_scale_validation/unique_config_entry.py new file mode 100644 index 00000000000..83b3d20bd80 --- /dev/null +++ b/script/hassfest/quality_scale_validation/unique_config_entry.py @@ -0,0 +1,52 @@ +"""Enforce that the integration prevents duplicates from being configured. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/unique-config-entry/ +""" + +import ast + +from script.hassfest import ast_parse_module +from script.hassfest.model import Config, Integration + + +def _has_method_call(module: ast.Module, name: str) -> bool: + """Test if the module calls a specific method.""" + return any( + type(item.func) is ast.Attribute and item.func.attr == name + for item in ast.walk(module) + if isinstance(item, ast.Call) + ) + + +def _has_abort_entries_match(module: ast.Module) -> bool: + """Test if the module calls `_async_abort_entries_match`.""" + return _has_method_call(module, "_async_abort_entries_match") + + +def _has_abort_unique_id_configured(module: ast.Module) -> bool: + """Test if the module calls defines (and checks for) a unique_id.""" + return _has_method_call(module, "async_set_unique_id") and _has_method_call( + module, "_abort_if_unique_id_configured" + ) + + +def validate( + config: Config, integration: Integration, *, rules_done: set[str] +) -> list[str] | None: + """Validate that the integration prevents duplicate devices.""" + + if integration.manifest.get("single_config_entry"): + return None + + config_flow_file = integration.path / "config_flow.py" + config_flow = ast_parse_module(config_flow_file) + + if not ( + _has_abort_entries_match(config_flow) + or _has_abort_unique_id_configured(config_flow) + ): + return [ + "Integration doesn't prevent the same device or service from being " + f"set up twice in {config_flow_file}" + ] + return None diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 8c9ab5c0c0b..3a0ebed76fe 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -77,6 +77,8 @@ CUSTOM_INTEGRATION_FIELD_SCHEMA = CORE_INTEGRATION_FIELD_SCHEMA.extend( CUSTOM_INTEGRATION_SECTION_SCHEMA = vol.Schema( { + vol.Optional("description"): str, + vol.Optional("name"): str, vol.Optional("collapsed"): bool, vol.Required("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}), } diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 2965ccb7406..b3d397dbd55 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -172,6 +172,9 @@ def gen_data_entry_schema( vol.Optional("sections"): { str: { vol.Optional("data"): {str: translation_value_validator}, + vol.Optional("data_description"): { + str: translation_value_validator + }, vol.Optional("description"): translation_value_validator, vol.Optional("name"): translation_value_validator, }, @@ -451,7 +454,7 @@ ONBOARDING_SCHEMA = vol.Schema( ) -def validate_translation_file( # noqa: C901 +def validate_translation_file( config: Config, integration: Integration, all_strings: dict[str, Any] | None, @@ -507,8 +510,8 @@ def validate_translation_file( # noqa: C901 ): integration.add_error( "translations", - "Don't specify title in translation strings if it's a brand " - "name or add exception to ALLOW_NAME_TRANSLATION", + "Don't specify title in translation strings if it's " + "a brand name or add exception to ALLOW_NAME_TRANSLATION", ) if config.specific_integrations: @@ -529,12 +532,15 @@ def validate_translation_file( # noqa: C901 if parts or key not in search: integration.add_error( "translations", - f"{reference['source']} contains invalid reference {reference['ref']}: Could not find {key}", + f"{reference['source']} contains invalid reference" + f"{reference['ref']}: Could not find {key}", ) elif match := re.match(RE_REFERENCE, search[key]): integration.add_error( "translations", - f"Lokalise supports only one level of references: \"{reference['source']}\" should point to directly to \"{match.groups()[0]}\"", + "Lokalise supports only one level of references: " + f'"{reference["source"]}" should point to directly ' + f'to "{match.groups()[0]}"', ) diff --git a/script/ruff.toml b/script/ruff.toml index c32b39022cc..a14712ec142 100644 --- a/script/ruff.toml +++ b/script/ruff.toml @@ -5,3 +5,7 @@ extend = "../pyproject.toml" forced-separate = [ "tests", ] + +[lint.flake8-tidy-imports.banned-api] +"async_timeout".msg = "use asyncio.timeout instead" +"pytz".msg = "use zoneinfo instead" diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 45dbed790e6..93c787df50f 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -28,7 +28,7 @@ def get_arguments() -> argparse.Namespace: return parser.parse_args() -def main(): +def main() -> int: """Scaffold an integration.""" if not Path("requirements_all.txt").is_file(): print("Run from project root") diff --git a/script/scaffold/gather_info.py b/script/scaffold/gather_info.py index cfa2669ebfe..d90e01c3ebd 100644 --- a/script/scaffold/gather_info.py +++ b/script/scaffold/gather_info.py @@ -93,7 +93,7 @@ def gather_new_integration(determine_auth: bool) -> Info: "prompt": ( f"""How will your integration gather data? -Valid values are {', '.join(SUPPORTED_IOT_CLASSES)} +Valid values are {", ".join(SUPPORTED_IOT_CLASSES)} More info @ https://developers.home-assistant.io/docs/creating_integration_manifest#iot-class """ diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index 0bee69b93f8..9ca5ead5719 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -19,7 +19,7 @@ def generate(template: str, info: Info) -> None: print() -def _generate(src_dir, target_dir, info: Info) -> None: +def _generate(src_dir: Path, target_dir: Path, info: Info) -> None: """Generate an integration.""" replaces = {"NEW_DOMAIN": info.domain, "NEW_NAME": info.name} diff --git a/script/scaffold/templates/config_flow/integration/__init__.py b/script/scaffold/templates/config_flow/integration/__init__.py index 0b752e71013..11759c48cf3 100644 --- a/script/scaffold/templates/config_flow/integration/__init__.py +++ b/script/scaffold/templates/config_flow/integration/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.LIGHT] +_PLATFORMS: list[Platform] = [Platform.LIGHT] # TODO Create ConfigEntry type alias with API object # TODO Rename type alias and update all entry annotations @@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO 3. Store an API object for your platforms to access # entry.runtime_data = MyAPI(...) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -32,4 +32,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 0bff976f288..06db7592840 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for NEW_NAME integration.""" +"""Config flow for the NEW_NAME integration.""" from __future__ import annotations diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 9a712834bae..66209f77e6a 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -15,7 +15,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} with patch( @@ -32,7 +32,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", @@ -63,7 +63,7 @@ async def test_form_invalid_auth( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} # Make sure the config flow tests finish with either an @@ -83,7 +83,7 @@ async def test_form_invalid_auth( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", @@ -114,7 +114,7 @@ async def test_form_cannot_connect( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} # Make sure the config flow tests finish with either an @@ -135,7 +135,7 @@ async def test_form_cannot_connect( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", diff --git a/script/scaffold/templates/config_flow_discovery/integration/__init__.py b/script/scaffold/templates/config_flow_discovery/integration/__init__.py index 06b91f51949..ba56b958273 100644 --- a/script/scaffold/templates/config_flow_discovery/integration/__init__.py +++ b/script/scaffold/templates/config_flow_discovery/integration/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] # TODO Create ConfigEntry type alias with API object # Alias name should be prefixed by integration name @@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # TODO 3. Store an API object for your platforms to access # entry.runtime_data = MyAPI(...) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -32,4 +32,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/script/scaffold/templates/config_flow_discovery/integration/config_flow.py b/script/scaffold/templates/config_flow_discovery/integration/config_flow.py index e2cfed40e1d..570b70b85aa 100644 --- a/script/scaffold/templates/config_flow_discovery/integration/config_flow.py +++ b/script/scaffold/templates/config_flow_discovery/integration/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for NEW_NAME.""" +"""Config flow for the NEW_NAME integration.""" import my_pypi_dependency diff --git a/script/scaffold/templates/config_flow_helper/integration/config_flow.py b/script/scaffold/templates/config_flow_helper/integration/config_flow.py index 5d89fec2da2..c2ab7a205da 100644 --- a/script/scaffold/templates/config_flow_helper/integration/config_flow.py +++ b/script/scaffold/templates/config_flow_helper/integration/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for NEW_NAME integration.""" +"""Config flow for the NEW_NAME integration.""" from __future__ import annotations diff --git a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py index 8e7854835d8..fbf705cfb26 100644 --- a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py @@ -24,7 +24,7 @@ async def test_config_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None result = await hass.config_entries.flow.async_configure( @@ -33,7 +33,7 @@ async def test_config_flow( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "My NEW_DOMAIN" assert result["data"] == {} assert result["options"] == { @@ -83,7 +83,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema assert get_suggested(schema, "entity_id") == input_sensor_1_entity_id @@ -94,7 +94,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: "entity_id": input_sensor_2_entity_id, }, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "entity_id": input_sensor_2_entity_id, "name": "My NEW_DOMAIN", diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index b8403392471..8eaf8b0e25a 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -11,7 +11,7 @@ from . import api # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.LIGHT] +_PLATFORMS: list[Platform] = [Platform.LIGHT] # TODO Create ConfigEntry type alias with ConfigEntryAuth or AsyncConfigEntryAuth object # TODO Rename type alias and update all entry annotations @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> aiohttp_client.async_get_clientsession(hass), session ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -45,4 +45,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py b/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py index 51ef70b1885..0f01c8402df 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/application_credentials.py @@ -1,11 +1,9 @@ -"""application_credentials platform the NEW_NAME integration.""" +"""Application credentials platform for the NEW_NAME integration.""" from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -# TODO Update with your own urls -OAUTH2_AUTHORIZE = "https://www.example.com/auth/authorize" -OAUTH2_TOKEN = "https://www.example.com/auth/token" +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: diff --git a/script/scaffold/templates/integration/integration/quality_scale.yaml b/script/scaffold/templates/integration/integration/quality_scale.yaml new file mode 100644 index 00000000000..201a91652e5 --- /dev/null +++ b/script/scaffold/templates/integration/integration/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + action-setup: todo + appropriate-polling: todo + brands: todo + common-modules: todo + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: todo + docs-actions: todo + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: todo + entity-unique-id: todo + has-entity-name: todo + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: todo + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/script/split_tests.py b/script/split_tests.py index c64de46a068..0018472e54e 100755 --- a/script/split_tests.py +++ b/script/split_tests.py @@ -79,7 +79,7 @@ class BucketHolder: """Create output file.""" with Path("pytest_buckets.txt").open("w") as file: for idx, bucket in enumerate(self._buckets): - print(f"Bucket {idx+1} has {bucket.total_tests} tests") + print(f"Bucket {idx + 1} has {bucket.total_tests} tests") file.write(bucket.get_paths_line()) diff --git a/script/translations/deduplicate.py b/script/translations/deduplicate.py index 8cc4cee3b10..ac608a1aa0e 100644 --- a/script/translations/deduplicate.py +++ b/script/translations/deduplicate.py @@ -7,8 +7,7 @@ from pathlib import Path from homeassistant.const import Platform from . import upload -from .develop import flatten_translations -from .util import get_base_arg_parser, load_json_from_path +from .util import flatten_translations, get_base_arg_parser, load_json_from_path def get_arguments() -> argparse.Namespace: @@ -71,8 +70,10 @@ def run(): # If we want to only add references to own integrations # but not include entity integrations if ( - args.limit_reference - and (key_integration != key_to_reference_integration and not is_common) + ( + args.limit_reference + and (key_integration != key_to_reference_integration and not is_common) + ) # Do not create self-references in entity integrations or key_integration in Platform.__members__.values() ): diff --git a/script/translations/develop.py b/script/translations/develop.py index 00465e1bc24..00ac7bf98ac 100644 --- a/script/translations/develop.py +++ b/script/translations/develop.py @@ -4,12 +4,11 @@ import argparse import json from pathlib import Path import re -from shutil import rmtree import sys from . import download, upload from .const import INTEGRATIONS_DIR -from .util import get_base_arg_parser +from .util import flatten_translations, get_base_arg_parser def valid_integration(integration): @@ -32,29 +31,6 @@ def get_arguments() -> argparse.Namespace: return parser.parse_args() -def flatten_translations(translations): - """Flatten all translations.""" - stack = [iter(translations.items())] - key_stack = [] - flattened_translations = {} - while stack: - for k, v in stack[-1]: - key_stack.append(k) - if isinstance(v, dict): - stack.append(iter(v.items())) - break - if isinstance(v, str): - common_key = "::".join(key_stack) - flattened_translations[common_key] = v - key_stack.pop() - else: - stack.pop() - if key_stack: - key_stack.pop() - - return flattened_translations - - def substitute_translation_references(integration_strings, flattened_translations): """Recursively processes all translation strings for the integration.""" result = {} @@ -106,9 +82,10 @@ def run_single(translations, flattened_translations, integration): ) if download.DOWNLOAD_DIR.is_dir(): - rmtree(str(download.DOWNLOAD_DIR)) - - download.DOWNLOAD_DIR.mkdir(parents=True) + for lang_file in download.DOWNLOAD_DIR.glob("*.json"): + lang_file.unlink() + else: + download.DOWNLOAD_DIR.mkdir(parents=True) (download.DOWNLOAD_DIR / "en.json").write_text( json.dumps({"component": {integration: translations["component"][integration]}}) diff --git a/script/translations/download.py b/script/translations/download.py index 756de46fb61..3fa7065d058 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -7,10 +7,11 @@ import json from pathlib import Path import re import subprocess +from typing import Any from .const import CLI_2_DOCKER_IMAGE, CORE_PROJECT_ID, INTEGRATIONS_DIR from .error import ExitApp -from .util import get_lokalise_token, load_json_from_path +from .util import flatten_translations, get_lokalise_token, load_json_from_path FILENAME_FORMAT = re.compile(r"strings\.(?P\w+)\.json") DOWNLOAD_DIR = Path("build/translations-download").absolute() @@ -103,7 +104,15 @@ def save_language_translations(lang, translations): f"Skipping {lang} for {component}, as the integration doesn't seem to exist." ) continue + if not ( + Path("homeassistant") / "components" / component / "strings.json" + ).exists(): + print( + f"Skipping {lang} for {component}, as the integration doesn't have a strings.json file." + ) + continue path.parent.mkdir(parents=True, exist_ok=True) + base_translations = pick_keys(component, base_translations) save_json(path, base_translations) if "platform" not in component_translations: @@ -131,6 +140,32 @@ def delete_old_translations(): fil.unlink() +def get_current_keys(component: str) -> dict[str, Any]: + """Get the current keys for a component.""" + strings_path = Path("homeassistant") / "components" / component / "strings.json" + return load_json_from_path(strings_path) + + +def pick_keys(component: str, translations: dict[str, Any]) -> dict[str, Any]: + """Pick the keys that are in the current strings.""" + flat_translations = flatten_translations(translations) + flat_current_keys = flatten_translations(get_current_keys(component)) + flatten_result = {} + for key in flat_current_keys: + if key in flat_translations: + flatten_result[key] = flat_translations[key] + result = {} + for key, value in flatten_result.items(): + parts = key.split("::") + d = result + for part in parts[:-1]: + if part not in d: + d[part] = {} + d = d[part] + d[parts[-1]] = value + return result + + def run(): """Run the script.""" DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) diff --git a/script/translations/util.py b/script/translations/util.py index 8892bb46b7a..d78b2c4faff 100644 --- a/script/translations/util.py +++ b/script/translations/util.py @@ -66,3 +66,26 @@ def load_json_from_path(path: pathlib.Path) -> Any: return json.loads(path.read_text()) except json.JSONDecodeError as err: raise JSONDecodeErrorWithPath(err.msg, err.doc, err.pos, path) from err + + +def flatten_translations(translations): + """Flatten all translations.""" + stack = [iter(translations.items())] + key_stack = [] + flattened_translations = {} + while stack: + for k, v in stack[-1]: + key_stack.append(k) + if isinstance(v, dict): + stack.append(iter(v.items())) + break + if isinstance(v, str): + common_key = "::".join(key_stack) + flattened_translations[common_key] = v + key_stack.pop() + else: + stack.pop() + if key_stack: + key_stack.pop() + + return flattened_translations diff --git a/tests/common.py b/tests/common.py index 8bd45e4d7f8..0315ee6d845 100644 --- a/tests/common.py +++ b/tests/common.py @@ -15,7 +15,7 @@ from collections.abc import ( ) from contextlib import asynccontextmanager, contextmanager, suppress from datetime import UTC, datetime, timedelta -from enum import Enum +from enum import Enum, StrEnum import functools as ft from functools import lru_cache from io import StringIO @@ -31,7 +31,6 @@ from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 import pytest from syrupy import SnapshotAssertion -from typing_extensions import TypeVar import voluptuous as vol from homeassistant import auth, bootstrap, config_entries, loader @@ -90,12 +89,12 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder, json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util, ulid as ulid_util from homeassistant.util.async_ import ( _SHUTDOWN_RUN_CALLBACK_THREADSAFE, get_scheduled_timer_handles, run_callback_threadsafe, ) -import homeassistant.util.dt as dt_util from homeassistant.util.event_type import EventType from homeassistant.util.json import ( JsonArrayType, @@ -106,22 +105,27 @@ from homeassistant.util.json import ( json_loads_object, ) from homeassistant.util.signal_type import SignalType -import homeassistant.util.ulid as ulid_util from homeassistant.util.unit_system import METRIC_SYSTEM -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import load_yaml_dict, loader as yaml_loader from .testing_config.custom_components.test_constant_deprecation import ( import_deprecated_constant, ) -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=dict[str, Any]) - _LOGGER = logging.getLogger(__name__) INSTANCES = [] CLIENT_ID = "https://example.com/app" CLIENT_REDIRECT_URI = "https://example.com/app/callback" +class QualityScaleStatus(StrEnum): + """Source of core configuration.""" + + DONE = "done" + EXEMPT = "exempt" + TODO = "todo" + + async def async_get_device_automations( hass: HomeAssistant, automation_type: device_automation.DeviceAutomationType, @@ -491,7 +495,7 @@ _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution def _async_fire_time_changed( hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool ) -> None: - timestamp = dt_util.utc_to_timestamp(utc_datetime) + timestamp = utc_datetime.timestamp() for task in list(get_scheduled_timer_handles(hass.loop)): if not isinstance(task, asyncio.TimerHandle): continue @@ -1189,16 +1193,16 @@ def assert_setup_component(count, domain=None): yield config if domain is None: - assert ( - len(config) == 1 - ), f"assert_setup_component requires DOMAIN: {list(config.keys())}" + assert len(config) == 1, ( + f"assert_setup_component requires DOMAIN: {list(config.keys())}" + ) domain = list(config.keys())[0] res = config.get(domain) res_len = 0 if res is None else len(res) - assert ( - res_len == count - ), f"setup_component failed, expected {count} got {res_len}: {res}" + assert res_len == count, ( + f"setup_component failed, expected {count} got {res_len}: {res}" + ) def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: @@ -1537,7 +1541,7 @@ def mock_platform( module_cache[platform_path] = module or Mock() -def async_capture_events( +def async_capture_events[_DataT: Mapping[str, Any] = dict[str, Any]]( hass: HomeAssistant, event_name: EventType[_DataT] | str ) -> list[Event[_DataT]]: """Create a helper that captures events.""" @@ -1806,12 +1810,48 @@ async def snapshot_platform( """Snapshot a platform.""" entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) assert entity_entries - assert ( - len({entity_entry.domain for entity_entry in entity_entries}) == 1 - ), "Please limit the loaded platforms to 1 platform." + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Please limit the loaded platforms to 1 platform." + ) for entity_entry in entity_entries: assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") assert entity_entry.disabled_by is None, "Please enable all entities." state = hass.states.get(entity_entry.entity_id) assert state, f"State not found for {entity_entry.entity_id}" assert state == snapshot(name=f"{entity_entry.entity_id}-state") + + +def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None: + """Reset translation cache for specified components. + + Use this if you are mocking a core component (for example via + mock_integration), to ensure that the mocked translations are not + persisted in the shared session cache. + """ + translations_cache = translation._async_get_translations_cache(hass) + for loaded_components in translations_cache.cache_data.loaded.values(): + for component_to_unload in components: + loaded_components.discard(component_to_unload) + for loaded_categories in translations_cache.cache_data.cache.values(): + for loaded_components in loaded_categories.values(): + for component_to_unload in components: + loaded_components.pop(component_to_unload, None) + + +@lru_cache +def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]: + """Load quality scale for integration.""" + quality_scale_file = pathlib.Path( + f"homeassistant/components/{integration}/quality_scale.yaml" + ) + if not quality_scale_file.exists(): + return {} + raw = load_yaml_dict(quality_scale_file) + return { + rule: ( + QualityScaleStatus(details) + if isinstance(details, str) + else QualityScaleStatus(details["status"]) + ) + for rule, details in raw["rules"].items() + } diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py index d556a20fa90..4be94a09ee8 100644 --- a/tests/components/abode/test_light.py +++ b/tests/components/abode/test_light.py @@ -6,7 +6,7 @@ from homeassistant.components.abode import ATTR_DEVICE_ID from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, @@ -46,7 +46,7 @@ async def test_attributes(hass: HomeAssistant) -> None: assert state.state == STATE_ON assert state.attributes.get(ATTR_BRIGHTNESS) == 204 assert state.attributes.get(ATTR_RGB_COLOR) == (0, 64, 255) - assert state.attributes.get(ATTR_COLOR_TEMP) is None + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None assert state.attributes.get(ATTR_DEVICE_ID) == "ZB:db5b1a" assert not state.attributes.get("battery_low") assert not state.attributes.get("no_response") diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr index 1cc3d8dbbc0..7011b20f68c 100644 --- a/tests/components/acaia/snapshots/test_init.ambr +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -5,6 +5,10 @@ 'config_entries': , 'configuration_url': None, 'connections': set({ + tuple( + 'bluetooth', + 'aa:bb:cc:dd:ee:ff', + ), }), 'disabled_by': None, 'entry_type': None, diff --git a/tests/components/acmeda/conftest.py b/tests/components/acmeda/conftest.py index 2c980351c09..4a803711959 100644 --- a/tests/components/acmeda/conftest.py +++ b/tests/components/acmeda/conftest.py @@ -1,5 +1,8 @@ """Define fixtures available for all Acmeda tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + import pytest from homeassistant.components.acmeda.const import DOMAIN @@ -18,3 +21,10 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: ) mock_config_entry.add_to_hass(hass) return mock_config_entry + + +@pytest.fixture +def mock_hub_run() -> Generator[AsyncMock]: + """Mock the hub run method.""" + with patch("homeassistant.components.acmeda.hub.aiopulse.Hub.run") as mock_run: + yield mock_run diff --git a/tests/components/acmeda/test_config_flow.py b/tests/components/acmeda/test_config_flow.py index 5227d283f25..7b92c1aac3b 100644 --- a/tests/components/acmeda/test_config_flow.py +++ b/tests/components/acmeda/test_config_flow.py @@ -28,13 +28,6 @@ def mock_hub_discover(): yield mock_discover -@pytest.fixture -def mock_hub_run(): - """Mock the hub run method.""" - with patch("aiopulse.Hub.run") as mock_run: - yield mock_run - - async def async_generator(items): """Async yields items provided in a list.""" for item in items: @@ -56,9 +49,8 @@ async def test_show_form_no_hubs(hass: HomeAssistant, mock_hub_discover) -> None assert len(mock_hub_discover.mock_calls) == 1 -async def test_show_form_one_hub( - hass: HomeAssistant, mock_hub_discover, mock_hub_run -) -> None: +@pytest.mark.usefixtures("mock_hub_run") +async def test_show_form_one_hub(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when one hub discovered.""" dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) @@ -102,9 +94,8 @@ async def test_show_form_two_hubs(hass: HomeAssistant, mock_hub_discover) -> Non assert len(mock_hub_discover.mock_calls) == 1 -async def test_create_second_entry( - hass: HomeAssistant, mock_hub_run, mock_hub_discover -) -> None: +@pytest.mark.usefixtures("mock_hub_run") +async def test_create_second_entry(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when a second hub is discovered.""" dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) diff --git a/tests/components/acmeda/test_cover.py b/tests/components/acmeda/test_cover.py index 0d908ecc915..d5b6997ee33 100644 --- a/tests/components/acmeda/test_cover.py +++ b/tests/components/acmeda/test_cover.py @@ -1,5 +1,7 @@ """Define tests for the Acmeda config flow.""" +import pytest + from homeassistant.components.acmeda.const import DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_hub_run") async def test_cover_id_migration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/acmeda/test_sensor.py b/tests/components/acmeda/test_sensor.py index 3d7090ce7dd..12195d3aec4 100644 --- a/tests/components/acmeda/test_sensor.py +++ b/tests/components/acmeda/test_sensor.py @@ -1,5 +1,7 @@ """Define tests for the Acmeda config flow.""" +import pytest + from homeassistant.components.acmeda.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_hub_run") async def test_sensor_id_migration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/adguard/__init__.py b/tests/components/adguard/__init__.py index 318e881ef2f..4d8ae091dc5 100644 --- a/tests/components/adguard/__init__.py +++ b/tests/components/adguard/__init__.py @@ -1 +1 @@ -"""Tests for the AdGuard Home component.""" +"""Tests for the AdGuard Home integration.""" diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py index 6644a4ca20f..bd0f1b0a08f 100644 --- a/tests/components/adguard/test_config_flow.py +++ b/tests/components/adguard/test_config_flow.py @@ -59,9 +59,9 @@ async def test_connection_error( ) assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} async def test_full_flow_implementation( @@ -83,25 +83,27 @@ async def test_full_flow_implementation( ) assert result - assert result.get("flow_id") - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["flow_id"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=FIXTURE_USER_INPUT ) - assert result2 - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == FIXTURE_USER_INPUT[CONF_HOST] + assert result + assert result["type"] is FlowResultType.CREATE_ENTRY - data = result2.get("data") - assert data - assert data[CONF_HOST] == FIXTURE_USER_INPUT[CONF_HOST] - assert data[CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] - assert data[CONF_PORT] == FIXTURE_USER_INPUT[CONF_PORT] - assert data[CONF_SSL] == FIXTURE_USER_INPUT[CONF_SSL] - assert data[CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert data[CONF_VERIFY_SSL] == FIXTURE_USER_INPUT[CONF_VERIFY_SSL] + config_entry = result["result"] + assert config_entry.title == FIXTURE_USER_INPUT[CONF_HOST] + assert config_entry.data == { + CONF_HOST: FIXTURE_USER_INPUT[CONF_HOST], + CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD], + CONF_PORT: FIXTURE_USER_INPUT[CONF_PORT], + CONF_SSL: FIXTURE_USER_INPUT[CONF_SSL], + CONF_USERNAME: FIXTURE_USER_INPUT[CONF_USERNAME], + CONF_VERIFY_SSL: FIXTURE_USER_INPUT[CONF_VERIFY_SSL], + } + assert not config_entry.options async def test_integration_already_exists(hass: HomeAssistant) -> None: @@ -116,8 +118,8 @@ async def test_integration_already_exists(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_USER}, ) assert result - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_hassio_already_configured(hass: HomeAssistant) -> None: @@ -141,8 +143,8 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_HASSIO}, ) assert result - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_hassio_ignored(hass: HomeAssistant) -> None: @@ -166,8 +168,8 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_HASSIO}, ) assert result - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_hassio_confirm( @@ -195,24 +197,25 @@ async def test_hassio_confirm( context={"source": config_entries.SOURCE_HASSIO}, ) assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "hassio_confirm" - assert result.get("description_placeholders") == {"addon": "AdGuard Home Addon"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["description_placeholders"] == {"addon": "AdGuard Home Addon"} - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result2 - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == "AdGuard Home Addon" + assert result + assert result["type"] is FlowResultType.CREATE_ENTRY - data = result2.get("data") - assert data - assert data[CONF_HOST] == "mock-adguard" - assert data[CONF_PASSWORD] is None - assert data[CONF_PORT] == 3000 - assert data[CONF_SSL] is False - assert data[CONF_USERNAME] is None - assert data[CONF_VERIFY_SSL] + config_entry = result["result"] + assert config_entry.title == "AdGuard Home Addon" + assert config_entry.data == { + CONF_HOST: "mock-adguard", + CONF_PASSWORD: None, + CONF_PORT: 3000, + CONF_SSL: False, + CONF_USERNAME: None, + CONF_VERIFY_SSL: True, + } async def test_hassio_connection_error( @@ -241,6 +244,6 @@ async def test_hassio_connection_error( result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "hassio_confirm" - assert result.get("errors") == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/aemet/snapshots/test_diagnostics.ambr b/tests/components/aemet/snapshots/test_diagnostics.ambr index 54546507dfa..0e40cce1b86 100644 --- a/tests/components/aemet/snapshots/test_diagnostics.ambr +++ b/tests/components/aemet/snapshots/test_diagnostics.ambr @@ -17,6 +17,7 @@ 'entry_id': '7442b231f139e813fc1939281123f220', 'minor_version': 1, 'options': dict({ + 'radar_updates': True, }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, @@ -33,6 +34,12 @@ ]), }), 'lib': dict({ + 'radar': dict({ + 'datetime': '2021-01-09T11:34:06.448809+00:00', + 'id': 'national', + 'image-bytes': '**REDACTED**', + 'image-type': 'image/gif', + }), 'station': dict({ 'altitude': 667.0, 'coordinates': '**REDACTED**', diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py index 0f3491b1c43..3dd8303c8cb 100644 --- a/tests/components/aemet/test_config_flow.py +++ b/tests/components/aemet/test_config_flow.py @@ -6,7 +6,11 @@ from aemet_opendata.exceptions import AuthError from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.aemet.const import CONF_STATION_UPDATES, DOMAIN +from homeassistant.components.aemet.const import ( + CONF_RADAR_UPDATES, + CONF_STATION_UPDATES, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant @@ -61,13 +65,20 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: @pytest.mark.parametrize( - ("user_input", "expected"), [({}, True), ({CONF_STATION_UPDATES: False}, False)] + ("user_input", "expected"), + [ + ({}, {CONF_RADAR_UPDATES: False, CONF_STATION_UPDATES: True}), + ( + {CONF_RADAR_UPDATES: False, CONF_STATION_UPDATES: False}, + {CONF_RADAR_UPDATES: False, CONF_STATION_UPDATES: False}, + ), + ], ) async def test_form_options( hass: HomeAssistant, freezer: FrozenDateTimeFactory, user_input: dict[str, bool], - expected: bool, + expected: dict[str, bool], ) -> None: """Test the form options.""" @@ -98,7 +109,8 @@ async def test_form_options( assert result["type"] is FlowResultType.CREATE_ENTRY assert entry.options == { - CONF_STATION_UPDATES: expected, + CONF_RADAR_UPDATES: expected[CONF_RADAR_UPDATES], + CONF_STATION_UPDATES: expected[CONF_STATION_UPDATES], } await hass.async_block_till_done() diff --git a/tests/components/aemet/test_image.py b/tests/components/aemet/test_image.py new file mode 100644 index 00000000000..4321daac883 --- /dev/null +++ b/tests/components/aemet/test_image.py @@ -0,0 +1,22 @@ +"""The image tests for the AEMET OpenData platform.""" + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + + +async def test_aemet_create_images( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test creation of AEMET images.""" + + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await async_init_integration(hass) + + state = hass.states.get("image.aemet_weather_radar") + assert state is not None + assert state.state == "2021-01-09T11:34:06.448809+00:00" diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py index cf3204782cd..d6229438582 100644 --- a/tests/components/aemet/test_init.py +++ b/tests/components/aemet/test_init.py @@ -9,6 +9,7 @@ from homeassistant.components.aemet.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .util import mock_api_call @@ -24,6 +25,7 @@ CONFIG = { async def test_unload_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test (un)loading the AEMET integration.""" @@ -47,6 +49,12 @@ async def test_unload_entry( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("weather.aemet") is None + assert entity_registry.async_get("weather.aemet") is None + async def test_init_town_not_found( hass: HomeAssistant, diff --git a/tests/components/aemet/util.py b/tests/components/aemet/util.py index 162ee657513..0361ca9e6d8 100644 --- a/tests/components/aemet/util.py +++ b/tests/components/aemet/util.py @@ -3,9 +3,9 @@ from typing import Any from unittest.mock import patch -from aemet_opendata.const import ATTR_DATA +from aemet_opendata.const import ATTR_BYTES, ATTR_DATA, ATTR_TIMESTAMP, ATTR_TYPE -from homeassistant.components.aemet.const import DOMAIN +from homeassistant.components.aemet.const import CONF_RADAR_UPDATES, DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant @@ -19,6 +19,14 @@ FORECAST_HOURLY_DATA_MOCK = { ATTR_DATA: load_json_value_fixture("aemet/town-28065-forecast-hourly-data.json"), } +RADAR_DATA_MOCK = { + ATTR_DATA: { + ATTR_TYPE: "image/gif", + ATTR_BYTES: bytes([0]), + }, + ATTR_TIMESTAMP: "2021-01-09T11:34:06.448809+00:00", +} + STATION_DATA_MOCK = { ATTR_DATA: load_json_value_fixture("aemet/station-3195-data.json"), } @@ -53,6 +61,9 @@ def mock_api_call(cmd: str, fetch_data: bool = False) -> dict[str, Any]: return FORECAST_DAILY_DATA_MOCK if cmd == "prediccion/especifica/municipio/horaria/28065": return FORECAST_HOURLY_DATA_MOCK + if cmd == "red/radar/nacional": + return RADAR_DATA_MOCK + return {} @@ -69,6 +80,9 @@ async def async_init_integration(hass: HomeAssistant): }, entry_id="7442b231f139e813fc1939281123f220", unique_id="40.30403754--3.72935236", + options={ + CONF_RADAR_UPDATES: True, + }, ) config_entry.add_to_hass(hass) diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index 941369ff266..3db188bed95 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -763,6 +763,57 @@ 'state': '16931', }) # --- +# name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_raw_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Raw PM2.5', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raw_pm02', + 'unique_id': '84fce612f5b8-pm02_raw', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Airgradient Raw PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.airgradient_raw_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- # name: test_all_entities[indoor][sensor.airgradient_raw_voc-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/airgradient/test_button.py b/tests/components/airgradient/test_button.py index 83de2c2f048..2440669b6e8 100644 --- a/tests/components/airgradient/test_button.py +++ b/tests/components/airgradient/test_button.py @@ -3,14 +3,16 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -97,3 +99,37 @@ async def test_cloud_creates_no_button( await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + mock_airgradient_client.request_co2_calibration.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.airgradient_calibrate_co2_sensor", + }, + blocking=True, + ) diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py index 73dbd17a213..4c035b09aa7 100644 --- a/tests/components/airgradient/test_config_flow.py +++ b/tests/components/airgradient/test_config_flow.py @@ -10,11 +10,11 @@ from airgradient import ( ) from homeassistant.components.airgradient.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -255,6 +255,20 @@ async def test_zeroconf_flow_abort_old_firmware(hass: HomeAssistant) -> None: assert result["reason"] == "invalid_version" +async def test_zeroconf_flow_abort_duplicate( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test zeroconf flow aborts with duplicate.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_user_flow_works_discovery( hass: HomeAssistant, mock_new_airgradient_client: AsyncMock, @@ -282,3 +296,99 @@ async def test_user_flow_works_discovery( # Verify the discovery flow was aborted assert not hass.config_entries.flow.async_progress(DOMAIN) + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_new_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "10.0.0.131", + } + + +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_new_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + mock_new_airgradient_client.get_current_measures.side_effect = ( + AirGradientConnectionError() + ) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + mock_new_airgradient_client.get_current_measures.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "10.0.0.132", + } + + +async def test_reconfigure_flow_unique_id_mismatch( + hass: HomeAssistant, + mock_new_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow aborts with unique id mismatch.""" + mock_config_entry.add_to_hass(hass) + + mock_new_airgradient_client.get_current_measures.return_value.serial_number = ( + "84fce612f5b9" + ) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data == { + CONF_HOST: "10.0.0.131", + } diff --git a/tests/components/airgradient/test_number.py b/tests/components/airgradient/test_number.py index 7aabda8f81c..2cbd72d033a 100644 --- a/tests/components/airgradient/test_number.py +++ b/tests/components/airgradient/test_number.py @@ -3,8 +3,9 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN @@ -15,6 +16,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -99,3 +101,37 @@ async def test_cloud_creates_no_number( await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_airgradient_client.set_display_brightness.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 50}, + target={ATTR_ENTITY_ID: "number.airgradient_display_brightness"}, + blocking=True, + ) diff --git a/tests/components/airgradient/test_select.py b/tests/components/airgradient/test_select.py index de4a7beaaa7..b8ae2cefa4e 100644 --- a/tests/components/airgradient/test_select.py +++ b/tests/components/airgradient/test_select.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -15,6 +15,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -94,3 +95,39 @@ async def test_cloud_creates_no_number( await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_airgradient_client.set_configuration_control.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.airgradient_configuration_source", + ATTR_OPTION: "local", + }, + blocking=True, + ) diff --git a/tests/components/airgradient/test_switch.py b/tests/components/airgradient/test_switch.py index a0cbdd17d75..475f38f554c 100644 --- a/tests/components/airgradient/test_switch.py +++ b/tests/components/airgradient/test_switch.py @@ -3,8 +3,9 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN @@ -16,6 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -99,3 +101,36 @@ async def test_cloud_creates_no_switch( await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_airgradient_client.enable_sharing_data.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + target={ATTR_ENTITY_ID: "switch.airgradient_post_data_to_airgradient"}, + blocking=True, + ) diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index fb4f6530b1e..0c3c0ba7c7a 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -140,6 +140,7 @@ 'heatStages': 1, 'heatangle': 0, 'humidity': 40, + 'master_zoneID': None, 'maxTemp': 30, 'minTemp': 15, 'mode': 3, @@ -274,6 +275,7 @@ 'config_entry': dict({ 'data': dict({ 'host': '192.168.1.100', + 'id': 0, 'port': 3000, }), 'disabled_by': None, @@ -281,7 +283,7 @@ }), 'domain': 'airzone', 'entry_id': '6e7a0798c1734ba81d26ced0e690eaec', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 072699c7a26..65897c6da7e 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -12,7 +12,6 @@ from aioairzone.exceptions import ( ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.airzone.config_flow import short_mac from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState @@ -20,6 +19,7 @@ from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .util import ( CONFIG, @@ -28,11 +28,12 @@ from .util import ( HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK, + USER_INPUT, ) from tests.common import MockConfigEntry -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="airzone", ip="192.168.1.100", macaddress=dr.format_mac("E84F25000000").replace(":", ""), @@ -81,7 +82,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( - result["flow_id"], CONFIG + result["flow_id"], USER_INPUT ) await hass.async_block_till_done() @@ -94,7 +95,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["title"] == f"Airzone {CONFIG[CONF_HOST]}:{CONFIG[CONF_PORT]}" assert result["data"][CONF_HOST] == CONFIG[CONF_HOST] assert result["data"][CONF_PORT] == CONFIG[CONF_PORT] - assert CONF_ID not in result["data"] + assert result["data"][CONF_ID] == CONFIG[CONF_ID] assert len(mock_setup_entry.mock_calls) == 1 @@ -129,7 +130,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) assert result["type"] is FlowResultType.FORM @@ -154,7 +155,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert ( result["title"] - == f"Airzone {CONFIG_ID1[CONF_HOST]}:{CONFIG_ID1[CONF_PORT]}" + == f"Airzone {CONFIG_ID1[CONF_HOST]}:{CONFIG_ID1[CONF_PORT]} #{CONFIG_ID1[CONF_ID]}" ) assert result["data"][CONF_HOST] == CONFIG_ID1[CONF_HOST] assert result["data"][CONF_PORT] == CONFIG_ID1[CONF_PORT] @@ -167,6 +168,7 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: """Test setting up duplicated entry.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", @@ -174,7 +176,7 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) assert result["type"] is FlowResultType.ABORT @@ -189,7 +191,7 @@ async def test_connection_error(hass: HomeAssistant) -> None: side_effect=AirzoneError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/airzone/test_coordinator.py b/tests/components/airzone/test_coordinator.py index 583758a6bee..fcdcad6a32a 100644 --- a/tests/components/airzone/test_coordinator.py +++ b/tests/components/airzone/test_coordinator.py @@ -25,6 +25,7 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: """Test ClientConnectorError on coordinator update.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", @@ -74,6 +75,7 @@ async def test_coordinator_new_devices( """Test new devices on coordinator update.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py index 293fc75acb5..a2783cb7c2f 100644 --- a/tests/components/airzone/test_init.py +++ b/tests/components/airzone/test_init.py @@ -2,14 +2,16 @@ from unittest.mock import patch +from aioairzone.const import DEFAULT_SYSTEM_ID from aioairzone.exceptions import HotWaterNotAvailable, InvalidMethod, SystemOutOfRange from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK +from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK, USER_INPUT from tests.common import MockConfigEntry @@ -19,7 +21,11 @@ async def test_unique_id_migrate( ) -> None: """Test unique id migration.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) + config_entry = MockConfigEntry( + minor_version=2, + domain=DOMAIN, + data=CONFIG, + ) config_entry.add_to_hass(hass) with ( @@ -89,6 +95,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: """Test unload.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", @@ -112,3 +119,42 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_migrate_entry_v2(hass: HomeAssistant) -> None: + """Test entry migration to v2.""" + + config_entry = MockConfigEntry( + minor_version=1, + data=USER_INPUT, + domain=DOMAIN, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + side_effect=SystemOutOfRange, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + side_effect=InvalidMethod, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.minor_version == 2 + assert config_entry.data.get(CONF_ID) == DEFAULT_SYSTEM_ID diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 278663b7a97..50d1964924d 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -28,6 +28,7 @@ from aioairzone.const import ( API_HEAT_STAGES, API_HUMIDITY, API_MAC, + API_MASTER_ZONE_ID, API_MAX_TEMP, API_MIN_TEMP, API_MODE, @@ -54,6 +55,7 @@ from aioairzone.const import ( API_WS_AZ, API_WS_TYPE, API_ZONE_ID, + DEFAULT_SYSTEM_ID, ) from homeassistant.components.airzone.const import DOMAIN @@ -62,13 +64,18 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -CONFIG = { +USER_INPUT = { CONF_HOST: "192.168.1.100", CONF_PORT: 3000, } +CONFIG = { + **USER_INPUT, + CONF_ID: DEFAULT_SYSTEM_ID, +} + CONFIG_ID1 = { - **CONFIG, + **USER_INPUT, CONF_ID: 1, } @@ -214,6 +221,7 @@ HVAC_MOCK = { API_FLOOR_DEMAND: 0, API_HEAT_ANGLE: 0, API_COLD_ANGLE: 0, + API_MASTER_ZONE_ID: None, }, ] }, @@ -357,6 +365,7 @@ async def async_init_integration( """Set up the Airzone integration in Home Assistant.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, entry_id="6e7a0798c1734ba81d26ced0e690eaec", domain=DOMAIN, diff --git a/tests/components/alarm_control_panel/__init__.py b/tests/components/alarm_control_panel/__init__.py index 1ef1161edd0..1f43c567844 100644 --- a/tests/components/alarm_control_panel/__init__.py +++ b/tests/components/alarm_control_panel/__init__.py @@ -1 +1,27 @@ """The tests for Alarm control panel platforms.""" + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + + +async def help_async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [ALARM_CONTROL_PANEL_DOMAIN] + ) + return True + + +async def help_async_unload_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Unload test config emntry.""" + return await hass.config_entries.async_unload_platforms( + config_entry, [Platform.ALARM_CONTROL_PANEL] + ) diff --git a/tests/components/alarm_control_panel/conftest.py b/tests/components/alarm_control_panel/conftest.py index 3e82b935493..ddf67b27860 100644 --- a/tests/components/alarm_control_panel/conftest.py +++ b/tests/components/alarm_control_panel/conftest.py @@ -1,7 +1,7 @@ """Fixturs for Alarm Control Panel tests.""" -from collections.abc import Generator -from unittest.mock import MagicMock +from collections.abc import AsyncGenerator, Generator +from unittest.mock import MagicMock, patch import pytest @@ -13,7 +13,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.components.alarm_control_panel.const import CodeFormat from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, frame from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import MockAlarm @@ -107,6 +107,22 @@ class MockFlow(ConfigFlow): """Test flow.""" +@pytest.fixture(name="mock_as_custom_component") +async def mock_frame(hass: HomeAssistant) -> AsyncGenerator[None]: + """Mock frame.""" + with patch( + "homeassistant.helpers.frame.get_integration_frame", + return_value=frame.IntegrationFrame( + custom_integration=True, + integration="alarm_control_panel", + module="test_init.py", + relative_filename="test_init.py", + frame=frame.get_current_frame(), + ), + ): + yield + + @pytest.fixture(autouse=True) def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 89a2a2a2b1a..168d7ecc269 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -1,6 +1,5 @@ """Test for the alarm control panel const module.""" -from types import ModuleType from typing import Any from unittest.mock import patch @@ -12,7 +11,6 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, SERVICE_ALARM_ARM_AWAY, @@ -25,20 +23,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import entity_registry as er, frame from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from .conftest import TEST_DOMAIN, MockAlarmControlPanel +from . import help_async_setup_entry_init, help_async_unload_entry +from .conftest import MockAlarmControlPanel from tests.common import ( MockConfigEntry, MockModule, - MockPlatform, - help_test_all, - import_and_test_deprecated_constant_enum, mock_integration, - mock_platform, + setup_test_component_platform, ) @@ -59,76 +54,6 @@ async def help_test_async_alarm_control_panel_service( await hass.async_block_till_done() -@pytest.mark.parametrize( - "module", - [alarm_control_panel, alarm_control_panel.const], -) -def test_all(module: ModuleType) -> None: - """Test module.__all__ is correctly set.""" - help_test_all(module) - - -@pytest.mark.parametrize( - "code_format", - list(alarm_control_panel.CodeFormat), -) -@pytest.mark.parametrize( - "module", - [alarm_control_panel, alarm_control_panel.const], -) -def test_deprecated_constant_code_format( - caplog: pytest.LogCaptureFixture, - code_format: alarm_control_panel.CodeFormat, - module: ModuleType, -) -> None: - """Test deprecated format constants.""" - import_and_test_deprecated_constant_enum( - caplog, module, code_format, "FORMAT_", "2025.1" - ) - - -@pytest.mark.parametrize( - "entity_feature", - list(alarm_control_panel.AlarmControlPanelEntityFeature), -) -@pytest.mark.parametrize( - "module", - [alarm_control_panel, alarm_control_panel.const], -) -def test_deprecated_support_alarm_constants( - caplog: pytest.LogCaptureFixture, - entity_feature: alarm_control_panel.AlarmControlPanelEntityFeature, - module: ModuleType, -) -> None: - """Test deprecated support alarm constants.""" - import_and_test_deprecated_constant_enum( - caplog, module, entity_feature, "SUPPORT_ALARM_", "2025.1" - ) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockAlarmControlPanelEntity(alarm_control_panel.AlarmControlPanelEntity): - _attr_supported_features = 1 - - entity = MockAlarmControlPanelEntity() - assert ( - entity.supported_features - is alarm_control_panel.AlarmControlPanelEntityFeature(1) - ) - assert "MockAlarmControlPanelEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "AlarmControlPanelEntityFeature.ARM_HOME" in caplog.text - caplog.clear() - assert ( - entity.supported_features - is alarm_control_panel.AlarmControlPanelEntityFeature(1) - ) - assert "is using deprecated supported features values" not in caplog.text - - async def test_set_mock_alarm_control_panel_options( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -297,6 +222,7 @@ async def test_alarm_control_panel_with_default_code( mock_alarm_control_panel_entity.calls_disarm.assert_called_with("1234") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_not_log_deprecated_state_warning( hass: HomeAssistant, mock_alarm_control_panel_entity: MockAlarmControlPanel, @@ -305,9 +231,14 @@ async def test_alarm_control_panel_not_log_deprecated_state_warning( """Test correctly using alarm_state doesn't log issue or raise repair.""" state = hass.states.get(mock_alarm_control_panel_entity.entity_id) assert state is not None - assert "Entities should implement the 'alarm_state' property and" not in caplog.text + assert ( + "the 'alarm_state' property and return its state using the AlarmControlPanelState enum" + not in caplog.text + ) +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop( hass: HomeAssistant, code_format: CodeFormat | None, @@ -317,23 +248,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop ) -> None: """Test incorrectly using state property does log issue and raise repair.""" - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups( - config_entry, [ALARM_CONTROL_PANEL_DOMAIN] - ) - return True - - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - ), - ) - class MockLegacyAlarmControlPanel(MockAlarmControlPanel): """Mocked alarm control entity.""" @@ -358,37 +272,38 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop code_format=code_format, code_arm_required=code_arm_required, ) - - async def async_setup_entry_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test alarm control panel platform via config entry.""" - async_add_entities([entity]) - - mock_platform( + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + mock_integration( hass, - f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}", - MockPlatform(async_setup_entry=async_setup_entry_platform), + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + built_in=False, ) - - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + setup_test_component_platform( + hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) state = hass.states.get(entity.entity_id) assert state is not None - assert "Entities should implement the 'alarm_state' property and" in caplog.text + assert ( + "Detected that custom integration 'alarm_control_panel' is setting state" + " directly. Entity None (.MockLegacyAlarmControlPanel'>) should implement" + " the 'alarm_state' property and return its state using the AlarmControlPanelState" + " enum at test_init.py, line 123: yield. This will stop working in Home Assistant" + " 2025.11, please create a bug report at" in caplog.text + ) +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state_attr( hass: HomeAssistant, code_format: CodeFormat | None, @@ -398,23 +313,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state ) -> None: """Test incorrectly using _attr_state attribute does log issue and raise repair.""" - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups( - config_entry, [ALARM_CONTROL_PANEL_DOMAIN] - ) - return True - - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - ), - ) - class MockLegacyAlarmControlPanel(MockAlarmControlPanel): """Mocked alarm control entity.""" @@ -438,59 +336,56 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state code_format=code_format, code_arm_required=code_arm_required, ) - - async def async_setup_entry_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test alarm control panel platform via config entry.""" - async_add_entities([entity]) - - mock_platform( + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + mock_integration( hass, - f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}", - MockPlatform(async_setup_entry=async_setup_entry_platform), + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), ) - - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + setup_test_component_platform( + hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) state = hass.states.get(entity.entity_id) assert state is not None - assert "Entities should implement the 'alarm_state' property and" not in caplog.text + assert ( + "Detected that custom integration 'alarm_control_panel' is setting state directly." + not in caplog.text + ) - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - await help_test_async_alarm_control_panel_service( - hass, entity.entity_id, SERVICE_ALARM_DISARM - ) + await help_test_async_alarm_control_panel_service( + hass, entity.entity_id, SERVICE_ALARM_DISARM + ) - assert "Entities should implement the 'alarm_state' property and" in caplog.text + assert ( + "Detected that custom integration 'alarm_control_panel' is setting state directly." + " Entity alarm_control_panel.test_alarm_control_panel" + " (.MockLegacyAlarmControlPanel'>) should implement the 'alarm_state' property" + " and return its state using the AlarmControlPanelState enum at test_init.py, line 123:" + " yield. This will stop working in Home Assistant 2025.11," + " please create a bug report at" in caplog.text + ) caplog.clear() - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - await help_test_async_alarm_control_panel_service( - hass, entity.entity_id, SERVICE_ALARM_DISARM - ) + await help_test_async_alarm_control_panel_service( + hass, entity.entity_id, SERVICE_ALARM_DISARM + ) # Test we only log once - assert "Entities should implement the 'alarm_state' property and" not in caplog.text + assert ( + "Detected that custom integration 'alarm_control_panel' is setting state directly." + not in caplog.text + ) +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_deprecated_state_does_not_break_state( hass: HomeAssistant, code_format: CodeFormat | None, @@ -500,23 +395,6 @@ async def test_alarm_control_panel_deprecated_state_does_not_break_state( ) -> None: """Test using _attr_state attribute does not break state.""" - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups( - config_entry, [ALARM_CONTROL_PANEL_DOMAIN] - ) - return True - - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - ), - ) - class MockLegacyAlarmControlPanel(MockAlarmControlPanel): """Mocked alarm control entity.""" @@ -541,43 +419,28 @@ async def test_alarm_control_panel_deprecated_state_does_not_break_state( code_format=code_format, code_arm_required=code_arm_required, ) - - async def async_setup_entry_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test alarm control panel platform via config entry.""" - async_add_entities([entity]) - - mock_platform( + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + mock_integration( hass, - f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}", - MockPlatform(async_setup_entry=async_setup_entry_platform), + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), ) - - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + setup_test_component_platform( + hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) state = hass.states.get(entity.entity_id) assert state is not None assert state.state == "armed_away" - with patch.object( - MockLegacyAlarmControlPanel, - "__module__", - "tests.custom_components.test.alarm_control_panel", - ): - await help_test_async_alarm_control_panel_service( - hass, entity.entity_id, SERVICE_ALARM_DISARM - ) + await help_test_async_alarm_control_panel_service( + hass, entity.entity_id, SERVICE_ALARM_DISARM + ) state = hass.states.get(entity.entity_id) assert state is not None diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 263fb69c883..27997a093e5 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -30,7 +30,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import MockEntityPlatform, async_mock_service NAME = "alert_test" DONE_MESSAGE = "alert_gone" @@ -338,6 +338,7 @@ async def test_skipfirst(hass: HomeAssistant, mock_notifier: list[ServiceCall]) async def test_done_message_state_tracker_reset_on_cancel(hass: HomeAssistant) -> None: """Test that the done message is reset when canceled.""" entity = alert.AlertEntity(hass, *TEST_NOACK) + entity.platform = MockEntityPlatform(hass) entity._cancel = lambda *args: None assert entity._send_done_message is False entity._send_done_message = True diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index a41c2f47b2d..b10a93df0c9 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -159,11 +159,11 @@ async def test_api_set_color_temperature(hass: HomeAssistant) -> None: assert len(call_light) == 1 assert call_light[0].data["entity_id"] == "light.test" - assert call_light[0].data["kelvin"] == 7500 + assert call_light[0].data["color_temp_kelvin"] == 7500 assert msg["header"]["name"] == "Response" -@pytest.mark.parametrize(("result", "initial"), [(383, "333"), (500, "500")]) +@pytest.mark.parametrize(("result", "initial"), [(2500, "3000"), (2000, "2000")]) async def test_api_decrease_color_temp( hass: HomeAssistant, result: int, initial: str ) -> None: @@ -176,7 +176,11 @@ async def test_api_decrease_color_temp( hass.states.async_set( "light.test", "off", - {"friendly_name": "Test light", "color_temp": initial, "max_mireds": 500}, + { + "friendly_name": "Test light", + "color_temp_kelvin": initial, + "min_color_temp_kelvin": 2000, + }, ) call_light = async_mock_service(hass, "light", "turn_on") @@ -189,11 +193,11 @@ async def test_api_decrease_color_temp( assert len(call_light) == 1 assert call_light[0].data["entity_id"] == "light.test" - assert call_light[0].data["color_temp"] == result + assert call_light[0].data["color_temp_kelvin"] == result assert msg["header"]["name"] == "Response" -@pytest.mark.parametrize(("result", "initial"), [(283, "333"), (142, "142")]) +@pytest.mark.parametrize(("result", "initial"), [(3500, "3000"), (7000, "7000")]) async def test_api_increase_color_temp( hass: HomeAssistant, result: int, initial: str ) -> None: @@ -206,7 +210,11 @@ async def test_api_increase_color_temp( hass.states.async_set( "light.test", "off", - {"friendly_name": "Test light", "color_temp": initial, "min_mireds": 142}, + { + "friendly_name": "Test light", + "color_temp_kelvin": initial, + "max_color_temp_kelvin": 7000, + }, ) call_light = async_mock_service(hass, "light", "turn_on") @@ -219,7 +227,7 @@ async def test_api_increase_color_temp( assert len(call_light) == 1 assert call_light[0].data["entity_id"] == "light.test" - assert call_light[0].data["color_temp"] == result + assert call_light[0].data["color_temp_kelvin"] == result assert msg["header"]["name"] == "Response" diff --git a/tests/components/androidtv/test_remote.py b/tests/components/androidtv/test_remote.py index d18e08d4df8..5a52fe52b3a 100644 --- a/tests/components/androidtv/test_remote.py +++ b/tests/components/androidtv/test_remote.py @@ -99,9 +99,9 @@ async def test_services_remote(hass: HomeAssistant, config) -> None: "adb_shell", {ATTR_COMMAND: ["BACK", "test"], ATTR_NUM_REPEATS: 2}, [ - f"input keyevent {KEYS["BACK"]}", + f"input keyevent {KEYS['BACK']}", "test", - f"input keyevent {KEYS["BACK"]}", + f"input keyevent {KEYS['BACK']}", "test", ], ) diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 02e15bca415..0968ea5acff 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.androidtv_remote.config_flow import ( APPS_NEW_ID, CONF_APP_DELETE, @@ -22,6 +21,7 @@ from homeassistant.components.androidtv_remote.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -444,7 +444,7 @@ async def test_zeroconf_flow_success( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -522,7 +522,7 @@ async def test_zeroconf_flow_cannot_connect( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -573,7 +573,7 @@ async def test_zeroconf_flow_pairing_invalid_auth( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -657,7 +657,7 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -710,7 +710,7 @@ async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -743,7 +743,7 @@ async def test_zeroconf_flow_abort_if_mac_is_missing( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -787,7 +787,7 @@ async def test_zeroconf_flow_already_configured_zeroconf_has_multiple_invalid_ip result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.5"), ip_addresses=[ip_address("1.2.3.5"), ip_address(host)], port=6466, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 65ede877281..fa5bcb8137a 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -16,7 +16,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm from homeassistant.setup import async_setup_component -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from tests.common import MockConfigEntry @@ -127,9 +127,9 @@ async def test_template_variables( hass, "hello", None, context, agent_id="conversation.claude" ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert "The user name is Test User." in mock_create.mock_calls[1][2]["system"] assert "The user id is 12345." in mock_create.mock_calls[1][2]["system"] @@ -472,7 +472,7 @@ async def test_conversation_id( assert result.conversation_id == conversation_id - unknown_id = ulid.ulid() + unknown_id = ulid_util.ulid() result = await conversation.async_converse( hass, "hello", unknown_id, None, agent_id="conversation.claude" diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 88594260579..0b8386dbb5a 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -125,6 +125,8 @@ async def test_flow_works(hass: HomeAssistant) -> None: ({"UPSNAME": "Friendly Name"}, "Friendly Name"), ({"MODEL": "MODEL X"}, "MODEL X"), ({"SERIALNO": "ZZZZ"}, "ZZZZ"), + # Some models report "Blank" as serial number, which we should treat it as not reported. + ({"SERIALNO": "Blank"}, "APC UPS"), ({}, "APC UPS"), ], ) diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 723ec164eae..6bb94ca2948 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -31,6 +31,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed # Does not contain either "SERIALNO" field. # We should _not_ create devices for the entities and their IDs will not have prefixes. MOCK_MINIMAL_STATUS, + # Some models report "Blank" as SERIALNO, but we should treat it as not reported. + MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, ], ) async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> None: @@ -41,7 +43,7 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No await async_init_integration(hass, status=status) prefix = "" - if "SERIALNO" in status: + if "SERIALNO" in status and status["SERIALNO"] != "Blank": prefix = slugify(status.get("UPSNAME", "APC UPS")) + "_" # Verify successful setup by querying the status sensor. @@ -56,6 +58,8 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No [ # We should not create device entries if SERIALNO is not reported. MOCK_MINIMAL_STATUS, + # Some models report "Blank" as SERIALNO, but we should treat it as not reported. + MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, # We should set the device name to be the friendly UPSNAME field if available. MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX", "UPSNAME": "MyUPS"}, # Otherwise, we should fall back to default device name --- "APC UPS". @@ -71,7 +75,7 @@ async def test_device_entry( await async_init_integration(hass, status=status) # Verify device info is properly set up. - if "SERIALNO" not in status: + if "SERIALNO" not in status or status["SERIALNO"] == "Blank": assert len(device_registry.devices) == 0 return diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 4567bd32582..a13eb3c605b 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -9,7 +9,6 @@ from pyatv.const import PairingRequirement, Protocol import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.apple_tv import CONF_ADDRESS, config_flow from homeassistant.components.apple_tv.const import ( CONF_IDENTIFIERS, @@ -19,12 +18,13 @@ from homeassistant.components.apple_tv.const import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import airplay_service, create_conf, mrp_service, raop_service from tests.common import MockConfigEntry -DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( +DMAP_SERVICE = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -35,7 +35,7 @@ DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( ) -RAOP_SERVICE = zeroconf.ZeroconfServiceInfo( +RAOP_SERVICE = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -566,7 +566,7 @@ async def test_zeroconf_unsupported_service_aborts(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -586,7 +586,7 @@ async def test_zeroconf_add_mrp_device(hass: HomeAssistant) -> None: unrelated_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -601,7 +601,7 @@ async def test_zeroconf_add_mrp_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -883,7 +883,7 @@ async def test_zeroconf_abort_if_other_in_progress( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -906,7 +906,7 @@ async def test_zeroconf_abort_if_other_in_progress( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -933,7 +933,7 @@ async def test_zeroconf_missing_device_during_protocol_resolve( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -955,7 +955,7 @@ async def test_zeroconf_missing_device_during_protocol_resolve( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -992,7 +992,7 @@ async def test_zeroconf_additional_protocol_resolve_failure( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1014,7 +1014,7 @@ async def test_zeroconf_additional_protocol_resolve_failure( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1053,7 +1053,7 @@ async def test_zeroconf_pair_additionally_found_protocols( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1096,7 +1096,7 @@ async def test_zeroconf_pair_additionally_found_protocols( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1158,7 +1158,7 @@ async def test_zeroconf_mismatch(hass: HomeAssistant, mock_scan: AsyncMock) -> N result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1242,7 +1242,7 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", diff --git a/tests/components/aprilaire/test_config_flow.py b/tests/components/aprilaire/test_config_flow.py index e4b7c167256..0cda1ed40ad 100644 --- a/tests/components/aprilaire/test_config_flow.py +++ b/tests/components/aprilaire/test_config_flow.py @@ -95,7 +95,6 @@ async def test_config_flow_data(client: AprilaireClient, hass: HomeAssistant) -> ) client.start_listen.assert_called_once() - client.wait_for_response.assert_any_call(FunctionalDomain.IDENTIFICATION, 4, 30) client.wait_for_response.assert_any_call(FunctionalDomain.CONTROL, 7, 30) client.wait_for_response.assert_any_call(FunctionalDomain.SENSORS, 2, 30) client.stop_listen.assert_called_once() diff --git a/tests/components/apsystems/conftest.py b/tests/components/apsystems/conftest.py index 0feccf21578..92af6885c0b 100644 --- a/tests/components/apsystems/conftest.py +++ b/tests/components/apsystems/conftest.py @@ -38,7 +38,7 @@ def mock_apsystems() -> Generator[MagicMock]: mock_api = mock_client.return_value mock_api.get_device_info.return_value = ReturnDeviceInfo( deviceId="MY_SERIAL_NUMBER", - devVer="1.0.0", + devVer="EZ1 1.0.0", ssid="MY_SSID", ipAddr="127.0.01", minPower=0, @@ -59,6 +59,7 @@ def mock_apsystems() -> Generator[MagicMock]: operating=False, ) mock_api.get_device_power_status.return_value = True + mock_api.get_max_power.return_value = 666 yield mock_api diff --git a/tests/components/apsystems/snapshots/test_number.ambr b/tests/components/apsystems/snapshots/test_number.ambr new file mode 100644 index 00000000000..a2b82e23596 --- /dev/null +++ b/tests/components/apsystems/snapshots/test_number.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_all_entities[number.mock_title_max_output-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1000, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_title_max_output', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max output', + 'platform': 'apsystems', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_output', + 'unique_id': 'MY_SERIAL_NUMBER_output_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.mock_title_max_output-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Mock Title Max output', + 'max': 1000, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_max_output', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '666', + }) +# --- diff --git a/tests/components/apsystems/test_init.py b/tests/components/apsystems/test_init.py new file mode 100644 index 00000000000..f127744dbf4 --- /dev/null +++ b/tests/components/apsystems/test_init.py @@ -0,0 +1,69 @@ +"""Test the APSystem setup.""" + +import datetime +from unittest.mock import AsyncMock + +from APsystemsEZ1 import InverterReturnedError +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.apsystems.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + +SCAN_INTERVAL = datetime.timedelta(seconds=12) + + +@pytest.mark.usefixtures("mock_apsystems") +async def test_load_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_failed( + hass: HomeAssistant, + mock_apsystems: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test update failed.""" + mock_apsystems.get_device_info.side_effect = TimeoutError + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_update( + hass: HomeAssistant, + mock_apsystems: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test update data with an inverter error and recover.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert "Inverter returned an error" not in caplog.text + mock_apsystems.get_output_data.side_effect = InverterReturnedError + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert "Error fetching APSystems Data data:" in caplog.text + caplog.clear() + mock_apsystems.get_output_data.side_effect = None + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert "Fetching APSystems Data data recovered" in caplog.text diff --git a/tests/components/apsystems/test_number.py b/tests/components/apsystems/test_number.py new file mode 100644 index 00000000000..912759b4a17 --- /dev/null +++ b/tests/components/apsystems/test_number.py @@ -0,0 +1,72 @@ +"""Test the APSystem number module.""" + +import datetime +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +SCAN_INTERVAL = datetime.timedelta(seconds=30) + + +async def test_number( + hass: HomeAssistant, + mock_apsystems: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test number command.""" + await setup_integration(hass, mock_config_entry) + entity_id = "number.mock_title_max_output" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 50.1}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_apsystems.set_max_power.assert_called_once_with(50) + mock_apsystems.get_max_power.return_value = 50 + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "50" + mock_apsystems.get_max_power.side_effect = TimeoutError() + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 50.1}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("mock_apsystems") +@patch("homeassistant.components.apsystems.PLATFORMS", [Platform.NUMBER]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/aranet/test_sensor.py b/tests/components/aranet/test_sensor.py index 7bd00af4837..78a1d4aa9c9 100644 --- a/tests/components/aranet/test_sensor.py +++ b/tests/components/aranet/test_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.aranet.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import ( DISABLED_INTEGRATIONS_SERVICE_INFO, @@ -20,7 +21,11 @@ from tests.components.bluetooth import inject_bluetooth_service_info @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_sensors_aranet_radiation(hass: HomeAssistant) -> None: +async def test_sensors_aranet_radiation( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test setting up creates the sensors for Aranet Radiation device.""" entry = MockConfigEntry( domain=DOMAIN, @@ -73,12 +78,24 @@ async def test_sensors_aranet_radiation(hass: HomeAssistant) -> None: assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + # Check device context for the battery sensor + entity = entity_registry.async_get("sensor.aranet_12345_battery") + device = device_registry.async_get(entity.device_id) + assert device.name == "Aranet☢ 12345" + assert device.model == "Aranet Radiation" + assert device.sw_version == "v1.4.38" + assert device.manufacturer == "SAF Tehnika" + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_sensors_aranet2(hass: HomeAssistant) -> None: +async def test_sensors_aranet2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test setting up creates the sensors for Aranet2 device.""" entry = MockConfigEntry( domain=DOMAIN, @@ -122,12 +139,24 @@ async def test_sensors_aranet2(hass: HomeAssistant) -> None: assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + # Check device context for the battery sensor + entity = entity_registry.async_get("sensor.aranet2_12345_battery") + device = device_registry.async_get(entity.device_id) + assert device.name == "Aranet2 12345" + assert device.model == "Aranet2" + assert device.sw_version == "v1.4.4" + assert device.manufacturer == "SAF Tehnika" + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_sensors_aranet4(hass: HomeAssistant) -> None: +async def test_sensors_aranet4( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test setting up creates the sensors for Aranet4 device.""" entry = MockConfigEntry( domain=DOMAIN, @@ -185,12 +214,24 @@ async def test_sensors_aranet4(hass: HomeAssistant) -> None: assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + # Check device context for the battery sensor + entity = entity_registry.async_get("sensor.aranet4_12345_battery") + device = device_registry.async_get(entity.device_id) + assert device.name == "Aranet4 12345" + assert device.model == "Aranet4" + assert device.sw_version == "v1.2.0" + assert device.manufacturer == "SAF Tehnika" + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_sensors_aranetrn(hass: HomeAssistant) -> None: +async def test_sensors_aranetrn( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test setting up creates the sensors for Aranet Radon device.""" entry = MockConfigEntry( domain=DOMAIN, @@ -250,6 +291,14 @@ async def test_sensors_aranetrn(hass: HomeAssistant) -> None: assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + # Check device context for the battery sensor + entity = entity_registry.async_get("sensor.aranetrn_12345_battery") + device = device_registry.async_get(entity.device_id) + assert device.name == "AranetRn+ 12345" + assert device.model == "Aranet Radon Plus" + assert device.sw_version == "v1.6.4" + assert device.manufacturer == "SAF Tehnika" + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/arcam_fmj/test_config_flow.py b/tests/components/arcam_fmj/test_config_flow.py index 60c68c5e102..1a578fc613d 100644 --- a/tests/components/arcam_fmj/test_config_flow.py +++ b/tests/components/arcam_fmj/test_config_flow.py @@ -7,12 +7,21 @@ from unittest.mock import AsyncMock, MagicMock, patch from arcam.fmj.client import ConnectionFailed import pytest -from homeassistant.components import ssdp from homeassistant.components.arcam_fmj.const import DOMAIN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_CONFIG_ENTRY, @@ -36,18 +45,18 @@ MOCK_UPNP_DEVICE = f""" MOCK_UPNP_LOCATION = f"http://{MOCK_HOST}:8080/dd.xml" -MOCK_DISCOVER = ssdp.SsdpServiceInfo( +MOCK_DISCOVER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOST}:8080/dd.xml", upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: "ARCAM", - ssdp.ATTR_UPNP_MODEL_NAME: " ", - ssdp.ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750", - ssdp.ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}", - ssdp.ATTR_UPNP_SERIAL: "12343", - ssdp.ATTR_UPNP_UDN: MOCK_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1", + ATTR_UPNP_MANUFACTURER: "ARCAM", + ATTR_UPNP_MODEL_NAME: " ", + ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750", + ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}", + ATTR_UPNP_SERIAL: "12343", + ATTR_UPNP_UDN: MOCK_UDN, + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1", }, ) @@ -115,7 +124,7 @@ async def test_ssdp_unable_to_connect( async def test_ssdp_invalid_id(hass: HomeAssistant) -> None: """Test a ssdp with invalid UDN.""" discover = replace( - MOCK_DISCOVER, upnp=MOCK_DISCOVER.upnp | {ssdp.ATTR_UPNP_UDN: "invalid"} + MOCK_DISCOVER, upnp=MOCK_DISCOVER.upnp | {ATTR_UPNP_UDN: "invalid"} ) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index c70d3944f88..526e1bff151 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -37,13 +37,14 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }), 'type': , }), dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -60,6 +61,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), @@ -126,20 +128,21 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en-US', + 'prefer_local_intents': False, }), 'type': , }), dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), 'data': dict({ 'code': 'no_intent_match', }), - 'language': 'en', + 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ @@ -149,6 +152,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), @@ -215,20 +219,21 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en-US', + 'prefer_local_intents': False, }), 'type': , }), dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), 'data': dict({ 'code': 'no_intent_match', }), - 'language': 'en', + 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ @@ -238,6 +243,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), @@ -328,13 +334,14 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }), 'type': , }), dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -351,6 +358,7 @@ }), }), }), + 'processed_locally': True, }), 'type': , }), @@ -379,6 +387,195 @@ }), ]) # --- +# name: test_pipeline_from_audio_stream_with_cloud_auth_fail + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'stt.mock_stt', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'language': 'en-US', + 'sample_rate': , + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'code': 'cloud-auth-failed', + 'message': 'Home Assistant Cloud authentication failed', + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_pipeline_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_stt_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-US', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_tts_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-us', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- # name: test_wake_word_detection_aborted list([ dict({ diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 566fb129959..917a9b654d5 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -36,12 +36,13 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -58,6 +59,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline.5 @@ -117,12 +119,13 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline_debug.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -139,6 +142,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline_debug.5 @@ -210,12 +214,13 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline_with_enhancements.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -232,6 +237,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline_with_enhancements.5 @@ -313,12 +319,13 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.6 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -335,6 +342,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.7 @@ -519,6 +527,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_intent_failed.2 @@ -541,6 +550,7 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_intent_timeout.2 @@ -569,12 +579,13 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'never mind', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_pipeline_empty_tts_output.2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -592,6 +603,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_pipeline_empty_tts_output.3 @@ -680,12 +692,13 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_text_only_pipeline[extra_msg0].2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -702,6 +715,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_text_only_pipeline[extra_msg0].3 @@ -724,12 +738,13 @@ 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', 'language': 'en', + 'prefer_local_intents': False, }) # --- # name: test_text_only_pipeline[extra_msg1].2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -746,6 +761,7 @@ }), }), }), + 'processed_locally': True, }) # --- # name: test_text_only_pipeline[extra_msg1].3 diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index b177530219e..a2cb9ef382a 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -8,6 +8,7 @@ import tempfile from unittest.mock import ANY, patch import wave +import hass_nabucasa import pytest from syrupy.assertion import SnapshotAssertion @@ -23,6 +24,7 @@ from homeassistant.components.assist_pipeline.const import ( CONF_DEBUG_RECORDING_DIR, DOMAIN, ) +from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import intent from homeassistant.setup import async_setup_component @@ -1098,3 +1100,265 @@ async def test_prefer_local_intents( ] == "Order confirmed" ) + + +async def test_stt_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that the STT language is used first when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": "test", + "stt_language": "en-US", + "tts_engine": "test", + "tts_language": "en-US", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.stt_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.stt_language + ) + + +async def test_tts_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that the TTS language is used after STT when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": "en-us", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.tts_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.tts_language + ) + + +async def test_pipeline_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that the pipeline language is used last when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.language + ) + + +async def test_pipeline_from_audio_stream_with_cloud_auth_fail( + hass: HomeAssistant, + mock_stt_provider_entity: MockSTTProviderEntity, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test creating a pipeline from an audio stream but the cloud authentication fails.""" + + events: list[assist_pipeline.PipelineEvent] = [] + + async def audio_data(): + yield b"audio" + + with patch.object( + mock_stt_provider_entity, + "async_process_audio_stream", + side_effect=hass_nabucasa.auth.Unauthenticated, + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), + ) + + assert process_events(events) == snapshot + assert len(events) == 4 # run start, stt start, error, run end + assert events[2].type == assist_pipeline.PipelineEventType.ERROR + assert events[2].data["code"] == "cloud-auth-failed" diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 9fb02e228d8..5ce3b1020d0 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -184,7 +184,7 @@ async def test_select_entity_changing_vad_sensitivity( hass: HomeAssistant, init_select: MockConfigEntry, ) -> None: - """Test entity tracking pipeline changes.""" + """Test entity tracking vad sensitivity changes.""" config_entry = init_select # nicer naming config_entry.mock_state(hass, ConfigEntryState.LOADED) @@ -192,7 +192,7 @@ async def test_select_entity_changing_vad_sensitivity( assert state is not None assert state.state == VadSensitivity.DEFAULT.value - # Change select to new pipeline + # Change select to new sensitivity await hass.services.async_call( "select", "select_option", diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index 9e9bfd959e6..d75cbd072e0 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -16,7 +16,9 @@ from homeassistant.components.assist_satellite import ( ) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.setup import async_setup_component +from homeassistant.util.ulid import ulid_hex from tests.common import ( MockConfigEntry, @@ -38,11 +40,17 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: class MockAssistSatellite(AssistSatelliteEntity): """Mock Assist Satellite Entity.""" - _attr_name = "Test Entity" - _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE - - def __init__(self) -> None: + def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None: """Initialize the mock entity.""" + self._attr_unique_id = ulid_hex() + self._attr_device_info = DeviceInfo( + { + "name": name, + "identifiers": {(TEST_DOMAIN, self._attr_unique_id)}, + } + ) + self._attr_name = name + self._attr_supported_features = features self.events = [] self.announcements: list[AssistSatelliteAnnouncement] = [] self.config = AssistSatelliteConfiguration( @@ -83,7 +91,19 @@ class MockAssistSatellite(AssistSatelliteEntity): @pytest.fixture def entity() -> MockAssistSatellite: """Mock Assist Satellite Entity.""" - return MockAssistSatellite() + return MockAssistSatellite("Test Entity", AssistSatelliteEntityFeature.ANNOUNCE) + + +@pytest.fixture +def entity2() -> MockAssistSatellite: + """Mock a second Assist Satellite Entity.""" + return MockAssistSatellite("Test Entity 2", AssistSatelliteEntityFeature.ANNOUNCE) + + +@pytest.fixture +def entity_no_features() -> MockAssistSatellite: + """Mock a third Assist Satellite Entity.""" + return MockAssistSatellite("Test Entity No features", 0) @pytest.fixture @@ -99,6 +119,8 @@ async def init_components( hass: HomeAssistant, config_entry: ConfigEntry, entity: MockAssistSatellite, + entity2: MockAssistSatellite, + entity_no_features: MockAssistSatellite, ) -> None: """Initialize components.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -125,7 +147,9 @@ async def init_components( async_unload_entry=async_unload_entry_init, ), ) - setup_test_component_platform(hass, AS_DOMAIN, [entity], from_config_entry=True) + setup_test_component_platform( + hass, AS_DOMAIN, [entity, entity2, entity_no_features], from_config_entry=True + ) mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) with mock_config_flow(TEST_DOMAIN, ConfigFlow): diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 884ba36782c..c3464beac97 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -63,7 +63,7 @@ async def test_entity_state( ) assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None - assert kwargs["device_id"] is None + assert kwargs["device_id"] is entity.device_entry.id assert kwargs["tts_audio_output"] is None assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( @@ -163,21 +163,32 @@ async def test_new_pipeline_cancels_pipeline( ( {"message": "Hello"}, AssistSatelliteAnnouncement( - "Hello", "https://www.home-assistant.io/resolved.mp3", "tts" + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://bla", + media_id_source="tts", ), ), ( { "message": "Hello", - "media_id": "media-source://bla", + "media_id": "media-source://given", }, AssistSatelliteAnnouncement( - "Hello", "https://www.home-assistant.io/resolved.mp3", "media_id" + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + media_id_source="media_id", ), ), ( {"media_id": "http://example.com/bla.mp3"}, - AssistSatelliteAnnouncement("", "http://example.com/bla.mp3", "url"), + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/bla.mp3", + original_media_id="http://example.com/bla.mp3", + media_id_source="url", + ), ), ], ) diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py new file mode 100644 index 00000000000..27107c7d2e9 --- /dev/null +++ b/tests/components/assist_satellite/test_intent.py @@ -0,0 +1,110 @@ +"""Test assist satellite intents.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.media_source import PlayMedia +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from .conftest import MockAssistSatellite + + +@pytest.fixture +def mock_tts(): + """Mock TTS service.""" + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + yield + + +async def test_broadcast_intent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + entity2: MockAssistSatellite, + entity_no_features: MockAssistSatellite, + mock_tts: None, +) -> None: + """Test we can invoke a broadcast intent.""" + + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) + + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [ + { + "id": "assist_satellite.test_entity", + "name": "Test Entity", + "type": intent.IntentResponseTargetType.ENTITY, + }, + { + "id": "assist_satellite.test_entity_2", + "name": "Test Entity 2", + "type": intent.IntentResponseTargetType.ENTITY, + }, + ], + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": { + "plain": { + "extra_data": None, + "speech": "Done", + } + }, + } + assert len(entity.announcements) == 1 + assert len(entity2.announcements) == 1 + assert len(entity_no_features.announcements) == 0 + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_BROADCAST, + {"message": {"value": "Hello"}}, + device_id=entity.device_entry.id, + ) + # Broadcast doesn't targets device that triggered it. + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [ + { + "id": "assist_satellite.test_entity_2", + "name": "Test Entity 2", + "type": intent.IntentResponseTargetType.ENTITY, + }, + ], + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": { + "plain": { + "extra_data": None, + "speech": "Done", + } + }, + } + assert len(entity.announcements) == 1 + assert len(entity2.announcements) == 2 diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 0036c40a6f2..929500f0bb7 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -82,6 +82,7 @@ def _setup_entry(hass: HomeAssistant, config, sensors, unique_id=None): options={CONF_CONSIDER_HOME: 60}, unique_id=unique_id, ) + config_entry.add_to_hass(hass) # init variable obj_prefix = slugify(HOST) @@ -131,8 +132,6 @@ async def _test_sensors( disabled_by=None, ) - config_entry.add_to_hass(hass) - # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/atag/test_sensors.py b/tests/components/atag/test_sensor.py similarity index 100% rename from tests/components/atag/test_sensors.py rename to tests/components/atag/test_sensor.py diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 1b8c98e299c..eb177a35cfb 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -20,8 +20,9 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from .mocks import ( @@ -453,8 +454,9 @@ async def test_open_throws_hass_service_not_supported_error( hass: HomeAssistant, ) -> None: """Test open throws correct error on entity does not support this service error.""" + await async_setup_component(hass, "homeassistant", {}) mocked_lock_detail = await _mock_operative_august_lock_detail(hass) await _create_august_with_devices(hass, [mocked_lock_detail]) data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} - with pytest.raises(HomeAssistantError, match="does not support this service"): + with pytest.raises(ServiceNotSupported, match="does not support action"): await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 4bc5a5d3086..0de8d923bb8 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -123,9 +123,9 @@ async def test_sensors(hass: HomeAssistant, entity_registry: EntityRegistry) -> ] for entity_id, _ in sensors: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/autarco/test_config_flow.py b/tests/components/autarco/test_config_flow.py index 621ad7f55c8..47c6a2fb084 100644 --- a/tests/components/autarco/test_config_flow.py +++ b/tests/components/autarco/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Autarco config flow.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from autarco import AutarcoAuthenticationError, AutarcoConnectionError import pytest @@ -92,6 +92,7 @@ async def test_exceptions( assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {"base": error} + # Recover from error mock_autarco_client.get_account.side_effect = None result = await hass.config_entries.flow.async_configure( @@ -99,3 +100,72 @@ async def test_exceptions( user_input={CONF_EMAIL: "test@autarco.com", CONF_PASSWORD: "test-password"}, ) assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_step_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + with patch("homeassistant.components.autarco.config_flow.Autarco", autospec=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AutarcoConnectionError, "cannot_connect"), + (AutarcoAuthenticationError, "invalid_auth"), + ], +) +async def test_step_reauth_exceptions( + hass: HomeAssistant, + mock_autarco_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test exceptions in reauth flow.""" + mock_autarco_client.get_account.side_effect = exception + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": error} + + # Recover from error + mock_autarco_client.get_account.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" diff --git a/tests/components/autarco/test_init.py b/tests/components/autarco/test_init.py index 81c5f947251..6c71eca5ef1 100644 --- a/tests/components/autarco/test_init.py +++ b/tests/components/autarco/test_init.py @@ -4,6 +4,8 @@ from __future__ import annotations from unittest.mock import AsyncMock +from autarco import AutarcoAuthenticationError, AutarcoConnectionError + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -26,3 +28,35 @@ async def test_load_unload_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_autarco_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Autarco configuration entry not ready.""" + mock_autarco_client.get_account.side_effect = AutarcoConnectionError + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_exception( + hass: HomeAssistant, + mock_autarco_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test ConfigEntryNotReady when API raises an exception during entry setup.""" + mock_config_entry.add_to_hass(hass) + mock_autarco_client.get_site.side_effect = AutarcoAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py index 1095c625fb2..1e7c616efeb 100644 --- a/tests/components/automation/test_blueprint.py +++ b/tests/components/automation/test_blueprint.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util, yaml +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service @@ -38,7 +38,7 @@ def patch_blueprint( return orig_load(self, path) return models.Blueprint( - yaml.load_yaml(data_path), + yaml_util.load_yaml(data_path), expected_domain=self.domain, path=path, schema=automation.config.AUTOMATION_BLUEPRINT_SCHEMA, diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 2bdc0f7516b..6466e5e7f22 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -50,9 +50,8 @@ from homeassistant.helpers.script import ( SCRIPT_MODE_SINGLE, _async_stop_scripts_at_shutdown, ) -from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util import homeassistant.util.dt as dt_util from tests.common import ( @@ -62,8 +61,6 @@ from tests.common import ( async_capture_events, async_fire_time_changed, async_mock_service, - help_test_all, - import_and_test_deprecated_constant, mock_restore_cache, ) from tests.components.logbook.common import MockRow, mock_humanify @@ -1379,7 +1376,9 @@ async def test_reload_automation_when_blueprint_changes( # Reload the automations without any change, but with updated blueprint blueprint_path = automation.async_get_blueprints(hass).blueprint_folder - blueprint_config = yaml.load_yaml(blueprint_path / "test_event_service.yaml") + blueprint_config = yaml_util.load_yaml( + blueprint_path / "test_event_service.yaml" + ) blueprint_config["actions"] = [blueprint_config["actions"]] blueprint_config["actions"].append(blueprint_config["actions"][-1]) @@ -1390,7 +1389,7 @@ async def test_reload_automation_when_blueprint_changes( return_value=config, ), patch( - "homeassistant.components.blueprint.models.yaml.load_yaml_dict", + "homeassistant.components.blueprint.models.yaml_util.load_yaml_dict", autospec=True, return_value=blueprint_config, ), @@ -2694,7 +2693,7 @@ async def test_blueprint_automation_fails_substitution( """Test blueprint automation with bad inputs.""" with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): assert await async_setup_component( hass, @@ -3153,30 +3152,6 @@ async def test_websocket_config( assert msg["error"]["code"] == "not_found" -def test_all() -> None: - """Test module.__all__ is correctly set.""" - help_test_all(automation) - - -@pytest.mark.parametrize( - ("constant_name", "replacement"), - [ - ("AutomationActionType", TriggerActionType), - ("AutomationTriggerData", TriggerData), - ("AutomationTriggerInfo", TriggerInfo), - ], -) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - constant_name: str, - replacement: Any, -) -> None: - """Test deprecated automation constants.""" - import_and_test_deprecated_constant( - caplog, automation, constant_name, replacement.__name__, replacement, "2025.1" - ) - - async def test_automation_turns_off_other_automation(hass: HomeAssistant) -> None: """Test an automation that turns off another automation.""" hass.set_state(CoreState.not_running) diff --git a/tests/components/awair/const.py b/tests/components/awair/const.py index f24eaeb971d..6dc9118f511 100644 --- a/tests/components/awair/const.py +++ b/tests/components/awair/const.py @@ -2,15 +2,15 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo AWAIR_UUID = "awair_24947" CLOUD_CONFIG = {CONF_ACCESS_TOKEN: "12345"} LOCAL_CONFIG = {CONF_HOST: "192.0.2.5"} CLOUD_UNIQUE_ID = "foo@bar.com" LOCAL_UNIQUE_ID = "00:B0:D0:63:C2:26" -ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("192.0.2.5"), ip_addresses=[ip_address("192.0.2.5")], hostname="mock_hostname", diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py index 6cc4bbd7c2f..9dcfbac4e7b 100644 --- a/tests/components/axis/test_camera.py +++ b/tests/components/axis/test_camera.py @@ -63,12 +63,12 @@ async def test_camera( assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi" assert ( camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi" - f"{"" if not stream_profile else f"?{stream_profile}"}" + f"{'' if not stream_profile else f'?{stream_profile}'}" ) assert ( await camera_entity.stream_source() == "rtsp://root:pass@1.2.3.4/axis-media/media.amp?videocodec=h264" - f"{"" if not stream_profile else f"&{stream_profile}"}" + f"{'' if not stream_profile else f'&{stream_profile}'}" ) diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 52dd9c2f8ad..c7c3097aaaa 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.axis import config_flow from homeassistant.components.axis.const import ( CONF_STREAM_PROFILE, @@ -33,6 +32,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DEFAULT_HOST, MAC, MODEL, NAME @@ -268,7 +270,7 @@ async def test_reconfiguration_flow_update_configuration( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip=DEFAULT_HOST, macaddress=DHCP_FORMATTED_MAC, @@ -276,7 +278,7 @@ async def test_reconfiguration_flow_update_configuration( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -312,7 +314,7 @@ async def test_reconfiguration_flow_update_configuration( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(DEFAULT_HOST), ip_addresses=[ip_address(DEFAULT_HOST)], port=80, @@ -376,7 +378,7 @@ async def test_discovery_flow( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip=DEFAULT_HOST, macaddress=DHCP_FORMATTED_MAC, @@ -384,7 +386,7 @@ async def test_discovery_flow( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -396,7 +398,7 @@ async def test_discovery_flow( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(DEFAULT_HOST), ip_addresses=[ip_address(DEFAULT_HOST)], hostname="mock_hostname", @@ -431,7 +433,7 @@ async def test_discovered_device_already_configured( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip="2.3.4.5", macaddress=DHCP_FORMATTED_MAC, @@ -440,7 +442,7 @@ async def test_discovered_device_already_configured( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -453,7 +455,7 @@ async def test_discovered_device_already_configured( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("2.3.4.5"), ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", @@ -507,7 +509,7 @@ async def test_discovery_flow_updated_configuration( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname="", ip="", macaddress=dr.format_mac("01234567890").replace(":", ""), @@ -515,7 +517,7 @@ async def test_discovery_flow_updated_configuration( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -527,7 +529,7 @@ async def test_discovery_flow_updated_configuration( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=None, ip_addresses=[], hostname="mock_hostname", @@ -556,7 +558,7 @@ async def test_discovery_flow_ignore_non_axis_device( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip="169.254.3.4", macaddress=DHCP_FORMATTED_MAC, @@ -564,7 +566,7 @@ async def test_discovery_flow_ignore_non_axis_device( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -576,7 +578,7 @@ async def test_discovery_flow_ignore_non_axis_device( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("169.254.3.4"), ip_addresses=[ip_address("169.254.3.4")], hostname="mock_hostname", diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py index 74cdb0164cd..b2f2d15d989 100644 --- a/tests/components/axis/test_hub.py +++ b/tests/components/axis/test_hub.py @@ -11,13 +11,14 @@ import axis as axislib import pytest from syrupy import SnapshotAssertion -from homeassistant.components import axis, zeroconf +from homeassistant.components import axis from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import RtspEventMock, RtspStateType from .const import ( @@ -93,7 +94,7 @@ async def test_update_address( mock_requests("2.3.4.5") await hass.config_entries.flow.async_init( AXIS_DOMAIN, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("2.3.4.5"), ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", diff --git a/tests/components/azure_data_explorer/test_config_flow.py b/tests/components/azure_data_explorer/test_config_flow.py index a700299be33..13ff6a8bb13 100644 --- a/tests/components/azure_data_explorer/test_config_flow.py +++ b/tests/components/azure_data_explorer/test_config_flow.py @@ -25,7 +25,7 @@ async def test_config_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> BASE_CONFIG.copy(), ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "cluster.region.kusto.windows.net" mock_setup_entry.assert_called_once() @@ -59,12 +59,12 @@ async def test_config_flow_errors( result["flow_id"], BASE_CONFIG.copy(), ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": expected} await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["type"] == data_entry_flow.FlowResultType.FORM # Retest error handling if error is corrected and connection is successful @@ -77,4 +77,4 @@ async def test_config_flow_errors( await hass.async_block_till_done() - assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY diff --git a/tests/components/azure_event_hub/test_init.py b/tests/components/azure_event_hub/test_init.py index 1b0550b147b..5ffc6106c11 100644 --- a/tests/components/azure_event_hub/test_init.py +++ b/tests/components/azure_event_hub/test_init.py @@ -112,6 +112,7 @@ async def test_send_batch_error( ) await hass.async_block_till_done() mock_send_batch.assert_called_once() + mock_send_batch.side_effect = None # Reset to avoid error in teardown async def test_late_event( diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 70b33d2de3f..4f456cc6d72 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,29 +2,179 @@ from __future__ import annotations +from collections.abc import AsyncIterator, Callable, Coroutine from pathlib import Path -from unittest.mock import patch +from typing import Any +from unittest.mock import ANY, AsyncMock, Mock, patch -from homeassistant.components.backup import DOMAIN -from homeassistant.components.backup.manager import Backup +from homeassistant.components.backup import ( + DOMAIN, + AddonInfo, + AgentBackup, + BackupAgent, + BackupAgentPlatformProtocol, + Folder, +) +from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -TEST_BACKUP = Backup( - slug="abc123", - name="Test", +from tests.common import MockPlatform, mock_platform + +LOCAL_AGENT_ID = f"{DOMAIN}.local" + +TEST_BACKUP_ABC123 = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + database_included=True, date="1970-01-01T00:00:00.000Z", - path=Path("abc123.tar"), - size=0.0, + extra_metadata={"instance_id": ANY, "with_automatic_settings": True}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0, ) +TEST_BACKUP_PATH_ABC123 = Path("abc123.tar") + +TEST_BACKUP_DEF456 = AgentBackup( + addons=[], + backup_id="def456", + database_included=False, + date="1980-01-01T00:00:00.000Z", + extra_metadata={"instance_id": "unknown_uuid", "with_automatic_settings": True}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test 2", + protected=False, + size=1, +) + +TEST_DOMAIN = "test" + + +class BackupAgentTest(BackupAgent): + """Test backup agent.""" + + domain = "test" + + def __init__(self, name: str, backups: list[AgentBackup] | None = None) -> None: + """Initialize the backup agent.""" + self.name = name + if backups is None: + backups = [ + AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + database_included=True, + date="1970-01-01T00:00:00Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=13, + ) + ] + + self._backup_data: bytearray | None = None + self._backups = {backup.backup_id: backup for backup in backups} + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + return AsyncMock(spec_set=["__aiter__"]) + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + self._backups[backup.backup_id] = backup + backup_stream = await open_stream() + self._backup_data = bytearray() + async for chunk in backup_stream: + self._backup_data += chunk + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + return list(self._backups.values()) + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + return self._backups.get(backup_id) + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" async def setup_backup_integration( hass: HomeAssistant, with_hassio: bool = False, configuration: ConfigType | None = None, + *, + backups: dict[str, list[AgentBackup]] | None = None, + remote_agents: list[str] | None = None, ) -> bool: """Set up the Backup integration.""" - with patch("homeassistant.components.backup.is_hassio", return_value=with_hassio): - return await async_setup_component(hass, DOMAIN, configuration or {}) + with ( + patch("homeassistant.components.backup.is_hassio", return_value=with_hassio), + patch( + "homeassistant.components.backup.backup.is_hassio", return_value=with_hassio + ), + ): + remote_agents = remote_agents or [] + platform = Mock( + async_get_backup_agents=AsyncMock( + return_value=[BackupAgentTest(agent, []) for agent in remote_agents] + ), + spec_set=BackupAgentPlatformProtocol, + ) + + mock_platform(hass, f"{TEST_DOMAIN}.backup", platform or MockPlatform()) + assert await async_setup_component(hass, TEST_DOMAIN, {}) + + result = await async_setup_component(hass, DOMAIN, configuration or {}) + await hass.async_block_till_done() + if not backups: + return result + + for agent_id, agent_backups in backups.items(): + if with_hassio and agent_id == LOCAL_AGENT_ID: + continue + agent = hass.data[DATA_MANAGER].backup_agents[agent_id] + agent._backups = {backups.backup_id: backups for backups in agent_backups} + if agent_id == LOCAL_AGENT_ID: + agent._loaded_backups = True + + return result + + +async def setup_backup_platform( + hass: HomeAssistant, + *, + domain: str, + platform: Any, +) -> None: + """Set up a mock domain.""" + mock_platform(hass, f"{domain}.backup", platform) + assert await async_setup_component(hass, domain, {}) + await hass.async_block_till_done() diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py new file mode 100644 index 00000000000..7831efeff9a --- /dev/null +++ b/tests/components/backup/conftest.py @@ -0,0 +1,133 @@ +"""Test fixtures for the Backup integration.""" + +from __future__ import annotations + +from asyncio import Future +from collections.abc import Generator +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from homeassistant.components.backup import DOMAIN +from homeassistant.components.backup.manager import NewBackup, WrittenBackup +from homeassistant.core import HomeAssistant + +from .common import TEST_BACKUP_PATH_ABC123 + +from tests.common import get_fixture_path + + +@pytest.fixture(name="mocked_json_bytes") +def mocked_json_bytes_fixture() -> Generator[Mock]: + """Mock json_bytes.""" + with patch( + "homeassistant.components.backup.manager.json_bytes", + return_value=b"{}", # Empty JSON + ) as mocked_json_bytes: + yield mocked_json_bytes + + +@pytest.fixture(name="mocked_tarfile") +def mocked_tarfile_fixture() -> Generator[Mock]: + """Mock tarfile.""" + with patch( + "homeassistant.components.backup.manager.SecureTarFile" + ) as mocked_tarfile: + yield mocked_tarfile + + +@pytest.fixture(name="path_glob") +def path_glob_fixture() -> Generator[MagicMock]: + """Mock path glob.""" + with patch( + "pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123] + ) as path_glob: + yield path_glob + + +CONFIG_DIR = { + "testing_config": [ + Path("test.txt"), + Path(".DS_Store"), + Path(".storage"), + Path("backups"), + Path("tmp_backups"), + Path("home-assistant_v2.db"), + ], + "backups": [ + Path("backups/backup.tar"), + Path("backups/not_backup"), + ], + "tmp_backups": [ + Path("tmp_backups/forgotten_backup.tar"), + Path("tmp_backups/not_backup"), + ], +} +CONFIG_DIR_DIRS = {Path(".storage"), Path("backups"), Path("tmp_backups")} + + +@pytest.fixture(name="create_backup") +def mock_create_backup() -> Generator[AsyncMock]: + """Mock manager create backup.""" + mock_written_backup = MagicMock(spec_set=WrittenBackup) + mock_written_backup.backup.backup_id = "abc123" + mock_written_backup.open_stream = AsyncMock() + mock_written_backup.release_stream = AsyncMock() + fut: Future[MagicMock] = Future() + fut.set_result(mock_written_backup) + with patch( + "homeassistant.components.backup.CoreBackupReaderWriter.async_create_backup" + ) as mock_create_backup: + mock_create_backup.return_value = (NewBackup(backup_job_id="abc123"), fut) + yield mock_create_backup + + +@pytest.fixture(name="mock_backup_generation") +def mock_backup_generation_fixture( + hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock +) -> Generator[None]: + """Mock backup generator.""" + + with ( + patch("pathlib.Path.iterdir", lambda x: CONFIG_DIR.get(x.name, [])), + patch("pathlib.Path.stat", return_value=MagicMock(st_size=123)), + patch("pathlib.Path.is_file", lambda x: x not in CONFIG_DIR_DIRS), + patch("pathlib.Path.is_dir", lambda x: x in CONFIG_DIR_DIRS), + patch( + "pathlib.Path.exists", + lambda x: x + not in ( + Path(hass.config.path("backups")), + Path(hass.config.path("tmp_backups")), + ), + ), + patch( + "pathlib.Path.is_symlink", + lambda _: False, + ), + patch( + "pathlib.Path.mkdir", + MagicMock(), + ), + patch( + "homeassistant.components.backup.manager.HAVERSION", + "2025.1.0", + ), + ): + yield + + +@pytest.fixture +def mock_backups() -> Generator[None]: + """Fixture to setup test backups.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.backup import backup as core_backup + + class CoreLocalBackupAgent(core_backup.CoreLocalBackupAgent): + def __init__(self, hass: HomeAssistant) -> None: + super().__init__(hass) + self._backup_dir = get_fixture_path("test_backups", DOMAIN) + + with patch.object(core_backup, "CoreLocalBackupAgent", CoreLocalBackupAgent): + yield diff --git a/tests/components/backup/fixtures/test_backups/2bcb3113.tar b/tests/components/backup/fixtures/test_backups/2bcb3113.tar new file mode 100644 index 00000000000..8a6556634f3 Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/2bcb3113.tar differ diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar new file mode 100644 index 00000000000..f3b2845d5eb Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar differ diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr new file mode 100644 index 00000000000..f91473e3b70 --- /dev/null +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -0,0 +1,216 @@ +# serializer version: 1 +# name: test_delete_backup[found_backups0-True-1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_backup[found_backups1-False-0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_backup[found_backups2-True-0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[None] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[None].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect1] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect1].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect2] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect2].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect3] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect3].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect4] + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_load_backups[side_effect4].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr new file mode 100644 index 00000000000..45af91645ad --- /dev/null +++ b/tests/components/backup/snapshots/test_store.ambr @@ -0,0 +1,171 @@ +# serializer version: 1 +# name: test_store_migration[store_data0] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data0].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data1] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + 'something_from_the_future': 'value', + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data1].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 096df37d704..43b4c1260dd 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -1,4 +1,32 @@ # serializer version: 1 +# name: test_agent_delete_backup + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_agents_info + dict({ + 'id': 1, + 'result': dict({ + 'agents': list([ + dict({ + 'agent_id': 'backup.local', + }), + dict({ + 'agent_id': 'domain.test', + }), + ]), + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_backup_end[with_hassio-hass_access_token] dict({ 'error': dict({ @@ -40,7 +68,7 @@ 'type': 'result', }) # --- -# name: test_backup_end_excepion[exception0] +# name: test_backup_end_exception[exception0] dict({ 'error': dict({ 'code': 'post_backup_actions_failed', @@ -51,7 +79,7 @@ 'type': 'result', }) # --- -# name: test_backup_end_excepion[exception1] +# name: test_backup_end_exception[exception1] dict({ 'error': dict({ 'code': 'post_backup_actions_failed', @@ -62,7 +90,7 @@ 'type': 'result', }) # --- -# name: test_backup_end_excepion[exception2] +# name: test_backup_end_exception[exception2] dict({ 'error': dict({ 'code': 'post_backup_actions_failed', @@ -114,7 +142,7 @@ 'type': 'result', }) # --- -# name: test_backup_start_excepion[exception0] +# name: test_backup_start_exception[exception0] dict({ 'error': dict({ 'code': 'pre_backup_actions_failed', @@ -125,7 +153,7 @@ 'type': 'result', }) # --- -# name: test_backup_start_excepion[exception1] +# name: test_backup_start_exception[exception1] dict({ 'error': dict({ 'code': 'pre_backup_actions_failed', @@ -136,7 +164,7 @@ 'type': 'result', }) # --- -# name: test_backup_start_excepion[exception2] +# name: test_backup_start_exception[exception2] dict({ 'error': dict({ 'code': 'pre_backup_actions_failed', @@ -147,121 +175,3674 @@ 'type': 'result', }) # --- -# name: test_details[with_hassio-with_backup_content] +# name: test_can_decrypt_on_download[backup.local-2bcb3113-hunter2] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'decrypt_not_supported', + 'message': 'Decrypt on download not supported', }), 'id': 1, 'success': False, 'type': 'result', }) # --- -# name: test_details[with_hassio-without_backup_content] +# name: test_can_decrypt_on_download[backup.local-c0cb53bd-hunter2] + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-c0cb53bd-wrong_password] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'password_incorrect', + 'message': 'Incorrect password', }), 'id': 1, 'success': False, 'type': 'result', }) # --- -# name: test_details[without_hassio-with_backup_content] +# name: test_can_decrypt_on_download[backup.local-no_such_backup-hunter2] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup no_such_backup not found in agent backup.local', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[no_such_agent-c0cb53bd-hunter2] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Invalid agent selected: no_such_agent', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data0] dict({ 'id': 1, 'result': dict({ - 'backup': dict({ - 'date': '1970-01-01T00:00:00.000Z', - 'name': 'Test', - 'path': 'abc123.tar', - 'size': 0.0, - 'slug': 'abc123', + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), }), }), 'success': True, 'type': 'result', }) # --- -# name: test_details[without_hassio-without_backup_content] +# name: test_config_info[storage_data1] dict({ 'id': 1, 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': list([ + 'test-addon', + ]), + 'include_all_addons': True, + 'include_database': True, + 'include_folders': list([ + 'media', + ]), + 'name': 'test-name', + 'password': 'test-password', + }), + 'last_attempted_automatic_backup': '2024-10-26T04:45:00+01:00', + 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data2] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data3] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': '2024-10-27T04:45:00+01:00', + 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_info[storage_data6] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command0].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command0].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command10].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command10].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command11] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command11].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command11].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command1].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T06:00:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': '06:00:00', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command1].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': '06:00:00', + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command2] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command2].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command2].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command3] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command3].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command3].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command4].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command4].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command5].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': list([ + 'test-addon', + ]), + 'include_all_addons': False, + 'include_database': True, + 'include_folders': list([ + 'media', + ]), + 'name': 'test-name', + 'password': 'test-password', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command5].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': list([ + 'test-addon', + ]), + 'include_all_addons': False, + 'include_database': True, + 'include_folders': list([ + 'media', + ]), + 'name': 'test-name', + 'password': 'test-password', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command6] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command6].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command6].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command7] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command7].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command7].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command8].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command8].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[command9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command9].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[command9].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update_errors[command0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command0].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command1].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command2] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command2].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command3] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command3].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command4].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command5].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command6] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command6].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command7] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command7].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command8].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents0-backups0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents0-backups0].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents0-backups0].2 + dict({ + 'id': 3, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents1-backups1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents1-backups1].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents1-backups1].2 + dict({ + 'id': 3, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents2-backups2] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents2-backups2].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents2-backups2].2 + dict({ + 'id': 3, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents3-backups3] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'protected': False, + 'size': 1, + 'with_automatic_settings': None, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents3-backups3].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents3-backups3].2 + dict({ + 'id': 3, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'protected': False, + 'size': 1, + 'with_automatic_settings': None, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents4-backups4] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents4-backups4].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete[remote_agents4-backups4].2 + dict({ + 'id': 3, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'The backup agent is unreachable.', + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data0].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_automatic_settings': None, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'The backup agent is unreachable.', + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[BackupAgentUnreachableError-storage_data1].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + 'test.remote', + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_automatic_settings': None, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[None-storage_data0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[None-storage_data0].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_automatic_settings': None, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[None-storage_data1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[None-storage_data1].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_automatic_settings': None, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[side_effect1-storage_data0] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Boom!', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[side_effect1-storage_data0].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_automatic_settings': None, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[side_effect1-storage_data1] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Boom!', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_delete_with_errors[side_effect1-storage_data1].1 + dict({ + 'id': 2, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'domain.test', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00Z', + 'failed_agent_ids': list([ + 'test.remote', + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 13, + 'with_automatic_settings': None, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details[remote_agents0-backups0] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), 'backup': None, }), 'success': True, 'type': 'result', }) # --- -# name: test_generate[with_hassio] - dict({ - 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', - }), - 'id': 1, - 'success': False, - 'type': 'result', - }) -# --- -# name: test_generate[without_hassio] +# name: test_details[remote_agents1-backups1] dict({ 'id': 1, 'result': dict({ - 'date': '1970-01-01T00:00:00.000Z', - 'name': 'Test', - 'path': 'abc123.tar', - 'size': 0.0, - 'slug': 'abc123', + 'agent_errors': dict({ + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), }), 'success': True, 'type': 'result', }) # --- -# name: test_info[with_hassio] +# name: test_details[remote_agents2-backups2] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details[remote_agents3-backups3] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details[remote_agents4-backups4] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details_with_errors[BackupAgentUnreachableError] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'The backup agent is unreachable.', + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details_with_errors[side_effect0] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'home_assistant_error', + 'message': 'Boom!', }), 'id': 1, 'success': False, 'type': 'result', }) # --- -# name: test_info[without_hassio] +# name: test_generate[None] + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[None].1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[None].2 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[None].3 + dict({ + 'id': 2, + 'result': dict({ + 'backup_job_id': '64331d85', + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[None].4 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'home_assistant', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[None].5 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'upload_to_agents', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[None].6 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'completed', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data1] + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data1].1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[data1].2 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data1].3 + dict({ + 'id': 2, + 'result': dict({ + 'backup_job_id': '64331d85', + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[data1].4 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'home_assistant', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data1].5 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'upload_to_agents', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data1].6 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'completed', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data2] + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data2].1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[data2].2 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data2].3 + dict({ + 'id': 2, + 'result': dict({ + 'backup_job_id': '64331d85', + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_generate[data2].4 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'home_assistant', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data2].5 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': 'upload_to_agents', + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_generate[data2].6 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'completed', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_info[remote_agents0-remote_backups0] dict({ 'id': 1, 'result': dict({ - 'backing_up': False, + 'agent_errors': dict({ + }), 'backups': list([ dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'path': 'abc123.tar', - 'size': 0.0, - 'slug': 'abc123', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, }), ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, }), 'success': True, 'type': 'result', }) # --- -# name: test_remove[with_hassio] +# name: test_info[remote_agents1-remote_backups1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info[remote_agents2-remote_backups2] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'test.remote', + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info[remote_agents3-remote_backups3] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backups': list([ + dict({ + 'addons': list([ + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'protected': False, + 'size': 1, + 'with_automatic_settings': None, + }), + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info_with_errors[BackupAgentUnreachableError] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'The backup agent is unreachable.', + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info_with_errors[side_effect0] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'home_assistant_error', + 'message': 'Boom!', }), 'id': 1, 'success': False, 'type': 'result', }) # --- -# name: test_remove[without_hassio] +# name: test_restore_local_agent[backups0] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent backup.local', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_restore_local_agent[backups0].1 + 0 +# --- +# name: test_restore_local_agent[backups1] dict({ 'id': 1, 'result': None, @@ -269,18 +3850,24 @@ 'type': 'result', }) # --- -# name: test_restore[with_hassio] +# name: test_restore_local_agent[backups1].1 + 1 +# --- +# name: test_restore_remote_agent[remote_agents0-backups0] dict({ 'error': dict({ - 'code': 'unknown_command', - 'message': 'Unknown command.', + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent test.remote', }), 'id': 1, 'success': False, 'type': 'result', }) # --- -# name: test_restore[without_hassio] +# name: test_restore_remote_agent[remote_agents0-backups0].1 + 0 +# --- +# name: test_restore_remote_agent[remote_agents1-backups1] dict({ 'id': 1, 'result': None, @@ -288,3 +3875,45 @@ 'type': 'result', }) # --- +# name: test_restore_remote_agent[remote_agents1-backups1].1 + 1 +# --- +# name: test_restore_wrong_password + dict({ + 'error': dict({ + 'code': 'password_incorrect', + 'message': 'Incorrect password', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_subscribe_event + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_subscribe_event.1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_subscribe_event.2 + dict({ + 'event': dict({ + 'manager_state': 'create_backup', + 'stage': None, + 'state': 'in_progress', + }), + 'id': 1, + 'type': 'event', + }) +# --- diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py new file mode 100644 index 00000000000..02252ef6fa5 --- /dev/null +++ b/tests/components/backup/test_backup.py @@ -0,0 +1,129 @@ +"""Test the builtin backup platform.""" + +from __future__ import annotations + +from collections.abc import Generator +from io import StringIO +import json +from pathlib import Path +from tarfile import TarError +from unittest.mock import MagicMock, mock_open, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.backup import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123 + +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture(name="read_backup") +def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: + """Mock read backup.""" + with patch( + "homeassistant.components.backup.backup.read_backup", + return_value=TEST_BACKUP_ABC123, + ) as read_backup: + yield read_backup + + +@pytest.mark.parametrize( + "side_effect", + [ + None, + OSError("Boom"), + TarError("Boom"), + json.JSONDecodeError("Boom", "test", 1), + KeyError("Boom"), + ], +) +async def test_load_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + read_backup: MagicMock, + side_effect: Exception | None, +) -> None: + """Test load backups.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + client = await hass_ws_client(hass) + read_backup.side_effect = side_effect + + # list agents + await client.send_json_auto_id({"type": "backup/agents/info"}) + assert await client.receive_json() == snapshot + + # load and list backups + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot + + +async def test_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test upload backup.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + client = await hass_client() + open_mock = mock_open() + + with ( + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert open_mock.call_count == 1 + assert move_mock.call_count == 1 + assert move_mock.mock_calls[0].args[1].name == "abc123.tar" + + +@pytest.mark.usefixtures("read_backup") +@pytest.mark.parametrize( + ("found_backups", "backup_exists", "unlink_calls"), + [ + ([TEST_BACKUP_PATH_ABC123], True, 1), + ([TEST_BACKUP_PATH_ABC123], False, 0), + (([], True, 0)), + ], +) +async def test_delete_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + path_glob: MagicMock, + found_backups: list[Path], + backup_exists: bool, + unlink_calls: int, +) -> None: + """Test delete backup.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + client = await hass_ws_client(hass) + path_glob.return_value = found_backups + + with ( + patch("pathlib.Path.exists", return_value=backup_exists), + patch("pathlib.Path.unlink") as unlink, + ): + await client.send_json_auto_id( + {"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id} + ) + assert await client.receive_json() == snapshot + + assert unlink.call_count == unlink_calls diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index 76b1f76b55b..b7b86cc1d45 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,33 +1,39 @@ """Tests for the Backup integration.""" import asyncio -from io import StringIO +from collections.abc import AsyncIterator, Iterable +from io import BytesIO, StringIO +import json +import tarfile +from typing import Any from unittest.mock import patch from aiohttp import web import pytest +from homeassistant.components.backup import AddonInfo, AgentBackup, Folder +from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP, setup_backup_integration +from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration -from tests.common import MockUser +from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator -async def test_downloading_backup( +async def test_downloading_local_backup( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> None: - """Test downloading a backup file.""" + """Test downloading a local backup file.""" await setup_backup_integration(hass) client = await hass_client() with ( patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - return_value=TEST_BACKUP, + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, ), patch("pathlib.Path.exists", return_value=True), patch( @@ -35,10 +41,159 @@ async def test_downloading_backup( return_value=web.Response(text=""), ), ): - resp = await client.get("/api/backup/download/abc123") + resp = await client.get("/api/backup/download/abc123?agent_id=backup.local") assert resp.status == 200 +async def test_downloading_remote_backup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a remote backup.""" + await setup_backup_integration( + hass, backups={"test.test": [TEST_BACKUP_ABC123]}, remote_agents=["test"] + ) + + client = await hass_client() + + with ( + patch.object(BackupAgentTest, "async_download_backup") as download_mock, + ): + download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) + resp = await client.get("/api/backup/download/abc123?agent_id=test.test") + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_downloading_local_encrypted_backup_file_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + await setup_backup_integration(hass) + client = await hass_client() + + with patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, + ): + resp = await client.get( + "/api/backup/download/abc123?agent_id=backup.local&password=blah" + ) + assert resp.status == 404 + + +@pytest.mark.usefixtures("mock_backups") +async def test_downloading_local_encrypted_backup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + await setup_backup_integration(hass) + await _test_downloading_encrypted_backup(hass_client, "backup.local") + + +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i + + +@patch.object(BackupAgentTest, "async_download_backup") +async def test_downloading_remote_encrypted_backup( + download_mock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + await setup_backup_integration(hass) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest( + "test", + [ + AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="c0cb53bd", + database_included=True, + date="1970-01-01T00:00:00Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=13, + ) + ], + ) + + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: + return aiter_from_iter((backup_path.read_bytes(),)) + + download_mock.side_effect = download_backup + await _test_downloading_encrypted_backup(hass_client, "domain.test") + + +async def _test_downloading_encrypted_backup( + hass_client: ClientSessionGenerator, + agent_id: str, +) -> None: + """Test downloading an encrypted backup file.""" + # Try downloading without supplying a password + client = await hass_client() + resp = await client.get(f"/api/backup/download/c0cb53bd?agent_id={agent_id}") + assert resp.status == 200 + backup = await resp.read() + # We expect a valid outer tar file, but the inner tar file is encrypted and + # can't be read + with tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar: + enc_metadata = json.loads(outer_tar.extractfile("./backup.json").read()) + assert enc_metadata["protected"] is True + with ( + outer_tar.extractfile("core.tar.gz") as inner_tar_file, + pytest.raises(tarfile.ReadError, match="file could not be opened"), + ): + # pylint: disable-next=consider-using-with + tarfile.open(fileobj=inner_tar_file, mode="r") + + # Download with the wrong password + resp = await client.get( + f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=wrong" + ) + assert resp.status == 200 + backup = await resp.read() + # We expect a truncated outer tar file + with ( + tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar, + pytest.raises(tarfile.ReadError, match="unexpected end of data"), + ): + outer_tar.getnames() + + # Finally download with the correct password + resp = await client.get( + f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=hunter2" + ) + assert resp.status == 200 + backup = await resp.read() + # We expect a valid outer tar file, the inner tar file is decrypted and can be read + with ( + tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar, + ): + dec_metadata = json.loads(outer_tar.extractfile("./backup.json").read()) + assert dec_metadata == enc_metadata | {"protected": False} + with ( + outer_tar.extractfile("core.tar.gz") as inner_tar_file, + tarfile.open(fileobj=inner_tar_file, mode="r") as inner_tar, + ): + assert inner_tar.getnames() == [ + ".", + "README.md", + "test_symlink", + "test1", + "test1/script.sh", + ] + + async def test_downloading_backup_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -48,7 +203,7 @@ async def test_downloading_backup_not_found( client = await hass_client() - resp = await client.get("/api/backup/download/abc123") + resp = await client.get("/api/backup/download/abc123?agent_id=backup.local") assert resp.status == 404 @@ -63,7 +218,7 @@ async def test_downloading_as_non_admin( client = await hass_client() - resp = await client.get("/api/backup/download/abc123") + resp = await client.get("/api/backup/download/abc123?agent_id=backup.local") assert resp.status == 401 @@ -80,7 +235,7 @@ async def test_uploading_a_backup_file( "homeassistant.components.backup.manager.BackupManager.async_receive_backup", ) as async_receive_backup_mock: resp = await client.post( - "/api/backup/upload", + "/api/backup/upload?agent_id=backup.local", data={"file": StringIO("test")}, ) assert resp.status == 201 @@ -90,7 +245,7 @@ async def test_uploading_a_backup_file( @pytest.mark.parametrize( ("error", "message"), [ - (OSError("Boom!"), "Can't write backup file Boom!"), + (OSError("Boom!"), "Can't write backup file: Boom!"), (asyncio.CancelledError("Boom!"), ""), ], ) @@ -110,7 +265,7 @@ async def test_error_handling_uploading_a_backup_file( side_effect=error, ): resp = await client.post( - "/api/backup/upload", + "/api/backup/upload?agent_id=backup.local", data={"file": StringIO("test")}, ) assert resp.status == 500 diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index e064939d618..925e2cb9b7a 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -1,15 +1,20 @@ """Tests for the Backup integration.""" +from typing import Any from unittest.mock import patch import pytest -from homeassistant.components.backup.const import DOMAIN +from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotFound from .common import setup_backup_integration +from tests.typing import WebSocketGenerator + +@pytest.mark.usefixtures("supervisor_client") async def test_setup_with_hassio( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -20,14 +25,14 @@ async def test_setup_with_hassio( with_hassio=True, configuration={DOMAIN: {}}, ) - assert ( - "The backup integration is not supported on this installation method, please" - " remove it from your configuration" - ) in caplog.text + manager = hass.data[DATA_MANAGER] + assert not manager.backup_agents +@pytest.mark.parametrize("service_data", [None, {}]) async def test_create_service( hass: HomeAssistant, + service_data: dict[str, Any] | None, ) -> None: """Test generate backup.""" await setup_backup_integration(hass) @@ -39,6 +44,103 @@ async def test_create_service( DOMAIN, "create", blocking=True, + service_data=service_data, ) - assert generate_backup.called + generate_backup.assert_called_once_with( + agent_ids=["backup.local"], + include_addons=None, + include_all_addons=False, + include_database=True, + include_folders=None, + include_homeassistant=True, + name=None, + password=None, + ) + + +async def test_create_service_with_hassio(hass: HomeAssistant) -> None: + """Test action backup.create does not exist with hassio.""" + await setup_backup_integration(hass, with_hassio=True) + + with pytest.raises(ServiceNotFound): + await hass.services.async_call(DOMAIN, "create", blocking=True) + + +@pytest.mark.parametrize( + ("commands", "expected_kwargs"), + [ + ( + [], + { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + "with_automatic_settings": True, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "include_homeassistant": True, + "name": "cool_backup", + "password": "hunter2", + "with_automatic_settings": True, + }, + ), + ], +) +@pytest.mark.parametrize("service_data", [None, {}]) +@pytest.mark.parametrize("with_hassio", [True, False]) +@pytest.mark.usefixtures("supervisor_client") +async def test_create_automatic_service( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + commands: list[dict[str, Any]], + expected_kwargs: dict[str, Any], + service_data: dict[str, Any] | None, + with_hassio: bool, +) -> None: + """Test generate backup.""" + await setup_backup_integration(hass, with_hassio=with_hassio) + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as generate_backup: + await hass.services.async_call( + DOMAIN, + "create_automatic", + blocking=True, + service_data=service_data, + ) + + generate_backup.assert_called_once_with(**expected_kwargs) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index a3f70267643..c961230e9e6 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -2,398 +2,2484 @@ from __future__ import annotations +import asyncio +from collections.abc import Generator +from dataclasses import replace +from io import StringIO +import json from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch +import tarfile +from typing import Any +from unittest.mock import ( + ANY, + DEFAULT, + AsyncMock, + MagicMock, + Mock, + call, + mock_open, + patch, +) -import aiohttp -from multidict import CIMultiDict, CIMultiDictProxy import pytest -from homeassistant.components.backup import BackupManager -from homeassistant.components.backup.manager import BackupPlatformProtocol +from homeassistant.components.backup import ( + DOMAIN, + AgentBackup, + BackupAgentPlatformProtocol, + BackupReaderWriterError, + Folder, + LocalBackupAgent, + backup as local_backup_platform, +) +from homeassistant.components.backup.agent import BackupAgentError +from homeassistant.components.backup.const import DATA_MANAGER +from homeassistant.components.backup.manager import ( + BackupManagerError, + BackupManagerState, + CreateBackupStage, + CreateBackupState, + NewBackup, + ReceiveBackupStage, + ReceiveBackupState, + RestoreBackupState, + WrittenBackup, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .common import TEST_BACKUP +from .common import ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123, + TEST_BACKUP_DEF456, + BackupAgentTest, + setup_backup_platform, +) -from tests.common import MockPlatform, mock_platform +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +_EXPECTED_FILES = [ + "test.txt", + ".storage", + "backups", + "backups/not_backup", + "tmp_backups", + "tmp_backups/not_backup", +] +_EXPECTED_FILES_WITH_DATABASE = { + True: [*_EXPECTED_FILES, "home-assistant_v2.db"], + False: _EXPECTED_FILES, +} -async def _mock_backup_generation(manager: BackupManager): - """Mock backup generator.""" - - def _mock_iterdir(path: Path) -> list[Path]: - if not path.name.endswith("testing_config"): - return [] - return [ - Path("test.txt"), - Path(".DS_Store"), - Path(".storage"), - ] - - with ( - patch( - "homeassistant.components.backup.manager.SecureTarFile" - ) as mocked_tarfile, - patch("pathlib.Path.iterdir", _mock_iterdir), - patch("pathlib.Path.stat", MagicMock(st_size=123)), - patch("pathlib.Path.is_file", lambda x: x.name != ".storage"), - patch( - "pathlib.Path.is_dir", - lambda x: x.name == ".storage", - ), - patch( - "pathlib.Path.exists", - lambda x: x != manager.backup_dir, - ), - patch( - "pathlib.Path.is_symlink", - lambda _: False, - ), - patch( - "pathlib.Path.mkdir", - MagicMock(), - ), - patch( - "homeassistant.components.backup.manager.json_bytes", - return_value=b"{}", # Empty JSON - ) as mocked_json_bytes, - patch( - "homeassistant.components.backup.manager.HAVERSION", - "2025.1.0", - ), - ): - await manager.async_create_backup() - - assert mocked_json_bytes.call_count == 1 - backup_json_dict = mocked_json_bytes.call_args[0][0] - assert isinstance(backup_json_dict, dict) - assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} - assert manager.backup_dir.as_posix() in str( - mocked_tarfile.call_args_list[0][0][0] - ) +@pytest.fixture(autouse=True) +def mock_delay_save() -> Generator[None]: + """Mock the delay save constant.""" + with patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0): + yield -async def _setup_mock_domain( - hass: HomeAssistant, - platform: BackupPlatformProtocol | None = None, -) -> None: - """Set up a mock domain.""" - mock_platform(hass, "some_domain.backup", platform or MockPlatform()) - assert await async_setup_component(hass, "some_domain", {}) - - -async def test_constructor(hass: HomeAssistant) -> None: - """Test BackupManager constructor.""" - manager = BackupManager(hass) - assert manager.backup_dir.as_posix() == hass.config.path("backups") - - -async def test_load_backups(hass: HomeAssistant) -> None: - """Test loading backups.""" - manager = BackupManager(hass) - with ( - patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]), - patch("tarfile.open", return_value=MagicMock()), - patch( - "homeassistant.components.backup.manager.json_loads_object", - return_value={ - "slug": TEST_BACKUP.slug, - "name": TEST_BACKUP.name, - "date": TEST_BACKUP.date, - }, - ), - patch( - "pathlib.Path.stat", - return_value=MagicMock(st_size=TEST_BACKUP.size), - ), - ): - await manager.load_backups() - backups = await manager.async_get_backups() - assert backups == {TEST_BACKUP.slug: TEST_BACKUP} - - -async def test_load_backups_with_exception( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test loading backups with exception.""" - manager = BackupManager(hass) - with ( - patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]), - patch("tarfile.open", side_effect=OSError("Test exception")), - ): - await manager.load_backups() - backups = await manager.async_get_backups() - assert f"Unable to read backup {TEST_BACKUP.path}: Test exception" in caplog.text - assert backups == {} - - -async def test_removing_backup( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test removing backup.""" - manager = BackupManager(hass) - manager.backups = {TEST_BACKUP.slug: TEST_BACKUP} - manager.loaded_backups = True - - with patch("pathlib.Path.exists", return_value=True): - await manager.async_remove_backup(slug=TEST_BACKUP.slug) - assert "Removed backup located at" in caplog.text - - -async def test_removing_non_existing_backup( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test removing not existing backup.""" - manager = BackupManager(hass) - - await manager.async_remove_backup(slug="non_existing") - assert "Removed backup located at" not in caplog.text - - -async def test_getting_backup_that_does_not_exist( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test getting backup that does not exist.""" - manager = BackupManager(hass) - manager.backups = {TEST_BACKUP.slug: TEST_BACKUP} - manager.loaded_backups = True - - with patch("pathlib.Path.exists", return_value=False): - backup = await manager.async_get_backup(slug=TEST_BACKUP.slug) - assert backup is None - - assert ( - f"Removing tracked backup ({TEST_BACKUP.slug}) that " - f"does not exists on the expected path {TEST_BACKUP.path}" - ) in caplog.text - - -async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None: - """Test generate backup.""" - manager = BackupManager(hass) - manager.backing_up = True - with pytest.raises(HomeAssistantError, match="Backup already in progress"): - await manager.async_create_backup() +@pytest.fixture(name="generate_backup_id") +def generate_backup_id_fixture() -> Generator[MagicMock]: + """Mock generate backup id.""" + with patch("homeassistant.components.backup.manager._generate_backup_id") as mock: + mock.return_value = "abc123" + yield mock +@pytest.mark.usefixtures("mock_backup_generation") async def test_async_create_backup( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, +) -> None: + """Test create backup.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + new_backup = NewBackup(backup_job_id="time-123") + backup_task = AsyncMock( + return_value=WrittenBackup( + backup=TEST_BACKUP_ABC123, + open_stream=AsyncMock(), + release_stream=AsyncMock(), + ), + )() # call it so that it can be awaited + + with patch( + "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup", + return_value=(new_backup, backup_task), + ) as create_backup: + await hass.services.async_call( + DOMAIN, + "create", + blocking=True, + ) + + assert create_backup.called + assert create_backup.call_args == call( + agent_ids=["backup.local"], + backup_name="Custom backup 2025.1.0", + extra_metadata={ + "instance_id": hass.data["core.uuid"], + "with_automatic_settings": False, + }, + include_addons=None, + include_all_addons=False, + include_database=True, + include_folders=None, + include_homeassistant=True, + on_progress=ANY, + password=None, + ) + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_create_backup_when_busy( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test generate backup with busy manager.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]} + ) + result = await ws_client.receive_json() + + assert result["success"] is True + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]} + ) + result = await ws_client.receive_json() + + assert result["success"] is False + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == "Backup manager busy: create_backup" + + +@pytest.mark.parametrize( + ("parameters", "expected_error"), + [ + ({"agent_ids": []}, "At least one agent must be selected"), + ({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"), + ( + {"include_addons": ["ssl"], "include_all_addons": True}, + "Cannot include all addons and specify specific addons", + ), + ( + {"include_homeassistant": False}, + "Home Assistant must be included in backup", + ), + ], +) +async def test_create_backup_wrong_parameters( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + parameters: dict[str, Any], + expected_error: str, +) -> None: + """Test create backup with wrong parameters.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + default_parameters = { + "agent_ids": [LOCAL_AGENT_ID], + "include_addons": [], + "include_all_addons": False, + "include_database": True, + "include_folders": [], + "include_homeassistant": True, + } + + await ws_client.send_json_auto_id( + {"type": "backup/generate"} | default_parameters | parameters + ) + result = await ws_client.receive_json() + + assert result["success"] is False + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == expected_error + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("agent_ids", "backup_directory", "temp_file_unlink_call_count"), + [ + ([LOCAL_AGENT_ID], "backups", 0), + (["test.remote"], "tmp_backups", 1), + ([LOCAL_AGENT_ID, "test.remote"], "backups", 0), + ], +) +@pytest.mark.parametrize( + "params", + [ + {}, + {"include_database": True, "name": "abc123"}, + {"include_database": False}, + {"password": "pass123"}, + ], +) +async def test_initiate_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, + generate_backup_id: MagicMock, + path_glob: MagicMock, + params: dict[str, Any], + agent_ids: list[str], + backup_directory: str, + temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" - manager = BackupManager(hass) - manager.loaded_backups = True + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) - await _mock_backup_generation(manager) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) - assert "Generated new backup with slug " in caplog.text - assert "Creating backup directory" in caplog.text - assert "Loaded 0 platforms" in caplog.text + ws_client = await hass_ws_client(hass) + include_database = params.get("include_database", True) + name = params.get("name", "Custom backup 2025.1.0") + password = params.get("password") + path_glob.return_value = [] -async def test_loading_platforms( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test loading backup platforms.""" - manager = BackupManager(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() - assert not manager.loaded_platforms - assert not manager.platforms + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } - await _setup_mock_domain( - hass, - Mock( - async_pre_backup=AsyncMock(), - async_post_backup=AsyncMock(), - ), + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.open", mock_open(read_data=b"test")), + patch("pathlib.Path.unlink") as unlink_mock, + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} | params + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert unlink_mock.call_count == temp_file_unlink_call_count + + assert mocked_json_bytes.call_count == 1 + backup_json_dict = mocked_json_bytes.call_args[0][0] + assert isinstance(backup_json_dict, dict) + assert backup_json_dict == { + "compressed": True, + "date": ANY, + "extra": { + "instance_id": hass.data["core.uuid"], + "with_automatic_settings": False, + }, + "homeassistant": { + "exclude_database": not include_database, + "version": "2025.1.0", + }, + "name": name, + "protected": bool(password), + "slug": backup_id, + "type": "partial", + "version": 2, + } + + await ws_client.send_json_auto_id( + {"type": "backup/details", "backup_id": backup_id} ) - await manager.load_platforms() + result = await ws_client.receive_json() + + backup_data = result["result"]["backup"] + backup_agent_ids = backup_data.pop("agent_ids") + + assert backup_agent_ids == agent_ids + assert backup_data == { + "addons": [], + "backup_id": backup_id, + "database_included": include_database, + "date": ANY, + "failed_agent_ids": [], + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.1.0", + "name": name, + "protected": bool(password), + "size": ANY, + "with_automatic_settings": False, + } + + outer_tar = mocked_tarfile.return_value + core_tar = outer_tar.create_inner_tar.return_value.__enter__.return_value + expected_files = [call(hass.config.path(), arcname="data", recursive=False)] + [ + call(file, arcname=f"data/{file}", recursive=False) + for file in _EXPECTED_FILES_WITH_DATABASE[include_database] + ] + assert core_tar.add.call_args_list == expected_files + + tar_file_path = str(mocked_tarfile.call_args_list[0][0][0]) + backup_directory = hass.config.path(backup_directory) + assert tar_file_path == f"{backup_directory}/{backup_id}.tar" + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")]) +async def test_initiate_backup_with_agent_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test agent upload error during backup generation.""" + agent_ids = [LOCAL_AGENT_ID, "test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id + backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id + backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id + backups_info: list[dict[str, Any]] = [ + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup1", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + { + "addons": [], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup2", + "database_included": False, + "date": "1980-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test 2", + "protected": False, + "size": 1, + "with_automatic_settings": None, + }, + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup3", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + ] + remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": backups_info, + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + await ws_client.send_json_auto_id( + {"type": "backup/config/update", "retention": {"copies": 1, "days": None}} + ) + result = await ws_client.receive_json() + assert result["success"] + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + delete_backup = AsyncMock() + + with ( + patch("pathlib.Path.open", mock_open(read_data=b"test")), + patch.object( + remote_agent, + "async_upload_backup", + side_effect=exception, + ), + patch.object(remote_agent, "async_delete_backup", delete_backup), + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + new_expected_backup_data = { + "addons": [], + "agent_ids": ["backup.local"], + "backup_id": "abc123", + "database_included": True, + "date": ANY, + "failed_agent_ids": ["test.remote"], + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.1.0", + "name": "Custom backup 2025.1.0", + "protected": False, + "size": 123, + "with_automatic_settings": False, + } + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + backups_response = result["result"].pop("backups") + + assert len(backups_response) == 4 + assert new_expected_backup_data in backups_response + assert result["result"] == { + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + await hass.async_block_till_done() + assert hass_storage[DOMAIN]["data"]["backups"] == [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ] + + # one of the two matching backups with the remote agent should have been deleted + assert delete_backup.call_count == 1 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("create_backup_command", "issues_after_create_backup"), + [ + ( + {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]}, + {(DOMAIN, "automatic_backup_failed")}, + ), + ( + {"type": "backup/generate_with_automatic_settings"}, + set(), + ), + ], +) +async def test_create_backup_success_clears_issue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + create_backup_command: dict[str, Any], + issues_after_create_backup: set[tuple[str, str]], +) -> None: + """Test backup issue is cleared after backup is created.""" + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - assert manager.loaded_platforms - assert len(manager.platforms) == 1 + # Create a backup issue + ir.async_create_issue( + hass, + DOMAIN, + "automatic_backup_failed", + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="automatic_backup_failed_create", + ) - assert "Loaded 1 platforms" in caplog.text + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": [LOCAL_AGENT_ID]}, + } + ) + result = await ws_client.receive_json() + assert result["success"] is True + + await ws_client.send_json_auto_id(create_backup_command) + result = await ws_client.receive_json() + assert result["success"] is True + + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert set(issue_registry.issues) == issues_after_create_backup +async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: + """Raise an exception after a delay.""" + + async def delayed_boom() -> None: + await asyncio.sleep(0) + raise Exception("Boom!") # noqa: TRY002 + + return (NewBackup(backup_job_id="abc123"), delayed_boom()) + + +@pytest.mark.parametrize( + ( + "create_backup_command", + "create_backup_side_effect", + "agent_upload_side_effect", + "create_backup_result", + "issues_after_create_backup", + ), + [ + # No error + ( + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + None, + None, + True, + {}, + ), + ( + {"type": "backup/generate_with_automatic_settings"}, + None, + None, + True, + {}, + ), + # Error raised in async_initiate_backup + ( + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + Exception("Boom!"), + None, + False, + {}, + ), + ( + {"type": "backup/generate_with_automatic_settings"}, + Exception("Boom!"), + None, + False, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_create", + "translation_placeholders": None, + } + }, + ), + # Error raised when awaiting the backup task + ( + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + delayed_boom, + None, + True, + {}, + ), + ( + {"type": "backup/generate_with_automatic_settings"}, + delayed_boom, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_create", + "translation_placeholders": None, + } + }, + ), + # Error raised in async_upload_backup + ( + {"type": "backup/generate", "agent_ids": ["test.remote"]}, + None, + Exception("Boom!"), + True, + {}, + ), + ( + {"type": "backup/generate_with_automatic_settings"}, + None, + Exception("Boom!"), + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_upload_agents", + "translation_placeholders": {"failed_agents": "test.remote"}, + } + }, + ), + ], +) +async def test_create_backup_failure_raises_issue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + create_backup: AsyncMock, + create_backup_command: dict[str, Any], + create_backup_side_effect: Exception | None, + agent_upload_side_effect: Exception | None, + create_backup_result: bool, + issues_after_create_backup: dict[tuple[str, str], dict[str, Any]], +) -> None: + """Test backup issue is cleared after backup is created.""" + remote_agent = BackupAgentTest("remote", backups=[]) + + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + create_backup.side_effect = create_backup_side_effect + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.remote"]}, + } + ) + result = await ws_client.receive_json() + assert result["success"] is True + + with patch.object( + remote_agent, "async_upload_backup", side_effect=agent_upload_side_effect + ): + await ws_client.send_json_auto_id(create_backup_command) + result = await ws_client.receive_json() + assert result["success"] == create_backup_result + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert set(issue_registry.issues) == set(issues_after_create_backup) + for issue_id, issue_data in issues_after_create_backup.items(): + issue = issue_registry.issues[issue_id] + assert issue.translation_key == issue_data["translation_key"] + assert issue.translation_placeholders == issue_data["translation_placeholders"] + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "exception", [BackupReaderWriterError("Boom!"), BaseException("Boom!")] +) +async def test_initiate_backup_non_agent_upload_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test an unknown or writer upload error during backup generation.""" + agent_ids = [LOCAL_AGENT_ID, "test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.open", mock_open(read_data=b"test")), + patch.object( + remote_agent, + "async_upload_backup", + side_effect=exception, + ), + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert DOMAIN not in hass_storage + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "exception", [BackupReaderWriterError("Boom!"), Exception("Boom!")] +) +async def test_initiate_backup_with_task_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + create_backup: AsyncMock, + exception: Exception, +) -> None: + """Test backup task error during backup generation.""" + backup_task: asyncio.Future[Any] = asyncio.Future() + backup_task.set_exception(exception) + create_backup.return_value = (NewBackup(backup_job_id="abc123"), backup_task) + agent_ids = [LOCAL_AGENT_ID, "test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "read_call_count", + "read_exception", + "close_call_count", + "close_exception", + "unlink_call_count", + "unlink_exception", + ), + [ + (1, OSError("Boom!"), 0, None, 0, None, 1, None), + (1, None, 1, OSError("Boom!"), 1, None, 1, None), + (1, None, 1, None, 1, OSError("Boom!"), 1, None), + (1, None, 1, None, 1, None, 1, OSError("Boom!")), + ], +) +async def test_initiate_backup_file_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + open_call_count: int, + open_exception: Exception | None, + read_call_count: int, + read_exception: Exception | None, + close_call_count: int, + close_exception: Exception | None, + unlink_call_count: int, + unlink_exception: Exception | None, +) -> None: + """Test file error during generate backup.""" + agent_ids = ["test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + open_mock = mock_open(read_data=b"test") + open_mock.side_effect = open_exception + open_mock.return_value.read.side_effect = read_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch("pathlib.Path.unlink", side_effect=unlink_exception) as unlink_mock, + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert open_mock.call_count == open_call_count + assert open_mock.return_value.read.call_count == read_call_count + assert open_mock.return_value.close.call_count == close_call_count + assert unlink_mock.call_count == unlink_call_count + + +class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): + """Local backup agent.""" + + def get_backup_path(self, backup_id: str) -> Path: + """Return the local path to a backup.""" + return Path("test.tar") + + +@pytest.mark.parametrize( + ("agent_class", "num_local_agents"), + [(LocalBackupAgentTest, 2), (BackupAgentTest, 1)], +) +async def test_loading_platform_with_listener( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + agent_class: type[BackupAgentTest], + num_local_agents: int, +) -> None: + """Test loading a backup agent platform which can be listened to.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, DOMAIN, {}) + manager = hass.data[DATA_MANAGER] + + get_agents_mock = AsyncMock(return_value=[agent_class("remote1", backups=[])]) + register_listener_mock = Mock() + + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=get_agents_mock, + async_register_backup_agents_listener=register_listener_mock, + ), + ) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local"}, + {"agent_id": "test.remote1"}, + ] + assert len(manager.local_backup_agents) == num_local_agents + + get_agents_mock.assert_called_once_with(hass) + register_listener_mock.assert_called_once_with(hass, listener=ANY) + + get_agents_mock.reset_mock() + get_agents_mock.return_value = [agent_class("remote2", backups=[])] + listener = register_listener_mock.call_args[1]["listener"] + listener() + + get_agents_mock.assert_called_once_with(hass) + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local"}, + {"agent_id": "test.remote2"}, + ] + assert len(manager.local_backup_agents) == num_local_agents + + +@pytest.mark.parametrize( + "platform_mock", + [ + Mock(async_pre_backup=AsyncMock(), spec=["async_pre_backup"]), + Mock(async_post_backup=AsyncMock(), spec=["async_post_backup"]), + Mock(spec=[]), + ], +) async def test_not_loading_bad_platforms( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, + platform_mock: Mock, ) -> None: - """Test loading backup platforms.""" - manager = BackupManager(hass) - - assert not manager.loaded_platforms - assert not manager.platforms - - await _setup_mock_domain(hass) - await manager.load_platforms() + """Test not loading bad backup platforms.""" + await setup_backup_platform( + hass, + domain="test", + platform=platform_mock, + ) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - assert manager.loaded_platforms - assert len(manager.platforms) == 0 - - assert "Loaded 0 platforms" in caplog.text - assert ( - "some_domain does not implement required functions for the backup platform" - in caplog.text - ) + assert platform_mock.mock_calls == [] -async def test_exception_plaform_pre(hass: HomeAssistant) -> None: +async def test_exception_platform_pre(hass: HomeAssistant) -> None: """Test exception in pre step.""" - manager = BackupManager(hass) - manager.loaded_backups = True async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") - await _setup_mock_domain( + remote_agent = BackupAgentTest("remote", backups=[]) + await setup_backup_platform( hass, - Mock( + domain="test", + platform=Mock( async_pre_backup=_mock_step, async_post_backup=AsyncMock(), + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager) + with pytest.raises(BackupManagerError) as err: + await hass.services.async_call( + DOMAIN, + "create", + blocking=True, + ) + + assert str(err.value) == "Error during pre-backup: Test exception" -async def test_exception_plaform_post(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_backup_generation") +async def test_exception_platform_post(hass: HomeAssistant) -> None: """Test exception in post step.""" - manager = BackupManager(hass) - manager.loaded_backups = True async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") - await _setup_mock_domain( + remote_agent = BackupAgentTest("remote", backups=[]) + await setup_backup_platform( hass, - Mock( + domain="test", + platform=Mock( async_pre_backup=AsyncMock(), async_post_backup=_mock_step, + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager) - - -async def test_loading_platforms_when_running_async_pre_backup_actions( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test loading backup platforms when running post backup actions.""" - manager = BackupManager(hass) - - assert not manager.loaded_platforms - assert not manager.platforms - - await _setup_mock_domain( - hass, - Mock( - async_pre_backup=AsyncMock(), - async_post_backup=AsyncMock(), - ), - ) - await manager.async_pre_backup_actions() - - assert manager.loaded_platforms - assert len(manager.platforms) == 1 - - assert "Loaded 1 platforms" in caplog.text - - -async def test_loading_platforms_when_running_async_post_backup_actions( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test loading backup platforms when running post backup actions.""" - manager = BackupManager(hass) - - assert not manager.loaded_platforms - assert not manager.platforms - - await _setup_mock_domain( - hass, - Mock( - async_pre_backup=AsyncMock(), - async_post_backup=AsyncMock(), - ), - ) - await manager.async_post_backup_actions() - - assert manager.loaded_platforms - assert len(manager.platforms) == 1 - - assert "Loaded 1 platforms" in caplog.text - - -async def test_async_receive_backup( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test receiving a backup file.""" - manager = BackupManager(hass) - - size = 2 * 2**16 - protocol = Mock(_reading_paused=False) - stream = aiohttp.StreamReader(protocol, 2**16) - stream.feed_data(b"0" * size + b"\r\n--:--") - stream.feed_eof() - - open_mock = mock_open() - - with patch("pathlib.Path.open", open_mock), patch("shutil.move") as mover_mock: - await manager.async_receive_backup( - contents=aiohttp.BodyPartReader( - b"--:", - CIMultiDictProxy( - CIMultiDict( - { - aiohttp.hdrs.CONTENT_DISPOSITION: "attachment; filename=abc123.tar" - } - ) - ), - stream, - ) + with pytest.raises(BackupManagerError) as err: + await hass.services.async_call( + DOMAIN, + "create", + blocking=True, ) - assert open_mock.call_count == 1 - assert mover_mock.call_count == 1 - assert mover_mock.mock_calls[0].args[1].name == "abc123.tar" + + assert str(err.value) == "Error during post-backup: Test exception" -async def test_async_trigger_restore( +@pytest.mark.parametrize( + ( + "agent_id_params", + "open_call_count", + "move_call_count", + "move_path_names", + "remote_agent_backups", + "remote_agent_backup_data", + "temp_file_unlink_call_count", + ), + [ + ( + "agent_id=backup.local&agent_id=test.remote", + 2, + 1, + ["abc123.tar"], + {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, + b"test", + 0, + ), + ( + "agent_id=backup.local", + 1, + 1, + ["abc123.tar"], + {}, + None, + 0, + ), + ( + "agent_id=test.remote", + 2, + 0, + [], + {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, + b"test", + 1, + ), + ], +) +async def test_receive_backup( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, + hass_client: ClientSessionGenerator, + agent_id_params: str, + open_call_count: int, + move_call_count: int, + move_path_names: list[str], + remote_agent_backups: dict[str, AgentBackup], + remote_agent_backup_data: bytes | None, + temp_file_unlink_call_count: int, ) -> None: - """Test trigger restore.""" - manager = BackupManager(hass) - manager.loaded_backups = True - manager.backups = {TEST_BACKUP.slug: TEST_BACKUP} + """Test receive backup and upload to the local and a remote agent.""" + remote_agent = BackupAgentTest("remote", backups=[]) + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + client = await hass_client() + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "homeassistant.components.backup.manager.make_backup_dir" + ) as make_backup_dir_mock, + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + f"/api/backup/upload?{agent_id_params}", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert open_mock.call_count == open_call_count + assert make_backup_dir_mock.call_count == move_call_count + 1 + assert move_mock.call_count == move_call_count + for index, name in enumerate(move_path_names): + assert move_mock.call_args_list[index].args[1].name == name + assert remote_agent._backups == remote_agent_backups + assert remote_agent._backup_data == remote_agent_backup_data + assert unlink_mock.call_count == temp_file_unlink_call_count + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_receive_backup_busy_manager( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + create_backup: AsyncMock, +) -> None: + """Test receive backup with a busy manager.""" + new_backup = NewBackup(backup_job_id="time-123") + backup_task: asyncio.Future[WrittenBackup] = asyncio.Future() + create_backup.return_value = (new_backup, backup_task) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + client = await hass_client() + ws_client = await hass_ws_client(hass) + + upload_data = "test" + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": "idle"} + + result = await ws_client.receive_json() + assert result["success"] is True + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["backup.local"]} + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == {"backup_job_id": "time-123"} + + assert create_backup.call_count == 1 + + resp = await client.post( + "/api/backup/upload?agent_id=backup.local", + data={"file": StringIO(upload_data)}, + ) + + assert resp.status == 500 + assert ( + await resp.text() + == "Can't upload backup file: Backup manager busy: create_backup" + ) + + # finish the backup + backup_task.set_result( + WrittenBackup( + backup=TEST_BACKUP_ABC123, + open_stream=AsyncMock(), + release_stream=AsyncMock(), + ) + ) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")]) +async def test_receive_backup_agent_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test upload error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id + backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id + backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id + backups_info: list[dict[str, Any]] = [ + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup1", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + { + "addons": [], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup2", + "database_included": False, + "date": "1980-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test 2", + "protected": False, + "size": 1, + "with_automatic_settings": None, + }, + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agent_ids": [ + "test.remote", + ], + "backup_id": "backup3", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0, + "with_automatic_settings": True, + }, + ] + remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": backups_info, + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + await ws_client.send_json_auto_id( + {"type": "backup/config/update", "retention": {"copies": 1, "days": None}} + ) + result = await ws_client.receive_json() + assert result["success"] + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + delete_backup = AsyncMock() + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch.object(remote_agent, "async_delete_backup", delete_backup), + patch.object(remote_agent, "async_upload_backup", side_effect=exception), + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": backups_info, + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + await hass.async_block_till_done() + assert hass_storage[DOMAIN]["data"]["backups"] == [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ] + + assert resp.status == 201 + assert open_mock.call_count == 1 + assert move_mock.call_count == 0 + assert unlink_mock.call_count == 1 + assert delete_backup.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [asyncio.CancelledError("Boom!")]) +async def test_receive_backup_non_agent_upload_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test non agent upload error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch.object(remote_agent, "async_upload_backup", side_effect=exception), + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert DOMAIN not in hass_storage + assert resp.status == 500 + assert open_mock.call_count == 1 + assert move_mock.call_count == 0 + assert unlink_mock.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "write_call_count", + "write_exception", + "close_call_count", + "close_exception", + ), + [ + (1, OSError("Boom!"), 0, None, 0, None), + (1, None, 1, OSError("Boom!"), 1, None), + (1, None, 1, None, 1, OSError("Boom!")), + ], +) +async def test_receive_backup_file_write_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + open_call_count: int, + open_exception: Exception | None, + write_call_count: int, + write_exception: Exception | None, + close_call_count: int, + close_exception: Exception | None, +) -> None: + """Test file write error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + open_mock.side_effect = open_exception + open_mock.return_value.write.side_effect = write_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == 500 + assert open_mock.call_count == open_call_count + assert open_mock.return_value.write.call_count == write_call_count + assert open_mock.return_value.close.call_count == close_call_count + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "exception", + [ + OSError("Boom!"), + tarfile.TarError("Boom!"), + json.JSONDecodeError("Boom!", "test", 1), + KeyError("Boom!"), + ], +) +async def test_receive_backup_read_tar_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + exception: Exception, +) -> None: + """Test read tar error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "homeassistant.components.backup.manager.read_backup", + side_effect=exception, + ) as read_backup, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == 500 + assert read_backup.call_count == 1 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "read_call_count", + "read_exception", + "close_call_count", + "close_exception", + "unlink_call_count", + "unlink_exception", + "final_state", + "response_status", + ), + [ + ( + 2, + [DEFAULT, OSError("Boom!")], + 0, + None, + 1, + [DEFAULT, DEFAULT], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + OSError("Boom!"), + 2, + [DEFAULT, DEFAULT], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + None, + 2, + [DEFAULT, OSError("Boom!")], + 1, + None, + ReceiveBackupState.COMPLETED, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + None, + 2, + [DEFAULT, DEFAULT], + 1, + OSError("Boom!"), + ReceiveBackupState.FAILED, + 500, + ), + ], +) +async def test_receive_backup_file_read_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + open_call_count: int, + open_exception: list[Exception | None], + read_call_count: int, + read_exception: Exception | None, + close_call_count: int, + close_exception: list[Exception | None], + unlink_call_count: int, + unlink_exception: Exception | None, + final_state: ReceiveBackupState, + response_status: int, +) -> None: + """Test file read error during backup receive.""" + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + open_mock.side_effect = open_exception + open_mock.return_value.read.side_effect = read_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch("pathlib.Path.unlink", side_effect=unlink_exception) as unlink_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "stage": None, + "state": final_state, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == response_status + assert open_mock.call_count == open_call_count + assert open_mock.return_value.read.call_count == read_call_count + assert open_mock.return_value.close.call_count == close_call_count + assert unlink_mock.call_count == unlink_call_count + + +@pytest.mark.usefixtures("path_glob") +@pytest.mark.parametrize( + ("agent_id", "password_param", "restore_database", "restore_homeassistant", "dir"), + [ + (LOCAL_AGENT_ID, {}, True, False, "backups"), + (LOCAL_AGENT_ID, {"password": "abc123"}, False, True, "backups"), + ("test.remote", {}, True, True, "tmp_backups"), + ], +) +async def test_restore_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + agent_id: str, + password_param: dict[str, str], + restore_database: bool, + restore_homeassistant: bool, + dir: str, +) -> None: + """Test restore backup.""" + password = password_param.get("password") + remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.open"), + patch("pathlib.Path.write_text") as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch( + "homeassistant.components.backup.manager.validate_password" + ) as validate_password_mock, + patch.object(remote_agent, "async_download_backup") as download_mock, + patch( + "homeassistant.components.backup.backup.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": agent_id, + "restore_database": restore_database, + "restore_homeassistant": restore_homeassistant, + } + | password_param + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.CORE_RESTART, + } + + # Note: The core restart is not tested here, in reality the following events + # are not sent because the core restart closes the WS connection. + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + expected_restore_file = json.dumps( + { + "path": backup_path, + "password": password, + "remove_after_restore": agent_id != LOCAL_AGENT_ID, + "restore_database": restore_database, + "restore_homeassistant": restore_homeassistant, + } + ) + validate_password_mock.assert_called_once_with(Path(backup_path), password) + assert mocked_write_text.call_args[0][0] == expected_restore_file + assert mocked_service_call.called + + +@pytest.mark.usefixtures("path_glob") +@pytest.mark.parametrize( + ("agent_id", "dir"), [(LOCAL_AGENT_ID, "backups"), ("test.remote", "tmp_backups")] +) +async def test_restore_backup_wrong_password( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + agent_id: str, + dir: str, +) -> None: + """Test restore backup wrong password.""" + password = "hunter2" + remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.open"), + patch("pathlib.Path.write_text") as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch( + "homeassistant.components.backup.manager.validate_password" + ) as validate_password_mock, + patch.object(remote_agent, "async_download_backup") as download_mock, + patch( + "homeassistant.components.backup.backup.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) + validate_password_mock.return_value = False + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": agent_id, + "password": password, + } + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == "password_incorrect" + + backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + validate_password_mock.assert_called_once_with(Path(backup_path), password) + mocked_write_text.assert_not_called() + mocked_service_call.assert_not_called() + + +@pytest.mark.usefixtures("path_glob") +@pytest.mark.parametrize( + ("parameters", "expected_error"), + [ + ( + {"backup_id": TEST_BACKUP_DEF456.backup_id}, + f"Backup def456 not found in agent {LOCAL_AGENT_ID}", + ), + ( + {"restore_addons": ["blah"]}, + "Addons and folders are not supported in core restore", + ), + ( + {"restore_folders": [Folder.ADDONS]}, + "Addons and folders are not supported in core restore", + ), + ( + {"restore_database": False, "restore_homeassistant": False}, + "Home Assistant or database must be included in restore", + ), + ], +) +async def test_restore_backup_wrong_parameters( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + parameters: dict[str, Any], + expected_error: str, +) -> None: + """Test restore backup wrong parameters.""" + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True with ( patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.write_text") as mocked_write_text, patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch( + "homeassistant.components.backup.backup.read_backup", + return_value=TEST_BACKUP_ABC123, + ), ): - await manager.async_restore_backup(TEST_BACKUP.slug) - assert mocked_write_text.call_args[0][0] == '{"path": "abc123.tar"}' - assert mocked_service_call.called + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": LOCAL_AGENT_ID, + } + | parameters + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } -async def test_async_trigger_restore_missing_backup(hass: HomeAssistant) -> None: - """Test trigger restore.""" - manager = BackupManager(hass) - manager.loaded_backups = True + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.FAILED, + } - with pytest.raises(HomeAssistantError, match="Backup abc123 not found"): - await manager.async_restore_backup(TEST_BACKUP.slug) + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == expected_error + + mocked_write_text.assert_not_called() + mocked_service_call.assert_not_called() diff --git a/tests/components/backup/test_models.py b/tests/components/backup/test_models.py new file mode 100644 index 00000000000..6a547f40dc3 --- /dev/null +++ b/tests/components/backup/test_models.py @@ -0,0 +1,11 @@ +"""Tests for the Backup integration.""" + +from homeassistant.components.backup import AgentBackup + +from .common import TEST_BACKUP_ABC123 + + +async def test_agent_backup_serialization() -> None: + """Test AgentBackup serialization.""" + + assert AgentBackup.from_dict(TEST_BACKUP_ABC123.as_dict()) == TEST_BACKUP_ABC123 diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py new file mode 100644 index 00000000000..cc84b66340c --- /dev/null +++ b/tests/components/backup/test_store.py @@ -0,0 +1,123 @@ +"""Tests for the Backup integration.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.backup.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .common import setup_backup_integration + +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def mock_delay_save() -> Generator[None]: + """Mock the delay save constant.""" + with patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0): + yield + + +@pytest.mark.parametrize( + "store_data", + [ + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "state": "never", + }, + }, + }, + "key": DOMAIN, + "version": 1, + }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "time": None, + }, + "something_from_the_future": "value", + }, + }, + "key": DOMAIN, + "version": 2, + }, + ], +) +async def test_store_migration( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + store_data: dict[str, Any], +) -> None: + """Test migrating the backup store.""" + hass_storage[DOMAIN] = store_data + await setup_backup_integration(hass) + await hass.async_block_till_done() + + # Check migrated data + assert hass_storage[DOMAIN] == snapshot + + # Update settings, then check saved data + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + } + ) + result = await client.receive_json() + assert result["success"] + await hass.async_block_till_done() + assert hass_storage[DOMAIN] == snapshot diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py new file mode 100644 index 00000000000..60cfc77b1aa --- /dev/null +++ b/tests/components/backup/test_util.py @@ -0,0 +1,132 @@ +"""Tests for the Backup integration's utility functions.""" + +from __future__ import annotations + +import tarfile +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.backup import AddonInfo, AgentBackup, Folder +from homeassistant.components.backup.util import read_backup, validate_password + + +@pytest.mark.parametrize( + ("backup_json_content", "expected_backup"), + [ + ( + b'{"compressed":true,"date":"2024-12-02T07:23:58.261875-05:00","homeassistant":' + b'{"exclude_database":true,"version":"2024.12.0.dev0"},"name":"test",' + b'"protected":true,"slug":"455645fe","type":"partial","version":2}', + AgentBackup( + addons=[], + backup_id="455645fe", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=1234, + ), + ), + ( + b'{"slug":"d4b8fdc6","version":2,"name":"Core 2025.1.0.dev0",' + b'"date":"2024-12-20T11:27:51.119062+00:00","type":"partial",' + b'"supervisor_version":"2024.12.1.dev1803",' + b'"extra":{"instance_id":"6b453733d2d74d2a9ae432ff2fbaaa64",' + b'"with_automatic_settings":false},"homeassistant":' + b'{"version":"2025.1.0.dev202412200230","exclude_database":false,"size":0.0},' + b'"compressed":true,"protected":true,"repositories":[' + b'"https://github.com/home-assistant/hassio-addons-development","local",' + b'"https://github.com/esphome/home-assistant-addon","core",' + b'"https://github.com/music-assistant/home-assistant-addon",' + b'"https://github.com/hassio-addons/repository"],"crypto":"aes128",' + b'"folders":["share","media"],"addons":[{"slug":"core_configurator",' + b'"name":"File editor","version":"5.5.0","size":0.0},' + b'{"slug":"ae6e943c_remote_api","name":"Remote API proxy",' + b'"version":"1.3.0","size":0.0}],"docker":{"registries":{}}}', + AgentBackup( + addons=[ + AddonInfo( + name="File editor", + slug="core_configurator", + version="5.5.0", + ), + AddonInfo( + name="Remote API proxy", + slug="ae6e943c_remote_api", + version="1.3.0", + ), + ], + backup_id="d4b8fdc6", + date="2024-12-20T11:27:51.119062+00:00", + database_included=True, + extra_metadata={ + "instance_id": "6b453733d2d74d2a9ae432ff2fbaaa64", + "with_automatic_settings": False, + }, + folders=[Folder.SHARE, Folder.MEDIA], + homeassistant_included=True, + homeassistant_version="2025.1.0.dev202412200230", + name="Core 2025.1.0.dev0", + protected=True, + size=1234, + ), + ), + ], +) +def test_read_backup(backup_json_content: bytes, expected_backup: AgentBackup) -> None: + """Test reading a backup.""" + mock_path = Mock() + mock_path.stat.return_value.st_size = 1234 + + with patch("homeassistant.components.backup.util.tarfile.open") as mock_open_tar: + mock_open_tar.return_value.__enter__.return_value.extractfile.return_value.read.return_value = backup_json_content + backup = read_backup(mock_path) + assert backup == expected_backup + + +@pytest.mark.parametrize("password", [None, "hunter2"]) +def test_validate_password(password: str | None) -> None: + """Test validating a password.""" + mock_path = Mock() + + with ( + patch("homeassistant.components.backup.util.tarfile.open"), + patch("homeassistant.components.backup.util.SecureTarFile"), + ): + assert validate_password(mock_path, password) is True + + +@pytest.mark.parametrize("password", [None, "hunter2"]) +@pytest.mark.parametrize("secure_tar_side_effect", [tarfile.ReadError, Exception]) +def test_validate_password_wrong_password( + password: str | None, secure_tar_side_effect: Exception +) -> None: + """Test validating a password.""" + mock_path = Mock() + + with ( + patch("homeassistant.components.backup.util.tarfile.open"), + patch( + "homeassistant.components.backup.util.SecureTarFile", + ) as mock_secure_tar, + ): + mock_secure_tar.return_value.__enter__.side_effect = secure_tar_side_effect + assert validate_password(mock_path, password) is False + + +def test_validate_password_no_homeassistant() -> None: + """Test validating a password.""" + mock_path = Mock() + + with ( + patch("homeassistant.components.backup.util.tarfile.open") as mock_open_tar, + ): + mock_open_tar.return_value.__enter__.return_value.extractfile.side_effect = ( + KeyError + ) + assert validate_password(mock_path, "hunter2") is False diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 125ba8adaad..52c04474162 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1,18 +1,81 @@ """Tests for the Backup integration.""" -from unittest.mock import patch +from collections.abc import Generator +from typing import Any +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup.manager import Backup +from homeassistant.components.backup import ( + AgentBackup, + BackupAgentError, + BackupAgentPlatformProtocol, + BackupReaderWriterError, + Folder, + store, +) +from homeassistant.components.backup.agent import BackupAgentUnreachableError +from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN +from homeassistant.components.backup.manager import ( + CreateBackupEvent, + CreateBackupState, + ManagerBackup, + NewBackup, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component -from .common import TEST_BACKUP, setup_backup_integration +from .common import ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123, + TEST_BACKUP_DEF456, + BackupAgentTest, + setup_backup_integration, + setup_backup_platform, +) +from tests.common import async_fire_time_changed, async_mock_service from tests.typing import WebSocketGenerator +BACKUP_CALL = call( + agent_ids=["test.test-agent"], + backup_name="test-name", + extra_metadata={"instance_id": ANY, "with_automatic_settings": True}, + include_addons=["test-addon"], + include_all_addons=False, + include_database=True, + include_folders=["media"], + include_homeassistant=True, + password="test-password", + on_progress=ANY, +) + +DEFAULT_STORAGE_DATA: dict[str, Any] = { + "backups": [], + "config": { + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": {"days": [], "recurrence": "never", "state": "never", "time": None}, + }, +} +DAILY = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] + @pytest.fixture def sync_access_token_proxy( @@ -26,145 +89,674 @@ def sync_access_token_proxy( return request.getfixturevalue(access_token_fixture_name) +@pytest.fixture(autouse=True) +def mock_delay_save() -> Generator[None]: + """Mock the delay save constant.""" + with patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0): + yield + + +@pytest.fixture(name="delete_backup") +def mock_delete_backup() -> Generator[AsyncMock]: + """Mock manager delete backup.""" + with patch( + "homeassistant.components.backup.BackupManager.async_delete_backup" + ) as mock_delete_backup: + yield mock_delete_backup + + +@pytest.fixture(name="get_backups") +def mock_get_backups() -> Generator[AsyncMock]: + """Mock manager get backups.""" + with patch( + "homeassistant.components.backup.BackupManager.async_get_backups" + ) as mock_get_backups: + yield mock_get_backups + + @pytest.mark.parametrize( - "with_hassio", + ("remote_agents", "remote_backups"), [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), + ([], {}), + (["remote"], {}), + (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), ], ) async def test_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + remote_agents: list[str], + remote_backups: dict[str, list[AgentBackup]], snapshot: SnapshotAssertion, - with_hassio: bool, ) -> None: """Test getting backup info.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + await setup_backup_integration( + hass, + with_hassio=False, + backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} | remote_backups, + remote_agents=remote_agents, + ) client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backups", - return_value={TEST_BACKUP.slug: TEST_BACKUP}, - ): - await client.send_json_auto_id({"type": "backup/info"}) - assert snapshot == await client.receive_json() + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot @pytest.mark.parametrize( - "backup_content", - [ - pytest.param(TEST_BACKUP, id="with_backup_content"), - pytest.param(None, id="without_backup_content"), - ], + "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError] ) +async def test_info_with_errors( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + side_effect: Exception, + snapshot: SnapshotAssertion, +) -> None: + """Test getting backup info with one unavailable agent.""" + await setup_backup_integration( + hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + ) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch.object(BackupAgentTest, "async_list_backups", side_effect=side_effect): + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot + + @pytest.mark.parametrize( - "with_hassio", + ("remote_agents", "backups"), [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), + ([], {}), + (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), + (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + ( + ["remote"], + { + LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], + "test.remote": [TEST_BACKUP_ABC123], + }, + ), ], ) async def test_details( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + remote_agents: list[str], + backups: dict[str, list[AgentBackup]], snapshot: SnapshotAssertion, - with_hassio: bool, - backup_content: Backup | None, ) -> None: """Test getting backup info.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + await setup_backup_integration( + hass, with_hassio=False, backups=backups, remote_agents=remote_agents + ) client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - return_value=backup_content, - ): - await client.send_json_auto_id({"type": "backup/details", "slug": "abc123"}) + with patch("pathlib.Path.exists", return_value=True): + await client.send_json_auto_id( + {"type": "backup/details", "backup_id": "abc123"} + ) assert await client.receive_json() == snapshot @pytest.mark.parametrize( - "with_hassio", - [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), - ], + "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError] ) -async def test_remove( +async def test_details_with_errors( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + side_effect: Exception, snapshot: SnapshotAssertion, - with_hassio: bool, ) -> None: - """Test removing a backup file.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + """Test getting backup info with one unavailable agent.""" + await setup_backup_integration( + hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + ) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_remove_backup", + with ( + patch("pathlib.Path.exists", return_value=True), + patch.object(BackupAgentTest, "async_get_backup", side_effect=side_effect), ): - await client.send_json_auto_id({"type": "backup/remove", "slug": "abc123"}) - assert snapshot == await client.receive_json() + await client.send_json_auto_id( + {"type": "backup/details", "backup_id": "abc123"} + ) + assert await client.receive_json() == snapshot @pytest.mark.parametrize( - "with_hassio", + ("remote_agents", "backups"), [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), + ([], {}), + (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), + (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + ( + ["remote"], + { + LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], + "test.remote": [TEST_BACKUP_ABC123], + }, + ), ], ) +async def test_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + remote_agents: list[str], + backups: dict[str, list[AgentBackup]], + snapshot: SnapshotAssertion, +) -> None: + """Test deleting a backup file.""" + await setup_backup_integration( + hass, with_hassio=False, backups=backups, remote_agents=remote_agents + ) + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot + + await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"}) + assert await client.receive_json() == snapshot + + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + "storage_data", + [ + DEFAULT_STORAGE_DATA, + DEFAULT_STORAGE_DATA + | { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ] + }, + ], +) +@pytest.mark.parametrize( + "side_effect", [None, HomeAssistantError("Boom!"), BackupAgentUnreachableError] +) +async def test_delete_with_errors( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + side_effect: Exception, + storage_data: dict[str, Any] | None, + snapshot: SnapshotAssertion, +) -> None: + """Test deleting a backup with one unavailable agent.""" + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + } + await setup_backup_integration( + hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + ) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch.object(BackupAgentTest, "async_delete_backup", side_effect=side_effect): + await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"}) + assert await client.receive_json() == snapshot + + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot + + +async def test_agent_delete_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test deleting a backup file with a mock agent.""" + await setup_backup_integration(hass) + hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")} + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch.object(BackupAgentTest, "async_delete_backup") as delete_mock: + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "abc123", + } + ) + assert await client.receive_json() == snapshot + + assert delete_mock.call_args == call("abc123") + + +@pytest.mark.parametrize( + "data", + [ + None, + {}, + {"password": "abc123"}, + ], +) +@pytest.mark.usefixtures("mock_backup_generation") async def test_generate( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + data: dict[str, Any] | None, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, - with_hassio: bool, ) -> None: """Test generating a backup.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + await setup_backup_integration(hass, with_hassio=False) client = await hass_ws_client(hass) + freezer.move_to("2024-11-13 12:01:00+01:00") await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_create_backup", - return_value=TEST_BACKUP, - ): - await client.send_json_auto_id({"type": "backup/generate"}) - assert snapshot == await client.receive_json() + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + assert await client.receive_json() == snapshot + await client.send_json_auto_id( + {"type": "backup/generate", **{"agent_ids": ["backup.local"]} | (data or {})} + ) + for _ in range(6): + assert await client.receive_json() == snapshot @pytest.mark.parametrize( - "with_hassio", + ("parameters", "expected_error"), [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), + ( + {"include_homeassistant": False}, + "Home Assistant must be included in backup", + ), + ( + {"include_addons": ["blah"]}, + "Addons and folders are not supported by core backup", + ), + ( + {"include_all_addons": True}, + "Addons and folders are not supported by core backup", + ), + ( + {"include_folders": ["ssl"]}, + "Addons and folders are not supported by core backup", + ), ], ) -async def test_restore( +async def test_generate_wrong_parameters( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + parameters: dict[str, Any], + expected_error: str, +) -> None: + """Test generating a backup.""" + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + + default_parameters = {"type": "backup/generate", "agent_ids": ["backup.local"]} + + await client.send_json_auto_id(default_parameters | parameters) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "home_assistant_error", + "message": expected_error, + } + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("params", "expected_extra_call_params"), + [ + ({"agent_ids": ["backup.local"]}, {"agent_ids": ["backup.local"]}), + ( + { + "agent_ids": ["backup.local"], + "include_database": False, + "name": "abc123", + }, + { + "agent_ids": ["backup.local"], + "include_addons": None, + "include_database": False, + "include_folders": None, + "name": "abc123", + }, + ), + ], +) +async def test_generate_calls_create( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + params: dict[str, Any], + expected_extra_call_params: dict[str, Any], +) -> None: + """Test translation of WS parameter to backup/generate to async_initiate_backup.""" + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + freezer.move_to("2024-11-13 12:01:00+01:00") + await hass.async_block_till_done() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_initiate_backup", + return_value=NewBackup(backup_job_id="abc123"), + ) as generate_backup: + await client.send_json_auto_id({"type": "backup/generate"} | params) + result = await client.receive_json() + assert result["success"] + assert result["result"] == {"backup_job_id": "abc123"} + generate_backup.assert_called_once_with( + **{ + "include_all_addons": False, + "include_homeassistant": True, + "include_addons": None, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + } + | expected_extra_call_params + ) + + +@pytest.mark.parametrize( + ( + "create_backup_settings", + "expected_call_params", + "side_effect", + "last_completed_automatic_backup", + ), + [ + ( + { + "agent_ids": ["test.remote"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + { + "agent_ids": ["test.remote"], + "backup_name": ANY, + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + None, + "2024-11-13T12:01:01+01:00", + ), + ( + { + "agent_ids": ["test.remote"], + "include_addons": ["test-addon"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + { + "agent_ids": ["test.remote"], + "backup_name": "test-name", + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, + "include_addons": ["test-addon"], + "include_all_addons": False, + "include_database": True, + "include_folders": [Folder.MEDIA], + "include_homeassistant": True, + "on_progress": ANY, + "password": "test-password", + }, + None, + "2024-11-13T12:01:01+01:00", + ), + ( + { + "agent_ids": ["test.remote"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + { + "agent_ids": ["test.remote"], + "backup_name": ANY, + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + BackupAgentError("Boom!"), + None, + ), + ], +) +async def test_generate_with_default_settings_calls_create( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, + create_backup: AsyncMock, + create_backup_settings: dict[str, Any], + expected_call_params: dict[str, Any], + side_effect: Exception | None, + last_completed_automatic_backup: str, +) -> None: + """Test backup/generate_with_automatic_settings calls async_initiate_backup.""" + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + remote_agent = BackupAgentTest("remote", backups=[]) + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + await client.send_json_auto_id( + {"type": "backup/config/update", "create_backup": create_backup_settings} + ) + result = await client.receive_json() + assert result["success"] + + freezer.tick() + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass_storage[DOMAIN]["data"]["config"]["create_backup"] + == create_backup_settings + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"] + is None + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"] + is None + ) + + with patch.object(remote_agent, "async_upload_backup", side_effect=side_effect): + await client.send_json_auto_id( + {"type": "backup/generate_with_automatic_settings"} + ) + result = await client.receive_json() + assert result["success"] + assert result["result"] == {"backup_job_id": "abc123"} + + await hass.async_block_till_done() + + create_backup.assert_called_once_with(**expected_call_params) + + freezer.tick() + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"] + == "2024-11-13T12:01:01+01:00" + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"] + == last_completed_automatic_backup + ) + + +@pytest.mark.parametrize( + "backups", + [ + {}, + {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}, + ], +) +async def test_restore_local_agent( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + backups: dict[str, list[AgentBackup]], snapshot: SnapshotAssertion, - with_hassio: bool, ) -> None: """Test calling the restore command.""" - await setup_backup_integration(hass, with_hassio=with_hassio) + await setup_backup_integration(hass, with_hassio=False, backups=backups) + restart_calls = async_mock_service(hass, "homeassistant", "restart") client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.write_text"), + patch("homeassistant.components.backup.manager.validate_password"), ): - await client.send_json_auto_id({"type": "backup/restore", "slug": "abc123"}) + await client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": "abc123", + "agent_id": "backup.local", + } + ) assert await client.receive_json() == snapshot + assert len(restart_calls) == snapshot + + +@pytest.mark.parametrize( + ("remote_agents", "backups"), + [ + (["remote"], {}), + (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + ], +) +async def test_restore_remote_agent( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + remote_agents: list[str], + backups: dict[str, list[AgentBackup]], + snapshot: SnapshotAssertion, +) -> None: + """Test calling the restore command.""" + await setup_backup_integration( + hass, with_hassio=False, backups=backups, remote_agents=remote_agents + ) + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with ( + patch("pathlib.Path.write_text"), + patch("pathlib.Path.open"), + patch("homeassistant.components.backup.manager.validate_password"), + ): + await client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": "abc123", + "agent_id": "test.remote", + } + ) + assert await client.receive_json() == snapshot + assert len(restart_calls) == snapshot + + +async def test_restore_wrong_password( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test calling the restore command.""" + await setup_backup_integration( + hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + ) + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.write_text"), + patch( + "homeassistant.components.backup.manager.validate_password", + return_value=False, + ), + ): + await client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": "abc123", + "agent_id": "backup.local", + } + ) + assert await client.receive_json() == snapshot + assert len(restart_calls) == 0 @pytest.mark.parametrize( @@ -178,6 +770,7 @@ async def test_restore( pytest.param(False, id="without_hassio"), ], ) +@pytest.mark.usefixtures("supervisor_client") async def test_backup_end( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -197,7 +790,7 @@ async def test_backup_end( "homeassistant.components.backup.manager.BackupManager.async_post_backup_actions", ): await client.send_json_auto_id({"type": "backup/end"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -211,6 +804,7 @@ async def test_backup_end( pytest.param(False, id="without_hassio"), ], ) +@pytest.mark.usefixtures("supervisor_client") async def test_backup_start( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -230,7 +824,7 @@ async def test_backup_start( "homeassistant.components.backup.manager.BackupManager.async_pre_backup_actions", ): await client.send_json_auto_id({"type": "backup/start"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -241,7 +835,8 @@ async def test_backup_start( Exception("Boom"), ], ) -async def test_backup_end_excepion( +@pytest.mark.usefixtures("supervisor_client") +async def test_backup_end_exception( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, @@ -259,7 +854,7 @@ async def test_backup_end_excepion( side_effect=exception, ): await client.send_json_auto_id({"type": "backup/end"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot @pytest.mark.parametrize( @@ -270,7 +865,8 @@ async def test_backup_end_excepion( Exception("Boom"), ], ) -async def test_backup_start_excepion( +@pytest.mark.usefixtures("supervisor_client") +async def test_backup_start_exception( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, @@ -288,4 +884,1976 @@ async def test_backup_start_excepion( side_effect=exception, ): await client.send_json_auto_id({"type": "backup/start"}) - assert snapshot == await client.receive_json() + assert await client.receive_json() == snapshot + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test getting backup agents info.""" + await setup_backup_integration(hass, with_hassio=False) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + assert await client.receive_json() == snapshot + + +@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.parametrize( + "storage_data", + [ + {}, + { + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": True, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": 3, "days": 7}, + "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00", + "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", + "schedule": { + "days": DAILY, + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": 3, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": 7}, + "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00", + "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon", "sun"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + ], +) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) +async def test_config_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + hass_storage: dict[str, Any], + storage_data: dict[str, Any] | None, +) -> None: + """Test getting backup config info.""" + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + + hass_storage.update(storage_data) + + await setup_backup_integration(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + + +@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.parametrize( + "command", + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 7}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"recurrence": "daily", "time": "06:00"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"recurrence": "never"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"}, + }, + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "schedule": {"recurrence": "daily"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3, "days": 7}, + "schedule": {"recurrence": "daily"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 7}, + "schedule": {"recurrence": "daily"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3}, + "schedule": {"recurrence": "daily"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"days": 7}, + "schedule": {"recurrence": "daily"}, + }, + ], +) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) +async def test_config_update( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + command: dict[str, Any], + hass_storage: dict[str, Any], +) -> None: + """Test updating the backup config.""" + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + + await setup_backup_integration(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + + await client.send_json_auto_id(command) + result = await client.receive_json() + + assert result["success"] + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + await hass.async_block_till_done() + + # Trigger store write + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass_storage[DOMAIN] == snapshot + + +@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.parametrize( + "command", + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": "blah", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": "never", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": {"state": "someday"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": {"time": "early"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": {"days": "mon"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": {"days": ["fun"]}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent", "test-agent"]}, + }, + { + "type": "backup/config/update", + "create_backup": {"include_addons": ["my-addon", "my-addon"]}, + }, + { + "type": "backup/config/update", + "create_backup": {"include_folders": ["media", "media"]}, + }, + ], +) +async def test_config_update_errors( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + command: dict[str, Any], +) -> None: + """Test errors when updating the backup config.""" + await setup_backup_integration(hass) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + + await client.send_json_auto_id(command) + result = await client.receive_json() + + assert not result["success"] + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ( + "commands", + "last_completed_automatic_backup", + "time_1", + "time_2", + "attempted_backup_time", + "completed_backup_time", + "scheduled_backup_time", + "additional_backup", + "backup_calls_1", + "backup_calls_2", + "call_args", + "create_backup_side_effect", + ), + [ + ( + # No config update + [], + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + None, + ), + ( + # Unchanged schedule + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "daily"}, + } + ], + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, + } + ], + "2024-11-11T04:45:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-25T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": { + "days": ["mon"], + "recurrence": "custom_days", + "time": "03:45", + }, + } + ], + "2024-11-11T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-25T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "daily", "time": "03:45"}, + } + ], + "2024-11-11T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-13T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"days": ["wed", "fri"], "recurrence": "custom_days"}, + } + ], + "2024-11-11T04:45:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-15T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "never"}, + } + ], + "2024-11-11T04:45:00+01:00", + "2034-11-11T12:00:00+01:00", # ten years later and still no backups + "2034-11-11T13:00:00+01:00", + "2024-11-11T04:45:00+01:00", + "2024-11-11T04:45:00+01:00", + None, + False, + 0, + 0, + None, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"days": [], "recurrence": "custom_days"}, + } + ], + "2024-11-11T04:45:00+01:00", + "2034-11-11T12:00:00+01:00", # ten years later and still no backups + "2034-11-11T13:00:00+01:00", + "2024-11-11T04:45:00+01:00", + "2024-11-11T04:45:00+01:00", + None, + False, + 0, + 0, + None, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "daily"}, + } + ], + "2024-10-26T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, + } + ], + "2024-10-26T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", + True, + 1, + 1, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "never"}, + } + ], + "2024-10-26T04:45:00+01:00", + "2034-11-11T12:00:00+01:00", # ten years later and still no backups + "2034-11-12T12:00:00+01:00", + "2024-10-26T04:45:00+01:00", + "2024-10-26T04:45:00+01:00", + None, + False, + 0, + 0, + None, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "daily"}, + } + ], + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # attempted to create backup but failed + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + [BackupReaderWriterError("Boom"), None], + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "daily"}, + } + ], + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # attempted to create backup but failed + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + [Exception("Boom"), None], # unknown error + ), + ], +) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) +async def test_config_schedule_logic( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], + create_backup: AsyncMock, + commands: list[dict[str, Any]], + last_completed_automatic_backup: str, + time_1: str, + time_2: str, + attempted_backup_time: str, + completed_backup_time: str, + scheduled_backup_time: str, + additional_backup: bool, + backup_calls_1: int, + backup_calls_2: int, + call_args: Any, + create_backup_side_effect: list[Exception | None] | None, +) -> None: + """Test config schedule logic.""" + client = await hass_ws_client(hass) + storage_data = { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test.test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": last_completed_automatic_backup, + "last_completed_automatic_backup": last_completed_automatic_backup, + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": None, + }, + }, + } + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + } + create_backup.side_effect = create_backup_side_effect + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-11 12:00:00+01:00") + + await setup_backup_integration(hass, remote_agents=["test-agent"]) + await hass.async_block_till_done() + + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + await client.send_json_auto_id({"type": "backup/info"}) + result = await client.receive_json() + assert result["result"]["next_automatic_backup"] == scheduled_backup_time + assert result["result"]["next_automatic_backup_additional"] == additional_backup + + freezer.move_to(time_1) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert create_backup.call_count == backup_calls_1 + assert create_backup.call_args == call_args + async_fire_time_changed(hass, fire_all=True) # flush out storage save + await hass.async_block_till_done() + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"] + == attempted_backup_time + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"] + == completed_backup_time + ) + + freezer.move_to(time_2) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert create_backup.call_count == backup_calls_2 + assert create_backup.call_args == call_args + + +@pytest.mark.parametrize( + ( + "command", + "backups", + "get_backups_agent_errors", + "delete_backup_agent_errors", + "last_backup_time", + "next_time", + "backup_time", + "backup_calls", + "get_backups_calls", + "delete_calls", + "delete_args_list", + ), + [ + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, # we get backups even if backup retention copies is None + 0, + [], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 0, + [], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 0, + [], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 1, + [call("backup-1")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 2, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 2, + [call("backup-1"), call("backup-2")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 2, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {"test-agent": BackupAgentError("Boom!")}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 1, + [call("backup-1")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 2, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {"test-agent": BackupAgentError("Boom!")}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 1, + [call("backup-1")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 0, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 3, + [call("backup-1"), call("backup-2"), call("backup-3")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 0, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 0, + [], + ), + ], +) +@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) +async def test_config_retention_copies_logic( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], + create_backup: AsyncMock, + delete_backup: AsyncMock, + get_backups: AsyncMock, + command: dict[str, Any], + backups: dict[str, Any], + get_backups_agent_errors: dict[str, Exception], + delete_backup_agent_errors: dict[str, Exception], + last_backup_time: str, + next_time: str, + backup_time: str, + backup_calls: int, + get_backups_calls: int, + delete_calls: int, + delete_args_list: Any, +) -> None: + """Test config backup retention copies logic.""" + client = await hass_ws_client(hass) + storage_data = { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": last_backup_time, + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": None, + }, + }, + } + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + } + get_backups.return_value = (backups, get_backups_agent_errors) + delete_backup.return_value = delete_backup_agent_errors + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-11 12:00:00+01:00") + + await setup_backup_integration(hass, remote_agents=["test-agent"]) + await hass.async_block_till_done() + + await client.send_json_auto_id(command) + result = await client.receive_json() + + assert result["success"] + + freezer.move_to(next_time) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert create_backup.call_count == backup_calls + assert get_backups.call_count == get_backups_calls + assert delete_backup.call_count == delete_calls + assert delete_backup.call_args_list == delete_args_list + async_fire_time_changed(hass, fire_all=True) # flush out storage save + await hass.async_block_till_done() + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"] + == backup_time + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"] + == backup_time + ) + + +@pytest.mark.parametrize( + ("backup_command", "backup_time"), + [ + ( + {"type": "backup/generate_with_automatic_settings"}, + "2024-11-11T12:00:00+01:00", + ), + ( + {"type": "backup/generate", "agent_ids": ["test.test-agent"]}, + None, + ), + ], +) +@pytest.mark.parametrize( + ( + "config_command", + "backups", + "get_backups_agent_errors", + "delete_backup_agent_errors", + "backup_calls", + "get_backups_calls", + "delete_calls", + "delete_args_list", + ), + [ + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "never"}, + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + 1, + 1, # we get backups even if backup retention copies is None + 0, + [], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "never"}, + }, + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + 1, + 1, + 0, + [], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "never"}, + }, + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + 1, + 1, + 1, + [call("backup-1")], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 2, "days": None}, + "schedule": {"recurrence": "never"}, + }, + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + 1, + 1, + 2, + [call("backup-1"), call("backup-2")], + ), + ], +) +async def test_config_retention_copies_logic_manual_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], + create_backup: AsyncMock, + delete_backup: AsyncMock, + get_backups: AsyncMock, + config_command: dict[str, Any], + backup_command: dict[str, Any], + backups: dict[str, Any], + get_backups_agent_errors: dict[str, Exception], + delete_backup_agent_errors: dict[str, Exception], + backup_time: str, + backup_calls: int, + get_backups_calls: int, + delete_calls: int, + delete_args_list: Any, +) -> None: + """Test config backup retention copies logic for manual backup.""" + client = await hass_ws_client(hass) + storage_data = { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": None, + }, + }, + } + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + } + get_backups.return_value = (backups, get_backups_agent_errors) + delete_backup.return_value = delete_backup_agent_errors + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-11 12:00:00+01:00") + + await setup_backup_integration(hass, remote_agents=["test-agent"]) + await hass.async_block_till_done() + + await client.send_json_auto_id(config_command) + result = await client.receive_json() + assert result["success"] + + # Create a manual backup + await client.send_json_auto_id(backup_command) + result = await client.receive_json() + assert result["success"] + + # Wait for backup creation to complete + await hass.async_block_till_done() + + assert create_backup.call_count == backup_calls + assert get_backups.call_count == get_backups_calls + assert delete_backup.call_count == delete_calls + assert delete_backup.call_args_list == delete_args_list + async_fire_time_changed(hass, fire_all=True) # flush out storage save + await hass.async_block_till_done() + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_attempted_automatic_backup"] + == backup_time + ) + assert ( + hass_storage[DOMAIN]["data"]["config"]["last_completed_automatic_backup"] + == backup_time + ) + + +@pytest.mark.parametrize( + ( + "stored_retained_days", + "commands", + "backups", + "get_backups_agent_errors", + "delete_backup_agent_errors", + "last_backup_time", + "start_time", + "next_time", + "get_backups_calls", + "delete_calls", + "delete_args_list", + ), + [ + # No config update - cleanup backups older than 2 days + ( + 2, + [], + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 1, + [call("backup-1")], + ), + # No config update - No cleanup + ( + None, + [], + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 0, + 0, + [], + ), + # Unchanged config + ( + 2, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 1, + [call("backup-1")], + ), + ( + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 1, + [call("backup-1")], + ), + ( + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 3}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 0, + [], + ), + ( + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 2, + [call("backup-1"), call("backup-2")], + ), + ( + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {"test-agent": BackupAgentError("Boom!")}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 1, + [call("backup-1")], + ), + ( + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {"test-agent": BackupAgentError("Boom!")}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 1, + [call("backup-1")], + ), + ( + None, + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 0}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": MagicMock( + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + 2, + [call("backup-1"), call("backup-2")], + ), + ], +) +async def test_config_retention_days_logic( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], + delete_backup: AsyncMock, + get_backups: AsyncMock, + stored_retained_days: int | None, + commands: list[dict[str, Any]], + backups: dict[str, Any], + get_backups_agent_errors: dict[str, Exception], + delete_backup_agent_errors: dict[str, Exception], + last_backup_time: str, + start_time: str, + next_time: str, + get_backups_calls: int, + delete_calls: int, + delete_args_list: list[Any], +) -> None: + """Test config backup retention logic.""" + client = await hass_ws_client(hass) + storage_data = { + "backups": [], + "config": { + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": None, "days": stored_retained_days}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": last_backup_time, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + } + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + } + get_backups.return_value = (backups, get_backups_agent_errors) + delete_backup.return_value = delete_backup_agent_errors + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to(start_time) + + await setup_backup_integration(hass) + await hass.async_block_till_done() + + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + freezer.move_to(next_time) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert get_backups.call_count == get_backups_calls + assert delete_backup.call_count == delete_calls + assert delete_backup.call_args_list == delete_args_list + async_fire_time_changed(hass, fire_all=True) # flush out storage save + await hass.async_block_till_done() + + +async def test_subscribe_event( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test subscribe event.""" + await setup_backup_integration(hass, with_hassio=False) + + manager = hass.data[DATA_MANAGER] + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + assert await client.receive_json() == snapshot + assert await client.receive_json() == snapshot + + manager.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) + ) + assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + ("agent_id", "backup_id", "password"), + [ + # Invalid agent or backup + ("no_such_agent", "c0cb53bd", "hunter2"), + ("backup.local", "no_such_backup", "hunter2"), + # Legacy backup, which can't be streamed + ("backup.local", "2bcb3113", "hunter2"), + # New backup, which can be streamed, try with correct and wrong password + ("backup.local", "c0cb53bd", "hunter2"), + ("backup.local", "c0cb53bd", "wrong_password"), + ], +) +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + agent_id: str, + backup_id: str, + password: str, +) -> None: + """Test can decrypt on download.""" + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": backup_id, + "agent_id": agent_id, + "password": password, + } + ) + assert await client.receive_json() == snapshot diff --git a/tests/components/baf/test_config_flow.py b/tests/components/baf/test_config_flow.py index 765801d22cf..14810f67ce3 100644 --- a/tests/components/baf/test_config_flow.py +++ b/tests/components/baf/test_config_flow.py @@ -4,11 +4,11 @@ from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.baf.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MOCK_NAME, MOCK_UUID, MockBAFDevice @@ -90,7 +90,7 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -128,7 +128,7 @@ async def test_zeroconf_updates_existing_ip(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -148,7 +148,7 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -167,7 +167,7 @@ async def test_user_flow_is_not_blocked_by_discovery(hass: HomeAssistant) -> Non discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index cbde856ff89..700d085dd11 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -56,7 +56,7 @@ from tests.common import MockConfigEntry @pytest.fixture def mock_config_entry() -> MockConfigEntry: - """Mock config entry.""" + """Mock config entry for Beosound Balance.""" return MockConfigEntry( domain=DOMAIN, unique_id=TEST_SERIAL_NUMBER, @@ -66,8 +66,8 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_config_entry_2() -> MockConfigEntry: - """Mock config entry.""" +def mock_config_entry_core() -> MockConfigEntry: + """Mock config entry for Beoconnect Core.""" return MockConfigEntry( domain=DOMAIN, unique_id=TEST_SERIAL_NUMBER_2, diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 6602a898eb6..c21afb4a130 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -31,28 +31,29 @@ from homeassistant.components.bang_olufsen.const import ( CONF_BEOLINK_JID, BangOlufsenSource, ) -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = "192.168.0.1" TEST_HOST_INVALID = "192.168.0" TEST_HOST_IPV6 = "1111:2222:3333:4444:5555:6666:7777:8888" TEST_MODEL_BALANCE = "Beosound Balance" +TEST_MODEL_CORE = "Beoconnect Core" TEST_MODEL_THEATRE = "Beosound Theatre" TEST_MODEL_LEVEL = "Beosound Level" TEST_SERIAL_NUMBER = "11111111" -TEST_SERIAL_NUMBER_2 = "22222222" TEST_NAME = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER}" -TEST_NAME_2 = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER_2}" TEST_FRIENDLY_NAME = "Living room Balance" TEST_TYPE_NUMBER = "1111" TEST_ITEM_NUMBER = "1111111" TEST_JID_1 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER}@products.bang-olufsen.com" TEST_MEDIA_PLAYER_ENTITY_ID = "media_player.beosound_balance_11111111" -TEST_FRIENDLY_NAME_2 = "Laundry room Balance" -TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.22222222@products.bang-olufsen.com" -TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beosound_balance_22222222" +TEST_FRIENDLY_NAME_2 = "Laundry room Core" +TEST_SERIAL_NUMBER_2 = "22222222" +TEST_NAME_2 = f"{TEST_MODEL_CORE}-{TEST_SERIAL_NUMBER_2}" +TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_2}@products.bang-olufsen.com" +TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beoconnect_core_22222222" TEST_HOST_2 = "192.168.0.2" TEST_FRIENDLY_NAME_3 = "Lego room Balance" @@ -65,6 +66,9 @@ TEST_JID_4 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.44444444@products.bang-oluf TEST_MEDIA_PLAYER_ENTITY_ID_4 = "media_player.beosound_balance_44444444" TEST_HOST_4 = "192.168.0.4" + +TEST_BUTTON_EVENT_ENTITY_ID = "event.beosound_balance_11111111_play_pause" + TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local." TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local." TEST_NAME_ZEROCONF = TEST_NAME.replace(" ", "-") + "." + TEST_TYPE_ZEROCONF @@ -80,8 +84,8 @@ TEST_DATA_CREATE_ENTRY = { CONF_NAME: TEST_NAME, } TEST_DATA_CREATE_ENTRY_2 = { - CONF_HOST: TEST_HOST, - CONF_MODEL: TEST_MODEL_BALANCE, + CONF_HOST: TEST_HOST_2, + CONF_MODEL: TEST_MODEL_CORE, CONF_BEOLINK_JID: TEST_JID_2, CONF_NAME: TEST_NAME_2, } diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e9540b5cec6 --- /dev/null +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_async_get_config_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '192.168.0.1', + 'jid': '1111.1111111.11111111@products.bang-olufsen.com', + 'model': 'Beosound Balance', + 'name': 'Beosound Balance-11111111', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'bang_olufsen', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Beosound Balance-11111111', + 'unique_id': '11111111', + 'version': 1, + }), + 'media_player': dict({ + 'attributes': dict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': 'music', + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': 2095933, + }), + 'entity_id': 'media_player.beosound_balance_11111111', + 'state': 'playing', + }), + 'websocket_connected': False, + }) +# --- diff --git a/tests/components/bang_olufsen/snapshots/test_event.ambr b/tests/components/bang_olufsen/snapshots/test_event.ambr new file mode 100644 index 00000000000..3b748d3a27a --- /dev/null +++ b/tests/components/bang_olufsen/snapshots/test_event.ambr @@ -0,0 +1,21 @@ +# serializer version: 1 +# name: test_button_event_creation + list([ + 'event.beosound_balance_11111111_bluetooth', + 'event.beosound_balance_11111111_microphone', + 'event.beosound_balance_11111111_next', + 'event.beosound_balance_11111111_play_pause', + 'event.beosound_balance_11111111_favourite_1', + 'event.beosound_balance_11111111_favourite_2', + 'event.beosound_balance_11111111_favourite_3', + 'event.beosound_balance_11111111_favourite_4', + 'event.beosound_balance_11111111_previous', + 'event.beosound_balance_11111111_volume', + 'media_player.beosound_balance_11111111', + ]) +# --- +# name: test_button_event_creation_beoconnect_core + list([ + 'media_player.beoconnect_core_22222222', + ]) +# --- diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index 36fcc72aa22..be7989a2cb9 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -243,7 +243,7 @@ 'state': 'playing', }) # --- -# name: test_async_beolink_join +# name: test_async_beolink_join[service_parameters0-method_parameters0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'beolink': dict({ @@ -291,6 +291,240 @@ 'state': 'playing', }) # --- +# name: test_async_beolink_join[service_parameters1-method_parameters1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_join[service_parameters2-method_parameters2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_join_invalid[service_parameters0-expected_result0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_join_invalid[service_parameters1-expected_result1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_join_invalid[service_parameters2-expected_result2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- # name: test_async_beolink_unexpand StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -408,7 +642,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -427,7 +661,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -503,7 +737,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -522,7 +756,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -597,7 +831,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -616,7 +850,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -690,7 +924,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -709,7 +943,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -769,7 +1003,7 @@ 'attributes': ReadOnlyDict({ 'beolink': dict({ 'leader': dict({ - 'Laundry room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + 'Laundry room Core': '1111.1111111.22222222@products.bang-olufsen.com', }), 'peers': dict({ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', @@ -783,7 +1017,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'media_player.beosound_balance_11111111', ]), 'media_content_type': , @@ -828,7 +1062,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -847,7 +1081,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py new file mode 100644 index 00000000000..7c99648ace4 --- /dev/null +++ b/tests/components/bang_olufsen/test_diagnostics.py @@ -0,0 +1,41 @@ +"""Test bang_olufsen config entry diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_async_get_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_mozart_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot( + exclude=props( + "created_at", + "entry_id", + "id", + "last_changed", + "last_reported", + "last_updated", + "media_position_updated_at", + "modified_at", + ) + ) diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py new file mode 100644 index 00000000000..855dab40db1 --- /dev/null +++ b/tests/components/bang_olufsen/test_event.py @@ -0,0 +1,108 @@ +"""Test the bang_olufsen event entities.""" + +from unittest.mock import AsyncMock + +from inflection import underscore +from mozart_api.models import ButtonEvent +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.bang_olufsen.const import ( + DEVICE_BUTTON_EVENTS, + DEVICE_BUTTONS, + EVENT_TRANSLATION_MAP, +) +from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from .const import TEST_BUTTON_EVENT_ENTITY_ID + +from tests.common import MockConfigEntry + + +async def test_button_event_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mozart_client: AsyncMock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test button event entities are created.""" + + # Load entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Add Button Event entity ids + entity_ids = [ + f"event.beosound_balance_11111111_{underscore(button_type)}".replace( + "preset", "favourite_" + ) + for button_type in DEVICE_BUTTONS + ] + + # Check that the entities are available + for entity_id in entity_ids: + assert entity_registry.async_get(entity_id) + + # Check number of entities + # The media_player entity and all of the button event entities should be the only available + entity_ids_available = list(entity_registry.entities.keys()) + assert len(entity_ids_available) == 1 + len(entity_ids) + + assert entity_ids_available == snapshot + + +async def test_button_event_creation_beoconnect_core( + hass: HomeAssistant, + mock_config_entry_core: MockConfigEntry, + mock_mozart_client: AsyncMock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test button event entities are not created when using a Beoconnect Core.""" + + # Load entry + mock_config_entry_core.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_core.entry_id) + + # Check number of entities + # The media_player entity should be the only available + entity_ids_available = list(entity_registry.entities.keys()) + assert len(entity_ids_available) == 1 + + assert entity_ids_available == snapshot + + +async def test_button( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mozart_client: AsyncMock, + entity_registry: EntityRegistry, +) -> None: + """Test button event entity.""" + # Load entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Enable the entity + entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None) + hass.config_entries.async_schedule_reload(mock_config_entry.entry_id) + + assert (states := hass.states.get(TEST_BUTTON_EVENT_ENTITY_ID)) + assert states.state is STATE_UNKNOWN + assert states.attributes[ATTR_EVENT_TYPES] == list(DEVICE_BUTTON_EVENTS) + + # Check button reacts as expected to WebSocket events + notification_callback = mock_mozart_client.get_button_notifications.call_args[0][0] + + notification_callback(ButtonEvent(button="PlayPause", state="shortPress (Release)")) + await hass.async_block_till_done() + + assert (states := hass.states.get(TEST_BUTTON_EVENT_ENTITY_ID)) + assert states.state is not None + assert ( + states.attributes[ATTR_EVENT_TYPE] + == EVENT_TRANSLATION_MAP["shortPress (Release)"] + ) diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index aa35b0265dc..70b826f0b92 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -18,6 +18,7 @@ from mozart_api.models import ( import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props +from voluptuous import Invalid, MultipleInvalid from homeassistant.components.bang_olufsen.const import ( BANG_OLUFSEN_REPEAT_FROM_HA, @@ -527,7 +528,7 @@ async def test_async_update_beolink_listener( snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, - mock_config_entry_2: MockConfigEntry, + mock_config_entry_core: MockConfigEntry, ) -> None: """Test _async_update_beolink as a listener.""" @@ -539,8 +540,8 @@ async def test_async_update_beolink_listener( ) # Add another entity - mock_config_entry_2.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry_2.entry_id) + mock_config_entry_core.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_core.entry_id) # Runs _async_update_beolink playback_metadata_callback( @@ -1385,7 +1386,7 @@ async def test_async_join_players( snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, - mock_config_entry_2: MockConfigEntry, + mock_config_entry_core: MockConfigEntry, group_members: list[str], expand_count: int, join_count: int, @@ -1400,8 +1401,8 @@ async def test_async_join_players( ) # Add another entity - mock_config_entry_2.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry_2.entry_id) + mock_config_entry_core.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_core.entry_id) # Set the source to a beolink expandable source source_change_callback(TEST_SOURCE) @@ -1452,7 +1453,7 @@ async def test_async_join_players_invalid( snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, - mock_config_entry_2: MockConfigEntry, + mock_config_entry_core: MockConfigEntry, source: Source, group_members: list[str], expected_result: AbstractContextManager, @@ -1467,8 +1468,8 @@ async def test_async_join_players_invalid( mock_mozart_client.get_source_change_notifications.call_args[0][0] ) - mock_config_entry_2.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry_2.entry_id) + mock_config_entry_core.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_core.entry_id) source_change_callback(source) @@ -1523,13 +1524,38 @@ async def test_async_unjoin_player( assert states == snapshot(exclude=props("media_position_updated_at")) +@pytest.mark.parametrize( + ( + "service_parameters", + "method_parameters", + ), + [ + # Defined JID + ( + {"beolink_jid": TEST_JID_2}, + {"jid": TEST_JID_2}, + ), + # Defined JID and source + ( + {"beolink_jid": TEST_JID_2, "source_id": TEST_SOURCE.id}, + {"jid": TEST_JID_2, "source": TEST_SOURCE.id}, + ), + # Defined JID and Beolink Converter NL/ML source + ( + {"beolink_jid": TEST_JID_2, "source_id": "cd"}, + {"jid": TEST_JID_2, "source": "CD"}, + ), + ], +) async def test_async_beolink_join( hass: HomeAssistant, snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, + service_parameters: dict[str, str], + method_parameters: dict[str, str], ) -> None: - """Test async_beolink_join with defined JID.""" + """Test async_beolink_join with defined JID and JID and source.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -1537,14 +1563,61 @@ async def test_async_beolink_join( await hass.services.async_call( DOMAIN, "beolink_join", - { - ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, - "beolink_jid": TEST_JID_2, - }, + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, **service_parameters}, blocking=True, ) - mock_mozart_client.join_beolink_peer.assert_called_once_with(jid=TEST_JID_2) + mock_mozart_client.join_beolink_peer.assert_called_once_with(**method_parameters) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +@pytest.mark.parametrize( + ( + "service_parameters", + "expected_result", + ), + [ + # Defined invalid JID + ( + {"beolink_jid": "not_a_jid"}, + pytest.raises(Invalid), + ), + # Defined invalid source + ( + {"source_id": "invalid_source"}, + pytest.raises(MultipleInvalid), + ), + # Defined invalid JID and invalid source + ( + {"beolink_jid": "not_a_jid", "source_id": "invalid_source"}, + pytest.raises(MultipleInvalid), + ), + ], +) +async def test_async_beolink_join_invalid( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, + service_parameters: dict[str, str], + expected_result: AbstractContextManager, +) -> None: + """Test invalid async_beolink_join calls with defined JID or source ID.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + with expected_result: + await hass.services.async_call( + DOMAIN, + "beolink_join", + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, **service_parameters}, + blocking=True, + ) + + mock_mozart_client.join_beolink_peer.assert_not_called() assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states == snapshot(exclude=props("media_position_updated_at")) diff --git a/tests/components/bang_olufsen/test_websocket.py b/tests/components/bang_olufsen/test_websocket.py index b17859a4f4e..ecf5b2d011e 100644 --- a/tests/components/bang_olufsen/test_websocket.py +++ b/tests/components/bang_olufsen/test_websocket.py @@ -135,7 +135,6 @@ async def test_on_all_notifications_raw( }, "eventType": "WebSocketEventVolume", } - raw_notification_full = raw_notification # Get device ID for the modified notification that is sent as an event and in the log assert mock_config_entry.unique_id @@ -144,12 +143,11 @@ async def test_on_all_notifications_raw( identifiers={(DOMAIN, mock_config_entry.unique_id)} ) ) - raw_notification_full.update( - { - "device_id": device.id, - "serial_number": mock_config_entry.unique_id, - } - ) + raw_notification_full = { + "device_id": device.id, + "serial_number": int(mock_config_entry.unique_id), + **raw_notification, + } caplog.set_level(logging.DEBUG) diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index ea0ad05a0db..26b8d919d72 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -17,8 +17,6 @@ from tests.common import ( MockConfigEntry, MockModule, MockPlatform, - help_test_all, - import_and_test_deprecated_constant_enum, mock_config_flow, mock_integration, mock_platform, @@ -198,22 +196,3 @@ async def test_entity_category_config_raises_error( "Entity binary_sensor.test2 cannot be added as the entity category is set to config" in caplog.text ) - - -def test_all() -> None: - """Test module.__all__ is correctly set.""" - help_test_all(binary_sensor) - - -@pytest.mark.parametrize( - "device_class", - list(binary_sensor.BinarySensorDeviceClass), -) -def test_deprecated_constant_device_class( - caplog: pytest.LogCaptureFixture, - device_class: binary_sensor.BinarySensorDeviceClass, -) -> None: - """Test deprecated binary sensor device classes.""" - import_and_test_deprecated_constant_enum( - caplog, binary_sensor, device_class, "DEVICE_CLASS_", "2025.1" - ) diff --git a/tests/components/blackbird/test_media_player.py b/tests/components/blackbird/test_media_player.py index db92dddcc77..5de41a1fb1e 100644 --- a/tests/components/blackbird/test_media_player.py +++ b/tests/components/blackbird/test_media_player.py @@ -10,7 +10,6 @@ from homeassistant.components.blackbird.const import DOMAIN, SERVICE_SETALLZONES from homeassistant.components.blackbird.media_player import ( DATA_BLACKBIRD, PLATFORM_SCHEMA, - setup_platform, ) from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -18,6 +17,9 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockEntityPlatform class AttrDict(dict): @@ -181,21 +183,21 @@ async def setup_blackbird(hass: HomeAssistant, mock_blackbird: MockBlackbird) -> "homeassistant.components.blackbird.media_player.get_blackbird", return_value=mock_blackbird, ): - await hass.async_add_executor_job( - setup_platform, + await async_setup_component( hass, + "media_player", { - "platform": "blackbird", - "port": "/dev/ttyUSB0", - "zones": {3: {"name": "Zone name"}}, - "sources": { - 1: {"name": "one"}, - 3: {"name": "three"}, - 2: {"name": "two"}, - }, + "media_player": { + "platform": "blackbird", + "port": "/dev/ttyUSB0", + "zones": {3: {"name": "Zone name"}}, + "sources": { + 1: {"name": "one"}, + 3: {"name": "three"}, + 2: {"name": "two"}, + }, + } }, - lambda *args, **kwargs: None, - {}, ) await hass.async_block_till_done() @@ -207,6 +209,7 @@ def media_player_entity( """Return the media player entity.""" media_player = hass.data[DATA_BLACKBIRD]["/dev/ttyUSB0-3"] media_player.hass = hass + media_player.platform = MockEntityPlatform(hass) media_player.entity_id = "media_player.zone_3" return media_player @@ -271,10 +274,6 @@ async def test_update( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test updating values from blackbird.""" - assert media_player_entity.state is None - assert media_player_entity.source is None - - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.state == STATE_ON assert media_player_entity.source == "one" @@ -291,9 +290,6 @@ async def test_state( mock_blackbird: MockBlackbird, ) -> None: """Test state property.""" - assert media_player_entity.state is None - - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.state == STATE_ON mock_blackbird.zones[3].power = False @@ -315,8 +311,6 @@ async def test_source( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test source property.""" - assert media_player_entity.source is None - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.source == "one" @@ -324,8 +318,6 @@ async def test_media_title( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test media title property.""" - assert media_player_entity.media_title is None - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.media_title == "one" diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 612c4f09424..4b0c1b23e79 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -7,12 +7,12 @@ import blebox_uniapi import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.blebox import config_flow from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from .conftest import mock_config, mock_feature, mock_only_feature, setup_product_mock @@ -227,7 +227,7 @@ async def test_flow_with_zeroconf(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -267,7 +267,7 @@ async def test_flow_with_zeroconf_when_already_configured(hass: HomeAssistant) - result2 = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -291,7 +291,7 @@ async def test_flow_with_zeroconf_when_device_unsupported(hass: HomeAssistant) - result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -317,7 +317,7 @@ async def test_flow_with_zeroconf_when_device_response_unsupported( result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, diff --git a/tests/components/blueprint/test_default_blueprints.py b/tests/components/blueprint/test_default_blueprints.py index f69126a7f25..fbbd48eedd3 100644 --- a/tests/components/blueprint/test_default_blueprints.py +++ b/tests/components/blueprint/test_default_blueprints.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.blueprint import BLUEPRINT_SCHEMA, models from homeassistant.components.blueprint.const import BLUEPRINT_FOLDER -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util DOMAINS = ["automation"] LOGGER = logging.getLogger(__name__) @@ -25,5 +25,5 @@ def test_default_blueprints(domain: str) -> None: for fil in items: LOGGER.info("Processing %s", fil) assert fil.name.endswith(".yaml") - data = yaml.load_yaml(fil) + data = yaml_util.load_yaml(fil) models.Blueprint(data, expected_domain=domain, schema=BLUEPRINT_SCHEMA) diff --git a/tests/components/bluesound/conftest.py b/tests/components/bluesound/conftest.py index b4ee61dee57..717c9f61850 100644 --- a/tests/components/bluesound/conftest.py +++ b/tests/components/bluesound/conftest.py @@ -81,11 +81,11 @@ class PlayerMockData: volume_db=0.5, volume=50, group=None, - master=None, - slaves=None, + leader=None, + followers=None, zone=None, - zone_master=None, - zone_slave=None, + zone_leader=None, + zone_follower=None, mute_volume_db=None, mute_volume=None, ) diff --git a/tests/components/bluesound/snapshots/test_media_player.ambr b/tests/components/bluesound/snapshots/test_media_player.ambr index 3e644d3038a..f71302f286d 100644 --- a/tests/components/bluesound/snapshots/test_media_player.ambr +++ b/tests/components/bluesound/snapshots/test_media_player.ambr @@ -9,7 +9,6 @@ 'media_artist': 'artist', 'media_content_type': , 'media_duration': 123, - 'media_position': 2, 'media_title': 'song', 'shuffle': False, 'source_list': list([ diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py index 63744cdf0ff..d0e0f75991b 100644 --- a/tests/components/bluesound/test_config_flow.py +++ b/tests/components/bluesound/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import AsyncMock from pyblu.errors import PlayerUnreachableError from homeassistant.components.bluesound.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import PlayerMocks @@ -113,63 +113,6 @@ async def test_user_flow_aleady_configured( player_mocks.player_data_for_already_configured.player.sync_status.assert_called_once() -async def test_import_flow_success( - hass: HomeAssistant, mock_setup_entry: AsyncMock, player_mocks: PlayerMocks -) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.1", CONF_PORT: 11000}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "player-name1111" - assert result["data"] == {CONF_HOST: "1.1.1.1", CONF_PORT: 11000} - assert result["result"].unique_id == "ff:ff:01:01:01:01-11000" - - mock_setup_entry.assert_called_once() - player_mocks.player_data.player.sync_status.assert_called_once() - - -async def test_import_flow_cannot_connect( - hass: HomeAssistant, player_mocks: PlayerMocks -) -> None: - """Test we handle cannot connect error.""" - player_mocks.player_data.player.sync_status.side_effect = PlayerUnreachableError( - "Player not reachable" - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.1", CONF_PORT: 11000}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - player_mocks.player_data.player.sync_status.assert_called_once() - - -async def test_import_flow_already_configured( - hass: HomeAssistant, - player_mocks: PlayerMocks, - config_entry: MockConfigEntry, -) -> None: - """Test we handle already configured.""" - config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "1.1.1.2", CONF_PORT: 11000}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - player_mocks.player_data_for_already_configured.player.sync_status.assert_called_once() - - async def test_zeroconf_flow_success( hass: HomeAssistant, mock_setup_entry: AsyncMock, player_mocks: PlayerMocks ) -> None: diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index 0bf615de3da..ed537d0bc57 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -11,7 +11,7 @@ from syrupy.filters import props from homeassistant.components.bluesound import DOMAIN as BLUESOUND_DOMAIN from homeassistant.components.bluesound.const import ATTR_MASTER -from homeassistant.components.bluesound.services import ( +from homeassistant.components.bluesound.media_player import ( SERVICE_CLEAR_TIMER, SERVICE_JOIN, SERVICE_SET_TIMER, @@ -127,7 +127,9 @@ async def test_attributes_set( ) -> None: """Test the media player attributes set.""" state = hass.states.get("media_player.player_name1111") - assert state == snapshot(exclude=props("media_position_updated_at")) + assert state == snapshot( + exclude=props("media_position_updated_at", "media_position") + ) async def test_stop_maps_to_idle( @@ -259,7 +261,7 @@ async def test_join( blocking=True, ) - player_mocks.player_data_secondary.player.add_slave.assert_called_once_with( + player_mocks.player_data_secondary.player.add_follower.assert_called_once_with( "1.1.1.1", 11000 ) @@ -273,7 +275,7 @@ async def test_unjoin( """Test the unjoin action.""" updated_sync_status = dataclasses.replace( player_mocks.player_data.sync_status_long_polling_mock.get(), - master=PairedPlayer("2.2.2.2", 11000), + leader=PairedPlayer("2.2.2.2", 11000), ) player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status) @@ -287,7 +289,7 @@ async def test_unjoin( blocking=True, ) - player_mocks.player_data_secondary.player.remove_slave.assert_called_once_with( + player_mocks.player_data_secondary.player.remove_follower.assert_called_once_with( "1.1.1.1", 11000 ) @@ -297,7 +299,7 @@ async def test_attr_master( setup_config_entry: None, player_mocks: PlayerMocks, ) -> None: - """Test the media player master.""" + """Test the media player leader.""" attr_master = hass.states.get("media_player.player_name1111").attributes[ ATTR_MASTER ] @@ -305,7 +307,7 @@ async def test_attr_master( updated_sync_status = dataclasses.replace( player_mocks.player_data.sync_status_long_polling_mock.get(), - slaves=[PairedPlayer("2.2.2.2", 11000)], + followers=[PairedPlayer("2.2.2.2", 11000)], ) player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status) @@ -325,17 +327,17 @@ async def test_attr_bluesound_group( setup_config_entry_secondary: None, player_mocks: PlayerMocks, ) -> None: - """Test the media player grouping.""" + """Test the media player grouping for leader.""" attr_bluesound_group = hass.states.get( "media_player.player_name1111" ).attributes.get("bluesound_group") assert attr_bluesound_group is None - updated_status = dataclasses.replace( - player_mocks.player_data.status_long_polling_mock.get(), - group_name="player-name1111+player-name2222", + updated_sync_status = dataclasses.replace( + player_mocks.player_data.sync_status_long_polling_mock.get(), + followers=[PairedPlayer("2.2.2.2", 11000)], ) - player_mocks.player_data.status_long_polling_mock.set(updated_status) + player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status) # give the long polling loop a chance to update the state; this could be any async call await hass.async_block_till_done() @@ -347,6 +349,45 @@ async def test_attr_bluesound_group( assert attr_bluesound_group == ["player-name1111", "player-name2222"] +async def test_attr_bluesound_group_for_follower( + hass: HomeAssistant, + setup_config_entry: None, + setup_config_entry_secondary: None, + player_mocks: PlayerMocks, +) -> None: + """Test the media player grouping for follower.""" + attr_bluesound_group = hass.states.get( + "media_player.player_name2222" + ).attributes.get("bluesound_group") + assert attr_bluesound_group is None + + updated_sync_status = dataclasses.replace( + player_mocks.player_data.sync_status_long_polling_mock.get(), + followers=[PairedPlayer("2.2.2.2", 11000)], + ) + player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + updated_sync_status = dataclasses.replace( + player_mocks.player_data_secondary.sync_status_long_polling_mock.get(), + leader=PairedPlayer("1.1.1.1", 11000), + ) + player_mocks.player_data_secondary.sync_status_long_polling_mock.set( + updated_sync_status + ) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + attr_bluesound_group = hass.states.get( + "media_player.player_name2222" + ).attributes.get("bluesound_group") + + assert attr_bluesound_group == ["player-name1111", "player-name2222"] + + async def test_volume_up_from_6_to_7( hass: HomeAssistant, setup_config_entry: None, diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 8794d808718..c672de7424b 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -14,7 +14,9 @@ from habluetooth import BaseHaScanner, BluetoothManager, get_manager from homeassistant.components.bluetooth import ( DOMAIN, + MONOTONIC_TIME, SOURCE_LOCAL, + BaseHaRemoteScanner, BluetoothServiceInfo, BluetoothServiceInfoBleak, async_get_advertisement_callback, @@ -25,17 +27,17 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry __all__ = ( + "MockBleakClient", + "generate_advertisement_data", + "generate_ble_device", "inject_advertisement", "inject_advertisement_with_source", "inject_advertisement_with_time_and_source", "inject_advertisement_with_time_and_source_connectable", "inject_bluetooth_service_info", "patch_all_discovered_devices", - "patch_discovered_devices", - "generate_advertisement_data", - "generate_ble_device", - "MockBleakClient", "patch_bluetooth_time", + "patch_discovered_devices", ) ADVERTISEMENT_DATA_DEFAULTS = { @@ -324,3 +326,26 @@ class FakeScanner(FakeScannerMixin, BaseHaScanner): ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and their advertisement data.""" return {} + + +class FakeRemoteScanner(BaseHaRemoteScanner): + """Fake remote scanner.""" + + def inject_advertisement( + self, + device: BLEDevice, + advertisement_data: AdvertisementData, + now: float | None = None, + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + now or MONOTONIC_TIME(), + ) diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 93a1c59cba1..6fa0b375e81 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -8,6 +8,11 @@ from dbus_fast.aio import message_bus import habluetooth.util as habluetooth_utils import pytest +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant + +from . import FakeScanner + @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") def disable_bluez_manager_socket(): @@ -304,3 +309,23 @@ def disable_new_discovery_flows_fixture(): "homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow" ) as mock_create_flow: yield mock_create_flow + + +@pytest.fixture +def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an hci0 scanner.""" + hci0_scanner = FakeScanner("hci0", "hci0") + cancel = bluetooth.async_register_scanner(hass, hci0_scanner) + yield + cancel() + bluetooth.async_remove_scanner(hass, hci0_scanner.source) + + +@pytest.fixture +def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an hci1 scanner.""" + hci1_scanner = FakeScanner("hci1", "hci1") + cancel = bluetooth.async_register_scanner(hass, hci1_scanner) + yield + cancel() + bluetooth.async_remove_scanner(hass, hci1_scanner.source) diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index abfbbaa15ab..e3bdca256c0 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -7,16 +7,12 @@ import time from typing import Any from unittest.mock import patch -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData - # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( - MONOTONIC_TIME, BaseHaRemoteScanner, HaBluetoothConnector, storage, @@ -28,12 +24,16 @@ from homeassistant.components.bluetooth.const import ( SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from . import ( + FakeRemoteScanner as FakeScanner, MockBleakClient, _get_manager, generate_advertisement_data, @@ -41,30 +41,7 @@ from . import ( patch_bluetooth_time, ) -from tests.common import async_fire_time_changed, load_fixture - - -class FakeScanner(BaseHaRemoteScanner): - """Fake scanner.""" - - def inject_advertisement( - self, - device: BLEDevice, - advertisement_data: AdvertisementData, - now: float | None = None, - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - now or MONOTONIC_TIME(), - ) +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture @pytest.mark.parametrize("name_2", [None, "w"]) @@ -545,3 +522,75 @@ async def test_scanner_stops_responding(hass: HomeAssistant) -> None: cancel() unsetup() + + +@pytest.mark.usefixtures("enable_bluetooth") +@pytest.mark.parametrize( + ("manufacturer", "source"), + [ + ("test", "test"), + ("Raspberry Pi Trading Ltd (test)", "28:CD:C1:11:23:45"), + ], +) +async def test_remote_scanner_bluetooth_config_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + manufacturer: str, + source: str, +) -> None: + """Test the remote scanner gets a bluetooth config entry.""" + manager: HomeAssistantBluetoothManager = _get_manager() + + switchbot_device = generate_ble_device( + "44:44:33:11:23:45", + "wohand", + {}, + rssi=-100, + ) + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeScanner(source, source, connector, True) + unsetup = scanner.async_setup() + assert scanner.source == source + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + cancel = manager.async_register_hass_scanner( + scanner, + source_domain="test", + source_model="test", + source_config_entry_id=entry.entry_id, + ) + await hass.async_block_till_done() + + scanner.inject_advertisement(switchbot_device, switchbot_device_adv) + assert len(scanner.discovered_devices) == 1 + + cancel() + unsetup() + + adapter_entry = hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) + assert adapter_entry is not None + assert adapter_entry.state is ConfigEntryState.LOADED + + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, scanner.source)} + ) + assert dev is not None + assert dev.config_entries == {adapter_entry.entry_id} + assert dev.manufacturer == manufacturer + + manager.async_remove_scanner(scanner.source) + await hass.async_block_till_done() + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 0a0cb3fa8e0..abb3a5e2393 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -6,16 +6,23 @@ from bluetooth_adapters import DEFAULT_ADDRESS, AdapterDetails import pytest from homeassistant import config_entries +from homeassistant.components.bluetooth import HaBluetoothConnector from homeassistant.components.bluetooth.const import ( CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, DOMAIN, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component +from . import FakeRemoteScanner, MockBleakClient, _get_manager + from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -450,6 +457,36 @@ async def test_options_flow_enabled_linux( await hass.config_entries.async_unload(entry.entry_id) +@pytest.mark.usefixtures( + "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) +async def test_options_flow_remote_adapter(hass: HomeAssistant) -> None: + """Test options are not available for remote adapters.""" + source_entry = MockConfigEntry( + domain="test", + ) + source_entry.add_to_hass(hass) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: "BB:BB:BB:BB:BB:BB", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: source_entry.entry_id, + }, + options={}, + unique_id="BB:BB:BB:BB:BB:BB", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "remote_adapters_not_supported" + + @pytest.mark.usefixtures("one_adapter") async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: """Test we give a hint that the adapter is ignored.""" @@ -467,3 +504,49 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_adapters" assert result["description_placeholders"] == {"ignored_adapters": "1"} + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_step_integration_discovery_remote_adapter( + hass: HomeAssistant, +) -> None: + """Test remote adapter configuration via integration discovery.""" + entry = MockConfigEntry(domain="test") + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("esp32", "esp32", connector, True) + manager = _get_manager() + cancel_scanner = manager.async_register_scanner(scanner) + + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.bluetooth.async_setup", return_value=True), + patch( + "homeassistant.components.bluetooth.async_setup_entry", return_value=True + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "esp32" + assert result["data"] == { + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + } + assert len(mock_setup_entry.mock_calls) == 1 + await hass.async_block_till_done() + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + cancel_scanner() + await hass.async_block_till_done() diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index ba8792a79a3..2c8c9e70e7f 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -18,6 +18,7 @@ from homeassistant.components.bluetooth import ( BluetoothChange, BluetoothScanningMode, BluetoothServiceInfo, + HaBluetoothConnector, async_process_advertisements, async_rediscover_address, async_track_unavailable, @@ -25,11 +26,16 @@ from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth.const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_PASSIVE, + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, DOMAIN, LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.components.bluetooth.match import ( ADDRESS, CONNECTABLE, @@ -46,7 +52,9 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( + FakeRemoteScanner, FakeScanner, + MockBleakClient, _get_manager, async_setup_with_default_adapter, async_setup_with_one_adapter, @@ -3022,6 +3030,23 @@ async def test_scanner_count_connectable(hass: HomeAssistant) -> None: cancel() +@pytest.mark.usefixtures("enable_bluetooth") +async def test_scanner_remove(hass: HomeAssistant) -> None: + """Test permanently removing a scanner.""" + scanner = FakeScanner("any", "any") + cancel = bluetooth.async_register_scanner(hass, scanner) + assert bluetooth.async_scanner_count(hass, connectable=True) == 1 + device = generate_ble_device("44:44:33:11:23:45", "name") + adv = generate_advertisement_data(local_name="name", service_uuids=[]) + inject_advertisement_with_time_and_source_connectable( + hass, device, adv, time.monotonic(), scanner.source, True + ) + cancel() + bluetooth.async_remove_scanner(hass, scanner.source) + manager: HomeAssistantBluetoothManager = _get_manager() + assert not manager.storage.async_get_advertisement_history(scanner.source) + + @pytest.mark.usefixtures("enable_bluetooth") async def test_scanner_count(hass: HomeAssistant) -> None: """Test getting the connectable and non-connectable scanner count.""" @@ -3245,3 +3270,33 @@ async def test_title_updated_if_mac_address( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.title == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_cleanup_orphened_remote_scanner_config_entry( + hass: HomeAssistant, +) -> None: + """Test the remote scanner config entries get cleaned up when orphened.""" + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("esp32", "esp32", connector, True) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: "no_longer_exists", + }, + unique_id=scanner.source, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Orphened remote scanner config entry should be cleaned up + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 0454df9a4a7..77071368dd0 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1,6 +1,5 @@ """Tests for the Bluetooth integration manager.""" -from collections.abc import Generator from datetime import timedelta import time from typing import Any @@ -8,6 +7,7 @@ from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory +from freezegun import freeze_time # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS @@ -36,10 +36,12 @@ from homeassistant.components.bluetooth.const import ( SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.dt import utcnow from homeassistant.util.json import json_loads from . import ( @@ -63,24 +65,6 @@ from tests.common import ( ) -@pytest.fixture -def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: - """Register an hci0 scanner.""" - hci0_scanner = FakeScanner("hci0", "hci0") - cancel = bluetooth.async_register_scanner(hass, hci0_scanner) - yield - cancel() - - -@pytest.fixture -def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: - """Register an hci1 scanner.""" - hci1_scanner = FakeScanner("hci1", "hci1") - cancel = bluetooth.async_register_scanner(hass, hci1_scanner) - yield - cancel() - - @pytest.mark.usefixtures("enable_bluetooth") async def test_advertisements_do_not_switch_adapters_for_no_reason( hass: HomeAssistant, @@ -1660,3 +1644,71 @@ async def test_bluetooth_rediscover_no_match( cancel() unsetup_connectable_scanner() cancel_connectable_scanner() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_register_disappeared_callback( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, +) -> None: + """Test bluetooth async_register_disappeared_callback handles failures.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + ) + + failed_disappeared: list[str] = [] + + def _failing_callback(_address: str) -> None: + """Failing callback.""" + failed_disappeared.append(_address) + raise ValueError("This is a test") + + ok_disappeared: list[str] = [] + + def _ok_callback(_address: str) -> None: + """Ok callback.""" + ok_disappeared.append(_address) + + manager: HomeAssistantBluetoothManager = _get_manager() + cancel1 = manager.async_register_disappeared_callback(_failing_callback) + # Make sure the second callback still works if the first one fails and + # raises an exception + cancel2 = manager.async_register_disappeared_callback(_ok_callback) + + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", + manufacturer_data={123: b"abc"}, + service_uuids=[], + rssi=-80, + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" + ) + + future_time = utcnow() + timedelta(seconds=3600) + future_monotonic_time = time.monotonic() + 3600 + with ( + freeze_time(future_time), + patch( + "habluetooth.manager.monotonic_time_coarse", + return_value=future_monotonic_time, + ), + ): + async_fire_time_changed(hass, future_time) + + assert len(ok_disappeared) == 1 + assert ok_disappeared[0] == address + assert len(failed_disappeared) == 1 + assert failed_disappeared[0] == address + + cancel1() + cancel2() diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index d7a7a8ba08c..e9274965e3c 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -1808,6 +1808,7 @@ async def test_naming(hass: HomeAssistant) -> None: sensor_entity: PassiveBluetoothProcessorEntity = sensor_entities[0] sensor_entity.hass = hass + sensor_entity.platform = MockEntityPlatform(hass) assert sensor_entity.available is True assert sensor_entity.name is UNDEFINED assert sensor_entity.device_class is SensorDeviceClass.TEMPERATURE diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py new file mode 100644 index 00000000000..c9670f2f895 --- /dev/null +++ b/tests/components/bluetooth/test_websocket_api.py @@ -0,0 +1,116 @@ +"""The tests for the bluetooth WebSocket API.""" + +import asyncio +from datetime import timedelta +import time +from unittest.mock import ANY, patch + +from freezegun import freeze_time +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from . import ( + generate_advertisement_data, + generate_ble_device, + inject_advertisement_with_source, +) + +from tests.common import async_fire_time_changed +from tests.typing import WebSocketGenerator + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_advertisements( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_advertisements.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + ) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_advertisements", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "address": "44:44:33:11:23:12", + "connectable": True, + "manufacturer_data": {}, + "name": "wohand_signal_100", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": "hci0", + "time": ANY, + "tx_power": -127, + } + ] + } + adv_time = response["event"]["add"][0]["time"] + + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", + manufacturer_data={123: b"abc"}, + service_uuids=[], + rssi=-80, + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "address": "44:44:33:11:23:12", + "connectable": True, + "manufacturer_data": {"123": "616263"}, + "name": "wohand_signal_100", + "rssi": -80, + "service_data": {}, + "service_uuids": [], + "source": "hci1", + "time": ANY, + "tx_power": -127, + } + ] + } + new_time = response["event"]["add"][0]["time"] + assert new_time > adv_time + future_time = utcnow() + timedelta(seconds=3600) + future_monotonic_time = time.monotonic() + 3600 + with ( + freeze_time(future_time), + patch( + "habluetooth.manager.monotonic_time_coarse", + return_value=future_monotonic_time, + ), + ): + async_fire_time_changed(hass, future_time) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == {"remove": [{"address": "44:44:33:11:23:12"}]} diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index 4d280a1d0e5..2cd65364604 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -9,6 +9,7 @@ import respx from homeassistant import config_entries from homeassistant.components.bmw_connected_drive.const import ( + CONF_CAPTCHA_TOKEN, CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, @@ -24,8 +25,12 @@ FIXTURE_USER_INPUT = { CONF_PASSWORD: "p4ssw0rd", CONF_REGION: "rest_of_world", } -FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN" -FIXTURE_GCID = "SOME_GCID" +FIXTURE_CAPTCHA_INPUT = { + CONF_CAPTCHA_TOKEN: "captcha_token", +} +FIXTURE_USER_INPUT_W_CAPTCHA = FIXTURE_USER_INPUT | FIXTURE_CAPTCHA_INPUT +FIXTURE_REFRESH_TOKEN = "another_token_string" +FIXTURE_GCID = "DUMMY" FIXTURE_CONFIG_ENTRY = { "entry_id": "1", @@ -43,6 +48,18 @@ FIXTURE_CONFIG_ENTRY = { "unique_id": f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}", } +REMOTE_SERVICE_EXC_REASON = "HTTPStatusError: 502 Bad Gateway" +REMOTE_SERVICE_EXC_TRANSLATION = ( + "Error executing remote service on vehicle. HTTPStatusError: 502 Bad Gateway" +) + +BIMMER_CONNECTED_LOGIN_PATCH = ( + "homeassistant.components.bmw_connected_drive.config_flow.MyBMWAuthentication.login" +) +BIMMER_CONNECTED_VEHICLE_PATCH = ( + "homeassistant.components.bmw_connected_drive.coordinator.MyBMWAccount.get_vehicles" +) + async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: """Mock a fully setup config entry and all components based on fixtures.""" diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 81ef1220069..b87da22a332 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -4833,7 +4833,7 @@ }), ]), 'info': dict({ - 'gcid': 'SOME_GCID', + 'gcid': 'DUMMY', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', @@ -7202,7 +7202,7 @@ }), ]), 'info': dict({ - 'gcid': 'SOME_GCID', + 'gcid': 'DUMMY', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', @@ -8925,7 +8925,7 @@ }), ]), 'info': dict({ - 'gcid': 'SOME_GCID', + 'gcid': 'DUMMY', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', diff --git a/tests/components/bmw_connected_drive/test_button.py b/tests/components/bmw_connected_drive/test_button.py index 88c7990cde9..356cfcb439e 100644 --- a/tests/components/bmw_connected_drive/test_button.py +++ b/tests/components/bmw_connected_drive/test_button.py @@ -13,7 +13,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) from tests.common import snapshot_platform @@ -81,11 +85,13 @@ async def test_service_call_fail( monkeypatch.setattr( RemoteServices, "trigger_remote_service", - AsyncMock(side_effect=MyBMWRemoteServiceError), + AsyncMock( + side_effect=MyBMWRemoteServiceError("HTTPStatusError: 502 Bad Gateway") + ), ) # Test - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match=REMOTE_SERVICE_EXC_TRANSLATION): await hass.services.async_call( "button", "press", diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index f57f1a304ac..2d4b1390ccc 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -4,17 +4,14 @@ from copy import deepcopy from unittest.mock import patch from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.models import ( - MyBMWAPIError, - MyBMWAuthError, - MyBMWCaptchaMissingError, -) +from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError from httpx import RequestError import pytest from homeassistant import config_entries from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN from homeassistant.components.bmw_connected_drive.const import ( + CONF_CAPTCHA_TOKEN, CONF_READ_ONLY, CONF_REFRESH_TOKEN, ) @@ -23,10 +20,14 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + BIMMER_CONNECTED_LOGIN_PATCH, + BIMMER_CONNECTED_VEHICLE_PATCH, + FIXTURE_CAPTCHA_INPUT, FIXTURE_CONFIG_ENTRY, FIXTURE_GCID, FIXTURE_REFRESH_TOKEN, FIXTURE_USER_INPUT, + FIXTURE_USER_INPUT_W_CAPTCHA, ) from tests.common import MockConfigEntry @@ -41,75 +42,11 @@ def login_sideeffect(self: MyBMWAuthentication): self.gcid = FIXTURE_GCID -async def test_show_form(hass: HomeAssistant) -> None: - """Test that the form is served with no input.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - -async def test_authentication_error(hass: HomeAssistant) -> None: - """Test we show user form on MyBMW authentication error.""" - - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=MyBMWAuthError("Login failed"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_connection_error(hass: HomeAssistant) -> None: - """Test we show user form on MyBMW API error.""" - - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=RequestError("Connection reset"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_api_error(hass: HomeAssistant) -> None: - """Test we show user form on general connection error.""" - - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=MyBMWAPIError("400 Bad Request"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: """Test registering an integration and finishing flow works.""" with ( patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", + BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True, ), @@ -118,23 +55,141 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=deepcopy(FIXTURE_USER_INPUT), ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] - assert result2["data"] == FIXTURE_COMPLETE_ENTRY + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_CAPTCHA_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] + assert result["data"] == FIXTURE_COMPLETE_ENTRY + assert ( + result["result"].unique_id + == f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}" + ) assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (MyBMWAuthError("Login failed"), "invalid_auth"), + (RequestError("Connection reset"), "cannot_connect"), + (MyBMWAPIError("400 Bad Request"), "cannot_connect"), + ], +) +async def test_error_display_with_successful_login( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we show user form on MyBMW authentication error and are still able to succeed.""" + + with patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + with ( + patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=login_sideeffect, + autospec=True, + ), + patch( + "homeassistant.components.bmw_connected_drive.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + deepcopy(FIXTURE_USER_INPUT), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_CAPTCHA_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] + assert result["data"] == FIXTURE_COMPLETE_ENTRY + assert ( + result["result"].unique_id + == f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}" + ) + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_unique_id_existing(hass: HomeAssistant) -> None: + """Test registering an integration and when the unique id already exists.""" + + mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) + mock_config_entry.add_to_hass(hass) + + with ( + patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=login_sideeffect, + autospec=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=deepcopy(FIXTURE_USER_INPUT), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_flow_missing_error(hass: HomeAssistant) -> None: + """Test the external flow with captcha failing once and succeeding the second time.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=deepcopy(FIXTURE_USER_INPUT), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_CAPTCHA_TOKEN: " "} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "missing_captcha"} + + async def test_options_flow_implementation(hass: HomeAssistant) -> None: """Test config flow options.""" with ( patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ), patch( @@ -171,7 +226,7 @@ async def test_reauth(hass: HomeAssistant) -> None: """Test the reauth form.""" with ( patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", + BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True, ), @@ -195,68 +250,32 @@ async def test_reauth(hass: HomeAssistant) -> None: result = await config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} + assert result["step_id"] == "change_password" + assert set(result["data_schema"].schema) == {CONF_PASSWORD} - suggested_values = { - key: key.description.get("suggested_value") - for key in result["data_schema"].schema - } - assert suggested_values[CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert suggested_values[CONF_PASSWORD] == wrong_password - assert suggested_values[CONF_REGION] == FIXTURE_USER_INPUT[CONF_REGION] - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_USER_INPUT + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]} ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_CAPTCHA_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert config_entry.data == FIXTURE_COMPLETE_ENTRY assert len(mock_setup_entry.mock_calls) == 2 -async def test_reauth_unique_id_abort(hass: HomeAssistant) -> None: - """Test aborting the reauth form if unique_id changes.""" - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=login_sideeffect, - autospec=True, - ): - wrong_password = "wrong" - - config_entry_with_wrong_password = deepcopy(FIXTURE_CONFIG_ENTRY) - config_entry_with_wrong_password["data"][CONF_PASSWORD] = wrong_password - - config_entry = MockConfigEntry(**config_entry_with_wrong_password) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.data == config_entry_with_wrong_password["data"] - - result = await config_entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {**FIXTURE_USER_INPUT, CONF_REGION: "north_america"} - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "account_mismatch" - assert config_entry.data == config_entry_with_wrong_password["data"] - - async def test_reconfigure(hass: HomeAssistant) -> None: """Test the reconfiguration form.""" with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", + BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True, ): @@ -268,79 +287,21 @@ async def test_reconfigure(hass: HomeAssistant) -> None: result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} + assert result["step_id"] == "change_password" + assert set(result["data_schema"].schema) == {CONF_PASSWORD} - suggested_values = { - key: key.description.get("suggested_value") - for key in result["data_schema"].schema - } - assert suggested_values[CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert suggested_values[CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] - assert suggested_values[CONF_REGION] == FIXTURE_USER_INPUT[CONF_REGION] - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_USER_INPUT - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reconfigure_successful" - assert config_entry.data == FIXTURE_COMPLETE_ENTRY - - -async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None: - """Test aborting the reconfiguration form if unique_id changes.""" - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=login_sideeffect, - autospec=True, - ): - config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - result = await config_entry.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {**FIXTURE_USER_INPUT, CONF_USERNAME: "somebody@email.com"}, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "account_mismatch" - assert config_entry.data == FIXTURE_COMPLETE_ENTRY - - -@pytest.mark.usefixtures("bmw_fixture") -async def test_captcha_flow_not_set(hass: HomeAssistant) -> None: - """Test the external flow with captcha failing once and succeeding the second time.""" - - TEST_REGION = "north_america" - - # Start flow and open form - # Start flow and open form - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - # Add login data - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication._login_row_na", - side_effect=MyBMWCaptchaMissingError( - "Missing hCaptcha token for North America login" - ), - ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, + result["flow_id"], {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]} ) - assert result["errors"]["base"] == "missing_captcha" + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_CAPTCHA_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == FIXTURE_COMPLETE_ENTRY diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py index 774a85eb6da..2e317ec1334 100644 --- a/tests/components/bmw_connected_drive/test_coordinator.py +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -1,7 +1,6 @@ -"""Test BMW coordinator.""" +"""Test BMW coordinator for general availability/unavailability of entities and raising issues.""" from copy import deepcopy -from datetime import timedelta from unittest.mock import patch from bimmer_connected.models import ( @@ -13,27 +12,56 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.components.bmw_connected_drive.const import ( + CONF_REFRESH_TOKEN, + SCAN_INTERVALS, +) from homeassistant.const import CONF_REGION from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.update_coordinator import UpdateFailed -from . import FIXTURE_CONFIG_ENTRY +from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY from tests.common import MockConfigEntry, async_fire_time_changed +FIXTURE_ENTITY_STATES = { + "binary_sensor.m340i_xdrive_door_lock_state": "off", + "lock.m340i_xdrive_lock": "locked", + "lock.i3_rex_lock": "unlocked", + "number.ix_xdrive50_target_soc": "80", + "sensor.ix_xdrive50_rear_left_tire_pressure": "2.61", + "sensor.ix_xdrive50_rear_right_tire_pressure": "2.69", +} +FIXTURE_DEFAULT_REGION = FIXTURE_CONFIG_ENTRY["data"][CONF_REGION] + @pytest.mark.usefixtures("bmw_fixture") -async def test_update_success(hass: HomeAssistant) -> None: - """Test the reauth form.""" - config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) +async def test_config_entry_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if the coordinator updates the refresh token in config entry.""" + config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) + config_entry_fixure["data"][CONF_REFRESH_TOKEN] = "old_token" + config_entry = MockConfigEntry(**config_entry_fixure) config_entry.add_to_hass(hass) + assert ( + hass.config_entries.async_get_entry(config_entry.entry_id).data[ + CONF_REFRESH_TOKEN + ] + == "old_token" + ) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.runtime_data.coordinator.last_update_success is True + assert ( + hass.config_entries.async_get_entry(config_entry.entry_id).data[ + CONF_REFRESH_TOKEN + ] + == "another_token_string" + ) @pytest.mark.usefixtures("bmw_fixture") @@ -41,125 +69,176 @@ async def test_update_failed( hass: HomeAssistant, freezer: FrozenDateTimeFactory, ) -> None: - """Test the reauth form.""" + """Test a failing API call.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data.coordinator - - assert coordinator.last_update_success is True - - freezer.tick(timedelta(minutes=5, seconds=1)) + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state + # On API error, entities should be unavailable + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAPIError("Test error"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, UpdateFailed) is True + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + + # And should recover on next update + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state @pytest.mark.usefixtures("bmw_fixture") -async def test_update_reauth( +async def test_auth_failed_as_update_failed( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" + """Test a single auth failure not initializing reauth flow.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data.coordinator + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state - assert coordinator.last_update_success is True - - freezer.tick(timedelta(minutes=5, seconds=1)) + # Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAuthError("Test error"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, UpdateFailed) is True + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" - freezer.tick(timedelta(minutes=5, seconds=1)) - with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", - side_effect=MyBMWAuthError("Test error"), - ): - async_fire_time_changed(hass) - await hass.async_block_till_done() + # And should recover on next update + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + async_fire_time_changed(hass) + await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state + + # Verify that no issues are raised and no reauth flow is initialized + assert len(issue_registry.issues) == 0 + assert len(hass.config_entries.flow.async_progress_by_handler(BMW_DOMAIN)) == 0 @pytest.mark.usefixtures("bmw_fixture") -async def test_init_reauth( +async def test_auth_failed_init_reauth( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" + """Test a two subsequent auth failures initializing reauth flow.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state assert len(issue_registry.issues) == 0 + # Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAuthError("Test error"), ): - await hass.config_entries.async_setup(config_entry.entry_id) + async_fire_time_changed(hass) await hass.async_block_till_done() + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + assert len(issue_registry.issues) == 0 + + # On second failure, we should initialize reauth flow + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + with patch( + BIMMER_CONNECTED_VEHICLE_PATCH, + side_effect=MyBMWAuthError("Test error"), + ): + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + assert len(issue_registry.issues) == 1 + reauth_issue = issue_registry.async_get_issue( HOMEASSISTANT_DOMAIN, f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", ) assert reauth_issue.active is True + # Check if reauth flow is initialized correctly + flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"]) + assert flow["handler"] == BMW_DOMAIN + assert flow["context"]["source"] == "reauth" + assert flow["context"]["unique_id"] == config_entry.unique_id + @pytest.mark.usefixtures("bmw_fixture") async def test_captcha_reauth( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" - TEST_REGION = "north_america" - - config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) - config_entry_fixure["data"][CONF_REGION] = TEST_REGION - config_entry = MockConfigEntry(**config_entry_fixure) + """Test a CaptchaError initializing reauth flow.""" + config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data.coordinator + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state - assert coordinator.last_update_success is True - - freezer.tick(timedelta(minutes=10, seconds=1)) + # If library decides a captcha is needed, we should initialize reauth flow + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", - side_effect=MyBMWCaptchaMissingError( - "Missing hCaptcha token for North America login" - ), + BIMMER_CONNECTED_VEHICLE_PATCH, + side_effect=MyBMWCaptchaMissingError("Missing hCaptcha token"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True - assert coordinator.last_exception.translation_key == "missing_captcha" + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + assert len(issue_registry.issues) == 1 + + reauth_issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, + f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", + ) + assert reauth_issue.active is True + + # Check if reauth flow is initialized correctly + flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"]) + assert flow["handler"] == BMW_DOMAIN + assert flow["context"]["source"] == "reauth" + assert flow["context"]["unique_id"] == config_entry.unique_id diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py index e523b2b3d02..d0624825cb5 100644 --- a/tests/components/bmw_connected_drive/test_init.py +++ b/tests/components/bmw_connected_drive/test_init.py @@ -10,14 +10,17 @@ from homeassistant.components.bmw_connected_drive.const import ( CONF_READ_ONLY, DOMAIN as BMW_DOMAIN, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import FIXTURE_CONFIG_ENTRY +from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY from tests.common import MockConfigEntry +BINARY_SENSOR_DOMAIN = Platform.BINARY_SENSOR.value +SENSOR_DOMAIN = Platform.SENSOR.value + VIN = "WBYYYYYYYYYYYYYYY" VEHICLE_NAME = "i3 (+ REX)" VEHICLE_NAME_SLUG = "i3_rex" @@ -109,6 +112,28 @@ async def test_migrate_options_from_data(hass: HomeAssistant) -> None: f"{VIN}-mileage", f"{VIN}-mileage", ), + ( + { + "domain": SENSOR_DOMAIN, + "platform": BMW_DOMAIN, + "unique_id": f"{VIN}-charging_status", + "suggested_object_id": f"{VEHICLE_NAME} Charging Status", + "disabled_by": None, + }, + f"{VIN}-charging_status", + f"{VIN}-fuel_and_battery.charging_status", + ), + ( + { + "domain": BINARY_SENSOR_DOMAIN, + "platform": BMW_DOMAIN, + "unique_id": f"{VIN}-charging_status", + "suggested_object_id": f"{VEHICLE_NAME} Charging Status", + "disabled_by": None, + }, + f"{VIN}-charging_status", + f"{VIN}-charging_status", + ), ], ) async def test_migrate_unique_ids( @@ -131,7 +156,7 @@ async def test_migrate_unique_ids( assert entity.unique_id == old_unique_id with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -187,7 +212,7 @@ async def test_dont_migrate_unique_ids( assert entity.unique_id == old_unique_id with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/bmw_connected_drive/test_lock.py b/tests/components/bmw_connected_drive/test_lock.py index 2fa694d426b..088534c79f5 100644 --- a/tests/components/bmw_connected_drive/test_lock.py +++ b/tests/components/bmw_connected_drive/test_lock.py @@ -16,7 +16,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_REASON, + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) from tests.common import snapshot_platform from tests.components.recorder.common import async_wait_recording_done @@ -118,11 +123,11 @@ async def test_service_call_fail( monkeypatch.setattr( RemoteServices, "trigger_remote_service", - AsyncMock(side_effect=MyBMWRemoteServiceError), + AsyncMock(side_effect=MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON)), ) # Test - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match=REMOTE_SERVICE_EXC_TRANSLATION): await hass.services.async_call( "lock", service, diff --git a/tests/components/bmw_connected_drive/test_notify.py b/tests/components/bmw_connected_drive/test_notify.py index 4113f618be0..1bade3be011 100644 --- a/tests/components/bmw_connected_drive/test_notify.py +++ b/tests/components/bmw_connected_drive/test_notify.py @@ -11,7 +11,11 @@ import respx from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) async def test_legacy_notify_service_simple( @@ -68,21 +72,21 @@ async def test_legacy_notify_service_simple( { "latitude": POI_DATA.get("lat"), }, - "Invalid data for point of interest: required key not provided @ data['longitude']", + r"Invalid data for point of interest: required key not provided @ data\['longitude'\]", ), ( { "latitude": POI_DATA.get("lat"), "longitude": "text", }, - "Invalid data for point of interest: invalid longitude for dictionary value @ data['longitude']", + r"Invalid data for point of interest: invalid longitude for dictionary value @ data\['longitude'\]", ), ( { "latitude": POI_DATA.get("lat"), "longitude": 9999, }, - "Invalid data for point of interest: invalid longitude for dictionary value @ data['longitude']", + r"Invalid data for point of interest: invalid longitude for dictionary value @ data\['longitude'\]", ), ], ) @@ -96,7 +100,7 @@ async def test_service_call_invalid_input( # Setup component assert await setup_mocked_integration(hass) - with pytest.raises(ServiceValidationError) as exc: + with pytest.raises(ServiceValidationError, match=exc_translation): await hass.services.async_call( "notify", "bmw_connected_drive_ix_xdrive50", @@ -106,7 +110,6 @@ async def test_service_call_invalid_input( }, blocking=True, ) - assert str(exc.value) == exc_translation @pytest.mark.usefixtures("bmw_fixture") @@ -132,11 +135,11 @@ async def test_service_call_fail( monkeypatch.setattr( RemoteServices, "trigger_remote_service", - AsyncMock(side_effect=raised), + AsyncMock(side_effect=raised("HTTPStatusError: 502 Bad Gateway")), ) # Test - with pytest.raises(expected): + with pytest.raises(expected, match=REMOTE_SERVICE_EXC_TRANSLATION): await hass.services.async_call( "notify", "bmw_connected_drive_ix_xdrive50", diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py index f2a50ce4df6..733f4fe3113 100644 --- a/tests/components/bmw_connected_drive/test_number.py +++ b/tests/components/bmw_connected_drive/test_number.py @@ -13,7 +13,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_REASON, + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) from tests.common import snapshot_platform @@ -89,7 +94,10 @@ async def test_service_call_invalid_input( old_value = hass.states.get(entity_id).state # Test - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match="Target SoC must be an integer between 20 and 100 that is a multiple of 5.", + ): await hass.services.async_call( "number", "set_value", @@ -102,17 +110,32 @@ async def test_service_call_invalid_input( @pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( - ("raised", "expected"), + ("raised", "expected", "exc_translation"), [ - (MyBMWRemoteServiceError, HomeAssistantError), - (MyBMWAPIError, HomeAssistantError), - (ValueError, ValueError), + ( + MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), + ( + MyBMWAPIError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), + ( + ValueError( + "Target SoC must be an integer between 20 and 100 that is a multiple of 5." + ), + ValueError, + "Target SoC must be an integer between 20 and 100 that is a multiple of 5.", + ), ], ) async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, + exc_translation: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" @@ -130,7 +153,7 @@ async def test_service_call_fail( ) # Test - with pytest.raises(expected): + with pytest.raises(expected, match=exc_translation): await hass.services.async_call( "number", "set_value", diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index a270f38ee01..878edefac27 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -16,7 +16,12 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.translation import async_get_translations -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_REASON, + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) from tests.common import snapshot_platform @@ -105,7 +110,10 @@ async def test_service_call_invalid_input( old_value = hass.states.get(entity_id).state # Test - with pytest.raises(ServiceValidationError): + with pytest.raises( + ServiceValidationError, + match=f"Option {value} is not valid for entity {entity_id}", + ): await hass.services.async_call( "select", "select_option", @@ -118,17 +126,25 @@ async def test_service_call_invalid_input( @pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( - ("raised", "expected"), + ("raised", "expected", "exc_translation"), [ - (MyBMWRemoteServiceError, HomeAssistantError), - (MyBMWAPIError, HomeAssistantError), - (ServiceValidationError, ServiceValidationError), + ( + MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), + ( + MyBMWAPIError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), ], ) async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, + exc_translation: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" @@ -146,7 +162,7 @@ async def test_service_call_fail( ) # Test - with pytest.raises(expected): + with pytest.raises(expected, match=exc_translation): await hass.services.async_call( "select", "select_option", diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py index 58bddbfc937..c28b651abaf 100644 --- a/tests/components/bmw_connected_drive/test_switch.py +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -13,7 +13,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import check_remote_service_call, setup_mocked_integration +from . import ( + REMOTE_SERVICE_EXC_REASON, + REMOTE_SERVICE_EXC_TRANSLATION, + check_remote_service_call, + setup_mocked_integration, +) from tests.common import snapshot_platform @@ -75,17 +80,25 @@ async def test_service_call_success( @pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( - ("raised", "expected"), + ("raised", "expected", "exc_translation"), [ - (MyBMWRemoteServiceError, HomeAssistantError), - (MyBMWAPIError, HomeAssistantError), - (ValueError, ValueError), + ( + MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), + ( + MyBMWAPIError(REMOTE_SERVICE_EXC_REASON), + HomeAssistantError, + REMOTE_SERVICE_EXC_TRANSLATION, + ), ], ) async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, + exc_translation: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" @@ -107,7 +120,7 @@ async def test_service_call_fail( assert hass.states.get(entity_id).state == old_value # Test - with pytest.raises(expected): + with pytest.raises(expected, match=exc_translation): await hass.services.async_call( "switch", "turn_on", @@ -122,7 +135,7 @@ async def test_service_call_fail( assert hass.states.get(entity_id).state == old_value # Test - with pytest.raises(expected): + with pytest.raises(expected, match=exc_translation): await hass.services.async_call( "switch", "turn_off", diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index d61ed4844a1..73aece4af6b 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -10,12 +10,12 @@ from unittest.mock import MagicMock, Mock, patch from aiohttp import ClientConnectionError, ClientResponseError from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.bond.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( patch_bond_bridge, @@ -219,7 +219,7 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -260,7 +260,7 @@ async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -302,7 +302,7 @@ async def test_zeroconf_form_token_times_out(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -349,7 +349,7 @@ async def test_zeroconf_form_with_token_available(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -393,7 +393,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -437,7 +437,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -475,7 +475,7 @@ async def test_zeroconf_in_setup_retry_state(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -522,7 +522,7 @@ async def test_zeroconf_already_configured_refresh_token(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -561,7 +561,7 @@ async def test_zeroconf_already_configured_no_reload_same_host( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.3"), ip_addresses=[ip_address("127.0.0.3")], hostname="mock_hostname", @@ -583,7 +583,7 @@ async def test_zeroconf_form_unexpected_error(hass: HomeAssistant) -> None: await _help_test_form_unexpected_error( hass, source=config_entries.SOURCE_ZEROCONF, - initial_input=zeroconf.ZeroconfServiceInfo( + initial_input=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index 63f7169b026..06fd5b9102c 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -13,11 +13,11 @@ from boschshcpy.information import SHCInformation import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.bosch_shc.config_flow import write_tls_asset from homeassistant.components.bosch_shc.const import CONF_SHC_CERT, CONF_SHC_KEY, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -25,7 +25,7 @@ MOCK_SETTINGS = { "name": "Test name", "device": {"mac": "test-mac", "hostname": "test-host"}, } -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="shc012345.local.", @@ -615,7 +615,7 @@ async def test_zeroconf_not_bosch_shc(hass: HomeAssistant) -> None: """Test we filter out non-bosch_shc devices.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 7a4f93f7f16..497e88053f5 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -10,7 +10,6 @@ from pybravia import ( ) import pytest -from homeassistant.components import ssdp from homeassistant.components.braviatv.const import ( CONF_NICKNAME, CONF_USE_PSK, @@ -22,6 +21,12 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import instance_id +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -46,14 +51,14 @@ BRAVIA_SOURCES = [ {"title": "AV/Component", "uri": "extInput:component?port=1"}, ] -BRAVIA_SSDP = ssdp.SsdpServiceInfo( +BRAVIA_SSDP = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://bravia-host:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:1234", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Living TV", - ssdp.ATTR_UPNP_MODEL_NAME: "KE-55XH9096", + ATTR_UPNP_UDN: "uuid:1234", + ATTR_UPNP_FRIENDLY_NAME: "Living TV", + ATTR_UPNP_MODEL_NAME: "KE-55XH9096", "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_ServiceList": { "X_ScalarWebAPI_ServiceType": [ @@ -68,14 +73,14 @@ BRAVIA_SSDP = ssdp.SsdpServiceInfo( }, ) -FAKE_BRAVIA_SSDP = ssdp.SsdpServiceInfo( +FAKE_BRAVIA_SSDP = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://soundbar-host:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:1234", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Sony Audio Device", - ssdp.ATTR_UPNP_MODEL_NAME: "HT-S700RF", + ATTR_UPNP_UDN: "uuid:1234", + ATTR_UPNP_FRIENDLY_NAME: "Sony Audio Device", + ATTR_UPNP_MODEL_NAME: "HT-S700RF", "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_ServiceList": { "X_ScalarWebAPI_ServiceType": ["guide", "system", "audio", "avContent"], diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index 62aa38d4e92..7d1b787ff0b 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -8,7 +8,7 @@ import uuid from bring_api.types import BringAuthResponse import pytest -from homeassistant.components.bring import DOMAIN +from homeassistant.components.bring.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from tests.common import MockConfigEntry, load_json_object_fixture @@ -43,6 +43,7 @@ def mock_bring_client() -> Generator[AsyncMock]: ): client = mock_client.return_value client.uuid = UUID + client.mail = EMAIL client.login.return_value = cast(BringAuthResponse, {"name": "Bring"}) client.load_lists.return_value = load_json_object_fixture("lists.json", DOMAIN) client.get_list.return_value = load_json_object_fixture("items.json", DOMAIN) diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py index 8d215a5d3ee..93e86051a75 100644 --- a/tests/components/bring/test_config_flow.py +++ b/tests/components/bring/test_config_flow.py @@ -188,3 +188,29 @@ async def test_flow_reauth_error_and_recover( assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 + + +async def test_flow_reauth_unique_id_mismatch( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, +) -> None: + """Test we abort reauth if unique id mismatch.""" + + mock_bring_client.uuid = "11111111-11111111-11111111-11111111" + + bring_config_entry.add_to_hass(hass) + + result = await bring_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 5ee66999ea4..8c215e024d5 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -1,21 +1,22 @@ """Unit tests for the bring integration.""" +from datetime import timedelta from unittest.mock import AsyncMock +from bring_api import BringAuthException, BringParseException, BringRequestException +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.bring import ( - BringAuthException, - BringParseException, - BringRequestException, - async_setup_entry, -) +from homeassistant.components.bring import async_setup_entry from homeassistant.components.bring.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr -from tests.common import MockConfigEntry +from .conftest import UUID + +from tests.common import MockConfigEntry, async_fire_time_changed async def setup_integration( @@ -115,13 +116,20 @@ async def test_config_entry_not_ready( @pytest.mark.parametrize( - "exception", [None, BringAuthException, BringRequestException, BringParseException] + ("exception", "state"), + [ + (None, ConfigEntryState.LOADED), + (BringAuthException, ConfigEntryState.SETUP_ERROR), + (BringRequestException, ConfigEntryState.SETUP_RETRY), + (BringParseException, ConfigEntryState.SETUP_RETRY), + ], ) async def test_config_entry_not_ready_auth_error( hass: HomeAssistant, bring_config_entry: MockConfigEntry, mock_bring_client: AsyncMock, exception: Exception | None, + state: ConfigEntryState, ) -> None: """Test config entry not ready from authentication error.""" @@ -132,4 +140,33 @@ async def test_config_entry_not_ready_auth_error( await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() - assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY + assert bring_config_entry.state is state + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_coordinator_skips_deactivated( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the coordinator skips fetching lists for deactivated lists.""" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert mock_bring_client.get_list.await_count == 2 + + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{UUID}_b4776778-7f6c-496e-951b-92a35d3db0dd")} + ) + device_registry.async_update_device(device.id, disabled_by=ConfigEntryDisabler.USER) + + mock_bring_client.get_list.reset_mock() + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_bring_client.get_list.await_count == 1 diff --git a/tests/components/bring/test_notification.py b/tests/components/bring/test_notification.py index b1fa28335ad..711598d3f4b 100644 --- a/tests/components/bring/test_notification.py +++ b/tests/components/bring/test_notification.py @@ -65,7 +65,7 @@ async def test_send_notification_exception( mock_bring_client.notify.side_effect = BringRequestException with pytest.raises( HomeAssistantError, - match="Failed to send push notification for bring due to a connection error, try again later", + match="Failed to send push notification for Bring! due to a connection error, try again later", ): await hass.services.async_call( DOMAIN, @@ -94,7 +94,7 @@ async def test_send_notification_service_validation_error( with pytest.raises( HomeAssistantError, match=re.escape( - "Failed to perform action bring.send_message. 'URGENT_MESSAGE' requires a value @ data['item']. Got None" + "This action requires field item, please enter a valid value for item" ), ): await hass.services.async_call( diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 0d9ed0c5345..88379530362 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -5,7 +5,7 @@ from typing import cast from bring_api import BringUserSettingsResponse import pytest -from homeassistant.components.bring import DOMAIN +from homeassistant.components.bring.const import DOMAIN from homeassistant.components.bring.coordinator import BringData from homeassistant.components.bring.util import list_language, sum_attributes diff --git a/tests/components/broadlink/test_config_flow.py b/tests/components/broadlink/test_config_flow.py index f31cb380631..14e41bbff19 100644 --- a/tests/components/broadlink/test_config_flow.py +++ b/tests/components/broadlink/test_config_flow.py @@ -8,10 +8,10 @@ import broadlink.exceptions as blke import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.broadlink.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import get_device @@ -828,7 +828,7 @@ async def test_dhcp_can_finish(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress=device.mac, @@ -862,7 +862,7 @@ async def test_dhcp_fails_to_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -881,7 +881,7 @@ async def test_dhcp_unreachable(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -900,7 +900,7 @@ async def test_dhcp_connect_unknown_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -922,7 +922,7 @@ async def test_dhcp_device_not_supported(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip=device.host, macaddress=device.mac, @@ -946,7 +946,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -971,7 +971,7 @@ async def test_dhcp_updates_host(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="4.5.6.7", macaddress="34ea34b43b5a", diff --git a/tests/components/broadlink/test_sensors.py b/tests/components/broadlink/test_sensor.py similarity index 100% rename from tests/components/broadlink/test_sensors.py rename to tests/components/broadlink/test_sensor.py diff --git a/tests/components/broadlink/test_switch.py b/tests/components/broadlink/test_switch.py index 2d4eb8e0e0b..7e3ae4efcab 100644 --- a/tests/components/broadlink/test_switch.py +++ b/tests/components/broadlink/test_switch.py @@ -92,7 +92,7 @@ async def test_slots_switch_setup_works( for slot, switch in enumerate(switches): assert ( hass.states.get(switch.entity_id).attributes[ATTR_FRIENDLY_NAME] - == f"{device.name} S{slot+1}" + == f"{device.name} S{slot + 1}" ) assert hass.states.get(switch.entity_id).state == STATE_OFF assert mock_setup.api.auth.call_count == 1 diff --git a/tests/components/brother/snapshots/test_sensor.ambr b/tests/components/brother/snapshots/test_sensor.ambr index a313e013f4b..4de85859461 100644 --- a/tests/components/brother/snapshots/test_sensor.ambr +++ b/tests/components/brother/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'bw_pages', 'unique_id': '0123456789_bw_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_b_w_pages-state] @@ -39,6 +39,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW B/W pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_b_w_pages', @@ -130,7 +131,7 @@ 'supported_features': 0, 'translation_key': 'black_drum_page_counter', 'unique_id': '0123456789_black_drum_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_black_drum_page_counter-state] @@ -138,6 +139,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Black drum page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_black_drum_page_counter', @@ -229,7 +231,7 @@ 'supported_features': 0, 'translation_key': 'black_drum_remaining_pages', 'unique_id': '0123456789_black_drum_remaining_pages', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_black_drum_remaining_pages-state] @@ -237,6 +239,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Black drum remaining pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_pages', @@ -328,7 +331,7 @@ 'supported_features': 0, 'translation_key': 'color_pages', 'unique_id': '0123456789_color_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_color_pages-state] @@ -336,6 +339,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Color pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_color_pages', @@ -377,7 +381,7 @@ 'supported_features': 0, 'translation_key': 'cyan_drum_page_counter', 'unique_id': '0123456789_cyan_drum_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_cyan_drum_page_counter-state] @@ -385,6 +389,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Cyan drum page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_cyan_drum_page_counter', @@ -476,7 +481,7 @@ 'supported_features': 0, 'translation_key': 'cyan_drum_remaining_pages', 'unique_id': '0123456789_cyan_drum_remaining_pages', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_cyan_drum_remaining_pages-state] @@ -484,6 +489,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Cyan drum remaining pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_pages', @@ -575,7 +581,7 @@ 'supported_features': 0, 'translation_key': 'drum_page_counter', 'unique_id': '0123456789_drum_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_drum_page_counter-state] @@ -583,6 +589,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Drum page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_drum_page_counter', @@ -674,7 +681,7 @@ 'supported_features': 0, 'translation_key': 'drum_remaining_pages', 'unique_id': '0123456789_drum_remaining_pages', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_drum_remaining_pages-state] @@ -682,6 +689,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Drum remaining pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_drum_remaining_pages', @@ -723,7 +731,7 @@ 'supported_features': 0, 'translation_key': 'duplex_unit_page_counter', 'unique_id': '0123456789_duplex_unit_pages_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_duplex_unit_page_counter-state] @@ -731,6 +739,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Duplex unit page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_duplex_unit_page_counter', @@ -869,7 +878,7 @@ 'supported_features': 0, 'translation_key': 'magenta_drum_page_counter', 'unique_id': '0123456789_magenta_drum_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_magenta_drum_page_counter-state] @@ -877,6 +886,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Magenta drum page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_magenta_drum_page_counter', @@ -968,7 +978,7 @@ 'supported_features': 0, 'translation_key': 'magenta_drum_remaining_pages', 'unique_id': '0123456789_magenta_drum_remaining_pages', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_magenta_drum_remaining_pages-state] @@ -976,6 +986,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Magenta drum remaining pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_magenta_drum_remaining_pages', @@ -1067,7 +1078,7 @@ 'supported_features': 0, 'translation_key': 'page_counter', 'unique_id': '0123456789_page_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_page_counter-state] @@ -1075,6 +1086,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_page_counter', @@ -1212,7 +1224,7 @@ 'supported_features': 0, 'translation_key': 'yellow_drum_page_counter', 'unique_id': '0123456789_yellow_drum_counter', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_yellow_drum_page_counter-state] @@ -1220,6 +1232,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Yellow drum page counter', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_yellow_drum_page_counter', @@ -1311,7 +1324,7 @@ 'supported_features': 0, 'translation_key': 'yellow_drum_remaining_pages', 'unique_id': '0123456789_yellow_drum_remaining_pages', - 'unit_of_measurement': None, + 'unit_of_measurement': 'pages', }) # --- # name: test_sensors[sensor.hl_l2340dw_yellow_drum_remaining_pages-state] @@ -1319,6 +1332,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL-L2340DW Yellow drum remaining pages', 'state_class': , + 'unit_of_measurement': 'pages', }), 'context': , 'entity_id': 'sensor.hl_l2340dw_yellow_drum_remaining_pages', diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 929e2f083e9..945f5549bbe 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, patch from brother import SnmpError, UnsupportedModelError import pytest -from homeassistant.components import zeroconf from homeassistant.components.brother.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import init_integration @@ -121,7 +121,7 @@ async def test_zeroconf_exception( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -145,7 +145,7 @@ async def test_zeroconf_unsupported_model(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -171,7 +171,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -200,7 +200,7 @@ async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -224,7 +224,7 @@ async def test_zeroconf_confirm_create_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 4ad5e11b8e4..36b102b933a 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -14,7 +14,8 @@ import voluptuous as vol from homeassistant.components.calendar import DOMAIN, SERVICE_GET_EVENTS from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from .conftest import MockCalendarEntity, MockConfigEntry @@ -214,8 +215,12 @@ async def test_unsupported_websocket( async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: """Test unsupported service call.""" - - with pytest.raises(HomeAssistantError, match="does not support this service"): + await async_setup_component(hass, "homeassistant", {}) + with pytest.raises( + ServiceNotSupported, + match="Entity calendar.calendar_1 does not " + "support action calendar.create_event", + ): await hass.services.async_call( DOMAIN, "create_event", diff --git a/tests/components/cambridge_audio/fixtures/get_presets_list.json b/tests/components/cambridge_audio/fixtures/get_presets_list.json index 87d49e9fd30..6443b7dfbcf 100644 --- a/tests/components/cambridge_audio/fixtures/get_presets_list.json +++ b/tests/components/cambridge_audio/fixtures/get_presets_list.json @@ -28,7 +28,7 @@ "name": "Unknown Preset Type", "type": "Unknown", "class": "stream.unknown", - "state": "OK" + "state": "UNAVAILABLE" } ] } diff --git a/tests/components/cambridge_audio/snapshots/test_diagnostics.ambr b/tests/components/cambridge_audio/snapshots/test_diagnostics.ambr index 1ba9c4093f6..8de3ccea746 100644 --- a/tests/components/cambridge_audio/snapshots/test_diagnostics.ambr +++ b/tests/components/cambridge_audio/snapshots/test_diagnostics.ambr @@ -78,7 +78,7 @@ 'name': 'Unknown Preset Type', 'preset_class': 'stream.unknown', 'preset_id': 3, - 'state': 'OK', + 'state': 'UNAVAILABLE', 'type': 'Unknown', }), ]), diff --git a/tests/components/cambridge_audio/snapshots/test_media_browser.ambr b/tests/components/cambridge_audio/snapshots/test_media_browser.ambr new file mode 100644 index 00000000000..180d5ed1bb0 --- /dev/null +++ b/tests/components/cambridge_audio/snapshots/test_media_browser.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_browse_media_root + list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'presets', + 'thumbnail': 'https://brands.home-assistant.io/_/cambridge_audio/logo.png', + 'title': 'Presets', + }), + ]) +# --- +# name: test_browse_presets + list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'music', + 'media_content_id': '1', + 'media_content_type': 'preset', + 'thumbnail': 'https://static.airable.io/43/68/432868.png', + 'title': 'Chicago House Radio', + }), + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'music', + 'media_content_id': '2', + 'media_content_type': 'preset', + 'thumbnail': 'https://i.scdn.co/image/ab67616d0000b27325a5a1ed28871e8e53e62d59', + 'title': 'Spotify: Good & Evil', + }), + ]) +# --- diff --git a/tests/components/cambridge_audio/test_config_flow.py b/tests/components/cambridge_audio/test_config_flow.py index 9a2d077b8f8..fc184ae2ef5 100644 --- a/tests/components/cambridge_audio/test_config_flow.py +++ b/tests/components/cambridge_audio/test_config_flow.py @@ -6,11 +6,11 @@ from unittest.mock import AsyncMock from aiostreammagic import StreamMagicError from homeassistant.components.cambridge_audio.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -192,3 +192,55 @@ async def test_zeroconf_duplicate( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def _start_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + + return await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], + {CONF_HOST: "192.168.20.219"}, + ) + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data == { + CONF_HOST: "192.168.20.219", + } + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure reconfigure flow aborts when the bride changes.""" + mock_stream_magic_client.info.unit_id = "different_udn" + + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/cambridge_audio/test_media_browser.py b/tests/components/cambridge_audio/test_media_browser.py new file mode 100644 index 00000000000..da72cfab534 --- /dev/null +++ b/tests/components/cambridge_audio/test_media_browser.py @@ -0,0 +1,61 @@ +"""Tests for the Cambridge Audio media browser.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .const import ENTITY_ID + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +async def test_browse_media_root( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the root browse page.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + + +async def test_browse_presets( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the presets browse page.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + "media_content_type": "presets", + "media_content_id": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index f9d30c240db..5a26e3b44f6 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -41,6 +41,7 @@ from homeassistant.util import dt as dt_util from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg from tests.common import ( + MockEntityPlatform, async_fire_time_changed, help_test_all, import_and_test_deprecated_constant_enum, @@ -802,32 +803,13 @@ async def test_use_stream_for_stills( @pytest.mark.parametrize( "module", - [camera, camera.const], + [camera], ) def test_all(module: ModuleType) -> None: """Test module.__all__ is correctly set.""" help_test_all(module) -@pytest.mark.parametrize( - "enum", - list(camera.const.StreamType), -) -@pytest.mark.parametrize( - "module", - [camera, camera.const], -) -def test_deprecated_stream_type_constants( - caplog: pytest.LogCaptureFixture, - enum: camera.const.StreamType, - module: ModuleType, -) -> None: - """Test deprecated stream type constants.""" - import_and_test_deprecated_constant_enum( - caplog, module, enum, "STREAM_TYPE_", "2025.1" - ) - - @pytest.mark.parametrize( "enum", list(camera.const.CameraState), @@ -845,21 +827,9 @@ def test_deprecated_state_constants( import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10") -@pytest.mark.parametrize( - "entity_feature", - list(camera.CameraEntityFeature), -) -def test_deprecated_support_constants( - caplog: pytest.LogCaptureFixture, - entity_feature: camera.CameraEntityFeature, +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: - """Test deprecated support constants.""" - import_and_test_deprecated_constant_enum( - caplog, camera, entity_feature, "SUPPORT_", "2025.1" - ) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: """Test deprecated supported features ints.""" class MockCamera(camera.Camera): @@ -869,6 +839,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> return 1 entity = MockCamera() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert entity.supported_features_compat is camera.CameraEntityFeature(1) assert "MockCamera" in caplog.text assert "is using deprecated supported features values" in caplog.text diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index 3fd696f5953..907071d8b1f 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -7,13 +7,12 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components.cert_expiry.const import DEFAULT_PORT, DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.components.cert_expiry.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .const import HOST, PORT -from .helpers import future_timestamp from tests.common import MockConfigEntry @@ -64,122 +63,6 @@ async def test_user_with_bad_cert(hass: HomeAssistant) -> None: assert result["result"].unique_id == f"{HOST}:{PORT}" -async def test_import_host_only(hass: HomeAssistant) -> None: - """Test import with host only.""" - with ( - patch( - "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" - ), - patch( - "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", - return_value=future_timestamp(1), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == DEFAULT_PORT - assert result["result"].unique_id == f"{HOST}:{DEFAULT_PORT}" - - -async def test_import_host_and_port(hass: HomeAssistant) -> None: - """Test import with host and port.""" - with ( - patch( - "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" - ), - patch( - "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", - return_value=future_timestamp(1), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == PORT - assert result["result"].unique_id == f"{HOST}:{PORT}" - - -async def test_import_non_default_port(hass: HomeAssistant) -> None: - """Test import with host and non-default port.""" - with ( - patch( - "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" - ), - patch( - "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", - return_value=future_timestamp(1), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST, CONF_PORT: 888}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == f"{HOST}:888" - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == 888 - assert result["result"].unique_id == f"{HOST}:888" - - -async def test_import_with_name(hass: HomeAssistant) -> None: - """Test import with name (deprecated).""" - with ( - patch( - "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" - ), - patch( - "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", - return_value=future_timestamp(1), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_NAME: "legacy", CONF_HOST: HOST, CONF_PORT: PORT}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == PORT - assert result["result"].unique_id == f"{HOST}:{PORT}" - - -async def test_bad_import(hass: HomeAssistant) -> None: - """Test import step.""" - with patch( - "homeassistant.components.cert_expiry.helper.async_get_cert", - side_effect=ConnectionRefusedError(), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "import_failed" - - async def test_abort_if_already_setup(hass: HomeAssistant) -> None: """Test we abort if the cert is already setup.""" MockConfigEntry( @@ -188,14 +71,6 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: unique_id=f"{HOST}:{PORT}", ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index e2c333cc6f3..5ba63ad1af1 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -1,59 +1,24 @@ """Tests for Cert Expiry setup.""" -from datetime import timedelta from unittest.mock import patch from freezegun import freeze_time from homeassistant.components.cert_expiry.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_PORT, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util from .const import HOST, PORT from .helpers import future_timestamp, static_datetime -from tests.common import MockConfigEntry, async_fire_time_changed - - -async def test_setup_with_config(hass: HomeAssistant) -> None: - """Test setup component with config.""" - assert hass.state is CoreState.running - - config = { - SENSOR_DOMAIN: [ - {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: PORT}, - {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: 888}, - ], - } - - with ( - patch( - "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" - ), - patch( - "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", - return_value=future_timestamp(1), - ), - ): - assert await async_setup_component(hass, SENSOR_DOMAIN, config) is True - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - next_update = dt_util.utcnow() + timedelta(seconds=20) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done(wait_background_tasks=True) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 2 +from tests.common import MockConfigEntry async def test_update_unique_id(hass: HomeAssistant) -> None: diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensor.py similarity index 100% rename from tests/components/cert_expiry/test_sensors.py rename to tests/components/cert_expiry/test_sensor.py diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py index a492d9805b5..4b5a578ecc4 100644 --- a/tests/components/climate/test_device_trigger.py +++ b/tests/components/climate/test_device_trigger.py @@ -48,7 +48,7 @@ async def test_get_triggers( ) hass.states.async_set( entity_entry.entity_id, - const.HVAC_MODE_COOL, + HVACMode.COOL, { const.ATTR_HVAC_ACTION: HVACAction.IDLE, const.ATTR_CURRENT_HUMIDITY: 23, diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index aa162e0b683..45570c63008 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -3,14 +3,12 @@ from __future__ import annotations from enum import Enum -from types import ModuleType from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock import pytest import voluptuous as vol -from homeassistant.components import climate from homeassistant.components.climate import ( DOMAIN, SET_TEMPERATURE_SCHEMA, @@ -24,6 +22,7 @@ from homeassistant.components.climate.const import ( ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -31,18 +30,15 @@ from homeassistant.components.climate.const import ( SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + SWING_HORIZONTAL_OFF, + SWING_HORIZONTAL_ON, ClimateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - PRECISION_WHOLE, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir @@ -54,9 +50,6 @@ from tests.common import ( MockModule, MockPlatform, async_mock_service, - help_test_all, - import_and_test_deprecated_constant, - import_and_test_deprecated_constant_enum, mock_integration, mock_platform, setup_test_component_platform, @@ -104,6 +97,7 @@ class MockClimateEntity(MockEntity, ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE ) _attr_preset_mode = "home" _attr_preset_modes = ["home", "away"] @@ -111,6 +105,8 @@ class MockClimateEntity(MockEntity, ClimateEntity): _attr_fan_modes = ["auto", "off"] _attr_swing_mode = "auto" _attr_swing_modes = ["auto", "off"] + _attr_swing_horizontal_mode = "on" + _attr_swing_horizontal_modes = [SWING_HORIZONTAL_ON, SWING_HORIZONTAL_OFF] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature = 20 _attr_target_temperature_high = 25 @@ -144,6 +140,10 @@ class MockClimateEntity(MockEntity, ClimateEntity): """Set swing mode.""" self._attr_swing_mode = swing_mode + def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set horizontal swing mode.""" + self._attr_swing_horizontal_mode = swing_horizontal_mode + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" self._attr_hvac_mode = hvac_mode @@ -194,67 +194,14 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s (enum_field, constant_prefix) for enum_field in enum if enum_field - not in [ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF] + not in [ + ClimateEntityFeature.TURN_ON, + ClimateEntityFeature.TURN_OFF, + ClimateEntityFeature.SWING_HORIZONTAL_MODE, + ] ] -@pytest.mark.parametrize( - "module", - [climate, climate.const], -) -def test_all(module: ModuleType) -> None: - """Test module.__all__ is correctly set.""" - help_test_all(module) - - -@pytest.mark.parametrize( - ("enum", "constant_prefix"), - _create_tuples(climate.ClimateEntityFeature, "SUPPORT_") - + _create_tuples(climate.HVACMode, "HVAC_MODE_"), -) -@pytest.mark.parametrize( - "module", - [climate, climate.const], -) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: Enum, - constant_prefix: str, - module: ModuleType, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, module, enum, constant_prefix, "2025.1" - ) - - -@pytest.mark.parametrize( - ("enum", "constant_postfix"), - [ - (climate.HVACAction.OFF, "OFF"), - (climate.HVACAction.HEATING, "HEAT"), - (climate.HVACAction.COOLING, "COOL"), - (climate.HVACAction.DRYING, "DRY"), - (climate.HVACAction.IDLE, "IDLE"), - (climate.HVACAction.FAN, "FAN"), - ], -) -def test_deprecated_current_constants( - caplog: pytest.LogCaptureFixture, - enum: climate.HVACAction, - constant_postfix: str, -) -> None: - """Test deprecated current constants.""" - import_and_test_deprecated_constant( - caplog, - climate.const, - "CURRENT_HVAC_" + constant_postfix, - f"{enum.__class__.__name__}.{enum.name}", - enum, - "2025.1", - ) - - async def test_temperature_features_is_valid( hass: HomeAssistant, register_test_integration: MockConfigEntry, @@ -339,6 +286,7 @@ async def test_mode_validation( assert state.attributes.get(ATTR_PRESET_MODE) == "home" assert state.attributes.get(ATTR_FAN_MODE) == "auto" assert state.attributes.get(ATTR_SWING_MODE) == "auto" + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "on" await hass.services.async_call( DOMAIN, @@ -358,6 +306,15 @@ async def test_mode_validation( }, blocking=True, ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + { + "entity_id": "climate.test", + "swing_horizontal_mode": "off", + }, + blocking=True, + ) await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, @@ -371,6 +328,7 @@ async def test_mode_validation( assert state.attributes.get(ATTR_PRESET_MODE) == "away" assert state.attributes.get(ATTR_FAN_MODE) == "off" assert state.attributes.get(ATTR_SWING_MODE) == "off" + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "off" await hass.services.async_call( DOMAIN, @@ -427,6 +385,25 @@ async def test_mode_validation( ) assert exc.value.translation_key == "not_valid_swing_mode" + with pytest.raises( + ServiceValidationError, + match="Horizontal swing mode invalid is not valid. Valid horizontal swing modes are: on, off", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + { + "entity_id": "climate.test", + "swing_horizontal_mode": "invalid", + }, + blocking=True, + ) + assert ( + str(exc.value) + == "Horizontal swing mode invalid is not valid. Valid horizontal swing modes are: on, off" + ) + assert exc.value.translation_key == "not_valid_horizontal_swing_mode" + with pytest.raises( ServiceValidationError, match="Fan mode invalid is not valid. Valid fan modes are: auto, off", @@ -447,289 +424,6 @@ async def test_mode_validation( assert exc.value.translation_key == "not_valid_fan_mode" -@pytest.mark.parametrize( - "supported_features_at_int", - [ - ClimateEntityFeature.TARGET_TEMPERATURE.value, - ClimateEntityFeature.TARGET_TEMPERATURE.value - | ClimateEntityFeature.TURN_ON.value - | ClimateEntityFeature.TURN_OFF.value, - ], -) -def test_deprecated_supported_features_ints( - caplog: pytest.LogCaptureFixture, supported_features_at_int: int -) -> None: - """Test deprecated supported features ints.""" - - class MockClimateEntity(ClimateEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return supported_features_at_int - - entity = MockClimateEntity() - assert entity.supported_features is ClimateEntityFeature(supported_features_at_int) - assert "MockClimateEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "ClimateEntityFeature.TARGET_TEMPERATURE" in caplog.text - caplog.clear() - assert entity.supported_features is ClimateEntityFeature(supported_features_at_int) - assert "is using deprecated supported features values" not in caplog.text - - -async def test_warning_not_implemented_turn_on_off_feature( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, -) -> None: - """Test adding feature flag and warn if missing when methods are set.""" - - called = [] - - class MockClimateEntityTest(MockClimateEntity): - """Mock Climate device.""" - - def turn_on(self) -> None: - """Turn on.""" - called.append("turn_on") - - def turn_off(self) -> None: - """Turn off.""" - called.append("turn_off") - - climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test") - - with patch.object( - MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" - ): - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.test") - assert state is not None - - assert ( - "Entity climate.test (.MockClimateEntityTest'>)" - " does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." - " Please report it to the author of the 'test' custom integration" - in caplog.text - ) - assert ( - "Entity climate.test (.MockClimateEntityTest'>)" - " does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." - " Please report it to the author of the 'test' custom integration" - in caplog.text - ) - - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON, - { - "entity_id": "climate.test", - }, - blocking=True, - ) - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_OFF, - { - "entity_id": "climate.test", - }, - blocking=True, - ) - - assert len(called) == 2 - assert "turn_on" in called - assert "turn_off" in called - - -async def test_implicit_warning_not_implemented_turn_on_off_feature( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, -) -> None: - """Test adding feature flag and warn if missing when methods are not set. - - (implicit by hvac mode) - """ - - class MockClimateEntityTest(MockEntity, ClimateEntity): - """Mock Climate device.""" - - _attr_temperature_unit = UnitOfTemperature.CELSIUS - - @property - def hvac_mode(self) -> HVACMode: - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVACMode.*. - """ - return HVACMode.HEAT - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return [HVACMode.OFF, HVACMode.HEAT] - - climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test") - - with patch.object( - MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" - ): - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.test") - assert state is not None - - assert ( - "Entity climate.test (.MockClimateEntityTest'>)" - " implements HVACMode(s): off, heat and therefore implicitly supports the turn_on/turn_off" - " methods without setting the proper ClimateEntityFeature. Please report it to the author" - " of the 'test' custom integration" in caplog.text - ) - - -async def test_no_warning_implemented_turn_on_off_feature( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, -) -> None: - """Test no warning when feature flags are set.""" - - class MockClimateEntityTest(MockClimateEntity): - """Mock Climate device.""" - - _attr_supported_features = ( - ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - - climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test") - - with patch.object( - MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" - ): - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.test") - assert state is not None - - assert ( - "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." - not in caplog.text - ) - assert ( - "does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." - not in caplog.text - ) - assert ( - " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" - not in caplog.text - ) - - -async def test_no_warning_integration_has_migrated( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, -) -> None: - """Test no warning when integration migrated using `_enable_turn_on_off_backwards_compatibility`.""" - - class MockClimateEntityTest(MockClimateEntity): - """Mock Climate device.""" - - _enable_turn_on_off_backwards_compatibility = False - _attr_supported_features = ( - ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - ) - - climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test") - - with patch.object( - MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" - ): - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.test") - assert state is not None - - assert ( - "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." - not in caplog.text - ) - assert ( - "does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." - not in caplog.text - ) - assert ( - " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" - not in caplog.text - ) - - -async def test_no_warning_integration_implement_feature_flags( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - register_test_integration: MockConfigEntry, -) -> None: - """Test no warning when integration uses the correct feature flags.""" - - class MockClimateEntityTest(MockClimateEntity): - """Mock Climate device.""" - - _attr_supported_features = ( - ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - - climate_entity = MockClimateEntityTest(name="test", entity_id="climate.test") - - with patch.object( - MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" - ): - setup_test_component_platform( - hass, DOMAIN, entities=[climate_entity], from_config_entry=True - ) - await hass.config_entries.async_setup(register_test_integration.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.test") - assert state is not None - - assert "does not set ClimateEntityFeature" not in caplog.text - assert "implements HVACMode(s):" not in caplog.text - - async def test_turn_on_off_toggle(hass: HomeAssistant) -> None: """Test turn_on/turn_off/toggle methods.""" @@ -768,7 +462,6 @@ async def test_sync_toggle(hass: HomeAssistant) -> None: class MockClimateEntityTest(MockClimateEntity): """Mock Climate device.""" - _enable_turn_on_off_backwards_compatibility = False _attr_supported_features = ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) diff --git a/tests/components/climate/test_reproduce_state.py b/tests/components/climate/test_reproduce_state.py index 0632ebcc9e4..3bc91467f14 100644 --- a/tests/components/climate/test_reproduce_state.py +++ b/tests/components/climate/test_reproduce_state.py @@ -6,6 +6,7 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -14,6 +15,7 @@ from homeassistant.components.climate import ( SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, HVACMode, @@ -96,6 +98,7 @@ async def test_state_with_context(hass: HomeAssistant) -> None: [ (SERVICE_SET_PRESET_MODE, ATTR_PRESET_MODE), (SERVICE_SET_SWING_MODE, ATTR_SWING_MODE), + (SERVICE_SET_SWING_HORIZONTAL_MODE, ATTR_SWING_HORIZONTAL_MODE), (SERVICE_SET_FAN_MODE, ATTR_FAN_MODE), (SERVICE_SET_HUMIDITY, ATTR_HUMIDITY), (SERVICE_SET_TEMPERATURE, ATTR_TEMPERATURE), @@ -122,6 +125,7 @@ async def test_attribute(hass: HomeAssistant, service, attribute) -> None: [ (SERVICE_SET_PRESET_MODE, ATTR_PRESET_MODE), (SERVICE_SET_SWING_MODE, ATTR_SWING_MODE), + (SERVICE_SET_SWING_HORIZONTAL_MODE, ATTR_SWING_HORIZONTAL_MODE), (SERVICE_SET_FAN_MODE, ATTR_FAN_MODE), ], ) diff --git a/tests/components/climate/test_significant_change.py b/tests/components/climate/test_significant_change.py index f060344722a..7d709090357 100644 --- a/tests/components/climate/test_significant_change.py +++ b/tests/components/climate/test_significant_change.py @@ -10,6 +10,7 @@ from homeassistant.components.climate import ( ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -66,6 +67,18 @@ async def test_significant_state_change(hass: HomeAssistant) -> None: ), (METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "old_value"}, False), (METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "new_value"}, True), + ( + METRIC, + {ATTR_SWING_HORIZONTAL_MODE: "old_value"}, + {ATTR_SWING_HORIZONTAL_MODE: "old_value"}, + False, + ), + ( + METRIC, + {ATTR_SWING_HORIZONTAL_MODE: "old_value"}, + {ATTR_SWING_HORIZONTAL_MODE: "new_value"}, + True, + ), # multiple attributes ( METRIC, diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 3b4868b56ac..ef7a99453f0 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -179,7 +179,7 @@ async def test_alexa_config_invalidate_token( assert await async_setup_component(hass, "homeassistant", {}) aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", @@ -192,7 +192,7 @@ async def test_alexa_config_invalidate_token( "mock-user-id", cloud_prefs, Mock( - alexa_server="example", + servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), ), @@ -239,7 +239,7 @@ async def test_alexa_config_fail_refresh_token( ) aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", @@ -256,7 +256,7 @@ async def test_alexa_config_fail_refresh_token( "mock-user-id", cloud_prefs, Mock( - alexa_server="example", + servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), ), @@ -286,7 +286,7 @@ async def test_alexa_config_fail_refresh_token( conf.async_invalidate_access_token() aioclient_mock.clear_requests() aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={"reason": reject_reason}, status=400, ) @@ -312,7 +312,7 @@ async def test_alexa_config_fail_refresh_token( # State reporting should now be re-enabled for Alexa aioclient_mock.clear_requests() aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py new file mode 100644 index 00000000000..db742525a48 --- /dev/null +++ b/tests/components/cloud/test_backup.py @@ -0,0 +1,654 @@ +"""Test the cloud backup platform.""" + +from collections.abc import AsyncGenerator, AsyncIterator, Generator +from io import StringIO +from typing import Any +from unittest.mock import Mock, PropertyMock, patch + +from aiohttp import ClientError +from hass_nabucasa import CloudError +import pytest +from yarl import URL + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, + Folder, +) +from homeassistant.components.cloud import DOMAIN +from homeassistant.components.cloud.backup import async_register_backup_agents_listener +from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.setup import async_setup_component + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_integration( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + cloud: MagicMock, + cloud_logged_in: None, +) -> AsyncGenerator[None]: + """Set up cloud integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + yield + + +@pytest.fixture +def mock_delete_file() -> Generator[MagicMock]: + """Mock list files.""" + with patch( + "homeassistant.components.cloud.backup.async_files_delete_file", + spec_set=True, + ) as delete_file: + yield delete_file + + +@pytest.fixture +def mock_get_download_details() -> Generator[MagicMock]: + """Mock list files.""" + with patch( + "homeassistant.components.cloud.backup.async_files_download_details", + spec_set=True, + ) as download_details: + download_details.return_value = { + "url": ( + "https://blabla.cloudflarestorage.com/blabla/backup/" + "462e16810d6841228828d9dd2f9e341e.tar?X-Amz-Algorithm=blah" + ), + } + yield download_details + + +@pytest.fixture +def mock_get_upload_details() -> Generator[MagicMock]: + """Mock list files.""" + with patch( + "homeassistant.components.cloud.backup.async_files_upload_details", + spec_set=True, + ) as download_details: + download_details.return_value = { + "url": ( + "https://blabla.cloudflarestorage.com/blabla/backup/" + "ea5c969e492c49df89d432a1483b8dc3.tar?X-Amz-Algorithm=blah" + ), + "headers": { + "content-md5": "HOhSM3WZkpHRYGiz4YRGIQ==", + "x-amz-meta-storage-type": "backup", + "x-amz-meta-b64json": ( + "eyJhZGRvbnMiOltdLCJiYWNrdXBfaWQiOiJjNDNiNWU2MCIsImRhdGUiOiIyMDI0LT" + "EyLTAzVDA0OjI1OjUwLjMyMDcwMy0wNTowMCIsImRhdGFiYXNlX2luY2x1ZGVkIjpm" + "YWxzZSwiZm9sZGVycyI6W10sImhvbWVhc3Npc3RhbnRfaW5jbHVkZWQiOnRydWUsIm" + "hvbWVhc3Npc3RhbnRfdmVyc2lvbiI6IjIwMjQuMTIuMC5kZXYwIiwibmFtZSI6ImVy" + "aWsiLCJwcm90ZWN0ZWQiOnRydWUsInNpemUiOjM1NjI0OTYwfQ==" + ), + }, + } + yield download_details + + +@pytest.fixture +def mock_list_files() -> Generator[MagicMock]: + """Mock list files.""" + with patch( + "homeassistant.components.cloud.backup.async_files_list", spec_set=True + ) as list_files: + list_files.return_value = [ + { + "Key": "462e16810d6841228828d9dd2f9e341e.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "storage-type": "backup", + }, + } + ] + yield list_files + + +@pytest.fixture +def cloud_logged_in(cloud: MagicMock): + """Mock cloud logged in.""" + type(cloud).is_logged_in = PropertyMock(return_value=True) + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local"}, {"agent_id": "cloud.cloud"}], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + mock_list_files: Mock, +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + mock_list_files.assert_called_once_with(cloud, storage_type="backup") + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": ["cloud.cloud"], + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) +async def test_agents_list_backups_fail_cloud( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + mock_list_files: Mock, + side_effect: Exception, +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + mock_list_files.side_effect = side_effect + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"cloud.cloud": "Failed to list backups"}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + +@pytest.mark.parametrize( + ("backup_id", "expected_result"), + [ + ( + "23e64aec", + { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": ["cloud.cloud"], + "failed_agent_ids": [], + "with_automatic_settings": None, + }, + ), + ( + "12345", + None, + ), + ], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + backup_id: str, + expected_result: dict[str, Any] | None, + mock_list_files: Mock, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + mock_list_files.assert_called_once_with(cloud, storage_type="backup") + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == expected_result + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_get_download_details: Mock, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = "23e64aec" + + aioclient_mock.get( + mock_get_download_details.return_value["url"], content=b"backup data" + ) + + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_download_fail_cloud( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_get_download_details: Mock, + side_effect: Exception, +) -> None: + """Test agent download backup, when cloud user is logged in.""" + client = await hass_client() + backup_id = "23e64aec" + mock_get_download_details.side_effect = side_effect + + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") + assert resp.status == 500 + content = await resp.content.read() + assert "Failed to get download details" in content.decode() + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_download_fail_get( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_get_download_details: Mock, +) -> None: + """Test agent download backup, when cloud user is logged in.""" + client = await hass_client() + backup_id = "23e64aec" + + aioclient_mock.get(mock_get_download_details.return_value["url"], status=500) + + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") + assert resp.status == 500 + content = await resp.content.read() + assert "Failed to download backup" in content.decode() + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_download_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test agent download backup raises error if not found.""" + client = await hass_client() + backup_id = "1234" + + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") + assert resp.status == 404 + assert await resp.content.read() == b"" + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, + mock_get_upload_details: Mock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=0, + ) + aioclient_mock.put(mock_get_upload_details.return_value["url"]) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO("test")}, + ) + + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[-1][0] == "PUT" + assert aioclient_mock.mock_calls[-1][1] == URL( + mock_get_upload_details.return_value["url"] + ) + assert isinstance(aioclient_mock.mock_calls[-1][2], AsyncIterator) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + + +@pytest.mark.parametrize("put_mock_kwargs", [{"status": 500}, {"exc": TimeoutError}]) +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_fail_put( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + aioclient_mock: AiohttpClientMocker, + mock_get_upload_details: Mock, + put_mock_kwargs: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test agent upload backup fails.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=0, + ) + aioclient_mock.put(mock_get_upload_details.return_value["url"], **put_mock_kwargs) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.asyncio.sleep"), + patch("homeassistant.components.cloud.backup.random.randint", return_value=60), + patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 2 + assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text + assert resp.status == 201 + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] + + +@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) +@pytest.mark.usefixtures("cloud_logged_in") +async def test_agents_upload_fail_cloud( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + mock_get_upload_details: Mock, + side_effect: Exception, +) -> None: + """Test agent upload backup, when cloud user is logged in.""" + client = await hass_client() + backup_id = "test-backup" + mock_get_upload_details.side_effect = side_effect + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=0, + ) + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.asyncio.sleep"), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] + + +async def test_agents_upload_not_protected( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test agent upload backup, when cloud user is logged in.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0, + ) + with ( + patch("pathlib.Path.open"), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_delete_file: Mock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "23e64aec" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_delete_file.assert_called_once() + + +@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_delete_fail_cloud( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_delete_file: Mock, + side_effect: Exception, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "23e64aec" + mock_delete_file.side_effect = side_effect + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"cloud.cloud": "Failed to delete backup"} + } + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_delete_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent download backup raises error if not found.""" + client = await hass_ws_client(hass) + backup_id = "1234" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + +@pytest.mark.parametrize("event_type", ["login", "logout"]) +async def test_calling_listener_on_login_logout( + hass: HomeAssistant, + event_type: str, +) -> None: + """Test calling listener for login and logout events.""" + listener = MagicMock() + async_register_backup_agents_listener(hass, listener=listener) + + assert listener.call_count == 0 + async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": event_type}) + await hass.async_block_till_done() + + assert listener.call_count == 1 + + +async def test_not_calling_listener_after_unsub(hass: HomeAssistant) -> None: + """Test only calling listener until unsub.""" + listener = MagicMock() + unsub = async_register_backup_agents_listener(hass, listener=listener) + + assert listener.call_count == 0 + async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "login"}) + await hass.async_block_till_done() + assert listener.call_count == 1 + + unsub() + + async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "login"}) + await hass.async_block_till_done() + assert listener.call_count == 1 + + +async def test_not_calling_listener_with_unknown_event_type( + hass: HomeAssistant, +) -> None: + """Test not calling listener if we did not get the expected event type.""" + listener = MagicMock() + async_register_backup_agents_listener(hass, listener=listener) + + assert listener.call_count == 0 + async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "unknown"}) + await hass.async_block_till_done() + assert listener.call_count == 0 diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 43eccc5ef9c..52457fe558c 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -441,6 +441,7 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: assert response == { "instance_id": "12345678901234567890", + "name": "test home", "remote": { "alias": None, "can_enable": True, diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index b152309b24a..cb456be5036 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -32,7 +32,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -264,6 +264,7 @@ async def test_google_entity_registry_sync( @pytest.mark.usefixtures("mock_cloud_login") async def test_google_device_registry_sync( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, cloud_prefs: CloudPreferences, ) -> None: @@ -275,8 +276,14 @@ async def test_google_device_registry_sync( # Enable exposing new entities to Google expose_new(hass, True) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entity_entry = entity_registry.async_get_or_create( - "light", "hue", "1234", device_id="1234" + "light", "hue", "1234", device_id=device_entry.id ) entity_entry = entity_registry.async_update_entity( entity_entry.entity_id, area_id="ABCD" @@ -294,7 +301,7 @@ async def test_google_device_registry_sync( dr.EVENT_DEVICE_REGISTRY_UPDATED, { "action": "update", - "device_id": "1234", + "device_id": device_entry.id, "changes": ["manufacturer"], }, ) @@ -308,7 +315,7 @@ async def test_google_device_registry_sync( dr.EVENT_DEVICE_REGISTRY_UPDATED, { "action": "update", - "device_id": "1234", + "device_id": device_entry.id, "changes": ["area_id"], }, ) @@ -324,7 +331,7 @@ async def test_google_device_registry_sync( dr.EVENT_DEVICE_REGISTRY_UPDATED, { "action": "update", - "device_id": "1234", + "device_id": device_entry.id, "changes": ["area_id"], }, ) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 216fc77db48..910fa03d46c 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -8,7 +8,12 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import aiohttp from hass_nabucasa import thingtalk -from hass_nabucasa.auth import Unauthenticated, UnknownError +from hass_nabucasa.auth import ( + InvalidTotpCode, + MFARequired, + Unauthenticated, + UnknownError, +) from hass_nabucasa.const import STATE_CONNECTED from hass_nabucasa.voice import TTS_VOICES import pytest @@ -107,7 +112,6 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: "cognito_client_id": "cognito_client_id", "user_pool_id": "user_pool_id", "region": "region", - "alexa_server": "alexa-api.nabucasa.com", "relayer_server": "relayer", "accounts_server": "api-test.hass.io", "google_actions": {"filter": {"include_domains": "light"}}, @@ -378,6 +382,128 @@ async def test_login_view_invalid_credentials( assert req.status == HTTPStatus.UNAUTHORIZED +async def test_login_view_mfa_required( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test logging in when MFA is required.""" + cloud_client = await hass_client() + cloud.login.side_effect = MFARequired(mfa_tokens={"session": "tokens"}) + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert req.status == HTTPStatus.UNAUTHORIZED + res = await req.json() + assert res["code"] == "mfarequired" + + +async def test_login_view_mfa_required_tokens_missing( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test logging in when MFA is required, code is provided, but session tokens are missing.""" + cloud_client = await hass_client() + cloud.login.side_effect = MFARequired(mfa_tokens={}) + + # Login with password and get MFA required error + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert req.status == HTTPStatus.UNAUTHORIZED + res = await req.json() + assert res["code"] == "mfarequired" + + # Login with TOTP code and get MFA expired error + req = await cloud_client.post( + "/api/cloud/login", + json={"email": "my_username", "code": "123346"}, + ) + + assert req.status == HTTPStatus.BAD_REQUEST + res = await req.json() + assert res["code"] == "mfaexpiredornotstarted" + + +async def test_login_view_mfa_password_and_totp_provided( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test logging in when password and TOTP code provided at once.""" + cloud_client = await hass_client() + + req = await cloud_client.post( + "/api/cloud/login", + json={"email": "my_username", "password": "my_password", "code": "123346"}, + ) + + assert req.status == HTTPStatus.BAD_REQUEST + + +async def test_login_view_invalid_totp_code( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test logging in when MFA is required and invalid code is provided.""" + cloud_client = await hass_client() + cloud.login.side_effect = MFARequired(mfa_tokens={"session": "tokens"}) + cloud.login_verify_totp.side_effect = InvalidTotpCode + + # Login with password and get MFA required error + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert req.status == HTTPStatus.UNAUTHORIZED + res = await req.json() + assert res["code"] == "mfarequired" + + # Login with TOTP code and get invalid TOTP code error + req = await cloud_client.post( + "/api/cloud/login", + json={"email": "my_username", "code": "123346"}, + ) + + assert req.status == HTTPStatus.BAD_REQUEST + res = await req.json() + assert res["code"] == "invalidtotpcode" + + +async def test_login_view_valid_totp_provided( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test logging in with valid TOTP code.""" + cloud_client = await hass_client() + cloud.login.side_effect = MFARequired(mfa_tokens={"session": "tokens"}) + + # Login with password and get MFA required error + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert req.status == HTTPStatus.UNAUTHORIZED + res = await req.json() + assert res["code"] == "mfarequired" + + # Login with TOTP code and get success response + req = await cloud_client.post( + "/api/cloud/login", + json={"email": "my_username", "code": "123346"}, + ) + + assert req.status == HTTPStatus.OK + result = await req.json() + assert result == {"success": True, "cloud_pipeline": None} + + async def test_login_view_unknown_error( cloud: MagicMock, setup_cloud: None, @@ -1692,3 +1818,45 @@ async def test_api_calls_require_admin( resp = await client.post(endpoint, json=data) assert resp.status == HTTPStatus.UNAUTHORIZED + + +async def test_login_view_dispatch_event( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, +) -> None: + """Test dispatching event while logging in.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + cloud_client = await hass_client() + + with patch( + "homeassistant.components.cloud.http_api.async_dispatcher_send" + ) as async_dispatcher_send_mock: + await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert async_dispatcher_send_mock.call_count == 1 + assert async_dispatcher_send_mock.mock_calls[0][1][1] == "cloud_event" + assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "login"} + + +async def test_logout_view_dispatch_event( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test dispatching event while logging out.""" + cloud_client = await hass_client() + + with patch( + "homeassistant.components.cloud.http_api.async_dispatcher_send" + ) as async_dispatcher_send_mock: + await cloud_client.post("/api/cloud/logout") + + assert async_dispatcher_send_mock.call_count == 1 + assert async_dispatcher_send_mock.mock_calls[0][1][1] == "cloud_event" + assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"} diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index ad123cded84..9a6d4abfc93 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -45,7 +45,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: "relayer_server": "test-relayer-server", "accounts_server": "test-acounts-server", "cloudhook_server": "test-cloudhook-server", - "alexa_server": "test-alexa-server", "acme_server": "test-acme-server", "remotestate_server": "test-remotestate-server", }, @@ -62,7 +61,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: assert cl.iot.ws_server_url == "wss://test-relayer-server/websocket" assert cl.accounts_server == "test-acounts-server" assert cl.cloudhook_server == "test-cloudhook-server" - assert cl.alexa_server == "test-alexa-server" assert cl.acme_server == "test-acme-server" assert cl.remotestate_server == "test-remotestate-server" diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index 03a8272e586..81c696bc6a7 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -7,6 +7,13 @@ import pytest from pytest_unordered import unordered from homeassistant.components.config import area_registry +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar from homeassistant.util.dt import utcnow @@ -24,10 +31,32 @@ async def client_fixture( return await hass_ws_client(hass) +@pytest.fixture +async def mock_temperature_humidity_entity(hass: HomeAssistant) -> None: + """Mock temperature and humidity sensors.""" + hass.states.async_set( + "sensor.mock_temperature", + "20", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set( + "sensor.mock_humidity", + "50", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + + async def test_list_areas( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test list entries.""" created_area1 = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00") @@ -39,10 +68,12 @@ async def test_list_areas( area2 = area_registry.async_create( "mock 2", aliases={"alias_1", "alias_2"}, - icon="mdi:garage", - picture="/image/example.png", floor_id="first_floor", + humidity_entity_id="sensor.mock_humidity", + icon="mdi:garage", labels={"label_1", "label_2"}, + picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", ) await client.send_json_auto_id({"type": "config/area_registry/list"}) @@ -52,24 +83,28 @@ async def test_list_areas( { "aliases": [], "area_id": area1.id, + "created_at": created_area1.timestamp(), "floor_id": None, + "humidity_entity_id": None, "icon": None, "labels": [], + "modified_at": created_area1.timestamp(), "name": "mock 1", "picture": None, - "created_at": created_area1.timestamp(), - "modified_at": created_area1.timestamp(), + "temperature_entity_id": None, }, { "aliases": unordered(["alias_1", "alias_2"]), "area_id": area2.id, + "created_at": created_area2.timestamp(), "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": unordered(["label_1", "label_2"]), + "modified_at": created_area2.timestamp(), "name": "mock 2", "picture": "/image/example.png", - "created_at": created_area2.timestamp(), - "modified_at": created_area2.timestamp(), + "temperature_entity_id": "sensor.mock_temperature", }, ] @@ -78,6 +113,7 @@ async def test_create_area( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test create entry.""" # Create area with only mandatory parameters @@ -97,6 +133,8 @@ async def test_create_area( "picture": None, "created_at": utcnow().timestamp(), "modified_at": utcnow().timestamp(), + "temperature_entity_id": None, + "humidity_entity_id": None, } assert len(area_registry.areas) == 1 @@ -109,12 +147,15 @@ async def test_create_area( "labels": ["label_1", "label_2"], "name": "mock 2", "picture": "/image/example.png", + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", "type": "config/area_registry/create", } ) msg = await client.receive_json() + assert msg["success"] assert msg["result"] == { "aliases": unordered(["alias_1", "alias_2"]), "area_id": ANY, @@ -125,6 +166,8 @@ async def test_create_area( "picture": "/image/example.png", "created_at": utcnow().timestamp(), "modified_at": utcnow().timestamp(), + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", } assert len(area_registry.areas) == 2 @@ -185,6 +228,7 @@ async def test_update_area( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test update entry.""" created_at = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00") @@ -195,14 +239,16 @@ async def test_update_area( await client.send_json_auto_id( { + "type": "config/area_registry/update", "aliases": ["alias_1", "alias_2"], "area_id": area.id, "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": ["label_1", "label_2"], "name": "mock 2", "picture": "/image/example.png", - "type": "config/area_registry/update", + "temperature_entity_id": "sensor.mock_temperature", } ) @@ -212,10 +258,12 @@ async def test_update_area( "aliases": unordered(["alias_1", "alias_2"]), "area_id": area.id, "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": unordered(["label_1", "label_2"]), "name": "mock 2", "picture": "/image/example.png", + "temperature_entity_id": "sensor.mock_temperature", "created_at": created_at.timestamp(), "modified_at": modified_at.timestamp(), } @@ -226,13 +274,15 @@ async def test_update_area( await client.send_json_auto_id( { + "type": "config/area_registry/update", "aliases": ["alias_1", "alias_1"], "area_id": area.id, "floor_id": None, + "humidity_entity_id": None, "icon": None, "labels": [], "picture": None, - "type": "config/area_registry/update", + "temperature_entity_id": None, } ) @@ -246,6 +296,8 @@ async def test_update_area( "labels": [], "name": "mock 2", "picture": None, + "temperature_entity_id": None, + "humidity_entity_id": None, "created_at": created_at.timestamp(), "modified_at": modified_at.timestamp(), } diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 40a9c85a8d3..b20b0fb5699 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -13,7 +13,7 @@ from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.typing import ClientSessionGenerator @@ -223,7 +223,7 @@ async def test_update_automation_config_with_blueprint_substitution_error( with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): resp = await client.post( "/api/config/automation/config/moon", diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index b96aa9ae006..ee000c5ada2 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -255,9 +255,7 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: async def test_remove_entry(hass: HomeAssistant, client: TestClient) -> None: """Test removing an entry via the API.""" - entry = MockConfigEntry( - domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED - ) + entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) entry.add_to_hass(hass) resp = await client.delete(f"/api/config/config_entries/entry/{entry.entry_id}") assert resp.status == HTTPStatus.OK @@ -268,11 +266,9 @@ async def test_remove_entry(hass: HomeAssistant, client: TestClient) -> None: async def test_reload_entry(hass: HomeAssistant, client: TestClient) -> None: """Test reloading an entry via the API.""" - entry = MockConfigEntry( - domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED - ) + entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) entry.add_to_hass(hass) - hass.config.components.add("kitchen_sink") + hass.config.components.add("test") resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" ) @@ -409,7 +405,7 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None: return self.async_show_form( step_id="user", - data_schema=schema, + data_schema=vol.Schema(schema), description_placeholders={ "url": "https://example.com", "show_advanced_options": self.show_advanced_options, @@ -792,7 +788,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non return self.async_show_form( step_id="user", - data_schema=schema, + data_schema=vol.Schema(schema), errors={"username": "Should be unique."}, ) @@ -830,7 +826,7 @@ async def test_get_progress_flow_unauth( return self.async_show_form( step_id="user", - data_schema=schema, + data_schema=vol.Schema(schema), errors={"username": "Should be unique."}, ) @@ -862,7 +858,7 @@ async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None: schema[vol.Required("enabled")] = bool return self.async_show_form( step_id="user", - data_schema=schema, + data_schema=vol.Schema(schema), description_placeholders={"enabled": "Set to true to be true"}, ) @@ -1157,11 +1153,9 @@ async def test_update_prefrences( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - entry = MockConfigEntry( - domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED - ) + entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) entry.add_to_hass(hass) - hass.config.components.add("kitchen_sink") + hass.config.components.add("test") assert entry.pref_disable_new_entities is False assert entry.pref_disable_polling is False @@ -1257,12 +1251,10 @@ async def test_disable_entry( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - entry = MockConfigEntry( - domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED - ) + entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) entry.add_to_hass(hass) assert entry.disabled_by is None - hass.config.components.add("kitchen_sink") + hass.config.components.add("test") # Disable await ws_client.send_json( diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 4550f2e08e5..ee133d3dddd 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -10,7 +10,7 @@ from homeassistant.components.config import core from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util, location +from homeassistant.util import dt as dt_util, location as location_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.common import MockUser @@ -238,7 +238,7 @@ async def test_detect_config_fail(hass: HomeAssistant, client) -> None: """Test detect config.""" with patch( "homeassistant.util.location.async_detect_location_info", - return_value=location.LocationInfo( + return_value=location_util.LocationInfo( ip=None, country_code=None, currency=None, diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index 88245eb567f..10d453b17f1 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -13,7 +13,7 @@ from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.typing import ClientSessionGenerator @@ -226,7 +226,7 @@ async def test_update_script_config_with_blueprint_substitution_error( with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): resp = await client.post( "/api/config/script/config/moon", diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py index 6c937473ddc..a4faab483ee 100644 --- a/tests/components/configurator/test_init.py +++ b/tests/components/configurator/test_init.py @@ -14,9 +14,9 @@ async def test_request_least_info(hass: HomeAssistant) -> None: """Test request config with least amount of data.""" request_id = configurator.async_request_config(hass, "Test Request", lambda _: None) - assert ( - len(hass.services.async_services().get(configurator.DOMAIN, [])) == 1 - ), "No new service registered" + assert len(hass.services.async_services().get(configurator.DOMAIN, [])) == 1, ( + "No new service registered" + ) states = hass.states.async_all() diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 42944a48022..9e1ce8d7f43 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -4,8 +4,10 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Callable, Generator +from functools import lru_cache from importlib.util import find_spec from pathlib import Path +import re import string from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch @@ -18,6 +20,7 @@ from aiohasupervisor.models import ( StoreInfo, ) import pytest +import voluptuous as vol from homeassistant.components import repairs from homeassistant.config_entries import ( @@ -27,15 +30,20 @@ from homeassistant.config_entries import ( OptionsFlowManager, ) from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant, ServiceRegistry, ServiceResponse from homeassistant.data_entry_flow import ( FlowContext, FlowHandler, FlowManager, FlowResultType, + section, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.translation import async_get_translations +from homeassistant.util import yaml as yaml_util + +from tests.common import QualityScaleStatus, get_quality_scale if TYPE_CHECKING: from homeassistant.components.hassio import AddonManager @@ -46,6 +54,9 @@ if TYPE_CHECKING: from .sensor.common import MockSensor from .switch.common import MockSwitch +# Regex for accessing the integration name from the test path +RE_REQUEST_DOMAIN = re.compile(r".*tests\/components\/([^/]+)\/.*") + @pytest.fixture(scope="session", autouse=find_spec("zeroconf") is not None) def patch_zeroconf_multiple_catcher() -> Generator[None]: @@ -69,9 +80,15 @@ def prevent_io() -> Generator[None]: @pytest.fixture def entity_registry_enabled_by_default() -> Generator[None]: """Test fixture that ensures all entities are enabled in the registry.""" - with patch( - "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", - return_value=True, + with ( + patch( + "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", + return_value=True, + ), + patch( + "homeassistant.components.device_tracker.config_entry.ScannerEntity.entity_registry_enabled_default", + return_value=True, + ), ): yield @@ -511,10 +528,14 @@ def resolution_suggestions_for_issue_fixture(supervisor_client: AsyncMock) -> As @pytest.fixture(name="supervisor_client") def supervisor_client() -> Generator[AsyncMock]: """Mock the supervisor client.""" + mounts_info_mock = AsyncMock(spec_set=["mounts"]) + mounts_info_mock.mounts = [] supervisor_client = AsyncMock() supervisor_client.addons = AsyncMock() supervisor_client.discovery = AsyncMock() supervisor_client.homeassistant = AsyncMock() + supervisor_client.host = AsyncMock() + supervisor_client.mounts.info.return_value = mounts_info_mock supervisor_client.os = AsyncMock() supervisor_client.resolution = AsyncMock() supervisor_client.supervisor = AsyncMock() @@ -531,6 +552,10 @@ def supervisor_client() -> Generator[AsyncMock]: "homeassistant.components.hassio.addon_manager.get_supervisor_client", return_value=supervisor_client, ), + patch( + "homeassistant.components.hassio.backup.get_supervisor_client", + return_value=supervisor_client, + ), patch( "homeassistant.components.hassio.discovery.get_supervisor_client", return_value=supervisor_client, @@ -613,6 +638,81 @@ def ignore_translations() -> str | list[str]: return [] +@lru_cache +def _get_integration_quality_scale(integration: str) -> dict[str, Any]: + """Get the quality scale for an integration.""" + try: + return yaml_util.load_yaml_dict( + f"homeassistant/components/{integration}/quality_scale.yaml" + ).get("rules", {}) + except FileNotFoundError: + return {} + + +def _get_integration_quality_scale_rule(integration: str, rule: str) -> str: + """Get the quality scale for an integration.""" + quality_scale = _get_integration_quality_scale(integration) + if not quality_scale or rule not in quality_scale: + return "todo" + status = quality_scale[rule] + return status if isinstance(status, str) else status["status"] + + +async def _check_step_or_section_translations( + hass: HomeAssistant, + translation_errors: dict[str, str], + category: str, + integration: str, + translation_prefix: str, + description_placeholders: dict[str, str], + data_schema: vol.Schema | None, +) -> None: + # neither title nor description are required + # - title defaults to integration name + # - description is optional + for header in ("title", "description"): + await _validate_translation( + hass, + translation_errors, + category, + integration, + f"{translation_prefix}.{header}", + description_placeholders, + translation_required=False, + ) + + if not data_schema: + return + + for data_key, data_value in data_schema.schema.items(): + if isinstance(data_value, section): + # check the nested section + await _check_step_or_section_translations( + hass, + translation_errors, + category, + integration, + f"{translation_prefix}.sections.{data_key}", + description_placeholders, + data_value.schema, + ) + continue + iqs_config_flow = _get_integration_quality_scale_rule( + integration, "config-flow" + ) + # data and data_description are compulsory + for header in ("data", "data_description"): + await _validate_translation( + hass, + translation_errors, + category, + integration, + f"{translation_prefix}.{header}.{data_key}", + description_placeholders, + translation_required=(iqs_config_flow == "done"), + ) + + async def _check_config_flow_result_translations( manager: FlowManager, flow: FlowHandler, @@ -645,19 +745,16 @@ async def _check_config_flow_result_translations( if result["type"] is FlowResultType.FORM: if step_id := result.get("step_id"): - # neither title nor description are required - # - title defaults to integration name - # - description is optional - for header in ("title", "description"): - await _validate_translation( - flow.hass, - translation_errors, - category, - integration, - f"{key_prefix}step.{step_id}.{header}", - result["description_placeholders"], - translation_required=False, - ) + await _check_step_or_section_translations( + flow.hass, + translation_errors, + category, + integration, + f"{key_prefix}step.{step_id}", + result["description_placeholders"], + result["data_schema"], + ) + if errors := result.get("errors"): for error in errors.values(): await _validate_translation( @@ -680,7 +777,7 @@ async def _check_config_flow_result_translations( translation_errors, category, integration, - f"{key_prefix}abort.{result["reason"]}", + f"{key_prefix}abort.{result['reason']}", result["description_placeholders"], ) @@ -713,15 +810,50 @@ async def _check_create_issue_translations( ) +def _get_request_quality_scale( + request: pytest.FixtureRequest, rule: str +) -> QualityScaleStatus: + if not (match := RE_REQUEST_DOMAIN.match(str(request.path))): + return QualityScaleStatus.TODO + integration = match.groups(1)[0] + return get_quality_scale(integration).get(rule, QualityScaleStatus.TODO) + + +async def _check_exception_translation( + hass: HomeAssistant, + exception: HomeAssistantError, + translation_errors: dict[str, str], + request: pytest.FixtureRequest, +) -> None: + if exception.translation_key is None: + if ( + _get_request_quality_scale(request, "exception-translations") + is QualityScaleStatus.DONE + ): + translation_errors["quality_scale"] = ( + f"Found untranslated {type(exception).__name__} exception: {exception}" + ) + return + await _validate_translation( + hass, + translation_errors, + "exceptions", + exception.translation_domain, + f"{exception.translation_key}.message", + exception.translation_placeholders, + ) + + @pytest.fixture(autouse=True) async def check_translations( - ignore_translations: str | list[str], + ignore_translations: str | list[str], request: pytest.FixtureRequest ) -> AsyncGenerator[None]: """Check that translation requirements are met. Current checks: - data entry flow results (ConfigFlow/OptionsFlow/RepairFlow) - issue registry entries + - action (service) exceptions """ if not isinstance(ignore_translations, list): ignore_translations = [ignore_translations] @@ -733,6 +865,7 @@ async def check_translations( # Keep reference to original functions _original_flow_manager_async_handle_step = FlowManager._async_handle_step _original_issue_registry_async_create_issue = ir.IssueRegistry.async_get_or_create + _original_service_registry_async_call = ServiceRegistry.async_call # Prepare override functions async def _flow_manager_async_handle_step( @@ -755,6 +888,35 @@ async def check_translations( ) return result + async def _service_registry_async_call( + self: ServiceRegistry, + domain: str, + service: str, + service_data: dict[str, Any] | None = None, + blocking: bool = False, + context: Context | None = None, + target: dict[str, Any] | None = None, + return_response: bool = False, + ) -> ServiceResponse: + try: + return await _original_service_registry_async_call( + self, + domain, + service, + service_data, + blocking, + context, + target, + return_response, + ) + except HomeAssistantError as err: + translation_coros.add( + _check_exception_translation( + self._hass, err, translation_errors, request + ) + ) + raise + # Use override functions with ( patch( @@ -765,6 +927,10 @@ async def check_translations( "homeassistant.helpers.issue_registry.IssueRegistry.async_get_or_create", _issue_registry_async_create_issue, ), + patch( + "homeassistant.core.ServiceRegistry.async_call", + _service_registry_async_call, + ), ): yield @@ -777,9 +943,6 @@ async def check_translations( f"Unused ignore translations: {', '.join(unused_ignore)}. " "Please remove them from the ignore_translations fixture." ) - for key, description in translation_errors.items(): - if key.startswith("component.cloud.issues."): - # cloud tests are flaky - continue + for description in translation_errors.values(): if description not in {"used", "unused"}: pytest.fail(description) diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr index b1f2ea0db75..c2b16ea2912 100644 --- a/tests/components/conversation/snapshots/test_default_agent.ambr +++ b/tests/components/conversation/snapshots/test_default_agent.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_custom_sentences dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -26,7 +26,7 @@ # --- # name: test_custom_sentences.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -51,7 +51,7 @@ # --- # name: test_custom_sentences_config dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -76,7 +76,7 @@ # --- # name: test_intent_alias_added_removed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -106,7 +106,7 @@ # --- # name: test_intent_alias_added_removed.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -136,7 +136,7 @@ # --- # name: test_intent_alias_added_removed.2 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -156,7 +156,7 @@ # --- # name: test_intent_conversion_not_expose_new dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -176,7 +176,7 @@ # --- # name: test_intent_conversion_not_expose_new.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -206,7 +206,7 @@ # --- # name: test_intent_entity_added_removed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -236,7 +236,7 @@ # --- # name: test_intent_entity_added_removed.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -266,7 +266,7 @@ # --- # name: test_intent_entity_added_removed.2 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -296,7 +296,7 @@ # --- # name: test_intent_entity_added_removed.3 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -308,7 +308,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called late added light', + 'speech': 'Sorry, I am not aware of any area called late added', }), }), }), @@ -316,7 +316,7 @@ # --- # name: test_intent_entity_exposed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -346,7 +346,7 @@ # --- # name: test_intent_entity_fail_if_unexposed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -366,7 +366,7 @@ # --- # name: test_intent_entity_remove_custom_name dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -378,7 +378,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called kitchen light', + 'speech': 'Sorry, I am not aware of any area called kitchen', }), }), }), @@ -386,7 +386,7 @@ # --- # name: test_intent_entity_remove_custom_name.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -416,7 +416,7 @@ # --- # name: test_intent_entity_remove_custom_name.2 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -428,7 +428,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called renamed light', + 'speech': 'Sorry, I am not aware of any area called renamed', }), }), }), @@ -436,7 +436,7 @@ # --- # name: test_intent_entity_renamed dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -466,7 +466,7 @@ # --- # name: test_intent_entity_renamed.1 dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index d9d859113f8..1102a41e6c3 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -6,7 +6,6 @@ 'id': 'conversation.home_assistant', 'name': 'Home Assistant', 'supported_languages': list([ - 'af', 'ar', 'bg', 'bn', @@ -24,7 +23,6 @@ 'fi', 'fr', 'gl', - 'gu', 'he', 'hi', 'hr', @@ -33,7 +31,6 @@ 'is', 'it', 'ka', - 'kn', 'ko', 'lb', 'lt', @@ -42,6 +39,7 @@ 'mn', 'ms', 'nb', + 'ne', 'nl', 'pl', 'pt', @@ -203,7 +201,7 @@ # --- # name: test_http_api_handle_failure dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -223,7 +221,7 @@ # --- # name: test_http_api_no_match dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -243,7 +241,7 @@ # --- # name: test_http_api_unexpected_failure dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -263,7 +261,7 @@ # --- # name: test_http_processing_intent[None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -293,7 +291,7 @@ # --- # name: test_http_processing_intent[conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -323,7 +321,7 @@ # --- # name: test_http_processing_intent[homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -353,7 +351,7 @@ # --- # name: test_ws_api[payload0] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -373,7 +371,7 @@ # --- # name: test_ws_api[payload1] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -393,7 +391,7 @@ # --- # name: test_ws_api[payload2] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -413,7 +411,7 @@ # --- # name: test_ws_api[payload3] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -433,7 +431,7 @@ # --- # name: test_ws_api[payload4] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -453,7 +451,7 @@ # --- # name: test_ws_api[payload5] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -541,7 +539,7 @@ 'name': 'HassTurnOn', }), 'match': True, - 'sentence_template': ' on [all] in ', + 'sentence_template': ' on [] ', 'slots': dict({ 'area': 'kitchen', 'domain': 'light', @@ -577,7 +575,7 @@ 'name': 'HassGetState', }), 'match': True, - 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [in ]', + 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} []', 'slots': dict({ 'area': 'kitchen', 'domain': 'lights', @@ -612,7 +610,7 @@ 'name': 'OrderBeer', }), 'match': True, - 'sentence_template': "I'd like to order a {beer_style} [please]", + 'sentence_template': "[I'd like to ]order a {beer_style} [please]", 'slots': dict({ 'beer_style': 'lager', }), @@ -699,6 +697,14 @@ }) # --- # name: test_ws_hass_agent_debug_sentence_trigger + dict({ + 'trigger_sentences': list([ + 'hello', + 'hello[ world]', + ]), + }) +# --- +# name: test_ws_hass_agent_debug_sentence_trigger.1 dict({ 'results': list([ dict({ diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 0327be064d4..911c7043a6d 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_custom_agent dict({ - 'conversation_id': 'test-conv-id', + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -44,7 +44,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -74,7 +74,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -104,7 +104,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -134,7 +134,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -164,7 +164,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -194,7 +194,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -224,7 +224,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -254,7 +254,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -284,7 +284,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -314,7 +314,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -344,7 +344,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-conversation.home_assistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -374,7 +374,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] dict({ - 'conversation_id': None, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/conversation/snapshots/test_session.ambr b/tests/components/conversation/snapshots/test_session.ambr new file mode 100644 index 00000000000..4e94157c601 --- /dev/null +++ b/tests/components/conversation/snapshots/test_session.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_template_error + dict({ + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Sorry, I had a problem with my template', + }), + }), + }), + }) +# --- +# name: test_unknown_llm_api + dict({ + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Error preparing LLM API', + }), + }), + }), + }) +# --- diff --git a/tests/components/conversation/test_agent_manager.py b/tests/components/conversation/test_agent_manager.py index 47b58a522a8..3f98c9bcd69 100644 --- a/tests/components/conversation/test_agent_manager.py +++ b/tests/components/conversation/test_agent_manager.py @@ -22,6 +22,7 @@ async def test_async_converse(hass: HomeAssistant, init_components) -> None: language="test lang", agent_id="conversation.home_assistant", device_id="test device id", + extra_system_prompt="test extra prompt", ) assert mock_process.called @@ -32,3 +33,4 @@ async def test_async_converse(hass: HomeAssistant, init_components) -> None: assert conversation_input.language == "test lang" assert conversation_input.agent_id == "conversation.home_assistant" assert conversation_input.device_id == "test device id" + assert conversation_input.extra_system_prompt == "test extra prompt" diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 6990ffe7717..54aa30b3fcf 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -11,7 +11,7 @@ import pytest from syrupy import SnapshotAssertion import yaml -from homeassistant.components import conversation, cover, media_player +from homeassistant.components import conversation, cover, media_player, weather from homeassistant.components.conversation import default_agent from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE @@ -30,6 +30,7 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, STATE_CLOSED, + STATE_OFF, STATE_ON, STATE_UNKNOWN, EntityCategory, @@ -397,10 +398,10 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: callback.reset_mock() result = await conversation.async_converse(hass, sentence, None, Context()) assert callback.call_count == 1 - assert callback.call_args[0][0] == sentence - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), sentence + assert callback.call_args[0][0].text == sentence + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + sentence + ) assert result.response.speech == { "plain": {"speech": trigger_response, "extra_data": None} } @@ -411,9 +412,9 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: callback.reset_mock() for sentence in test_sentences: result = await conversation.async_converse(hass, sentence, None, Context()) - assert ( - result.response.response_type == intent.IntentResponseType.ERROR - ), sentence + assert result.response.response_type == intent.IntentResponseType.ERROR, ( + sentence + ) assert len(callback.mock_calls) == 0 @@ -2930,7 +2931,7 @@ async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None: ) result = await agent.async_recognize_intent(user_input) assert result is not None - assert result.unmatched_entities["name"].text == "test light" + assert result.unmatched_entities["area"].text == "test " # Mark this result so we know it is from cache next time mark = "_from_cache" @@ -3013,3 +3014,167 @@ async def test_entities_filtered_by_input(hass: HomeAssistant) -> None: assert len(name_list.values) == 2 assert name_list.values[0].text_in.text == "test light" assert name_list.values[1].text_in.text == "test light" + + +@pytest.mark.usefixtures("init_components") +async def test_entities_names_are_not_templates(hass: HomeAssistant) -> None: + """Test that entities names are not treated as hassil templates.""" + # Contains hassil template characters + hass.states.async_set( + "light.test_light", "off", attributes={ATTR_FRIENDLY_NAME: " None: + """Test turn on/off in multiple languages.""" + entity_id = "light.light1234" + hass.states.async_set( + entity_id, STATE_OFF, attributes={ATTR_FRIENDLY_NAME: light_name} + ) + + on_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + await conversation.async_converse( + hass, + on_sentence, + None, + Context(), + language=language, + ) + assert len(on_calls) == 1 + assert on_calls[0].data.get("entity_id") == [entity_id] + + off_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off") + await conversation.async_converse( + hass, + off_sentence, + None, + Context(), + language=language, + ) + assert len(off_calls) == 1 + assert off_calls[0].data.get("entity_id") == [entity_id] + + +@pytest.mark.parametrize( + ("error_code", "return_response"), + [ + (intent.IntentResponseErrorCode.NO_INTENT_MATCH, False), + (intent.IntentResponseErrorCode.NO_VALID_TARGETS, False), + (intent.IntentResponseErrorCode.FAILED_TO_HANDLE, True), + (intent.IntentResponseErrorCode.UNKNOWN, True), + ], +) +@pytest.mark.usefixtures("init_components") +async def test_handle_intents_with_response_errors( + hass: HomeAssistant, + init_components: None, + area_registry: ar.AreaRegistry, + error_code: intent.IntentResponseErrorCode, + return_response: bool, +) -> None: + """Test that handle_intents does not return response errors.""" + assert await async_setup_component(hass, "climate", {}) + area_registry.async_create("living room") + + agent: default_agent.DefaultAgent = hass.data[DATA_DEFAULT_ENTITY] + + user_input = ConversationInput( + text="What is the temperature in the living room?", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + + with patch( + "homeassistant.components.conversation.default_agent.DefaultAgent._async_process_intent_result", + return_value=default_agent._make_error_result( + user_input.language, error_code, "Mock error message" + ), + ) as mock_process: + response = await agent.async_handle_intents(user_input) + + assert len(mock_process.mock_calls) == 1 + + if return_response: + assert response is not None and response.error_code == error_code + else: + assert response is None + + +@pytest.mark.usefixtures("init_components") +async def test_state_names_are_not_translated( + hass: HomeAssistant, + init_components: None, +) -> None: + """Test that state names are not translated in responses.""" + await async_setup_component(hass, "weather", {}) + + hass.states.async_set("weather.test_weather", weather.ATTR_CONDITION_PARTLYCLOUDY) + expose_entity(hass, "weather.test_weather", True) + + with patch( + "homeassistant.helpers.template.Template.async_render" + ) as mock_async_render: + result = await conversation.async_converse( + hass, "what is the weather like?", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER + mock_async_render.assert_called_once() + + assert ( + mock_async_render.call_args.args[0]["state"].state + == weather.ATTR_CONDITION_PARTLYCLOUDY + ) diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index e792d8c6913..6d69ec3c739 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -501,6 +501,19 @@ async def test_ws_hass_agent_debug_sentence_trigger( client = await hass_ws_client(hass) + # List sentence + await client.send_json_auto_id( + { + "type": "conversation/sentences/list", + } + ) + await hass.async_block_till_done() + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + # Use trigger sentence await client.send_json_auto_id( { diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 0100e62cf81..6900ba2d419 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -236,12 +236,17 @@ async def test_prepare_agent( assert len(mock_prepare.mock_calls) == 1 -async def test_async_handle_sentence_triggers(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("response_template", "expected_response"), + [("response {{ trigger.device_id }}", "response 1234"), ("", "")], +) +async def test_async_handle_sentence_triggers( + hass: HomeAssistant, response_template: str, expected_response: str +) -> None: """Test handling sentence triggers with async_handle_sentence_triggers.""" assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) - response_template = "response {{ trigger.device_id }}" assert await async_setup_component( hass, "automation", @@ -260,7 +265,6 @@ async def test_async_handle_sentence_triggers(hass: HomeAssistant) -> None: # Device id will be available in response template device_id = "1234" - expected_response = f"response {device_id}" actual_response = await async_handle_sentence_triggers( hass, ConversationInput( diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_session.py new file mode 100644 index 00000000000..feb6ca2a9e8 --- /dev/null +++ b/tests/components/conversation/test_session.py @@ -0,0 +1,415 @@ +"""Test the conversation session.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import Mock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.conversation import ConversationInput, session +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + + +@pytest.fixture +def mock_conversation_input(hass: HomeAssistant) -> ConversationInput: + """Return a conversation input instance.""" + return ConversationInput( + text="Hello", + context=Context(), + conversation_id=None, + agent_id="mock-agent-id", + device_id=None, + language="en", + ) + + +@pytest.fixture +def mock_ulid() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.util.ulid.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + +@pytest.mark.parametrize( + ("start_id", "given_id"), + [ + (None, "mock-ulid"), + # This ULID is not known as a session + ("01JHXE0952TSJCFJZ869AW6HMD", "mock-ulid"), + ("not-a-ulid", "not-a-ulid"), + ], +) +async def test_conversation_id( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + mock_ulid: Mock, + start_id: str | None, + given_id: str, +) -> None: + """Test conversation ID generation.""" + mock_conversation_input.conversation_id = start_id + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert chat_session.conversation_id == given_id + + +async def test_cleanup( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Mock cleanup of the conversation session.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 2 + conversation_id = chat_session.conversation_id + + # Generate session entry. + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + # Because we didn't add a message to the session in the last block, + # the conversation was not be persisted and we get a new ID + assert chat_session.conversation_id != conversation_id + conversation_id = chat_session.conversation_id + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + assert len(chat_session.messages) == 3 + + # Reuse conversation ID to ensure we can chat with same session + mock_conversation_input.conversation_id = conversation_id + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 4 + assert chat_session.conversation_id == conversation_id + + # Set the last updated to be older than the timeout + hass.data[session.DATA_CHAT_HISTORY][conversation_id].last_updated = ( + dt_util.utcnow() + session.CONVERSATION_TIMEOUT + ) + + async_fire_time_changed( + hass, dt_util.utcnow() + session.CONVERSATION_TIMEOUT + timedelta(seconds=1) + ) + + # Should not be cleaned up, but it should have scheduled another cleanup + mock_conversation_input.conversation_id = conversation_id + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 4 + assert chat_session.conversation_id == conversation_id + + async_fire_time_changed( + hass, dt_util.utcnow() + session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1) + ) + + # It should be cleaned up now and we start a new conversation + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert chat_session.conversation_id != conversation_id + assert len(chat_session.messages) == 2 + + +def test_chat_message() -> None: + """Test chat message.""" + with pytest.raises(ValueError): + session.ChatMessage(role="native", agent_id=None, content="", native=None) + + +async def test_add_message( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test filtering of messages.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + assert len(chat_session.messages) == 2 + + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage(role="system", agent_id=None, content="") + ) + + # No 2 user messages in a row + assert chat_session.messages[1].role == "user" + + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage(role="user", agent_id=None, content="") + ) + + # No 2 assistant messages in a row + chat_session.async_add_message( + session.ChatMessage(role="assistant", agent_id=None, content="") + ) + assert len(chat_session.messages) == 3 + assert chat_session.messages[-1].role == "assistant" + + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage(role="assistant", agent_id=None, content="") + ) + + +async def test_message_filtering( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test filtering of messages.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + messages = chat_session.async_get_messages(agent_id=None) + assert len(messages) == 2 + assert messages[0] == session.ChatMessage( + role="system", + agent_id=None, + content="", + ) + assert messages[1] == session.ChatMessage( + role="user", + agent_id=mock_conversation_input.agent_id, + content=mock_conversation_input.text, + ) + # Cannot add a second user message in a row + with pytest.raises(ValueError): + chat_session.async_add_message( + session.ChatMessage( + role="user", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + native="assistant-reply-native", + ) + ) + # Different agent, will be filtered out. + chat_session.async_add_message( + session.ChatMessage( + role="native", agent_id="another-mock-agent-id", content="", native=1 + ) + ) + chat_session.async_add_message( + session.ChatMessage( + role="native", agent_id="mock-agent-id", content="", native=1 + ) + ) + + assert len(chat_session.messages) == 5 + + messages = chat_session.async_get_messages(agent_id="mock-agent-id") + assert len(messages) == 4 + + assert messages[2] == session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + native="assistant-reply-native", + ) + assert messages[3] == session.ChatMessage( + role="native", agent_id="mock-agent-id", content="", native=1 + ) + + +async def test_llm_api( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test when we reference an LLM API.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + + assert isinstance(chat_session.llm_api, llm.APIInstance) + assert chat_session.llm_api.api.id == "assist" + + +async def test_unknown_llm_api( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, +) -> None: + """Test when we reference an LLM API that does not exists.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + with pytest.raises(session.ConverseError) as exc_info: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="unknown-api", + user_llm_prompt=None, + ) + + assert str(exc_info.value) == "Error getting LLM API unknown-api" + assert exc_info.value.as_conversation_result().as_dict() == snapshot + + +async def test_template_error( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, +) -> None: + """Test that template error handling works.""" + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + with pytest.raises(session.ConverseError) as exc_info: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt="{{ invalid_syntax", + ) + + assert str(exc_info.value) == "Error rendering prompt" + assert exc_info.value.as_conversation_result().as_dict() == snapshot + + +async def test_template_variables( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test that template variables work.""" + mock_user = Mock() + mock_user.id = "12345" + mock_user.name = "Test User" + mock_conversation_input.context = Context(user_id=mock_user.id) + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + with patch( + "homeassistant.auth.AuthManager.async_get_user", return_value=mock_user + ): + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=( + "The instance name is {{ ha_name }}. " + "The user name is {{ user_name }}. " + "The user id is {{ llm_context.context.user_id }}." + "The calling platform is {{ llm_context.platform }}." + ), + ) + + assert chat_session.user_name == "Test User" + + assert "The instance name is test home." in chat_session.messages[0].content + assert "The user name is Test User." in chat_session.messages[0].content + assert "The user id is 12345." in chat_session.messages[0].content + assert "The calling platform is test." in chat_session.messages[0].content + + +async def test_extra_systen_prompt( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test that extra system prompt works.""" + extra_system_prompt = "Garage door cover.garage_door has been left open for 30 minutes. We asked the user if they want to close it." + extra_system_prompt2 = ( + "User person.paulus came home. Asked him what he wants to do." + ) + mock_conversation_input.extra_system_prompt = extra_system_prompt + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert chat_session.extra_system_prompt == extra_system_prompt + assert chat_session.messages[0].content.endswith(extra_system_prompt) + + # Verify that follow-up conversations with no system prompt take previous one + mock_conversation_input.conversation_id = chat_session.conversation_id + mock_conversation_input.extra_system_prompt = None + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + + assert chat_session.extra_system_prompt == extra_system_prompt + assert chat_session.messages[0].content.endswith(extra_system_prompt) + + # Verify that we take new system prompts + mock_conversation_input.extra_system_prompt = extra_system_prompt2 + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert chat_session.extra_system_prompt == extra_system_prompt2 + assert chat_session.messages[0].content.endswith(extra_system_prompt2) + assert extra_system_prompt not in chat_session.messages[0].content + + # Verify that follow-up conversations with no system prompt take previous one + mock_conversation_input.extra_system_prompt = None + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + + assert chat_session.extra_system_prompt == extra_system_prompt2 + assert chat_session.messages[0].content.endswith(extra_system_prompt2) diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 903bc405cf0..9b57bb43b58 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -40,18 +40,31 @@ async def test_if_fires_on_event( }, "action": { "service": "test.automation", - "data_template": {"data": "{{ trigger }}"}, + "data": { + "data": { + "alias": "{{ trigger.alias }}", + "id": "{{ trigger.id }}", + "idx": "{{ trigger.idx }}", + "platform": "{{ trigger.platform }}", + "sentence": "{{ trigger.sentence }}", + "slots": "{{ trigger.slots }}", + "details": "{{ trigger.details }}", + "device_id": "{{ trigger.device_id }}", + "user_input": "{{ trigger.user_input }}", + } + }, }, } }, ) - + context = Context() service_response = await hass.services.async_call( "conversation", "process", {"text": "Ha ha ha"}, blocking=True, return_response=True, + context=context, ) assert service_response["response"]["speech"]["plain"]["speech"] == "Done" @@ -61,13 +74,22 @@ async def test_if_fires_on_event( assert service_calls[1].service == "automation" assert service_calls[1].data["data"] == { "alias": None, - "id": "0", - "idx": "0", + "id": 0, + "idx": 0, "platform": "conversation", "sentence": "Ha ha ha", "slots": {}, "details": {}, "device_id": None, + "user_input": { + "agent_id": None, + "context": context.as_dict(), + "conversation_id": None, + "device_id": None, + "language": "en", + "text": "Ha ha ha", + "extra_system_prompt": None, + }, } @@ -152,7 +174,19 @@ async def test_response_same_sentence( {"delay": "0:0:0.100"}, { "service": "test.automation", - "data_template": {"data": "{{ trigger }}"}, + "data_template": { + "data": { + "alias": "{{ trigger.alias }}", + "id": "{{ trigger.id }}", + "idx": "{{ trigger.idx }}", + "platform": "{{ trigger.platform }}", + "sentence": "{{ trigger.sentence }}", + "slots": "{{ trigger.slots }}", + "details": "{{ trigger.details }}", + "device_id": "{{ trigger.device_id }}", + "user_input": "{{ trigger.user_input }}", + } + }, }, {"set_conversation_response": "response 2"}, ], @@ -168,13 +202,14 @@ async def test_response_same_sentence( ] }, ) - + context = Context() service_response = await hass.services.async_call( "conversation", "process", {"text": "test sentence"}, blocking=True, return_response=True, + context=context, ) await hass.async_block_till_done() @@ -188,12 +223,21 @@ async def test_response_same_sentence( assert service_calls[1].data["data"] == { "alias": None, "id": "trigger1", - "idx": "0", + "idx": 0, "platform": "conversation", "sentence": "test sentence", "slots": {}, "details": {}, "device_id": None, + "user_input": { + "agent_id": None, + "context": context.as_dict(), + "conversation_id": None, + "device_id": None, + "language": "en", + "text": "test sentence", + "extra_system_prompt": None, + }, } @@ -231,13 +275,14 @@ async def test_response_same_sentence_with_error( ] }, ) - + context = Context() service_response = await hass.services.async_call( "conversation", "process", {"text": "test sentence"}, blocking=True, return_response=True, + context=context, ) await hass.async_block_till_done() @@ -320,12 +365,24 @@ async def test_same_trigger_multiple_sentences( }, "action": { "service": "test.automation", - "data_template": {"data": "{{ trigger }}"}, + "data_template": { + "data": { + "alias": "{{ trigger.alias }}", + "id": "{{ trigger.id }}", + "idx": "{{ trigger.idx }}", + "platform": "{{ trigger.platform }}", + "sentence": "{{ trigger.sentence }}", + "slots": "{{ trigger.slots }}", + "details": "{{ trigger.details }}", + "device_id": "{{ trigger.device_id }}", + "user_input": "{{ trigger.user_input }}", + } + }, }, } }, ) - + context = Context() await hass.services.async_call( "conversation", "process", @@ -333,6 +390,7 @@ async def test_same_trigger_multiple_sentences( "text": "hello", }, blocking=True, + context=context, ) # Only triggers once @@ -342,13 +400,22 @@ async def test_same_trigger_multiple_sentences( assert service_calls[1].service == "automation" assert service_calls[1].data["data"] == { "alias": None, - "id": "0", - "idx": "0", + "id": 0, + "idx": 0, "platform": "conversation", "sentence": "hello", "slots": {}, "details": {}, "device_id": None, + "user_input": { + "agent_id": None, + "context": context.as_dict(), + "conversation_id": None, + "device_id": None, + "language": "en", + "text": "hello", + "extra_system_prompt": None, + }, } @@ -371,7 +438,19 @@ async def test_same_sentence_multiple_triggers( }, "action": { "service": "test.automation", - "data_template": {"data": "{{ trigger }}"}, + "data_template": { + "data": { + "alias": "{{ trigger.alias }}", + "id": "{{ trigger.id }}", + "idx": "{{ trigger.idx }}", + "platform": "{{ trigger.platform }}", + "sentence": "{{ trigger.sentence }}", + "slots": "{{ trigger.slots }}", + "details": "{{ trigger.details }}", + "device_id": "{{ trigger.device_id }}", + "user_input": "{{ trigger.user_input }}", + } + }, }, }, { @@ -384,7 +463,19 @@ async def test_same_sentence_multiple_triggers( }, "action": { "service": "test.automation", - "data_template": {"data": "{{ trigger }}"}, + "data_template": { + "data": { + "alias": "{{ trigger.alias }}", + "id": "{{ trigger.id }}", + "idx": "{{ trigger.idx }}", + "platform": "{{ trigger.platform }}", + "sentence": "{{ trigger.sentence }}", + "slots": "{{ trigger.slots }}", + "details": "{{ trigger.details }}", + "device_id": "{{ trigger.device_id }}", + "user_input": "{{ trigger.user_input }}", + } + }, }, }, ], @@ -488,12 +579,25 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall]) }, "action": { "service": "test.automation", - "data_template": {"data": "{{ trigger }}"}, + "data_template": { + "data": { + "alias": "{{ trigger.alias }}", + "id": "{{ trigger.id }}", + "idx": "{{ trigger.idx }}", + "platform": "{{ trigger.platform }}", + "sentence": "{{ trigger.sentence }}", + "slots": "{{ trigger.slots }}", + "details": "{{ trigger.details }}", + "device_id": "{{ trigger.device_id }}", + "user_input": "{{ trigger.user_input }}", + } + }, }, } }, ) + context = Context() await hass.services.async_call( "conversation", "process", @@ -501,6 +605,7 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall]) "text": "play the white album by the beatles", }, blocking=True, + context=context, ) await hass.async_block_till_done() @@ -509,8 +614,8 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall]) assert service_calls[1].service == "automation" assert service_calls[1].data["data"] == { "alias": None, - "id": "0", - "idx": "0", + "id": 0, + "idx": 0, "platform": "conversation", "sentence": "play the white album by the beatles", "slots": { @@ -530,6 +635,15 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall]) }, }, "device_id": None, + "user_input": { + "agent_id": None, + "context": context.as_dict(), + "conversation_id": None, + "device_id": None, + "language": "en", + "text": "play the white album by the beatles", + "extra_system_prompt": None, + }, } diff --git a/tests/components/cookidoo/__init__.py b/tests/components/cookidoo/__init__.py new file mode 100644 index 00000000000..043f627ecc6 --- /dev/null +++ b/tests/components/cookidoo/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Cookidoo integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Mock setup of the cookidoo integration.""" + cookidoo_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(cookidoo_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py new file mode 100644 index 00000000000..a14bc285379 --- /dev/null +++ b/tests/components/cookidoo/conftest.py @@ -0,0 +1,78 @@ +"""Common fixtures for the Cookidoo tests.""" + +from collections.abc import Generator +from typing import cast +from unittest.mock import AsyncMock, patch + +from cookidoo_api import ( + CookidooAdditionalItem, + CookidooAuthResponse, + CookidooIngredientItem, +) +import pytest + +from homeassistant.components.cookidoo.const import DOMAIN +from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD + +from tests.common import MockConfigEntry, load_json_object_fixture + +EMAIL = "test-email" +PASSWORD = "test-password" +COUNTRY = "CH" +LANGUAGE = "de-CH" + +TEST_UUID = "sub_uuid" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.cookidoo.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_cookidoo_client() -> Generator[AsyncMock]: + """Mock a Cookidoo client.""" + with patch( + "homeassistant.components.cookidoo.helpers.Cookidoo", + autospec=True, + ) as mock_client: + client = mock_client.return_value + client.login.return_value = cast(CookidooAuthResponse, {"name": "Cookidoo"}) + client.get_ingredient_items.return_value = [ + CookidooIngredientItem(**item) + for item in load_json_object_fixture("ingredient_items.json", DOMAIN)[ + "data" + ] + ] + client.get_additional_items.return_value = [ + CookidooAdditionalItem(**item) + for item in load_json_object_fixture("additional_items.json", DOMAIN)[ + "data" + ] + ] + client.login.return_value = CookidooAuthResponse( + **load_json_object_fixture("login.json", DOMAIN) + ) + yield client + + +@pytest.fixture(name="cookidoo_config_entry") +def mock_cookidoo_config_entry() -> MockConfigEntry: + """Mock cookidoo configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=2, + data={ + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, + }, + entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + unique_id=TEST_UUID, + ) diff --git a/tests/components/cookidoo/fixtures/additional_items.json b/tests/components/cookidoo/fixtures/additional_items.json new file mode 100644 index 00000000000..97cd206f6ad --- /dev/null +++ b/tests/components/cookidoo/fixtures/additional_items.json @@ -0,0 +1,9 @@ +{ + "data": [ + { + "id": "unique_id_tomaten", + "name": "Tomaten", + "is_owned": false + } + ] +} diff --git a/tests/components/cookidoo/fixtures/ingredient_items.json b/tests/components/cookidoo/fixtures/ingredient_items.json new file mode 100644 index 00000000000..7fbeb90e91a --- /dev/null +++ b/tests/components/cookidoo/fixtures/ingredient_items.json @@ -0,0 +1,10 @@ +{ + "data": [ + { + "id": "unique_id_mehl", + "name": "Mehl", + "description": "200 g", + "is_owned": false + } + ] +} diff --git a/tests/components/cookidoo/fixtures/login.json b/tests/components/cookidoo/fixtures/login.json new file mode 100644 index 00000000000..e7bd6e8716c --- /dev/null +++ b/tests/components/cookidoo/fixtures/login.json @@ -0,0 +1,7 @@ +{ + "access_token": "eyJhbGci", + "expires_in": 43199, + "refresh_token": "eyJhbGciOiJSUzI1NiI", + "token_type": "bearer", + "sub": "sub_uuid" +} diff --git a/tests/components/cookidoo/snapshots/test_button.ambr b/tests/components/cookidoo/snapshots/test_button.ambr new file mode 100644 index 00000000000..a6223059aa1 --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_all_entities[button.cookidoo_clear_shopping_list_and_additional_purchases-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.cookidoo_clear_shopping_list_and_additional_purchases', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clear shopping list and additional purchases', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'todo_clear', + 'unique_id': 'sub_uuid_todo_clear', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.cookidoo_clear_shopping_list_and_additional_purchases-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cookidoo Clear shopping list and additional purchases', + }), + 'context': , + 'entity_id': 'button.cookidoo_clear_shopping_list_and_additional_purchases', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/cookidoo/snapshots/test_todo.ambr b/tests/components/cookidoo/snapshots/test_todo.ambr new file mode 100644 index 00000000000..be641432929 --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_todo.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_todo[todo.cookidoo_additional_purchases-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'todo', + 'entity_category': None, + 'entity_id': 'todo.cookidoo_additional_purchases', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Additional purchases', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'additional_item_list', + 'unique_id': 'sub_uuid_additional_items', + 'unit_of_measurement': None, + }) +# --- +# name: test_todo[todo.cookidoo_additional_purchases-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cookidoo Additional purchases', + 'supported_features': , + }), + 'context': , + 'entity_id': 'todo.cookidoo_additional_purchases', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_todo[todo.cookidoo_shopping_list-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'todo', + 'entity_category': None, + 'entity_id': 'todo.cookidoo_shopping_list', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Shopping list', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ingredient_list', + 'unique_id': 'sub_uuid_ingredients', + 'unit_of_measurement': None, + }) +# --- +# name: test_todo[todo.cookidoo_shopping_list-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cookidoo Shopping list', + 'supported_features': , + }), + 'context': , + 'entity_id': 'todo.cookidoo_shopping_list', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- diff --git a/tests/components/cookidoo/test_button.py b/tests/components/cookidoo/test_button.py new file mode 100644 index 00000000000..3e832ec9fe6 --- /dev/null +++ b/tests/components/cookidoo/test_button.py @@ -0,0 +1,84 @@ +"""Tests for the Cookidoo button platform.""" + +from unittest.mock import AsyncMock, patch + +from cookidoo_api import CookidooRequestException +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, cookidoo_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_pressing_button( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test pressing button.""" + await setup_integration(hass, cookidoo_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.cookidoo_clear_shopping_list_and_additional_purchases", + }, + blocking=True, + ) + mock_cookidoo_client.clear_shopping_list.assert_called_once() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_pressing_button_exception( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test pressing button with exception.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + mock_cookidoo_client.clear_shopping_list.side_effect = CookidooRequestException + with pytest.raises( + HomeAssistantError, + match="Failed to clear all items from the Cookidoo shopping list", + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.cookidoo_clear_shopping_list_and_additional_purchases", + }, + blocking=True, + ) diff --git a/tests/components/cookidoo/test_config_flow.py b/tests/components/cookidoo/test_config_flow.py new file mode 100644 index 00000000000..069442517a0 --- /dev/null +++ b/tests/components/cookidoo/test_config_flow.py @@ -0,0 +1,480 @@ +"""Test the Cookidoo config flow.""" + +from unittest.mock import AsyncMock + +from cookidoo_api.exceptions import ( + CookidooAuthException, + CookidooException, + CookidooRequestException, +) +import pytest + +from homeassistant.components.cookidoo.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD +from .test_init import setup_integration + +from tests.common import MockConfigEntry + +MOCK_DATA_USER_STEP = { + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, +} + +MOCK_DATA_LANGUAGE_STEP = { + CONF_LANGUAGE: LANGUAGE, +} + + +async def test_flow_user_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_cookidoo_client: AsyncMock +) -> None: + """Test we get the user flow and create entry with success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["handler"] == "cookidoo" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_USER_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "language" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LANGUAGE_STEP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Cookidoo" + assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CookidooRequestException(), "cannot_connect"), + (CookidooAuthException(), "invalid_auth"), + (CookidooException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_user_init_data_unknown_error_and_recover_on_step_1( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test unknown errors.""" + mock_cookidoo_client.login.side_effect = raise_error + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_USER_STEP, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == text_error + + # Recover + mock_cookidoo_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_USER_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "language" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LANGUAGE_STEP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].title == "Cookidoo" + + assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CookidooRequestException(), "cannot_connect"), + (CookidooAuthException(), "invalid_auth"), + (CookidooException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_user_init_data_unknown_error_and_recover_on_step_2( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test unknown errors.""" + mock_cookidoo_client.get_additional_items.side_effect = raise_error + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_USER_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "language" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LANGUAGE_STEP, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == text_error + + # Recover + mock_cookidoo_client.get_additional_items.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LANGUAGE_STEP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].title == "Cookidoo" + + assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + + +async def test_flow_user_init_data_already_configured( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test we abort user data set when entry is already configured.""" + + cookidoo_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_USER_STEP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_reconfigure_success( + hass: HomeAssistant, + cookidoo_config_entry: AsyncMock, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test we get the reconfigure flow and create entry with success.""" + cookidoo_config_entry.add_to_hass(hass) + await setup_integration(hass, cookidoo_config_entry) + + result = await cookidoo_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["handler"] == "cookidoo" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: "DE", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "language" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LANGUAGE: "de-DE"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert cookidoo_config_entry.data == { + **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: "DE", + CONF_LANGUAGE: "de-DE", + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CookidooRequestException(), "cannot_connect"), + (CookidooException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1( + hass: HomeAssistant, + cookidoo_config_entry: AsyncMock, + mock_cookidoo_client: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test unknown errors.""" + mock_cookidoo_client.login.side_effect = raise_error + + cookidoo_config_entry.add_to_hass(hass) + await setup_integration(hass, cookidoo_config_entry) + + result = await cookidoo_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["handler"] == "cookidoo" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == text_error + + # Recover + mock_cookidoo_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "language" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LANGUAGE: "de-DE"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert cookidoo_config_entry.data == { + **MOCK_DATA_USER_STEP, + CONF_COUNTRY: "DE", + CONF_LANGUAGE: "de-DE", + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CookidooRequestException(), "cannot_connect"), + (CookidooException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_2( + hass: HomeAssistant, + cookidoo_config_entry: AsyncMock, + mock_cookidoo_client: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test unknown errors.""" + mock_cookidoo_client.get_additional_items.side_effect = raise_error + + cookidoo_config_entry.add_to_hass(hass) + await setup_integration(hass, cookidoo_config_entry) + + result = await cookidoo_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["handler"] == "cookidoo" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "language" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LANGUAGE: "de-DE"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == text_error + + # Recover + mock_cookidoo_client.get_additional_items.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LANGUAGE: "de-DE"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert cookidoo_config_entry.data == { + **MOCK_DATA_USER_STEP, + CONF_COUNTRY: "DE", + CONF_LANGUAGE: "de-DE", + } + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_flow_reconfigure_id_mismatch( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test we abort when the new config is not for the same user.""" + + cookidoo_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + cookidoo_config_entry, unique_id="some_other_uuid" + ) + + result = await cookidoo_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: "DE", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + +async def test_flow_reauth( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + + cookidoo_config_entry.add_to_hass(hass) + + result = await cookidoo_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert cookidoo_config_entry.data == { + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CookidooRequestException(), "cannot_connect"), + (CookidooAuthException(), "invalid_auth"), + (CookidooException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_reauth_error_and_recover( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, + raise_error, + text_error, +) -> None: + """Test reauth flow.""" + + cookidoo_config_entry.add_to_hass(hass) + + result = await cookidoo_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_cookidoo_client.login.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_cookidoo_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert cookidoo_config_entry.data == { + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, + } + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_flow_reauth_id_mismatch( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test we abort when the new auth is not for the same user.""" + + cookidoo_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + cookidoo_config_entry, unique_id="some_other_uuid" + ) + + result = await cookidoo_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: PASSWORD}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/cookidoo/test_init.py b/tests/components/cookidoo/test_init.py new file mode 100644 index 00000000000..e97bf93bb21 --- /dev/null +++ b/tests/components/cookidoo/test_init.py @@ -0,0 +1,337 @@ +"""Unit tests for the cookidoo integration.""" + +from unittest.mock import AsyncMock + +from cookidoo_api import CookidooAuthException, CookidooRequestException +import pytest + +from homeassistant.components.cookidoo.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration +from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD, TEST_UUID + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_load_unload( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading of the config entry.""" + await setup_integration(hass, cookidoo_config_entry) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(cookidoo_config_entry.entry_id) + assert cookidoo_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "status"), + [ + (CookidooRequestException, ConfigEntryState.SETUP_RETRY), + (CookidooAuthException, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_init_failure( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + status: ConfigEntryState, + exception: Exception, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test an initialization error on integration load.""" + mock_cookidoo_client.login.side_effect = exception + await setup_integration(hass, cookidoo_config_entry) + assert cookidoo_config_entry.state == status + + +@pytest.mark.parametrize( + "cookidoo_method", + [ + "get_ingredient_items", + "get_additional_items", + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + cookidoo_method: str, +) -> None: + """Test config entry not ready.""" + getattr( + mock_cookidoo_client, cookidoo_method + ).side_effect = CookidooRequestException() + cookidoo_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(cookidoo_config_entry.entry_id) + await hass.async_block_till_done() + + assert cookidoo_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("exception", "status"), + [ + (None, ConfigEntryState.LOADED), + (CookidooRequestException, ConfigEntryState.SETUP_RETRY), + (CookidooAuthException, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_config_entry_not_ready_auth_error( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + exception: Exception | None, + status: ConfigEntryState, +) -> None: + """Test config entry not ready from authentication error.""" + + mock_cookidoo_client.get_ingredient_items.side_effect = CookidooAuthException + mock_cookidoo_client.refresh_token.side_effect = exception + + cookidoo_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(cookidoo_config_entry.entry_id) + await hass.async_block_till_done() + + assert cookidoo_config_entry.state is status + + +MOCK_CONFIG_ENTRY_MIGRATION = { + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, +} + +OLD_ENTRY_ID = "OLD_OLD_ENTRY_ID" + + +@pytest.mark.parametrize( + ( + "from_version", + "from_minor_version", + "config_data", + "unique_id", + ), + [ + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + ), + (1, 2, MOCK_CONFIG_ENTRY_MIGRATION, TEST_UUID), + ], +) +async def test_migration_from( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + from_version, + from_minor_version, + config_data, + unique_id, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test different expected migration paths.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + title=f"MIGRATION_TEST from {from_version}.{from_minor_version}", + version=from_version, + minor_version=from_minor_version, + unique_id=unique_id, + entry_id=OLD_ENTRY_ID, + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, OLD_ENTRY_ID)}, + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_ingredients", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_additional_items", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="button", + unique_id=f"{OLD_ENTRY_ID}_todo_clear", + device_id=device.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.LOADED + + # Check change in config entry and verify most recent version + assert config_entry.version == 1 + assert config_entry.minor_version == 2 + assert config_entry.unique_id == TEST_UUID + + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{TEST_UUID}_ingredients", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{TEST_UUID}_additional_items", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.BUTTON, + DOMAIN, + f"{TEST_UUID}_todo_clear", + ) + ) + ) + + +@pytest.mark.parametrize( + ( + "from_version", + "from_minor_version", + "config_data", + "unique_id", + "login_exception", + ), + [ + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooRequestException, + ), + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooAuthException, + ), + ], +) +async def test_migration_from_with_error( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + from_version, + from_minor_version, + config_data, + unique_id, + login_exception: Exception, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test different expected migration paths but with connection issues.""" + # Migration can fail due to connection issues as we have to fetch the uuid + mock_cookidoo_client.login.side_effect = login_exception + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + title=f"MIGRATION_TEST from {from_version}.{from_minor_version} with login exception '{login_exception}'", + version=from_version, + minor_version=from_minor_version, + unique_id=unique_id, + entry_id=OLD_ENTRY_ID, + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, OLD_ENTRY_ID)}, + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_ingredients", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_additional_items", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="button", + unique_id=f"{OLD_ENTRY_ID}_todo_clear", + device_id=device.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{OLD_ENTRY_ID}_ingredients", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{OLD_ENTRY_ID}_additional_items", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.BUTTON, + DOMAIN, + f"{OLD_ENTRY_ID}_todo_clear", + ) + ) + ) diff --git a/tests/components/cookidoo/test_todo.py b/tests/components/cookidoo/test_todo.py new file mode 100644 index 00000000000..d66c4f357c2 --- /dev/null +++ b/tests/components/cookidoo/test_todo.py @@ -0,0 +1,293 @@ +"""Test for todo platform of the Cookidoo integration.""" + +from collections.abc import Generator +import re +from unittest.mock import AsyncMock, patch + +from cookidoo_api import ( + CookidooAdditionalItem, + CookidooIngredientItem, + CookidooRequestException, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.todo import ( + ATTR_ITEM, + ATTR_RENAME, + ATTR_STATUS, + DOMAIN as TODO_DOMAIN, + TodoItemStatus, + TodoServices, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def todo_only() -> Generator[None]: + """Enable only the todo platform.""" + with patch( + "homeassistant.components.cookidoo.PLATFORMS", + [Platform.TODO], + ): + yield + + +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_todo( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of todo platform.""" + + with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.TODO]): + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, cookidoo_config_entry.entry_id + ) + + +async def test_update_ingredient( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test update ingredient item.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + service_data={ + ATTR_ITEM: "unique_id_mehl", + ATTR_STATUS: TodoItemStatus.COMPLETED, + }, + target={ATTR_ENTITY_ID: "todo.cookidoo_shopping_list"}, + blocking=True, + ) + + mock_cookidoo_client.edit_ingredient_items_ownership.assert_called_once_with( + [ + CookidooIngredientItem( + id="unique_id_mehl", + name="", + description="", + is_owned=True, + ) + ], + ) + + +async def test_update_ingredient_exception( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test update ingredient with exception.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + mock_cookidoo_client.edit_ingredient_items_ownership.side_effect = ( + CookidooRequestException + ) + with pytest.raises( + HomeAssistantError, match="Failed to update Mehl in Cookidoo shopping list" + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + service_data={ + ATTR_ITEM: "unique_id_mehl", + ATTR_STATUS: TodoItemStatus.COMPLETED, + }, + target={ATTR_ENTITY_ID: "todo.cookidoo_shopping_list"}, + blocking=True, + ) + + +async def test_add_additional_item( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test add additional item to list.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.ADD_ITEM, + service_data={ATTR_ITEM: "Äpfel"}, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + mock_cookidoo_client.add_additional_items.assert_called_once_with( + ["Äpfel"], + ) + + +async def test_add_additional_item_exception( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test add additional item to list with exception.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + mock_cookidoo_client.add_additional_items.side_effect = CookidooRequestException + with pytest.raises( + HomeAssistantError, match="Failed to save Äpfel to Cookidoo shopping list" + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.ADD_ITEM, + service_data={ATTR_ITEM: "Äpfel"}, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + +async def test_update_additional_item( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test update additional item.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + service_data={ + ATTR_ITEM: "unique_id_tomaten", + ATTR_RENAME: "Peperoni", + ATTR_STATUS: TodoItemStatus.COMPLETED, + }, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + mock_cookidoo_client.edit_additional_items_ownership.assert_called_once_with( + [ + CookidooAdditionalItem( + id="unique_id_tomaten", + name="Peperoni", + is_owned=True, + ) + ], + ) + mock_cookidoo_client.edit_additional_items.assert_called_once_with( + [ + CookidooAdditionalItem( + id="unique_id_tomaten", + name="Peperoni", + is_owned=True, + ) + ], + ) + + +async def test_update_additional_item_exception( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test update additional item with exception.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + mock_cookidoo_client.edit_additional_items_ownership.side_effect = ( + CookidooRequestException + ) + mock_cookidoo_client.edit_additional_items.side_effect = CookidooRequestException + with pytest.raises( + HomeAssistantError, match="Failed to update Peperoni in Cookidoo shopping list" + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + service_data={ + ATTR_ITEM: "unique_id_tomaten", + ATTR_RENAME: "Peperoni", + ATTR_STATUS: TodoItemStatus.COMPLETED, + }, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + +async def test_delete_additional_items( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test delete additional item.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.REMOVE_ITEM, + service_data={ATTR_ITEM: "unique_id_tomaten"}, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + mock_cookidoo_client.remove_additional_items.assert_called_once_with( + ["unique_id_tomaten"] + ) + + +async def test_delete_additional_items_exception( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test delete additional item.""" + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + mock_cookidoo_client.remove_additional_items.side_effect = CookidooRequestException + with pytest.raises( + HomeAssistantError, + match=re.escape("Failed to delete 1 item(s) from Cookidoo shopping list"), + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.REMOVE_ITEM, + service_data={ATTR_ITEM: "unique_id_tomaten"}, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) diff --git a/tests/components/coolmaster/test_init.py b/tests/components/coolmaster/test_init.py index 4a90d0d9276..f8ff761517f 100644 --- a/tests/components/coolmaster/test_init.py +++ b/tests/components/coolmaster/test_init.py @@ -1,6 +1,5 @@ """The test for the Coolmaster integration.""" -from homeassistant.components.coolmaster.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant @@ -20,8 +19,6 @@ async def test_unload_entry( load_int: ConfigEntry, ) -> None: """Test Coolmaster unloading an entry.""" - assert load_int.entry_id in hass.data.get(DOMAIN) await hass.config_entries.async_unload(load_int.entry_id) await hass.async_block_till_done() assert load_int.state is ConfigEntryState.NOT_LOADED - assert not hass.data.get(DOMAIN) diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index e6021d22326..a6c10d4acf1 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -766,10 +766,7 @@ async def test_if_fires_on_position( ] ) == sorted( [ - ( - f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open" - " - None" - ), + f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_gt_45 - device - {entry.entity_id} - open - closed - None", ] @@ -925,10 +922,7 @@ async def test_if_fires_on_tilt_position( ] ) == sorted( [ - ( - f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open" - " - None" - ), + f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_gt_45 - device - {entry.entity_id} - open - closed - None", ] diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 6b80dd1ab9a..f1997066638 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -14,8 +14,8 @@ from homeassistant.setup import async_setup_component from .common import MockCover from tests.common import ( + MockEntityPlatform, help_test_all, - import_and_test_deprecated_constant_enum, setup_test_component_platform, ) @@ -161,29 +161,17 @@ def test_all() -> None: help_test_all(cover) -@pytest.mark.parametrize( - ("enum", "constant_prefix"), - _create_tuples(cover.CoverEntityFeature, "SUPPORT_") - + _create_tuples(cover.CoverDeviceClass, "DEVICE_CLASS_"), -) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: Enum, - constant_prefix: str, +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, cover, enum, constant_prefix, "2025.1" - ) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: """Test deprecated supported features ints.""" class MockCoverEntity(cover.CoverEntity): _attr_supported_features = 1 entity = MockCoverEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert entity.supported_features is cover.CoverEntityFeature(1) assert "MockCoverEntity" in caplog.text assert "is using deprecated supported features values" in caplog.text diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 5c432e111dd..612ae7ab649 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -7,12 +7,12 @@ from aiohttp import ClientError, web_exceptions from pydaikin.exceptions import DaikinException import pytest -from homeassistant.components import zeroconf from homeassistant.components.daikin.const import KEY_MAC from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -121,7 +121,7 @@ async def test_api_password_abort(hass: HomeAssistant) -> None: [ ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(HOST), ip_addresses=[ip_address(HOST)], hostname="mock_hostname", diff --git a/tests/components/deako/test_init.py b/tests/components/deako/test_init.py index b4c0e8bb1f7..c2291330feb 100644 --- a/tests/components/deako/test_init.py +++ b/tests/components/deako/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from pydeako.deako import DeviceListTimeout, FindDevicesTimeout +from pydeako import FindDevicesError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -37,7 +37,7 @@ async def test_deako_async_setup_entry( assert mock_config_entry.runtime_data == pydeako_deako_mock.return_value -async def test_deako_async_setup_entry_device_list_timeout( +async def test_deako_async_setup_entry_devices_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, pydeako_deako_mock: MagicMock, @@ -47,32 +47,7 @@ async def test_deako_async_setup_entry_device_list_timeout( mock_config_entry.add_to_hass(hass) - pydeako_deako_mock.return_value.find_devices.side_effect = DeviceListTimeout() - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - pydeako_deako_mock.assert_called_once_with( - pydeako_discoverer_mock.return_value.get_address - ) - pydeako_deako_mock.return_value.connect.assert_called_once() - pydeako_deako_mock.return_value.find_devices.assert_called_once() - pydeako_deako_mock.return_value.disconnect.assert_called_once() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_deako_async_setup_entry_find_devices_timeout( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - pydeako_deako_mock: MagicMock, - pydeako_discoverer_mock: MagicMock, -) -> None: - """Test async_setup_entry raises ConfigEntryNotReady when pydeako raises FindDevicesTimeout.""" - - mock_config_entry.add_to_hass(hass) - - pydeako_deako_mock.return_value.find_devices.side_effect = FindDevicesTimeout() + pydeako_deako_mock.return_value.find_devices.side_effect = FindDevicesError() await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index ce13bbfa5d4..fe5fe022427 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pydeconz import pytest -from homeassistant.components import ssdp from homeassistant.components.deconz.config_flow import ( CONF_MANUAL_INPUT, CONF_SERIAL, @@ -20,12 +19,16 @@ from homeassistant.components.deconz.const import ( DOMAIN as DECONZ_DOMAIN, HASSIO_CONFIGURATION_URL, ) -from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from .conftest import API_KEY, BRIDGE_ID @@ -435,7 +438,7 @@ async def test_flow_ssdp_discovery( """Test that config flow for one discovered bridge works.""" result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -483,7 +486,7 @@ async def test_ssdp_discovery_update_configuration( ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://2.3.4.5:80/", @@ -509,7 +512,7 @@ async def test_ssdp_discovery_dont_update_configuration( result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -533,7 +536,7 @@ async def test_ssdp_discovery_dont_update_existing_hassio_configuration( """Test to ensure the SSDP discovery does not update an Hass.io entry.""" result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", diff --git a/tests/components/deconz/test_hub.py b/tests/components/deconz/test_hub.py index 43c51179337..1b000828b85 100644 --- a/tests/components/deconz/test_hub.py +++ b/tests/components/deconz/test_hub.py @@ -6,18 +6,18 @@ from pydeconz.websocket import State import pytest from syrupy import SnapshotAssertion -from homeassistant.components import ssdp from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN -from homeassistant.components.ssdp import ( - ATTR_UPNP_MANUFACTURER_URL, - ATTR_UPNP_SERIAL, - ATTR_UPNP_UDN, -) from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import BRIDGE_ID @@ -81,7 +81,7 @@ async def test_update_address( ): await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_st="mock_st", ssdp_usn="mock_usn", ssdp_location="http://2.3.4.5:80/", diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 15135a333ce..9ac15d4867b 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -11,7 +11,7 @@ from homeassistant.components.deconz.const import CONF_ALLOW_DECONZ_GROUPS from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, @@ -391,7 +391,7 @@ async def test_light_state_change( "call": { ATTR_ENTITY_ID: "light.hue_go", ATTR_BRIGHTNESS: 200, - ATTR_COLOR_TEMP: 200, + ATTR_COLOR_TEMP_KELVIN: 5000, ATTR_TRANSITION: 5, ATTR_FLASH: FLASH_SHORT, ATTR_EFFECT: EFFECT_COLORLOOP, @@ -804,7 +804,7 @@ async def test_groups( "call": { ATTR_ENTITY_ID: "light.group", ATTR_BRIGHTNESS: 200, - ATTR_COLOR_TEMP: 200, + ATTR_COLOR_TEMP_KELVIN: 5000, ATTR_TRANSITION: 5, ATTR_FLASH: FLASH_SHORT, ATTR_EFFECT: EFFECT_COLORLOOP, @@ -1079,7 +1079,7 @@ async def test_non_color_light_reports_color( hass.states.get("light.group").attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP ) - assert hass.states.get("light.group").attributes[ATTR_COLOR_TEMP] == 250 + assert hass.states.get("light.group").attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 # Updating a scene will return a faulty color value # for a non-color light causing an exception in hs_color @@ -1099,7 +1099,7 @@ async def test_non_color_light_reports_color( group = hass.states.get("light.group") assert group.attributes[ATTR_COLOR_MODE] == ColorMode.XY assert group.attributes[ATTR_HS_COLOR] == (40.571, 41.176) - assert group.attributes.get(ATTR_COLOR_TEMP) is None + assert group.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None @pytest.mark.parametrize( diff --git a/tests/components/demo/test_light.py b/tests/components/demo/test_light.py index 8fcdb8a9c2e..b39b09d9307 100644 --- a/tests/components/demo/test_light.py +++ b/tests/components/demo/test_light.py @@ -9,11 +9,10 @@ from homeassistant.components.demo import DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, - ATTR_KELVIN, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, @@ -79,25 +78,33 @@ async def test_state_attributes(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_EFFECT: "none", ATTR_COLOR_TEMP: 400}, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_EFFECT: "none", + ATTR_COLOR_TEMP_KELVIN: 2500, + }, blocking=True, ) state = hass.states.get(ENTITY_LIGHT) - assert state.attributes.get(ATTR_COLOR_TEMP) == 400 - assert state.attributes.get(ATTR_MIN_MIREDS) == 153 - assert state.attributes.get(ATTR_MAX_MIREDS) == 500 + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) == 2500 + assert state.attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN) == 6535 + assert state.attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN) == 2000 assert state.attributes.get(ATTR_EFFECT) == "none" await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS_PCT: 50, ATTR_KELVIN: 3000}, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_BRIGHTNESS_PCT: 50, + ATTR_COLOR_TEMP_KELVIN: 3000, + }, blocking=True, ) state = hass.states.get(ENTITY_LIGHT) - assert state.attributes.get(ATTR_COLOR_TEMP) == 333 + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) == 3000 assert state.attributes.get(ATTR_BRIGHTNESS) == 128 diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index a4e4d6f0e1f..f910e6e53ac 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -22,11 +22,7 @@ from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, + VacuumActivity, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -75,35 +71,35 @@ async def test_supported_features(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.attributes.get(ATTR_FAN_SPEED) == "medium" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_MOST) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12412 assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.attributes.get(ATTR_FAN_SPEED) == "medium" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_BASIC) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12360 assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_MINIMAL) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_NONE) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED async def test_methods(hass: HomeAssistant) -> None: @@ -111,29 +107,29 @@ async def test_methods(hass: HomeAssistant) -> None: await common.async_start(hass, ENTITY_VACUUM_BASIC) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_BASIC) - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING await common.async_stop(hass, ENTITY_VACUUM_BASIC) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_BASIC) - assert state.state == STATE_IDLE + assert state.state == VacuumActivity.IDLE state = hass.states.get(ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED await async_setup_component(hass, "notify", {}) await hass.async_block_till_done() await common.async_locate(hass, ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_IDLE + assert state.state == VacuumActivity.IDLE await common.async_return_to_base(hass, ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_RETURNING + assert state.state == VacuumActivity.RETURNING await common.async_set_fan_speed( hass, FAN_SPEEDS[-1], entity_id=ENTITY_VACUUM_COMPLETE @@ -145,21 +141,21 @@ async def test_methods(hass: HomeAssistant) -> None: await common.async_clean_spot(hass, ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING await common.async_pause(hass, ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_PAUSED + assert state.state == VacuumActivity.PAUSED await common.async_return_to_base(hass, ENTITY_VACUUM_COMPLETE) state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_RETURNING + assert state.state == VacuumActivity.RETURNING async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED async def test_unsupported_methods(hass: HomeAssistant) -> None: @@ -251,4 +247,4 @@ async def test_send_command(hass: HomeAssistant) -> None: new_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) assert old_state_complete != new_state_complete - assert new_state_complete.state == STATE_IDLE + assert new_state_complete.state == VacuumActivity.IDLE diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py index 324b795052c..92fe381ac4d 100644 --- a/tests/components/denonavr/test_config_flow.py +++ b/tests/components/denonavr/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.denonavr.config_flow import ( CONF_MANUFACTURER, CONF_SERIAL_NUMBER, @@ -21,6 +20,12 @@ from homeassistant.components.denonavr.config_flow import ( from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -313,14 +318,14 @@ async def test_config_flow_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, - ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) @@ -353,14 +358,14 @@ async def test_config_flow_ssdp_not_denon(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: "NotSupported", - ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: "NotSupported", + ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) @@ -377,12 +382,12 @@ async def test_config_flow_ssdp_missing_info(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, }, ), ) @@ -399,14 +404,14 @@ async def test_config_flow_ssdp_ignored_model(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, - ssdp.ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) diff --git a/tests/components/devialet/__init__.py b/tests/components/devialet/__init__.py index 28ab6229c44..08ccaffa92d 100644 --- a/tests/components/devialet/__init__.py +++ b/tests/components/devialet/__init__.py @@ -5,10 +5,10 @@ from ipaddress import ip_address from aiohttp import ClientError as ServerTimeoutError from devialet.const import UrlSuffix -from homeassistant.components import zeroconf from homeassistant.components.devialet.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -25,7 +25,7 @@ CONF_DATA = { MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} MOCK_USER_INPUT = {CONF_HOST: HOST} -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(HOST), ip_addresses=[ip_address(HOST)], hostname="PhantomISilver-L00P00000AB11.local.", diff --git a/tests/components/devialet/fixtures/general_info.json b/tests/components/devialet/fixtures/general_info.json index 6ff1a724f08..3efe8ae2080 100644 --- a/tests/components/devialet/fixtures/general_info.json +++ b/tests/components/devialet/fixtures/general_info.json @@ -1,18 +1,22 @@ { + "availableFeatures": ["powerManagement"], "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", "deviceName": "Livingroom", "firmwareFamily": "DOS", "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "installationId": "abc1eca1-1234-5251-a123-12ac1a3e9fe8", "ipControlVersion": "1", + "isSystemLeader": true, "model": "Phantom I Silver", + "modelFamily": "Phantom I", + "powerRating": "105 dB", "release": { "buildType": "release", - "canonicalVersion": "2.16.1.49152", - "version": "2.16.1" + "canonicalVersion": "2.17.6.49152", + "version": "2.17.6" }, "role": "FrontLeft", "serial": "L00P00000AB11", - "standbyEntryDelay": 0, - "standbyState": "Unknown", + "setupState": "ongoing", "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl" } diff --git a/tests/components/devialet/fixtures/source_state.json b/tests/components/devialet/fixtures/source_state.json index d389675ac98..f24575cd264 100644 --- a/tests/components/devialet/fixtures/source_state.json +++ b/tests/components/devialet/fixtures/source_state.json @@ -1,5 +1,5 @@ { - "availableOptions": ["play", "pause", "previous", "next", "seek"], + "availableOperations": ["play", "pause", "previous", "next", "seek"], "metadata": { "album": "1 (Remastered)", "artist": "The Beatles", diff --git a/tests/components/devialet/fixtures/system_info.json b/tests/components/devialet/fixtures/system_info.json index f496e5557d2..a43b4e83901 100644 --- a/tests/components/devialet/fixtures/system_info.json +++ b/tests/components/devialet/fixtures/system_info.json @@ -1,6 +1,29 @@ { - "availableFeatures": ["nightMode", "equalizer", "balance"], + "availableFeatures": [ + "nightMode", + "equalizer", + "balance", + "preferredInterfaceControl" + ], + "devices": [ + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "isSystemLeader": true, + "name": "Livingroom", + "role": null, + "serial": "L05P00066EW14" + }, + { + "deviceId": "1zzyyxx2-3456-67g8-9h0i-1zz23456lz78", + "isSystemLeader": false, + "name": "Phantom I Silver-123a", + "role": null, + "serial": "M05P00066EW15" + } + ], "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "multiroomFamily": "sync33", "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl", - "systemName": "Devialet" + "systemName": "Devialet", + "systemType": "stereo" } diff --git a/tests/components/devialet/test_diagnostics.py b/tests/components/devialet/test_diagnostics.py index 97c23efe713..6bf643ce682 100644 --- a/tests/components/devialet/test_diagnostics.py +++ b/tests/components/devialet/test_diagnostics.py @@ -31,11 +31,13 @@ async def test_diagnostics( "source_list": [ "Airplay", "Bluetooth", - "Online", "Optical left", "Optical right", "Raat", "Spotify Connect", + "UPnP", ], "source": "spotifyconnect", + "upnp_device_type": "Not available", + "upnp_device_url": "Not available", } diff --git a/tests/components/devialet/test_media_player.py b/tests/components/devialet/test_media_player.py index 4e8f9b1dc03..6ca3d23218f 100644 --- a/tests/components/devialet/test_media_player.py +++ b/tests/components/devialet/test_media_player.py @@ -96,7 +96,7 @@ SERVICE_TO_DATA = { ], SERVICE_SELECT_SOURCE: [ {ATTR_INPUT_SOURCE: "Optical left"}, - {ATTR_INPUT_SOURCE: "Online"}, + {ATTR_INPUT_SOURCE: "UPnP"}, ], } @@ -203,7 +203,7 @@ async def test_media_player_playing( ) with patch.object( - DevialetApi, "available_options", new_callable=PropertyMock + DevialetApi, "available_operations", new_callable=PropertyMock ) as mock: mock.return_value = None await hass.config_entries.async_reload(entry.entry_id) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 362258b035a..6226669aa0f 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -5,7 +5,6 @@ from datetime import datetime, timedelta import json import logging import os -from types import ModuleType from unittest.mock import call, patch import pytest @@ -37,8 +36,6 @@ from .common import MockScanner, mock_legacy_device_tracker_setup from tests.common import ( assert_setup_component, async_fire_time_changed, - help_test_all, - import_and_test_deprecated_constant_enum, mock_registry, mock_restore_cache, patch_yaml_files, @@ -162,9 +159,9 @@ async def test_duplicate_mac_dev_id(mock_warning, hass: HomeAssistant) -> None: ] legacy.DeviceTracker(hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) - assert ( - mock_warning.call_count == 1 - ), "The only warning call should be duplicates (check DEBUG)" + assert mock_warning.call_count == 1, ( + "The only warning call should be duplicates (check DEBUG)" + ) args, _ = mock_warning.call_args assert "Duplicate device MAC" in args[0], "Duplicate MAC warning expected" @@ -180,9 +177,9 @@ async def test_duplicate_mac_dev_id(mock_warning, hass: HomeAssistant) -> None: legacy.DeviceTracker(hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) - assert ( - mock_warning.call_count == 1 - ), "The only warning call should be duplicates (check DEBUG)" + assert mock_warning.call_count == 1, ( + "The only warning call should be duplicates (check DEBUG)" + ) args, _ = mock_warning.call_args assert "Duplicate device IDs" in args[0], "Duplicate device IDs warning expected" @@ -739,28 +736,3 @@ def test_see_schema_allowing_ios_calls() -> None: "hostname": "beer", } ) - - -@pytest.mark.parametrize( - "module", - [device_tracker, device_tracker.const], -) -def test_all(module: ModuleType) -> None: - """Test module.__all__ is correctly set.""" - help_test_all(module) - - -@pytest.mark.parametrize(("enum"), list(SourceType)) -@pytest.mark.parametrize( - "module", - [device_tracker, device_tracker.const], -) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: SourceType, - module: ModuleType, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, module, enum, "SOURCE_TYPE_", "2025.1" - ) diff --git a/tests/components/devolo_home_control/__init__.py b/tests/components/devolo_home_control/__init__.py index f0e18eaf1a2..a1bf9d56aac 100644 --- a/tests/components/devolo_home_control/__init__.py +++ b/tests/components/devolo_home_control/__init__.py @@ -11,7 +11,6 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: config = { "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", } entry = MockConfigEntry( domain=DOMAIN, data=config, entry_id="123456", unique_id="123456" diff --git a/tests/components/devolo_home_control/const.py b/tests/components/devolo_home_control/const.py index 3351e42c988..06e7a8bcd9c 100644 --- a/tests/components/devolo_home_control/const.py +++ b/tests/components/devolo_home_control/const.py @@ -2,9 +2,9 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], port=14791, @@ -22,7 +22,7 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( }, ) -DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", @@ -32,7 +32,7 @@ DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -DISCOVERY_INFO_WRONG_DEVICE = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WRONG_DEVICE = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index 33c0a230e90..d611c73cf2c 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -70,6 +70,18 @@ class MultiLevelSensorPropertyMock(MultiLevelSensorProperty): self._logger = MagicMock() +class BrightnessSensorPropertyMock(MultiLevelSensorProperty): + """devolo Home Control brightness sensor mock.""" + + def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called + """Initialize the mock.""" + self.element_uid = "Test" + self.sensor_type = "light" + self._unit = "%" + self._value = 20 + self._logger = MagicMock() + + class MultiLevelSwitchPropertyMock(MultiLevelSwitchProperty): """devolo Home Control multi level switch mock.""" @@ -138,7 +150,18 @@ class BinarySensorMockOverload(DeviceMock): """Initialize the mock.""" super().__init__() self.binary_sensor_property = {"Overload": BinarySensorPropertyMock()} - self.binary_sensor_property["Overload"].sensor_type = "overload" + self.binary_sensor_property["Overload"].sub_type = "overload" + + +class BrightnessSensorMock(DeviceMock): + """devolo Home Control brightness sensor device mock.""" + + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.multi_level_sensor_property = { + "devolo.MultiLevelSensor:Test": BrightnessSensorPropertyMock() + } class ClimateMock(DeviceMock): @@ -275,6 +298,19 @@ class HomeControlMockBinarySensor(HomeControlMock): self.publisher.unregister = MagicMock() +class HomeControlMockBrightness(HomeControlMock): + """devolo Home Control gateway mock with brightness devices.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + super().__init__() + self.devices = { + "Test": BrightnessSensorMock(), + } + self.publisher = Publisher(self.devices.keys()) + self.publisher.unregister = MagicMock() + + class HomeControlMockClimate(HomeControlMock): """devolo Home Control gateway mock with climate devices.""" diff --git a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr index 0980a550c7b..c5daed73b33 100644 --- a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr @@ -88,7 +88,7 @@ 'platform': 'devolo_home_control', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'overload', 'unique_id': 'Overload', 'unit_of_measurement': None, }) @@ -134,7 +134,7 @@ 'platform': 'devolo_home_control', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'button', 'unique_id': 'Test_1', 'unit_of_measurement': None, }) diff --git a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr index 6a7ef1fc6d3..abedc128756 100644 --- a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -33,7 +33,6 @@ ]), 'entry': dict({ 'data': dict({ - 'mydevolo_url': 'https://test_mydevolo_url.test', 'password': '**REDACTED**', 'username': '**REDACTED**', }), diff --git a/tests/components/devolo_home_control/snapshots/test_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_sensor.ambr index 7f67c70f6ac..3c23385594a 100644 --- a/tests/components/devolo_home_control/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_sensor.ambr @@ -3,12 +3,12 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Test Battery level', + 'friendly_name': 'Test Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.test_battery_level', + 'entity_id': 'sensor.test_battery', 'last_changed': , 'last_reported': , 'last_updated': , @@ -29,7 +29,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.test_battery_level', + 'entity_id': 'sensor.test_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -41,7 +41,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery level', + 'original_name': 'Battery', 'platform': 'devolo_home_control', 'previous_unique_id': None, 'supported_features': 0, @@ -50,16 +50,66 @@ 'unit_of_measurement': '%', }) # --- +# name: test_brightness_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Brightness', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_brightness_sensor.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brightness', + 'platform': 'devolo_home_control', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness', + 'unique_id': 'devolo.MultiLevelSensor:Test', + 'unit_of_measurement': '%', + }) +# --- # name: test_consumption_sensor StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Test Current consumption', + 'friendly_name': 'Test Power', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.test_current_consumption', + 'entity_id': 'sensor.test_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -80,7 +130,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_current_consumption', + 'entity_id': 'sensor.test_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -92,7 +142,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current consumption', + 'original_name': 'Power', 'platform': 'devolo_home_control', 'previous_unique_id': None, 'supported_features': 0, @@ -105,12 +155,12 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test Total consumption', + 'friendly_name': 'Test Energy', 'state_class': , 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.test_total_consumption', + 'entity_id': 'sensor.test_energy', 'last_changed': , 'last_reported': , 'last_updated': , @@ -131,7 +181,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_total_consumption', + 'entity_id': 'sensor.test_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -143,7 +193,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total consumption', + 'original_name': 'Energy', 'platform': 'devolo_home_control', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/devolo_home_control/test_config_flow.py b/tests/components/devolo_home_control/test_config_flow.py index 7c9bfdeff63..aab3e69b38f 100644 --- a/tests/components/devolo_home_control/test_config_flow.py +++ b/tests/components/devolo_home_control/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components.devolo_home_control.const import DEFAULT_MYDEVOLO, DOMAIN +from homeassistant.components.devolo_home_control.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType @@ -90,7 +90,6 @@ async def test_form_advanced_options(hass: HomeAssistant) -> None: { "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", }, ) await hass.async_block_till_done() @@ -100,7 +99,6 @@ async def test_form_advanced_options(hass: HomeAssistant) -> None: assert result2["data"] == { "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", } assert len(mock_setup_entry.mock_calls) == 1 @@ -170,7 +168,6 @@ async def test_form_reauth(hass: HomeAssistant) -> None: data={ "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", }, ) mock_config.add_to_hass(hass) @@ -207,7 +204,6 @@ async def test_form_invalid_credentials_reauth(hass: HomeAssistant) -> None: data={ "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", }, ) mock_config.add_to_hass(hass) @@ -229,7 +225,6 @@ async def test_form_uuid_change_reauth(hass: HomeAssistant) -> None: data={ "username": "test-username", "password": "test-password", - "mydevolo_url": "https://test_mydevolo_url.test", }, ) mock_config.add_to_hass(hass) @@ -281,7 +276,6 @@ async def _setup(hass: HomeAssistant, result: FlowResult) -> None: assert result2["data"] == { "username": "test-username", "password": "test-password", - "mydevolo_url": DEFAULT_MYDEVOLO, } assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/devolo_home_control/test_sensor.py b/tests/components/devolo_home_control/test_sensor.py index 08b53dae865..ba4c493c366 100644 --- a/tests/components/devolo_home_control/test_sensor.py +++ b/tests/components/devolo_home_control/test_sensor.py @@ -10,7 +10,30 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import configure_integration -from .mocks import HomeControlMock, HomeControlMockConsumption, HomeControlMockSensor +from .mocks import ( + HomeControlMock, + HomeControlMockBrightness, + HomeControlMockConsumption, + HomeControlMockSensor, +) + + +async def test_brightness_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: + """Test setup of a brightness sensor device.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockBrightness() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{SENSOR_DOMAIN}.test_brightness") + assert state == snapshot + assert entity_registry.async_get(f"{SENSOR_DOMAIN}.test_brightness") == snapshot async def test_temperature_sensor( @@ -45,14 +68,14 @@ async def test_battery_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{SENSOR_DOMAIN}.test_battery_level") + state = hass.states.get(f"{SENSOR_DOMAIN}.test_battery") assert state == snapshot - assert entity_registry.async_get(f"{SENSOR_DOMAIN}.test_battery_level") == snapshot + assert entity_registry.async_get(f"{SENSOR_DOMAIN}.test_battery") == snapshot # Emulate websocket message: value changed test_gateway.publisher.dispatch("Test", ("Test", 10, "battery_level")) await hass.async_block_till_done() - assert hass.states.get(f"{SENSOR_DOMAIN}.test_battery_level").state == "10" + assert hass.states.get(f"{SENSOR_DOMAIN}.test_battery").state == "10" async def test_consumption_sensor( @@ -68,37 +91,26 @@ async def test_consumption_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{SENSOR_DOMAIN}.test_current_consumption") + state = hass.states.get(f"{SENSOR_DOMAIN}.test_power") assert state == snapshot - assert ( - entity_registry.async_get(f"{SENSOR_DOMAIN}.test_current_consumption") - == snapshot - ) + assert entity_registry.async_get(f"{SENSOR_DOMAIN}.test_power") == snapshot - state = hass.states.get(f"{SENSOR_DOMAIN}.test_total_consumption") + state = hass.states.get(f"{SENSOR_DOMAIN}.test_energy") assert state == snapshot - assert ( - entity_registry.async_get(f"{SENSOR_DOMAIN}.test_total_consumption") == snapshot - ) + assert entity_registry.async_get(f"{SENSOR_DOMAIN}.test_energy") == snapshot # Emulate websocket message: value changed test_gateway.devices["Test"].consumption_property["devolo.Meter:Test"].total = 50.0 test_gateway.publisher.dispatch("Test", ("devolo.Meter:Test", 50.0)) await hass.async_block_till_done() - assert hass.states.get(f"{SENSOR_DOMAIN}.test_total_consumption").state == "50.0" + assert hass.states.get(f"{SENSOR_DOMAIN}.test_energy").state == "50.0" # Emulate websocket message: device went offline test_gateway.devices["Test"].status = 1 test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() - assert ( - hass.states.get(f"{SENSOR_DOMAIN}.test_current_consumption").state - == STATE_UNAVAILABLE - ) - assert ( - hass.states.get(f"{SENSOR_DOMAIN}.test_total_consumption").state - == STATE_UNAVAILABLE - ) + assert hass.states.get(f"{SENSOR_DOMAIN}.test_power").state == STATE_UNAVAILABLE + assert hass.states.get(f"{SENSOR_DOMAIN}.test_energy").state == STATE_UNAVAILABLE async def test_voltage_sensor(hass: HomeAssistant) -> None: diff --git a/tests/components/devolo_home_network/__init__.py b/tests/components/devolo_home_network/__init__.py index 05ccbca0c56..f6d1c13299a 100644 --- a/tests/components/devolo_home_network/__init__.py +++ b/tests/components/devolo_home_network/__init__.py @@ -4,7 +4,7 @@ from homeassistant.components.devolo_home_network.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import IP +from .const import DISCOVERY_INFO, IP from tests.common import MockConfigEntry @@ -15,7 +15,12 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: CONF_IP_ADDRESS: IP, CONF_PASSWORD: "test", } - entry = MockConfigEntry(domain=DOMAIN, data=config, entry_id="123456") + entry = MockConfigEntry( + domain=DOMAIN, + data=config, + entry_id="123456", + unique_id=DISCOVERY_INFO.properties["SN"], + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index 7b0551b1daf..f3c469e61b2 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -14,7 +14,7 @@ from devolo_plc_api.device_api import ( ) from devolo_plc_api.plcnet_api import LOCAL, REMOTE, LogicalNetwork -from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo IP = "192.0.2.1" IP_ALT = "192.0.2.2" diff --git a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr index 3da8c76c2b4..53940bf5119 100644 --- a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr @@ -33,7 +33,7 @@ 'pref_disable_polling': False, 'source': 'user', 'title': 'Mock Title', - 'unique_id': None, + 'unique_id': '1234567890', 'version': 1, }), }) diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py index 5234d0f073e..92163b5cb95 100644 --- a/tests/components/devolo_home_network/test_config_flow.py +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -29,8 +29,6 @@ from .const import ( ) from .mock import MockDevice -from tests.common import MockConfigEntry - async def test_form(hass: HomeAssistant, info: dict[str, Any]) -> None: """Test we get the form.""" @@ -125,6 +123,8 @@ async def test_zeroconf(hass: HomeAssistant) -> None: CONF_IP_ADDRESS: IP, CONF_PASSWORD: "", } + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["result"].unique_id == "1234567890" async def test_abort_zeroconf_wrong_device(hass: HomeAssistant) -> None: @@ -139,13 +139,9 @@ async def test_abort_zeroconf_wrong_device(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("info") -async def test_abort_if_configued(hass: HomeAssistant) -> None: +async def test_abort_if_configured(hass: HomeAssistant) -> None: """Test we abort config flow if already configured.""" - serial_number = DISCOVERY_INFO.properties["SN"] - entry = MockConfigEntry( - domain=DOMAIN, unique_id=serial_number, data={CONF_IP_ADDRESS: IP} - ) - entry.add_to_hass(hass) + entry = configure_integration(hass) # Abort on concurrent user flow result = await hass.config_entries.flow.async_init( diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index 7f70524fa5b..4fe7a173309 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -141,7 +141,7 @@ async def test_device_failure_update( async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None: - """Test updating unautherized triggers the reauth flow.""" + """Test updating unauthorized triggers the reauth flow.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() state_key = f"{PLATFORM}.{device_name}_firmware" diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 6852f4369cc..9f3435f0cd9 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -34,6 +34,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.device_registry as dr from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -41,6 +42,7 @@ from tests.common import ( MockConfigEntry, MockModule, async_fire_time_changed, + import_and_test_deprecated_constant, mock_integration, ) @@ -1353,3 +1355,30 @@ async def test_dhcp_rediscover_no_match( await hass.async_block_till_done() assert len(mock_init.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "DhcpServiceInfo", + "homeassistant.helpers.service_info.dhcp.DhcpServiceInfo", + DhcpServiceInfo, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + dhcp, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/directv/__init__.py b/tests/components/directv/__init__.py index ae22e280000..48a334611d3 100644 --- a/tests/components/directv/__init__.py +++ b/tests/components/directv/__init__.py @@ -2,10 +2,10 @@ from http import HTTPStatus -from homeassistant.components import ssdp from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN from homeassistant.const import CONF_HOST, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -16,7 +16,7 @@ SSDP_LOCATION = "http://127.0.0.1/" UPNP_SERIAL = "RID-028877455858" MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py index ad22aa871b7..b698873d1e9 100644 --- a/tests/components/directv/test_config_flow.py +++ b/tests/components/directv/test_config_flow.py @@ -6,11 +6,11 @@ from unittest.mock import patch from aiohttp import ClientError as HTTPClientError from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_SERIAL from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL from . import ( HOST, diff --git a/tests/components/directv/test_init.py b/tests/components/directv/test_init.py index 4bfe8e2121f..102c338e757 100644 --- a/tests/components/directv/test_init.py +++ b/tests/components/directv/test_init.py @@ -1,6 +1,5 @@ """Tests for the DirecTV integration.""" -from homeassistant.components.directv.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,11 +23,9 @@ async def test_unload_config_entry( """Test the DirecTV configuration entry unloading.""" entry = await setup_integration(hass, aioclient_mock) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index 470ef65fccd..23c4a0f7cee 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -20,7 +20,7 @@ async def test_form(hass: HomeAssistant, discovergy: AsyncMock) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["errors"] == {} with patch( "homeassistant.components.discovergy.async_setup_entry", @@ -51,7 +51,7 @@ async def test_reauth( config_entry.add_to_hass(hass) init_result = await config_entry.start_reauth_flow(hass) assert init_result["type"] is FlowResultType.FORM - assert init_result["step_id"] == "reauth_confirm" + assert init_result["step_id"] == "user" with patch( "homeassistant.components.discovergy.async_setup_entry", @@ -60,7 +60,7 @@ async def test_reauth( configure_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], { - CONF_EMAIL: "test@example.com", + CONF_EMAIL: "user@example.org", CONF_PASSWORD: "test-password", }, ) @@ -111,3 +111,30 @@ async def test_form_fail( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test@example.com" assert "errors" not in result + + +async def test_reauth_unique_id_mismatch( + hass: HomeAssistant, config_entry: MockConfigEntry, discovergy: AsyncMock +) -> None: + """Test reauth flow with unique id mismatch.""" + config_entry.add_to_hass(hass) + + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.discovergy.async_setup_entry", + return_value=True, + ): + configure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "user2@example.org", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert configure_result["type"] is FlowResultType.ABORT + assert configure_result["reason"] == "account_mismatch" diff --git a/tests/components/dlink/conftest.py b/tests/components/dlink/conftest.py index c56b93c4d3d..d59e06ef444 100644 --- a/tests/components/dlink/conftest.py +++ b/tests/components/dlink/conftest.py @@ -6,11 +6,11 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components import dhcp from homeassistant.components.dlink.const import CONF_USE_LEGACY_PROTOCOL, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -29,13 +29,13 @@ CONF_DHCP_DATA = { CONF_DATA = CONF_DHCP_DATA | {CONF_HOST: HOST} -CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW = DhcpServiceInfo( ip=HOST, macaddress=DHCP_FORMATTED_MAC, hostname="dsp-w215", ) -CONF_DHCP_FLOW_NEW_IP = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW_NEW_IP = DhcpServiceInfo( ip="5.6.7.8", macaddress=DHCP_FORMATTED_MAC, hostname="dsp-w215", diff --git a/tests/components/dlink/test_config_flow.py b/tests/components/dlink/test_config_flow.py index b6f025bb5b0..0449f68263c 100644 --- a/tests/components/dlink/test_config_flow.py +++ b/tests/components/dlink/test_config_flow.py @@ -2,12 +2,12 @@ from unittest.mock import MagicMock, patch -from homeassistant.components import dhcp from homeassistant.components.dlink.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import ( CONF_DATA, @@ -160,7 +160,7 @@ async def test_dhcp_unique_id_assignment( hass: HomeAssistant, mocked_plug: MagicMock ) -> None: """Test dhcp initialized flow with no unique id for matching entry.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="2.3.4.5", macaddress="11:22:33:44:55:66", hostname="dsp-w215", diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index cb32001e1e5..e02baceb380 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -12,7 +12,6 @@ from async_upnp_client.exceptions import UpnpError import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.dlna_dmr.const import ( CONF_BROWSE_UNFILTERED, CONF_CALLBACK_URL_OVERRIDE, @@ -23,6 +22,15 @@ from homeassistant.components.dlna_dmr.const import ( from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_DEVICE_HOST_ADDR, @@ -48,17 +56,17 @@ CHANGED_DEVICE_UDN = "uuid:7cc6da13-7f5d-4ace-9729-badbadbadbad" MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE" -MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_location=MOCK_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, ssdp_headers={"_host": MOCK_DEVICE_HOST_ADDR}, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, - ssdp.ATTR_UPNP_SERVICE_LIST: { + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_SERVICE_LIST: { "service": [ { "SCPDURL": "/AVTransport/scpd.xml", @@ -358,15 +366,15 @@ async def test_ssdp_flow_existing( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -492,15 +500,15 @@ async def test_ssdp_flow_upnp_udn( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -514,7 +522,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # No service list at all discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] + del discovery.upnp[ATTR_UPNP_SERVICE_LIST] result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -526,7 +534,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # Service list does not contain services discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = discovery.upnp.copy() - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -538,10 +546,10 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # AVTransport service is missing discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = { + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = { "service": [ service - for service in discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]["service"] + for service in discovery.upnp[ATTR_UPNP_SERVICE_LIST]["service"] if service.get("serviceId") != "urn:upnp-org:serviceId:AVTransport" ] } @@ -560,10 +568,10 @@ async def test_ssdp_single_service(hass: HomeAssistant) -> None: """ discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = discovery.upnp.copy() - service_list = discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST].copy() + service_list = discovery.upnp[ATTR_UPNP_SERVICE_LIST].copy() # Turn mock's list of service dicts into a single dict service_list["service"] = service_list["service"][0] - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = service_list result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, @@ -589,9 +597,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_DEVICE_TYPE] = ( - "urn:schemas-upnp-org:device:ZonePlayer:1" - ) + discovery.upnp[ATTR_UPNP_DEVICE_TYPE] = "urn:schemas-upnp-org:device:ZonePlayer:1" result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -608,8 +614,8 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: ): discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_MANUFACTURER] = manufacturer - discovery.upnp[ssdp.ATTR_UPNP_MODEL_NAME] = model + discovery.upnp[ATTR_UPNP_MANUFACTURER] = manufacturer + discovery.upnp[ATTR_UPNP_MODEL_NAME] = model result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index 3d8f9da8ed9..a92f7807912 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -47,6 +47,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.setup import async_setup_component from .conftest import ( @@ -1413,7 +1414,7 @@ async def test_become_available( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1484,7 +1485,7 @@ async def test_alive_but_gone( # Send an SSDP notification from the still missing device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1506,7 +1507,7 @@ async def test_alive_but_gone( # Send the same SSDP notification, expecting no extra connection attempts domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1525,7 +1526,7 @@ async def test_alive_but_gone( # Send an SSDP notification with a new BOOTID, indicating the device has rebooted domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1546,7 +1547,7 @@ async def test_alive_but_gone( # should result in a reconnect attempt even with same BOOTID. domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st=MOCK_DEVICE_TYPE, upnp={}, @@ -1554,7 +1555,7 @@ async def test_alive_but_gone( ssdp.SsdpChange.BYEBYE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1597,7 +1598,7 @@ async def test_multiple_ssdp_alive( # Send two SSDP notifications with the new device URL ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1606,7 +1607,7 @@ async def test_multiple_ssdp_alive( ssdp.SsdpChange.ALIVE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1637,7 +1638,7 @@ async def test_ssdp_byebye( # First byebye will cause a disconnect ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -1656,7 +1657,7 @@ async def test_ssdp_byebye( # Second byebye will do nothing await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -1689,7 +1690,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1702,7 +1703,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1727,7 +1728,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with same next boot ID, again await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1752,7 +1753,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with bad next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1777,7 +1778,7 @@ async def test_ssdp_update_seen_bootid( # Send a new SSDP alive with the new boot ID, device should not reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -1816,7 +1817,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1829,7 +1830,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP update with skipped boot ID (not previously seen) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1854,7 +1855,7 @@ async def test_ssdp_update_missed_bootid( # Send a new SSDP alive with the new boot ID, device should reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"}, @@ -1893,7 +1894,7 @@ async def test_ssdp_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1913,7 +1914,7 @@ async def test_ssdp_bootid( # Send SSDP alive with same boot ID, nothing should happen await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1933,7 +1934,7 @@ async def test_ssdp_bootid( # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -2354,7 +2355,7 @@ async def test_connections_restored( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, diff --git a/tests/components/dlna_dms/test_config_flow.py b/tests/components/dlna_dms/test_config_flow.py index 14da36a0381..76890f328e4 100644 --- a/tests/components/dlna_dms/test_config_flow.py +++ b/tests/components/dlna_dms/test_config_flow.py @@ -12,11 +12,17 @@ from async_upnp_client.exceptions import UpnpError import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.dlna_dms.const import CONF_SOURCE_ID, DOMAIN from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_DEVICE_HOST, @@ -35,16 +41,16 @@ WRONG_DEVICE_TYPE: Final = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" MOCK_ROOT_DEVICE_UDN: Final = "ROOT_DEVICE" -MOCK_DISCOVERY: Final = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY: Final = SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, - ssdp.ATTR_UPNP_SERVICE_LIST: { + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_SERVICE_LIST: { "service": [ { "SCPDURL": "/ContentDirectory/scpd.xml", @@ -195,15 +201,15 @@ async def test_ssdp_flow_existing( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st="mock_st", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -279,7 +285,7 @@ async def test_duplicate_name( ssdp_udn=new_device_udn, ) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_UDN] = new_device_udn + discovery.upnp[ATTR_UPNP_UDN] = new_device_udn result = await hass.config_entries.flow.async_init( DOMAIN, @@ -312,15 +318,15 @@ async def test_ssdp_flow_upnp_udn( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -334,7 +340,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # No service list at all discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] + del discovery.upnp[ATTR_UPNP_SERVICE_LIST] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -346,7 +352,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # Service list does not contain services discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -358,10 +364,10 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # ContentDirectory service is missing discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = { + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = { "service": [ service - for service in discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]["service"] + for service in discovery.upnp[ATTR_UPNP_SERVICE_LIST]["service"] if service.get("serviceId") != "urn:upnp-org:serviceId:ContentDirectory" ] } @@ -380,10 +386,10 @@ async def test_ssdp_single_service(hass: HomeAssistant) -> None: """ discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - service_list = dict(discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]) + service_list = dict(discovery.upnp[ATTR_UPNP_SERVICE_LIST]) # Turn mock's list of service dicts into a single dict service_list["service"] = service_list["service"][0] - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = service_list result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/dlna_dms/test_device_availability.py b/tests/components/dlna_dms/test_device_availability.py index 1be68f91733..01976c16247 100644 --- a/tests/components/dlna_dms/test_device_availability.py +++ b/tests/components/dlna_dms/test_device_availability.py @@ -18,6 +18,7 @@ from homeassistant.components.dlna_dms.dms import get_domain_data from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ( MOCK_DEVICE_LOCATION, @@ -179,7 +180,7 @@ async def test_become_available( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -207,7 +208,7 @@ async def test_alive_but_gone( # Send an SSDP notification from the still missing device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -227,7 +228,7 @@ async def test_alive_but_gone( # Send the same SSDP notification, expecting no extra connection attempts upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -244,7 +245,7 @@ async def test_alive_but_gone( # Send an SSDP notification with a new BOOTID, indicating the device has rebooted upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -263,7 +264,7 @@ async def test_alive_but_gone( # should result in a reconnect attempt even with same BOOTID. upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st=MOCK_DEVICE_TYPE, upnp={}, @@ -271,7 +272,7 @@ async def test_alive_but_gone( ssdp.SsdpChange.BYEBYE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -310,7 +311,7 @@ async def test_multiple_ssdp_alive( # Send two SSDP notifications with the new device URL ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -319,7 +320,7 @@ async def test_multiple_ssdp_alive( ssdp.SsdpChange.ALIVE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -345,7 +346,7 @@ async def test_ssdp_byebye( # First byebye will cause a disconnect ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -360,7 +361,7 @@ async def test_ssdp_byebye( # Second byebye will do nothing await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -388,7 +389,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -405,7 +406,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -426,7 +427,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with same next boot ID, again await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -447,7 +448,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with bad next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -468,7 +469,7 @@ async def test_ssdp_update_seen_bootid( # Send a new SSDP alive with the new boot ID, device should not reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -500,7 +501,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -517,7 +518,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP update with skipped boot ID (not previously seen) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -538,7 +539,7 @@ async def test_ssdp_update_missed_bootid( # Send a new SSDP alive with the new boot ID, device should reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"}, @@ -570,7 +571,7 @@ async def test_ssdp_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -586,7 +587,7 @@ async def test_ssdp_bootid( # Send SSDP alive with same boot ID, nothing should happen await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -602,7 +603,7 @@ async def test_ssdp_bootid( # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py index 7907d40c415..5576066f781 100644 --- a/tests/components/dlna_dms/test_dms_device_source.py +++ b/tests/components/dlna_dms/test_dms_device_source.py @@ -16,6 +16,7 @@ from homeassistant.components.dlna_dms.dms import DidlPlayMedia from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import BrowseMediaSource, Unresolvable from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ( MOCK_DEVICE_BASE_URL, @@ -68,7 +69,7 @@ async def test_catch_request_error_unavailable( # DmsDevice notifies of disconnect via SSDP ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index 3abdd2b87a3..98b2189dfd9 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -8,7 +8,6 @@ from doorbirdpy import DoorBird import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.doorbird.const import ( CONF_EVENTS, DEFAULT_DOORBELL_EVENT, @@ -18,6 +17,7 @@ from homeassistant.components.doorbird.const import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ( VALID_CONFIG, @@ -74,7 +74,7 @@ async def test_form_zeroconf_wrong_oui(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.8"), ip_addresses=[ip_address("192.168.1.8")], hostname="mock_hostname", @@ -94,7 +94,7 @@ async def test_form_zeroconf_link_local_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("169.254.103.61"), ip_addresses=[ip_address("169.254.103.61")], hostname="mock_hostname", @@ -121,7 +121,7 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -142,7 +142,7 @@ async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -164,7 +164,7 @@ async def test_form_zeroconf_correct_oui( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", @@ -230,7 +230,7 @@ async def test_form_zeroconf_correct_oui_wrong_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 2301b9dfc80..ccb7920e141 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -45,9 +45,9 @@ def dsmr_connection_fixture() -> Generator[tuple[MagicMock, MagicMock, MagicMock @pytest.fixture -def rfxtrx_dsmr_connection_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def rfxtrx_dsmr_connection_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks RFXtrx connection.""" transport = MagicMock(spec=asyncio.Transport) @@ -73,9 +73,9 @@ def rfxtrx_dsmr_connection_fixture() -> ( @pytest.fixture -def dsmr_connection_send_validate_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def dsmr_connection_send_validate_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks serial connection.""" transport = MagicMock(spec=asyncio.Transport) @@ -156,9 +156,9 @@ def dsmr_connection_send_validate_fixture() -> ( @pytest.fixture -def rfxtrx_dsmr_connection_send_validate_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def rfxtrx_dsmr_connection_send_validate_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks serial connection.""" transport = MagicMock(spec=asyncio.Transport) diff --git a/tests/components/dsmr/test_diagnostics.py b/tests/components/dsmr/test_diagnostics.py index 8fc996f6e34..9bcde251f6f 100644 --- a/tests/components/dsmr/test_diagnostics.py +++ b/tests/components/dsmr/test_diagnostics.py @@ -58,7 +58,7 @@ async def test_diagnostics( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m³"}, + {"value": Decimal("745.695"), "unit": "m³"}, ], ), "GAS_METER_READING", diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 7c7d182aa97..d590666b060 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -10,6 +10,7 @@ from dsmr_parser.obis_references import ( MBUS_METER_READING, ) from dsmr_parser.objects import CosemObject, MBusObject, Telegram +import pytest from homeassistant.components.dsmr.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -85,7 +86,7 @@ async def test_migrate_gas_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -102,6 +103,17 @@ async def test_migrate_gas_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is created and the old device has been removed + assert len(device_registry.devices) == 1 + assert not device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + new_dev_entities = er.async_entries_for_device( + entity_registry, new_device.id, include_disabled_entities=True + ) + assert new_dev_entities == [new_entity] + + # Check no entities are connected to the old device dev_entities = er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True ) @@ -185,7 +197,7 @@ async def test_migrate_hourly_gas_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1722749707)}, - {"value": Decimal(778.963), "unit": "m3"}, + {"value": Decimal("778.963"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -202,6 +214,17 @@ async def test_migrate_hourly_gas_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is created and the old device has been removed + assert len(device_registry.devices) == 1 + assert not device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + new_dev_entities = er.async_entries_for_device( + entity_registry, new_device.id, include_disabled_entities=True + ) + assert new_dev_entities == [new_entity] + + # Check no entities are connected to the old device dev_entities = er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True ) @@ -285,7 +308,7 @@ async def test_migrate_gas_with_devid_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -302,6 +325,18 @@ async def test_migrate_gas_with_devid_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is not created and the old device has not been removed + assert len(device_registry.devices) == 1 + assert device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + assert new_device.id == device.id + # Check entities are still connected to the old device + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert dev_entities == [new_entity] + assert ( entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) is None @@ -319,6 +354,7 @@ async def test_migrate_gas_to_mbus_exists( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], + caplog: pytest.LogCaptureFixture, ) -> None: """Test migration of unique_id.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -380,7 +416,7 @@ async def test_migrate_gas_to_mbus_exists( telegram = Telegram() telegram.add( MBUS_DEVICE_TYPE, - CosemObject((0, 0), [{"value": "003", "unit": ""}]), + CosemObject((0, 1), [{"value": "003", "unit": ""}]), "MBUS_DEVICE_TYPE", ) telegram.add( @@ -397,7 +433,7 @@ async def test_migrate_gas_to_mbus_exists( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -414,7 +450,32 @@ async def test_migrate_gas_to_mbus_exists( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is not created and the old device has not been removed + assert len(device_registry.devices) == 2 + assert device_registry.async_get(device.id) + assert device_registry.async_get(device2.id) + entity = entity_registry.async_get("sensor.gas_meter_reading") + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert dev_entities == [entity] + entity2 = entity_registry.async_get("sensor.gas_meter_reading_alt") + dev2_entities = er.async_entries_for_device( + entity_registry, device2.id, include_disabled_entities=True + ) + assert dev2_entities == [entity2] + assert ( entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) == "sensor.gas_meter_reading" ) + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "37464C4F32313139303333373331" + ) + == "sensor.gas_meter_reading_alt" + ) + assert ( + "Skip migration of sensor.gas_meter_reading because it already exists" + in caplog.text + ) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 4a2951f4ed8..fbe14b38aa3 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -89,7 +89,7 @@ async def test_default_setup( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": UnitOfVolume.CUBIC_METERS}, + {"value": Decimal("745.695"), "unit": UnitOfVolume.CUBIC_METERS}, ], ), "GAS_METER_READING", @@ -152,7 +152,7 @@ async def test_default_setup( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.701), "unit": UnitOfVolume.CUBIC_METERS}, + {"value": Decimal("745.701"), "unit": UnitOfVolume.CUBIC_METERS}, ], ), "GAS_METER_READING", @@ -279,7 +279,7 @@ async def test_v4_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "HOURLY_GAS_METER_READING", @@ -336,9 +336,9 @@ async def test_v4_meter( @pytest.mark.parametrize( ("value", "state"), [ - (Decimal(745.690), "745.69"), - (Decimal(745.695), "745.695"), - (Decimal(0.000), STATE_UNKNOWN), + (Decimal("745.690"), "745.69"), + (Decimal("745.695"), "745.695"), + (Decimal("0.000"), STATE_UNKNOWN), ], ) async def test_v5_meter( @@ -440,7 +440,7 @@ async def test_luxembourg_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "HOURLY_GAS_METER_READING", @@ -449,7 +449,7 @@ async def test_luxembourg_meter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -457,7 +457,7 @@ async def test_luxembourg_meter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -533,7 +533,7 @@ async def test_belgian_meter( BELGIUM_CURRENT_AVERAGE_DEMAND, CosemObject( (0, 0), - [{"value": Decimal(1.75), "unit": "kW"}], + [{"value": Decimal("1.75"), "unit": "kW"}], ), "BELGIUM_CURRENT_AVERAGE_DEMAND", ) @@ -543,7 +543,7 @@ async def test_belgian_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(4.11), "unit": "kW"}, + {"value": Decimal("4.11"), "unit": "kW"}, ], ), "BELGIUM_MAXIMUM_DEMAND_MONTH", @@ -567,7 +567,7 @@ async def test_belgian_meter( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -591,7 +591,7 @@ async def test_belgian_meter( (0, 2), [ {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(678.695), "unit": "m3"}, + {"value": Decimal("678.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -615,7 +615,7 @@ async def test_belgian_meter( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -639,7 +639,7 @@ async def test_belgian_meter( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -782,7 +782,7 @@ async def test_belgian_meter_alt( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(123.456), "unit": "m3"}, + {"value": Decimal("123.456"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -806,7 +806,7 @@ async def test_belgian_meter_alt( (0, 2), [ {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(678.901), "unit": "m3"}, + {"value": Decimal("678.901"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -830,7 +830,7 @@ async def test_belgian_meter_alt( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642217)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -854,7 +854,7 @@ async def test_belgian_meter_alt( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1001,7 +1001,7 @@ async def test_belgian_meter_mbus( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642217)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1017,7 +1017,7 @@ async def test_belgian_meter_mbus( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1154,7 +1154,7 @@ async def test_swedish_meter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -1162,7 +1162,7 @@ async def test_swedish_meter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -1229,7 +1229,7 @@ async def test_easymeter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(54184.6316), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("54184.6316"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -1237,7 +1237,7 @@ async def test_easymeter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(19981.1069), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("19981.1069"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -1489,7 +1489,7 @@ async def test_gas_meter_providing_energy_reading( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(123.456), "unit": UnitOfEnergy.GIGA_JOULE}, + {"value": Decimal("123.456"), "unit": UnitOfEnergy.GIGA_JOULE}, ], ), "GAS_METER_READING", @@ -1549,7 +1549,7 @@ async def test_heat_meter_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "GJ"}, + {"value": Decimal("745.695"), "unit": "GJ"}, ], ), "MBUS_METER_READING", diff --git a/tests/components/dsmr_reader/test_definitions.py b/tests/components/dsmr_reader/test_definitions.py index 2ddd8395e78..86805fb456f 100644 --- a/tests/components/dsmr_reader/test_definitions.py +++ b/tests/components/dsmr_reader/test_definitions.py @@ -12,7 +12,7 @@ from homeassistant.components.dsmr_reader.sensor import DSMRSensor from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.common import MockConfigEntry, MockEntityPlatform, async_fire_mqtt_message @pytest.mark.parametrize( @@ -93,6 +93,7 @@ async def test_entity_dsmr_transform(hass: HomeAssistant) -> None: ) sensor = DSMRSensor(description, config_entry) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) await sensor.async_added_to_hass() # Test dsmr version, if it's a digit diff --git a/tests/components/dte_energy_bridge/__init__.py b/tests/components/dte_energy_bridge/__init__.py deleted file mode 100644 index 615944bda88..00000000000 --- a/tests/components/dte_energy_bridge/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the dte_energy_bridge component.""" diff --git a/tests/components/dte_energy_bridge/test_sensor.py b/tests/components/dte_energy_bridge/test_sensor.py deleted file mode 100644 index 41d340fae48..00000000000 --- a/tests/components/dte_energy_bridge/test_sensor.py +++ /dev/null @@ -1,58 +0,0 @@ -"""The tests for the DTE Energy Bridge.""" - -import requests_mock - -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -DTE_ENERGY_BRIDGE_CONFIG = {"platform": "dte_energy_bridge", "ip": "192.168.1.1"} - - -async def test_setup_with_config(hass: HomeAssistant) -> None: - """Test the platform setup with configuration.""" - assert await async_setup_component( - hass, "sensor", {"dte_energy_bridge": DTE_ENERGY_BRIDGE_CONFIG} - ) - await hass.async_block_till_done() - - -async def test_setup_correct_reading(hass: HomeAssistant) -> None: - """Test DTE Energy bridge returns a correct value.""" - with requests_mock.Mocker() as mock_req: - mock_req.get( - f"http://{DTE_ENERGY_BRIDGE_CONFIG['ip']}/instantaneousdemand", - text=".411 kW", - ) - assert await async_setup_component( - hass, "sensor", {"sensor": DTE_ENERGY_BRIDGE_CONFIG} - ) - await hass.async_block_till_done() - assert hass.states.get("sensor.current_energy_usage").state == "0.411" - - -async def test_setup_incorrect_units_reading(hass: HomeAssistant) -> None: - """Test DTE Energy bridge handles a value with incorrect units.""" - with requests_mock.Mocker() as mock_req: - mock_req.get( - f"http://{DTE_ENERGY_BRIDGE_CONFIG['ip']}/instantaneousdemand", - text="411 kW", - ) - assert await async_setup_component( - hass, "sensor", {"sensor": DTE_ENERGY_BRIDGE_CONFIG} - ) - await hass.async_block_till_done() - assert hass.states.get("sensor.current_energy_usage").state == "0.411" - - -async def test_setup_bad_format_reading(hass: HomeAssistant) -> None: - """Test DTE Energy bridge handles an invalid value.""" - with requests_mock.Mocker() as mock_req: - mock_req.get( - f"http://{DTE_ENERGY_BRIDGE_CONFIG['ip']}/instantaneousdemand", - text="411", - ) - assert await async_setup_component( - hass, "sensor", {"sensor": DTE_ENERGY_BRIDGE_CONFIG} - ) - await hass.async_block_till_done() - assert hass.states.get("sensor.current_energy_usage").state == "unknown" diff --git a/tests/components/duke_energy/conftest.py b/tests/components/duke_energy/conftest.py index ed4182f450f..f74ef43bf07 100644 --- a/tests/components/duke_energy/conftest.py +++ b/tests/components/duke_energy/conftest.py @@ -11,12 +11,12 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/easyenergy/test_config_flow.py b/tests/components/easyenergy/test_config_flow.py index da7048793b3..44bc8421126 100644 --- a/tests/components/easyenergy/test_config_flow.py +++ b/tests/components/easyenergy/test_config_flow.py @@ -7,6 +7,8 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + async def test_full_user_flow( hass: HomeAssistant, @@ -31,3 +33,18 @@ async def test_full_user_flow( assert result2.get("data") == {} assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_single_instance( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test abort when setting up a duplicate entry.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" diff --git a/tests/components/easyenergy/test_init.py b/tests/components/easyenergy/test_init.py index 74293049fd1..c3c917bc9ed 100644 --- a/tests/components/easyenergy/test_init.py +++ b/tests/components/easyenergy/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch from easyenergy import EasyEnergyConnectionError -from homeassistant.components.easyenergy.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,7 +23,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/ecovacs/fixtures/devices/qhe2o2/device.json b/tests/components/ecovacs/fixtures/devices/qhe2o2/device.json new file mode 100644 index 00000000000..0fbaaf896ee --- /dev/null +++ b/tests/components/ecovacs/fixtures/devices/qhe2o2/device.json @@ -0,0 +1,29 @@ +{ + "did": "8516fbb1-17f1-4194-0000001", + "name": "E1234567890000000003", + "class": "qhe2o2", + "resource": "NHl5", + "company": "eco-ng", + "bindTs": 1734792100110, + "service": { + "jmq": "jmq-ngiot-eu.dc.ww.ecouser.net", + "mqs": "api-ngiot.dc-eu.ww.ecouser.net" + }, + "deviceName": "DEEBOT N20 PRO PLUS", + "icon": "https: //portal-ww.ecouser.net/api/pim/file/get/0000001", + "ota": true, + "UILogicId": "y2_ww_h_y2h5", + "materialNo": "110-2406-0001", + "pid": "0000001", + "product_category": "DEEBOT", + "model": "Y2_AES_BLACK_INT", + "updateInfo": { + "needUpdate": false, + "changeLog": "" + }, + "nick": "Dusty", + "homeId": "1234567890abcdef12345678", + "homeSort": 1, + "status": 1, + "otaUpgrade": {} +} diff --git a/tests/components/ecovacs/snapshots/test_button.ambr b/tests/components/ecovacs/snapshots/test_button.ambr index efae8896962..f21d019a7b1 100644 --- a/tests/components/ecovacs/snapshots/test_button.ambr +++ b/tests/components/ecovacs/snapshots/test_button.ambr @@ -91,6 +91,328 @@ 'state': '2024-01-01T00:00:00+00:00', }) # --- +# name: test_buttons[qhe2o2][button.dusty_empty_dustbin:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.dusty_empty_dustbin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Empty dustbin', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'station_action_empty_dustbin', + 'unique_id': '8516fbb1-17f1-4194-0000001_station_action_empty_dustbin', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_empty_dustbin:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Empty dustbin', + }), + 'context': , + 'entity_id': 'button.dusty_empty_dustbin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_relocate:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_relocate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relocate', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relocate', + 'unique_id': '8516fbb1-17f1-4194-0000001_relocate', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_relocate:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Relocate', + }), + 'context': , + 'entity_id': 'button.dusty_relocate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_reset_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_filter', + 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Reset filter lifespan', + }), + 'context': , + 'entity_id': 'button.dusty_reset_filter_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_main_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_reset_main_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset main brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_brush', + 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_main_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Reset main brush lifespan', + }), + 'context': , + 'entity_id': 'button.dusty_reset_main_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_round_mop_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_reset_round_mop_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset round mop lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_round_mop', + 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_round_mop', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_round_mop_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Reset round mop lifespan', + }), + 'context': , + 'entity_id': 'button.dusty_reset_round_mop_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_side_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_reset_side_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset side brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_side_brush', + 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_side_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_side_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Reset side brush lifespan', + }), + 'context': , + 'entity_id': 'button.dusty_reset_side_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_unit_care_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dusty_reset_unit_care_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset unit care lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_unit_care', + 'unique_id': '8516fbb1-17f1-4194-0000001_reset_lifespan_unit_care', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[qhe2o2][button.dusty_reset_unit_care_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Reset unit care lifespan', + }), + 'context': , + 'entity_id': 'button.dusty_reset_unit_care_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- # name: test_buttons[yna5x1][button.ozmo_950_relocate:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index 9c76c00b5b7..755fcda9e7d 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -725,6 +725,781 @@ 'state': 'Testnetwork', }) # --- +# name: test_sensors[qhe2o2][sensor.dusty_area_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_area_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Area cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stats_area', + 'unique_id': '8516fbb1-17f1-4194-0000001_stats_area', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_area_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Area cleaned', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dusty_area_cleaned', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_battery:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '8516fbb1-17f1-4194-0000001_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_battery:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dusty Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_cleaning_duration:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_cleaning_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning duration', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stats_time', + 'unique_id': '8516fbb1-17f1-4194-0000001_stats_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_cleaning_duration:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Dusty Cleaning duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dusty_cleaning_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_error:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error', + 'unique_id': '8516fbb1-17f1-4194-0000001_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_error:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'description': 'NoError: Robot is operational', + 'friendly_name': 'Dusty Error', + }), + 'context': , + 'entity_id': 'sensor.dusty_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_filter', + 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_filter', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Filter lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_filter_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '56', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_ip_address:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_ip_address', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IP address', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_ip', + 'unique_id': '8516fbb1-17f1-4194-0000001_network_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_ip_address:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty IP address', + }), + 'context': , + 'entity_id': 'sensor.dusty_ip_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '192.168.0.10', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_main_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_main_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Main brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_brush', + 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_brush', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_main_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Main brush lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_main_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_round_mop_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_round_mop_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Round mop lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_round_mop', + 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_round_mop', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_round_mop_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Round mop lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_round_mop_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_side_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_side_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Side brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_side_brush', + 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_side_brush', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_side_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Side brush lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_side_brush_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_station_state:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'idle', + 'emptying_dustbin', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_station_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Station state', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'station_state', + 'unique_id': '8516fbb1-17f1-4194-0000001_station_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_station_state:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dusty Station state', + 'options': list([ + 'idle', + 'emptying_dustbin', + ]), + }), + 'context': , + 'entity_id': 'sensor.dusty_station_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'emptying_dustbin', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_area_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_total_area_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total area cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_area', + 'unique_id': '8516fbb1-17f1-4194-0000001_total_stats_area', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_area_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Total area cleaned', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dusty_total_area_cleaned', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_cleaning_duration:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_total_cleaning_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning duration', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_time', + 'unique_id': '8516fbb1-17f1-4194-0000001_total_stats_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_cleaning_duration:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Dusty Total cleaning duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dusty_total_cleaning_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.000', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_cleanings:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dusty_total_cleanings', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleanings', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_cleanings', + 'unique_id': '8516fbb1-17f1-4194-0000001_total_stats_cleanings', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_total_cleanings:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Total cleanings', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.dusty_total_cleanings', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_unit_care_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_unit_care_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Unit care lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_unit_care', + 'unique_id': '8516fbb1-17f1-4194-0000001_lifespan_unit_care', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_unit_care_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Unit care lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dusty_unit_care_lifespan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_wi_fi_rssi:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_wi_fi_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi RSSI', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_rssi', + 'unique_id': '8516fbb1-17f1-4194-0000001_network_rssi', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_wi_fi_rssi:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Wi-Fi RSSI', + }), + 'context': , + 'entity_id': 'sensor.dusty_wi_fi_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-62', + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dusty_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_ssid', + 'unique_id': '8516fbb1-17f1-4194-0000001_network_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[qhe2o2][sensor.dusty_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.dusty_wi_fi_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Testnetwork', + }) +# --- # name: test_sensors[yna5x1][sensor.ozmo_950_area_cleaned:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py index 4b3068f6cda..3021db62e6f 100644 --- a/tests/components/ecovacs/test_button.py +++ b/tests/components/ecovacs/test_button.py @@ -1,7 +1,12 @@ """Tests for Ecovacs sensors.""" from deebot_client.command import Command -from deebot_client.commands.json import ResetLifeSpan, SetRelocationState +from deebot_client.commands import StationAction +from deebot_client.commands.json import ( + ResetLifeSpan, + SetRelocationState, + station_action, +) from deebot_client.events import LifeSpan import pytest from syrupy import SnapshotAssertion @@ -60,8 +65,38 @@ def platforms() -> Platform | list[Platform]: ), ], ), + ( + "qhe2o2", + [ + ("button.dusty_relocate", SetRelocationState()), + ( + "button.dusty_reset_main_brush_lifespan", + ResetLifeSpan(LifeSpan.BRUSH), + ), + ( + "button.dusty_reset_filter_lifespan", + ResetLifeSpan(LifeSpan.FILTER), + ), + ( + "button.dusty_reset_side_brush_lifespan", + ResetLifeSpan(LifeSpan.SIDE_BRUSH), + ), + ( + "button.dusty_reset_unit_care_lifespan", + ResetLifeSpan(LifeSpan.UNIT_CARE), + ), + ( + "button.dusty_reset_round_mop_lifespan", + ResetLifeSpan(LifeSpan.ROUND_MOP), + ), + ( + "button.dusty_empty_dustbin", + station_action.StationAction(StationAction.EMPTY_DUSTBIN), + ), + ], + ), ], - ids=["yna5x1", "5xu9h3"], + ids=["yna5x1", "5xu9h3", "qhe2o2"], ) async def test_buttons( hass: HomeAssistant, @@ -126,8 +161,8 @@ async def test_disabled_by_default_buttons( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_config_flow.py b/tests/components/ecovacs/test_config_flow.py index 5bf1144db0b..3a0cb188146 100644 --- a/tests/components/ecovacs/test_config_flow.py +++ b/tests/components/ecovacs/test_config_flow.py @@ -1,6 +1,7 @@ """Test Ecovacs config flow.""" from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field import ssl from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -28,15 +29,20 @@ from .const import ( VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT, ) +from tests.common import MockConfigEntry + _USER_STEP_SELF_HOSTED = {CONF_MODE: InstanceMode.SELF_HOSTED} -_TEST_FN_AUTH_ARG = "user_input_auth" -_TEST_FN_USER_ARG = "user_input_user" + +@dataclass +class _TestFnUserInput: + auth: dict[str, Any] + user: dict[str, Any] = field(default_factory=dict) async def _test_user_flow( hass: HomeAssistant, - user_input_auth: dict[str, Any], + user_input: _TestFnUserInput, ) -> dict[str, Any]: """Test config flow.""" result = await hass.config_entries.flow.async_init( @@ -50,15 +56,13 @@ async def _test_user_flow( return await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=user_input_auth, + user_input=user_input.auth, ) async def _test_user_flow_show_advanced_options( hass: HomeAssistant, - *, - user_input_auth: dict[str, Any], - user_input_user: dict[str, Any] | None = None, + user_input: _TestFnUserInput, ) -> dict[str, Any]: """Test config flow.""" result = await hass.config_entries.flow.async_init( @@ -72,7 +76,7 @@ async def _test_user_flow_show_advanced_options( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=user_input_user or {}, + user_input=user_input.user, ) assert result["type"] is FlowResultType.FORM @@ -81,29 +85,26 @@ async def _test_user_flow_show_advanced_options( return await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=user_input_auth, + user_input=user_input.auth, ) @pytest.mark.parametrize( - ("test_fn", "test_fn_args", "entry_data"), + ("test_fn", "test_fn_user_input", "entry_data"), [ ( _test_user_flow_show_advanced_options, - {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), VALID_ENTRY_DATA_CLOUD, ), ( _test_user_flow_show_advanced_options, - { - _TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_SELF_HOSTED, - _TEST_FN_USER_ARG: _USER_STEP_SELF_HOSTED, - }, + _TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED), VALID_ENTRY_DATA_SELF_HOSTED, ), ( _test_user_flow, - {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), VALID_ENTRY_DATA_CLOUD, ), ], @@ -114,18 +115,12 @@ async def test_user_flow( mock_setup_entry: AsyncMock, mock_authenticator_authenticate: AsyncMock, mock_mqtt_client: Mock, - test_fn: Callable[[HomeAssistant, dict[str, Any]], Awaitable[dict[str, Any]]] - | Callable[ - [HomeAssistant, dict[str, Any], dict[str, Any]], Awaitable[dict[str, Any]] - ], - test_fn_args: dict[str, Any], + test_fn: Callable[[HomeAssistant, _TestFnUserInput], Awaitable[dict[str, Any]]], + test_fn_user_input: _TestFnUserInput, entry_data: dict[str, Any], ) -> None: """Test the user config flow.""" - result = await test_fn( - hass, - **test_fn_args, - ) + result = await test_fn(hass, test_fn_user_input) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == entry_data[CONF_USERNAME] assert result["data"] == entry_data @@ -161,24 +156,21 @@ def _cannot_connect_error(user_input: dict[str, Any]) -> str: ids=["cannot_connect", "invalid_auth", "unknown"], ) @pytest.mark.parametrize( - ("test_fn", "test_fn_args", "entry_data"), + ("test_fn", "test_fn_user_input", "entry_data"), [ ( _test_user_flow_show_advanced_options, - {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), VALID_ENTRY_DATA_CLOUD, ), ( _test_user_flow_show_advanced_options, - { - _TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_SELF_HOSTED, - _TEST_FN_USER_ARG: _USER_STEP_SELF_HOSTED, - }, + _TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED), VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT, ), ( _test_user_flow, - {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), VALID_ENTRY_DATA_CLOUD, ), ], @@ -193,22 +185,16 @@ async def test_user_flow_raise_error( reason_rest: str, side_effect_mqtt: Exception, errors_mqtt: Callable[[dict[str, Any]], str], - test_fn: Callable[[HomeAssistant, dict[str, Any]], Awaitable[dict[str, Any]]] - | Callable[ - [HomeAssistant, dict[str, Any], dict[str, Any]], Awaitable[dict[str, Any]] - ], - test_fn_args: dict[str, Any], + test_fn: Callable[[HomeAssistant, _TestFnUserInput], Awaitable[dict[str, Any]]], + test_fn_user_input: _TestFnUserInput, entry_data: dict[str, Any], ) -> None: """Test handling error on library calls.""" - user_input_auth = test_fn_args[_TEST_FN_AUTH_ARG] + user_input_auth = test_fn_user_input.auth # Authenticator raises error mock_authenticator_authenticate.side_effect = side_effect_rest - result = await test_fn( - hass, - **test_fn_args, - ) + result = await test_fn(hass, test_fn_user_input) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" assert result["errors"] == {"base": reason_rest} @@ -256,12 +242,14 @@ async def test_user_flow_self_hosted_error( result = await _test_user_flow_show_advanced_options( hass, - user_input_auth=VALID_ENTRY_DATA_SELF_HOSTED - | { - CONF_OVERRIDE_REST_URL: "bla://localhost:8000", - CONF_OVERRIDE_MQTT_URL: "mqtt://", - }, - user_input_user=_USER_STEP_SELF_HOSTED, + _TestFnUserInput( + VALID_ENTRY_DATA_SELF_HOSTED + | { + CONF_OVERRIDE_REST_URL: "bla://localhost:8000", + CONF_OVERRIDE_MQTT_URL: "mqtt://", + }, + _USER_STEP_SELF_HOSTED, + ), ) assert result["type"] is FlowResultType.FORM @@ -298,3 +286,39 @@ async def test_user_flow_self_hosted_error( mock_setup_entry.assert_called() mock_authenticator_authenticate.assert_called() mock_mqtt_client.verify_config.assert_called() + + +@pytest.mark.parametrize( + ("test_fn", "test_fn_user_input"), + [ + ( + _test_user_flow_show_advanced_options, + _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), + ), + ( + _test_user_flow_show_advanced_options, + _TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED), + ), + ( + _test_user_flow, + _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), + ), + ], + ids=["advanced_cloud", "advanced_self_hosted", "cloud"], +) +async def test_already_exists( + hass: HomeAssistant, + test_fn: Callable[[HomeAssistant, _TestFnUserInput], Awaitable[dict[str, Any]]], + test_fn_user_input: _TestFnUserInput, +) -> None: + """Test we don't allow duplicated config entries.""" + MockConfigEntry(domain=DOMAIN, data=test_fn_user_input.auth).add_to_hass(hass) + + result = await test_fn( + hass, + test_fn_user_input, + ) + + assert result + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 2185ae4c9eb..13b73d853d5 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -116,6 +116,6 @@ async def test_all_entities_loaded( entities: int, ) -> None: """Test that all entities are loaded together.""" - assert ( - hass.states.async_entity_ids_count() == entities - ), f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}" + assert hass.states.async_entity_ids_count() == entities, ( + f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}" + ) diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index a735863d40a..32bc8f90696 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -136,9 +136,9 @@ async def test_disabled_by_default_number_entities( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 53c57999776..8222e9976d5 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -11,6 +11,7 @@ from deebot_client.events import ( NetworkInfoEvent, StatsEvent, TotalStatsEvent, + station, ) import pytest from syrupy import SnapshotAssertion @@ -45,6 +46,7 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): event_bus.notify(LifeSpanEvent(LifeSpan.FILTER, 56, 40 * 60)) event_bus.notify(LifeSpanEvent(LifeSpan.SIDE_BRUSH, 40, 20 * 60)) event_bus.notify(ErrorEvent(0, "NoError: Robot is operational")) + event_bus.notify(station.StationEvent(station.State.EMPTYING)) await block_till_done(hass, event_bus) @@ -87,8 +89,29 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): "sensor.goat_g1_error", ], ), + ( + "qhe2o2", + [ + "sensor.dusty_area_cleaned", + "sensor.dusty_cleaning_duration", + "sensor.dusty_total_area_cleaned", + "sensor.dusty_total_cleaning_duration", + "sensor.dusty_total_cleanings", + "sensor.dusty_battery", + "sensor.dusty_ip_address", + "sensor.dusty_wi_fi_rssi", + "sensor.dusty_wi_fi_ssid", + "sensor.dusty_station_state", + "sensor.dusty_main_brush_lifespan", + "sensor.dusty_filter_lifespan", + "sensor.dusty_side_brush_lifespan", + "sensor.dusty_unit_care_lifespan", + "sensor.dusty_round_mop_lifespan", + "sensor.dusty_error", + ], + ), ], - ids=["yna5x1", "5xu9h3"], + ids=["yna5x1", "5xu9h3", "qhe2o2"], ) async def test_sensors( hass: HomeAssistant, @@ -99,7 +122,7 @@ async def test_sensors( entity_ids: list[str], ) -> None: """Test that sensor entity snapshots match.""" - assert entity_ids == hass.states.async_entity_ids() + assert hass.states.async_entity_ids() == entity_ids for entity_id in entity_ids: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" assert state.state == STATE_UNKNOWN @@ -149,9 +172,9 @@ async def test_disabled_by_default_sensors( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py index b14cafeaba4..040528debaa 100644 --- a/tests/components/ecovacs/test_switch.py +++ b/tests/components/ecovacs/test_switch.py @@ -214,8 +214,8 @@ async def test_disabled_by_default_switch_entities( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/eheimdigital/__init__.py b/tests/components/eheimdigital/__init__.py new file mode 100644 index 00000000000..1f608f868de --- /dev/null +++ b/tests/components/eheimdigital/__init__.py @@ -0,0 +1 @@ +"""Tests for the EHEIM Digital integration.""" diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py new file mode 100644 index 00000000000..cdad628de6b --- /dev/null +++ b/tests/components/eheimdigital/conftest.py @@ -0,0 +1,58 @@ +"""Configurations for the EHEIM Digital tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.hub import EheimDigitalHub +from eheimdigital.types import EheimDeviceType, LightMode +import pytest + +from homeassistant.components.eheimdigital.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "eheimdigital"}, unique_id="00:00:00:00:00:01" + ) + + +@pytest.fixture +def classic_led_ctrl_mock(): + """Mock a classicLEDcontrol device.""" + classic_led_ctrl_mock = MagicMock(spec=EheimDigitalClassicLEDControl) + classic_led_ctrl_mock.tankconfig = [["CLASSIC_DAYLIGHT"], []] + classic_led_ctrl_mock.mac_address = "00:00:00:00:00:01" + classic_led_ctrl_mock.device_type = ( + EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + classic_led_ctrl_mock.name = "Mock classicLEDcontrol+e" + classic_led_ctrl_mock.aquarium_name = "Mock Aquarium" + classic_led_ctrl_mock.light_mode = LightMode.DAYCL_MODE + classic_led_ctrl_mock.light_level = (10, 39) + return classic_led_ctrl_mock + + +@pytest.fixture +def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMock]: + """Mock eheimdigital hub.""" + with ( + patch( + "homeassistant.components.eheimdigital.coordinator.EheimDigitalHub", + spec=EheimDigitalHub, + ) as eheimdigital_hub_mock, + patch( + "homeassistant.components.eheimdigital.config_flow.EheimDigitalHub", + new=eheimdigital_hub_mock, + ), + ): + eheimdigital_hub_mock.return_value.devices = { + "00:00:00:00:00:01": classic_led_ctrl_mock + } + eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock + yield eheimdigital_hub_mock diff --git a/tests/components/eheimdigital/snapshots/test_light.ambr b/tests/components/eheimdigital/snapshots/test_light.ambr new file mode 100644 index 00000000000..8df4745997e --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_light.ambr @@ -0,0 +1,316 @@ +# serializer version: 1 +# name: test_dynamic_new_devices[light.mock_classicledcontrol_e_channel_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Channel 0', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_dynamic_new_devices[light.mock_classicledcontrol_e_channel_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 26, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig0][light.mock_classicledcontrol_e_channel_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Channel 0', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig0][light.mock_classicledcontrol_e_channel_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 26, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig1][light.mock_classicledcontrol_e_channel_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Channel 1', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig1][light.mock_classicledcontrol_e_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 99, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Channel 0', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 26, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'daycl_mode', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Channel 1', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'channel', + 'unique_id': '00:00:00:00:00:01_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_led_ctrl[tankconfig2][light.mock_classicledcontrol_e_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 99, + 'color_mode': , + 'effect': 'daycl_mode', + 'effect_list': list([ + 'daycl_mode', + ]), + 'friendly_name': 'Mock classicLEDcontrol+e Channel 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_classicledcontrol_e_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/eheimdigital/test_config_flow.py b/tests/components/eheimdigital/test_config_flow.py new file mode 100644 index 00000000000..4bfd45e9259 --- /dev/null +++ b/tests/components/eheimdigital/test_config_flow.py @@ -0,0 +1,212 @@ +"""Tests the config flow of EHEIM Digital.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohttp import ClientConnectionError +import pytest + +from homeassistant.components.eheimdigital.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("192.0.2.1"), + ip_addresses=[ip_address("192.0.2.1")], + hostname="eheimdigital.local.", + name="eheimdigital._http._tcp.local.", + port=80, + type="_http._tcp.local.", + properties={}, +) + +USER_INPUT = {CONF_HOST: "eheimdigital"} + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_full_flow(hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_HOST] + assert result["data"] == USER_INPUT + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +@pytest.mark.parametrize( + ("side_effect", "error_value"), + [(ClientConnectionError(), "cannot_connect"), (Exception(), "unknown")], +) +async def test_flow_errors( + hass: HomeAssistant, + eheimdigital_hub_mock: AsyncMock, + side_effect: BaseException, + error_value: str, +) -> None: + """Test flow errors.""" + eheimdigital_hub_mock.return_value.connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_value} + + eheimdigital_hub_mock.return_value.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_HOST] + assert result["data"] == USER_INPUT + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_zeroconf_flow( + hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_DISCOVERY.host + assert result["data"] == { + CONF_HOST: ZEROCONF_DISCOVERY.host, + } + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + +@pytest.mark.parametrize( + ("side_effect", "error_value"), + [(ClientConnectionError(), "cannot_connect"), (Exception(), "unknown")], +) +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + side_effect: BaseException, + error_value: str, +) -> None: + """Test zeroconf flow errors.""" + eheimdigital_hub_mock.return_value.connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error_value + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_abort(hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock) -> None: + """Test flow abort on matching data or unique_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_HOST] + assert result["data"] == USER_INPUT + assert ( + result["result"].unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_HOST: "eheimdigital2"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/eheimdigital/test_init.py b/tests/components/eheimdigital/test_init.py new file mode 100644 index 00000000000..211a8b3b6fd --- /dev/null +++ b/tests/components/eheimdigital/test_init.py @@ -0,0 +1,55 @@ +"""Tests for the init module.""" + +from unittest.mock import MagicMock + +from eheimdigital.types import EheimDeviceType + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +async def test_remove_device( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test removing a device.""" + assert await async_setup_component(hass, "config", {}) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + mac_address: str = eheimdigital_hub_mock.return_value.main.mac_address + + device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, + ) + assert device_entry is not None + + hass_client = await hass_ws_client(hass) + + # Do not allow to delete a connected device + response = await hass_client.remove_device( + device_entry.id, mock_config_entry.entry_id + ) + assert not response["success"] + + eheimdigital_hub_mock.return_value.devices = {} + + # Allow to delete a not connected device + response = await hass_client.remove_device( + device_entry.id, mock_config_entry.entry_id + ) + assert response["success"] diff --git a/tests/components/eheimdigital/test_light.py b/tests/components/eheimdigital/test_light.py new file mode 100644 index 00000000000..da224979c43 --- /dev/null +++ b/tests/components/eheimdigital/test_light.py @@ -0,0 +1,249 @@ +"""Tests for the light module.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from aiohttp import ClientError +from eheimdigital.types import EheimDeviceType, LightMode +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.eheimdigital.const import EFFECT_DAYCL_MODE +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.color import value_to_brightness + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.parametrize( + "tankconfig", + [ + [["CLASSIC_DAYLIGHT"], []], + [[], ["CLASSIC_DAYLIGHT"]], + [["CLASSIC_DAYLIGHT"], ["CLASSIC_DAYLIGHT"]], + ], +) +async def test_setup_classic_led_ctrl( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + tankconfig: list[list[str]], + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test light platform setup with different channels.""" + mock_config_entry.add_to_hass(hass) + + classic_led_ctrl_mock.tankconfig = tankconfig + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_dynamic_new_devices( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + classic_led_ctrl_mock: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light platform setup with at first no devices and dynamically adding a device.""" + mock_config_entry.add_to_hass(hass) + + eheimdigital_hub_mock.return_value.devices = {} + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert ( + len( + entity_registry.entities.get_entries_for_config_entry_id( + mock_config_entry.entry_id + ) + ) + == 0 + ) + + eheimdigital_hub_mock.return_value.devices = { + "00:00:00:00:00:01": classic_led_ctrl_mock + } + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("eheimdigital_hub_mock") +async def test_turn_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test turning off the light.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await mock_config_entry.runtime_data._async_device_found( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0"}, + blocking=True, + ) + + classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.MAN_MODE) + classic_led_ctrl_mock.turn_off.assert_awaited_once_with(0) + + +@pytest.mark.parametrize( + ("dim_input", "expected_dim_value"), + [ + (3, 1), + (255, 100), + (128, 50), + ], +) +async def test_turn_on_brightness( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, + dim_input: int, + expected_dim_value: int, +) -> None: + """Test turning on the light with different brightness values.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0", + ATTR_BRIGHTNESS: dim_input, + }, + blocking=True, + ) + + classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.MAN_MODE) + classic_led_ctrl_mock.turn_on.assert_awaited_once_with(expected_dim_value, 0) + + +async def test_turn_on_effect( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test turning on the light with an effect value.""" + mock_config_entry.add_to_hass(hass) + + classic_led_ctrl_mock.light_mode = LightMode.MAN_MODE + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_0", + ATTR_EFFECT: EFFECT_DAYCL_MODE, + }, + blocking=True, + ) + + classic_led_ctrl_mock.set_light_mode.assert_awaited_once_with(LightMode.DAYCL_MODE) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_led_ctrl_mock: MagicMock, +) -> None: + """Test the light state update.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + classic_led_ctrl_mock.light_level = (20, 30) + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("light.mock_classicledcontrol_e_channel_0")) + assert state.attributes["brightness"] == value_to_brightness((1, 100), 20) + + +async def test_update_failed( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test an failed update.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E + ) + await hass.async_block_till_done() + + eheimdigital_hub_mock.return_value.update.side_effect = ClientError + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("light.mock_classicledcontrol_e_channel_0").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/elevenlabs/conftest.py b/tests/components/elevenlabs/conftest.py index c4d9a87b5ad..1c261e2947a 100644 --- a/tests/components/elevenlabs/conftest.py +++ b/tests/components/elevenlabs/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from elevenlabs.core import ApiError from elevenlabs.types import GetVoicesResponse +from httpx import ConnectError import pytest from homeassistant.components.elevenlabs.const import CONF_MODEL, CONF_VOICE @@ -24,26 +25,65 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -@pytest.fixture -def mock_async_client() -> Generator[AsyncMock]: - """Override async ElevenLabs client.""" +def _client_mock(): client_mock = AsyncMock() client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) client_mock.models.get_all.return_value = MOCK_MODELS - with patch( - "elevenlabs.client.AsyncElevenLabs", return_value=client_mock - ) as mock_async_client: + return client_mock + + +@pytest.fixture +def mock_async_client() -> Generator[AsyncMock]: + """Override async ElevenLabs client.""" + with ( + patch( + "homeassistant.components.elevenlabs.AsyncElevenLabs", + return_value=_client_mock(), + ) as mock_async_client, + patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + new=mock_async_client, + ), + ): yield mock_async_client @pytest.fixture -def mock_async_client_fail() -> Generator[AsyncMock]: +def mock_async_client_api_error() -> Generator[AsyncMock]: + """Override async ElevenLabs client with ApiError side effect.""" + client_mock = _client_mock() + client_mock.models.get_all.side_effect = ApiError + client_mock.voices.get_all.side_effect = ApiError + + with ( + patch( + "homeassistant.components.elevenlabs.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client, + patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + new=mock_async_client, + ), + ): + yield mock_async_client + + +@pytest.fixture +def mock_async_client_connect_error() -> Generator[AsyncMock]: """Override async ElevenLabs client.""" - with patch( - "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", - return_value=AsyncMock(), - ) as mock_async_client: - mock_async_client.side_effect = ApiError + client_mock = _client_mock() + client_mock.models.get_all.side_effect = ConnectError("Unknown") + client_mock.voices.get_all.side_effect = ConnectError("Unknown") + with ( + patch( + "homeassistant.components.elevenlabs.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client, + patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + new=mock_async_client, + ), + ): yield mock_async_client diff --git a/tests/components/elevenlabs/test_config_flow.py b/tests/components/elevenlabs/test_config_flow.py index 971fa75939a..7eeb0a6eb46 100644 --- a/tests/components/elevenlabs/test_config_flow.py +++ b/tests/components/elevenlabs/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock +import pytest + from homeassistant.components.elevenlabs.const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, @@ -56,7 +58,10 @@ async def test_user_step( async def test_invalid_api_key( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_async_client_fail: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client_api_error: AsyncMock, + request: pytest.FixtureRequest, ) -> None: """Test user step with invalid api key.""" @@ -73,10 +78,28 @@ async def test_invalid_api_key( }, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] + assert result["errors"] == {"base": "invalid_api_key"} mock_setup_entry.assert_not_called() + # Use a working client + request.getfixturevalue("mock_async_client") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ElevenLabs" + assert result["data"] == { + "api_key": "api_key", + } + assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + + mock_setup_entry.assert_called_once() + async def test_options_flow_init( hass: HomeAssistant, diff --git a/tests/components/elevenlabs/test_setup.py b/tests/components/elevenlabs/test_setup.py new file mode 100644 index 00000000000..18b90ca3561 --- /dev/null +++ b/tests/components/elevenlabs/test_setup.py @@ -0,0 +1,36 @@ +"""Tests for the ElevenLabs TTS entity.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + mock_async_client: MagicMock, + mock_entry: MockConfigEntry, +) -> None: + """Test entry setup without any exceptions.""" + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + assert mock_entry.state == ConfigEntryState.LOADED + # Unload + await hass.config_entries.async_unload(mock_entry.entry_id) + assert mock_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_setup_connect_error( + hass: HomeAssistant, + mock_async_client_connect_error: MagicMock, + mock_entry: MockConfigEntry, +) -> None: + """Test entry setup with a connection error.""" + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + # Ensure is not ready + assert mock_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index 7151aab10f2..c4234cb38ae 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -13,6 +13,7 @@ import pytest from homeassistant.components import tts from homeassistant.components.elevenlabs.const import ( + ATTR_MODEL, CONF_MODEL, CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, @@ -163,6 +164,16 @@ async def mock_config_entry_setup( @pytest.mark.parametrize( ("setup", "tts_service", "service_data"), [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + ), ( "mock_config_entry_setup", "speak", @@ -173,6 +184,26 @@ async def mock_config_entry_setup( tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, }, ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, + }, + ), ], indirect=["setup"], ) @@ -206,11 +237,13 @@ async def test_tts_service_speak( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == HTTPStatus.OK ) + voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "voice1") + model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, "model1") tts_entity._client.generate.assert_called_once_with( text="There is a person at the front door.", - voice="voice2", - model="model1", + voice=voice_id, + model=model_id, voice_settings=tts_entity._voice_settings, optimize_streaming_latency=tts_entity._latency, ) diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index 73b09421576..afa89f8eb27 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -7,7 +7,7 @@ from elgato import BatteryInfo, ElgatoNoBatteryError, Info, Settings, State import pytest from homeassistant.components.elgato.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, get_fixture_path, load_fixture @@ -35,7 +35,6 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_HOST: "127.0.0.1", CONF_MAC: "AA:BB:CC:DD:EE:FF", - CONF_PORT: 9123, }, unique_id="CN11A1A00001", ) diff --git a/tests/components/elgato/snapshots/test_config_flow.ambr b/tests/components/elgato/snapshots/test_config_flow.ambr deleted file mode 100644 index d5d005cff9c..00000000000 --- a/tests/components/elgato/snapshots/test_config_flow.ambr +++ /dev/null @@ -1,134 +0,0 @@ -# serializer version: 1 -# name: test_full_user_flow_implementation - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': 'CN11A1A00001', - }), - 'data': dict({ - 'host': '127.0.0.1', - 'mac': None, - 'port': 9123, - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'elgato', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'host': '127.0.0.1', - 'mac': None, - 'port': 9123, - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'elgato', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': 'CN11A1A00001', - 'unique_id': 'CN11A1A00001', - 'version': 1, - }), - 'title': 'CN11A1A00001', - 'type': , - 'version': 1, - }) -# --- -# name: test_full_zeroconf_flow_implementation - FlowResultSnapshot({ - 'context': dict({ - 'confirm_only': True, - 'source': 'zeroconf', - 'unique_id': 'CN11A1A00001', - }), - 'data': dict({ - 'host': '127.0.0.1', - 'mac': 'AA:BB:CC:DD:EE:FF', - 'port': 9123, - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'elgato', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'host': '127.0.0.1', - 'mac': 'AA:BB:CC:DD:EE:FF', - 'port': 9123, - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'elgato', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'zeroconf', - 'title': 'CN11A1A00001', - 'unique_id': 'CN11A1A00001', - 'version': 1, - }), - 'title': 'CN11A1A00001', - 'type': , - 'version': 1, - }) -# --- -# name: test_zeroconf_during_onboarding - FlowResultSnapshot({ - 'context': dict({ - 'source': 'zeroconf', - 'unique_id': 'CN11A1A00001', - }), - 'data': dict({ - 'host': '127.0.0.1', - 'mac': 'AA:BB:CC:DD:EE:FF', - 'port': 9123, - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'elgato', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'host': '127.0.0.1', - 'mac': 'AA:BB:CC:DD:EE:FF', - 'port': 9123, - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'elgato', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'zeroconf', - 'title': 'CN11A1A00001', - 'unique_id': 'CN11A1A00001', - 'version': 1, - }), - 'title': 'CN11A1A00001', - 'type': , - 'version': 1, - }) -# --- diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 009feefc145..4bb4644ab86 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -11,10 +11,10 @@ 27.316, 47.743, ), - 'max_color_temp_kelvin': 6993, + 'max_color_temp_kelvin': 7000, 'max_mireds': 344, - 'min_color_temp_kelvin': 2906, - 'min_mireds': 143, + 'min_color_temp_kelvin': 2900, + 'min_mireds': 142, 'rgb_color': tuple( 255, 189, @@ -43,10 +43,10 @@ }), 'area_id': None, 'capabilities': dict({ - 'max_color_temp_kelvin': 6993, + 'max_color_temp_kelvin': 7000, 'max_mireds': 344, - 'min_color_temp_kelvin': 2906, - 'min_mireds': 143, + 'min_color_temp_kelvin': 2900, + 'min_mireds': 142, 'supported_color_modes': list([ , ]), @@ -126,9 +126,9 @@ 27.316, 47.743, ), - 'max_color_temp_kelvin': 6535, + 'max_color_temp_kelvin': 6500, 'max_mireds': 285, - 'min_color_temp_kelvin': 3508, + 'min_color_temp_kelvin': 3500, 'min_mireds': 153, 'rgb_color': tuple( 255, @@ -159,9 +159,9 @@ }), 'area_id': None, 'capabilities': dict({ - 'max_color_temp_kelvin': 6535, + 'max_color_temp_kelvin': 6500, 'max_mireds': 285, - 'min_color_temp_kelvin': 3508, + 'min_color_temp_kelvin': 3500, 'min_mireds': 153, 'supported_color_modes': list([ , @@ -243,9 +243,9 @@ 358.0, 6.0, ), - 'max_color_temp_kelvin': 6535, + 'max_color_temp_kelvin': 6500, 'max_mireds': 285, - 'min_color_temp_kelvin': 3508, + 'min_color_temp_kelvin': 3500, 'min_mireds': 153, 'rgb_color': tuple( 255, @@ -276,9 +276,9 @@ }), 'area_id': None, 'capabilities': dict({ - 'max_color_temp_kelvin': 6535, + 'max_color_temp_kelvin': 6500, 'max_mireds': 285, - 'min_color_temp_kelvin': 3508, + 'min_color_temp_kelvin': 3500, 'min_mireds': 153, 'supported_color_modes': list([ , diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 6da99241b64..c647d36902a 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -5,14 +5,13 @@ from unittest.mock import AsyncMock, MagicMock from elgato import ElgatoConnectionError import pytest -from syrupy.assertion import SnapshotAssertion -from homeassistant.components import zeroconf from homeassistant.components.elgato.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -21,7 +20,6 @@ async def test_full_user_flow_implementation( hass: HomeAssistant, mock_elgato: MagicMock, mock_setup_entry: AsyncMock, - snapshot: SnapshotAssertion, ) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( @@ -29,15 +27,22 @@ async def test_full_user_flow_implementation( context={"source": SOURCE_USER}, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 9123} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1"} ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "CN11A1A00001" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_MAC: None, + } + assert not config_entry.options assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_elgato.info.mock_calls) == 1 @@ -47,13 +52,12 @@ async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, mock_elgato: MagicMock, mock_setup_entry: AsyncMock, - snapshot: SnapshotAssertion, ) -> None: """Test the zeroconf flow from start to finish.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -64,9 +68,9 @@ async def test_full_zeroconf_flow_implementation( ), ) - assert result.get("description_placeholders") == {"serial_number": "CN11A1A00001"} - assert result.get("step_id") == "zeroconf_confirm" - assert result.get("type") is FlowResultType.FORM + assert result["description_placeholders"] == {"serial_number": "CN11A1A00001"} + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] is FlowResultType.FORM progress = hass.config_entries.flow.async_progress() assert len(progress) == 1 @@ -74,12 +78,19 @@ async def test_full_zeroconf_flow_implementation( assert "context" in progress[0] assert progress[0]["context"].get("confirm_only") is True - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "CN11A1A00001" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + assert not config_entry.options assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_elgato.info.mock_calls) == 1 @@ -94,12 +105,31 @@ async def test_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "127.0.0.1", CONF_PORT: 9123}, + data={CONF_HOST: "127.0.0.1"}, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {"base": "cannot_connect"} - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + assert result["step_id"] == "user" + + # Recover from error + mock_elgato.info.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "127.0.0.2"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "CN11A1A00001" + assert config_entry.data == { + CONF_HOST: "127.0.0.2", + CONF_MAC: None, + } + assert not config_entry.options async def test_zeroconf_connection_error( @@ -111,7 +141,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -122,8 +152,8 @@ async def test_zeroconf_connection_error( ), ) - assert result.get("reason") == "cannot_connect" - assert result.get("type") is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + assert result["type"] is FlowResultType.ABORT @pytest.mark.usefixtures("mock_elgato") @@ -135,11 +165,11 @@ async def test_user_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "127.0.0.1", CONF_PORT: 9123}, + data={CONF_HOST: "127.0.0.1"}, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.usefixtures("mock_elgato") @@ -151,7 +181,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -162,8 +192,8 @@ async def test_zeroconf_device_exists_abort( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" entries = hass.config_entries.async_entries(DOMAIN) assert entries[0].data[CONF_HOST] == "127.0.0.1" @@ -172,7 +202,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -183,8 +213,8 @@ async def test_zeroconf_device_exists_abort( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" entries = hass.config_entries.async_entries(DOMAIN) assert entries[0].data[CONF_HOST] == "127.0.0.2" @@ -195,13 +225,12 @@ async def test_zeroconf_during_onboarding( mock_elgato: MagicMock, mock_setup_entry: AsyncMock, mock_onboarding: MagicMock, - snapshot: SnapshotAssertion, ) -> None: """Test the zeroconf creates an entry during onboarding.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -212,8 +241,15 @@ async def test_zeroconf_during_onboarding( ), ) - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "CN11A1A00001" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + assert not config_entry.options assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_elgato.info.mock_calls) == 1 diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index 40c0232c2b3..43fad1faa77 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -9,7 +9,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.elgato.const import DOMAIN, SERVICE_IDENTIFY from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, DOMAIN as LIGHT_DOMAIN, ) @@ -74,7 +74,7 @@ async def test_light_change_state_temperature( { ATTR_ENTITY_ID: "light.frenck", ATTR_BRIGHTNESS: 255, - ATTR_COLOR_TEMP: 100, + ATTR_COLOR_TEMP_KELVIN: 10000, }, blocking=True, ) diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py index e56bb5f4699..5355013bf94 100644 --- a/tests/components/elkm1/test_config_flow.py +++ b/tests/components/elkm1/test_config_flow.py @@ -7,12 +7,12 @@ from elkm1_lib.discovery import ElkSystem import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.elkm1.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( ELK_DISCOVERY, @@ -27,7 +27,7 @@ from . import ( from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( MOCK_IP_ADDRESS, "", dr.format_mac(MOCK_MAC).replace(":", "") ) ELK_DISCOVERY_INFO = asdict(ELK_DISCOVERY) @@ -1141,7 +1141,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=MOCK_IP_ADDRESS, macaddress="00:00:00:00:00:00", diff --git a/tests/components/elmax/conftest.py b/tests/components/elmax/conftest.py index f92fc2f1827..f8cf33ffe1a 100644 --- a/tests/components/elmax/conftest.py +++ b/tests/components/elmax/conftest.py @@ -1,6 +1,7 @@ """Configuration for Elmax tests.""" from collections.abc import Generator +from datetime import datetime, timedelta import json from unittest.mock import AsyncMock, patch @@ -11,6 +12,7 @@ from elmax_api.constants import ( ENDPOINT_LOGIN, ) from httpx import Response +import jwt import pytest import respx @@ -64,9 +66,20 @@ def httpx_mock_direct_fixture() -> Generator[respx.MockRouter]: ) as respx_mock: # Mock Login POST. login_route = respx_mock.post(f"/api/v2/{ENDPOINT_LOGIN}", name="login") - login_route.return_value = Response( - 200, json=json.loads(load_fixture("direct/login.json", "elmax")) + + login_json = json.loads(load_fixture("direct/login.json", "elmax")) + decoded_jwt = jwt.decode_complete( + login_json["token"].split(" ")[1], + algorithms="HS256", + options={"verify_signature": False}, ) + expiration = datetime.now() + timedelta(hours=1) + decoded_jwt["payload"]["exp"] = int(expiration.timestamp()) + jws_string = jwt.encode( + payload=decoded_jwt["payload"], algorithm="HS256", key="" + ) + login_json["token"] = f"JWT {jws_string}" + login_route.return_value = Response(200, json=login_json) # Mock Device list GET. list_devices_route = respx_mock.get( diff --git a/tests/components/elmax/test_config_flow.py b/tests/components/elmax/test_config_flow.py index 7a4d9755fa5..be89ee4d5d6 100644 --- a/tests/components/elmax/test_config_flow.py +++ b/tests/components/elmax/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import patch from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.elmax.const import ( CONF_ELMAX_MODE, CONF_ELMAX_MODE_CLOUD, @@ -23,6 +22,7 @@ from homeassistant.components.elmax.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ( MOCK_DIRECT_CERT, @@ -40,7 +40,7 @@ from . import ( from tests.common import MockConfigEntry -MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST, ip_addresses=[MOCK_DIRECT_HOST], hostname="VideoBox.local", @@ -54,7 +54,7 @@ MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( }, type="_elmax-ssl._tcp", ) -MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST_CHANGED, ip_addresses=[MOCK_DIRECT_HOST_CHANGED], hostname="VideoBox.local", @@ -68,7 +68,7 @@ MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = zeroconf.ZeroconfServiceInfo( }, type="_elmax-ssl._tcp", ) -MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST, ip_addresses=[MOCK_DIRECT_HOST], hostname="VideoBox.local", diff --git a/tests/components/emoncms/snapshots/test_sensor.ambr b/tests/components/emoncms/snapshots/test_sensor.ambr index f6a2745fb1a..210196ce414 100644 --- a/tests/components/emoncms/snapshots/test_sensor.ambr +++ b/tests/components/emoncms/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_coordinator_update[sensor.emoncms_1_1_1_1_parameter_1-entry] +# name: test_coordinator_update[sensor.temperature_tag_parameter_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13,8 +13,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1', - 'has_entity_name': False, + 'entity_id': 'sensor.temperature_tag_parameter_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -25,16 +25,16 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'emoncms@1.1.1.1 parameter 1', + 'original_name': 'Temperature tag parameter 1', 'platform': 'emoncms', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'temperature', 'unique_id': '123-53535292-1', 'unit_of_measurement': , }) # --- -# name: test_coordinator_update[sensor.emoncms_1_1_1_1_parameter_1-state] +# name: test_coordinator_update[sensor.temperature_tag_parameter_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'FeedId': '1', @@ -45,12 +45,12 @@ 'Tag': 'tag', 'UserId': '1', 'device_class': 'temperature', - 'friendly_name': 'emoncms@1.1.1.1 parameter 1', + 'friendly_name': 'Temperature tag parameter 1', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1', + 'entity_id': 'sensor.temperature_tag_parameter_1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/emonitor/test_config_flow.py b/tests/components/emonitor/test_config_flow.py index e77ebcc08b0..3e5f4004d1a 100644 --- a/tests/components/emonitor/test_config_flow.py +++ b/tests/components/emonitor/test_config_flow.py @@ -6,15 +6,15 @@ from aioemonitor.monitor import EmonitorNetwork, EmonitorStatus import aiohttp from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.emonitor.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="emonitor", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index a445f8bae0d..8a340d5e2dd 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -793,7 +793,10 @@ async def test_put_light_state( await hass_hue.services.async_call( light.DOMAIN, const.SERVICE_TURN_ON, - {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_COLOR_TEMP: 20}, + { + const.ATTR_ENTITY_ID: "light.ceiling_lights", + light.ATTR_COLOR_TEMP_KELVIN: 50000, + }, blocking=True, ) @@ -802,8 +805,10 @@ async def test_put_light_state( ) assert ( - hass_hue.states.get("light.ceiling_lights").attributes[light.ATTR_COLOR_TEMP] - == 50 + hass_hue.states.get("light.ceiling_lights").attributes[ + light.ATTR_COLOR_TEMP_KELVIN + ] + == 20000 ) # mock light.turn_on call @@ -1785,7 +1790,7 @@ async def test_get_light_state_when_none( light.ATTR_BRIGHTNESS: None, light.ATTR_RGB_COLOR: None, light.ATTR_HS_COLOR: None, - light.ATTR_COLOR_TEMP: None, + light.ATTR_COLOR_TEMP_KELVIN: None, light.ATTR_XY_COLOR: None, light.ATTR_SUPPORTED_COLOR_MODES: [ light.COLOR_MODE_COLOR_TEMP, @@ -1813,7 +1818,7 @@ async def test_get_light_state_when_none( light.ATTR_BRIGHTNESS: None, light.ATTR_RGB_COLOR: None, light.ATTR_HS_COLOR: None, - light.ATTR_COLOR_TEMP: None, + light.ATTR_COLOR_TEMP_KELVIN: None, light.ATTR_XY_COLOR: None, light.ATTR_SUPPORTED_COLOR_MODES: [ light.COLOR_MODE_COLOR_TEMP, diff --git a/tests/components/energyzero/__init__.py b/tests/components/energyzero/__init__.py index 287bdf6a2f4..35a1346790f 100644 --- a/tests/components/energyzero/__init__.py +++ b/tests/components/energyzero/__init__.py @@ -1 +1,12 @@ """Tests for the EnergyZero integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/energyzero/conftest.py b/tests/components/energyzero/conftest.py index d42283c0d4b..3fd93ee31f8 100644 --- a/tests/components/energyzero/conftest.py +++ b/tests/components/energyzero/conftest.py @@ -29,7 +29,8 @@ def mock_config_entry() -> MockConfigEntry: title="energy", domain=DOMAIN, data={}, - unique_id="unique_thingy", + unique_id=DOMAIN, + entry_id="12345", ) diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr deleted file mode 100644 index 72e504c97c8..00000000000 --- a/tests/components/energyzero/snapshots/test_config_flow.ambr +++ /dev/null @@ -1,39 +0,0 @@ -# serializer version: 1 -# name: test_full_user_flow - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': 'energyzero', - }), - 'data': dict({ - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'energyzero', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'energyzero', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': 'EnergyZero', - 'unique_id': 'energyzero', - 'version': 1, - }), - 'title': 'EnergyZero', - 'type': , - 'version': 1, - }) -# --- diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr index 90c11ecfc6f..aaa52cfeb7e 100644 --- a/tests/components/energyzero/snapshots/test_diagnostics.ambr +++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr @@ -1,26 +1,4 @@ # serializer version: 1 -# name: test_diagnostics - dict({ - 'energy': dict({ - 'average_price': 0.37, - 'current_hour_price': 0.49, - 'highest_price_time': '2022-12-07T16:00:00+00:00', - 'hours_priced_equal_or_lower': 23, - 'lowest_price_time': '2022-12-07T02:00:00+00:00', - 'max_price': 0.55, - 'min_price': 0.26, - 'next_hour_price': 0.55, - 'percentage_of_max': 89.09, - }), - 'entry': dict({ - 'title': 'energy', - }), - 'gas': dict({ - 'current_hour_price': 1.47, - 'next_hour_price': 1.47, - }), - }) -# --- # name: test_diagnostics_no_gas_today dict({ 'energy': dict({ @@ -43,3 +21,25 @@ }), }) # --- +# name: test_entry_diagnostics + dict({ + 'energy': dict({ + 'average_price': 0.37, + 'current_hour_price': 0.49, + 'highest_price_time': '2022-12-07T16:00:00+00:00', + 'hours_priced_equal_or_lower': 23, + 'lowest_price_time': '2022-12-07T02:00:00+00:00', + 'max_price': 0.55, + 'min_price': 0.26, + 'next_hour_price': 0.55, + 'percentage_of_max': 89.09, + }), + 'entry': dict({ + 'title': 'energy', + }), + 'gas': dict({ + 'current_hour_price': 1.47, + 'next_hour_price': 1.47, + }), + }) +# --- diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index 3a66f25fd32..452f4ae748e 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -1,20 +1,5 @@ # serializer version: 1 -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Average - today', - 'unit_of_measurement': '€/kWh', - }), - 'context': , - 'entity_id': 'sensor.energyzero_today_energy_average_price', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.37', - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_average_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -43,52 +28,26 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'average_price', + 'unique_id': '12345_today_energy_average_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_average_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Current hour', - 'state_class': , + 'friendly_name': 'Energy market price Average - today', 'unit_of_measurement': '€/kWh', }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'entity_id': 'sensor.energyzero_today_energy_average_price', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.49', + 'state': '0.37', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,51 +78,27 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', + 'unique_id': '12345_today_energy_current_hour_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'device_class': 'timestamp', - 'friendly_name': 'Energy market price Time of highest price - today', + 'friendly_name': 'Energy market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/kWh', }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2022-12-07T16:00:00+00:00', + 'state': '0.49', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -192,51 +127,26 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'highest_price_time', + 'unique_id': '12345_today_energy_highest_price_time', 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Hours priced equal or lower than current - today', - 'unit_of_measurement': , + 'device_class': 'timestamp', + 'friendly_name': 'Energy market price Time of highest price - today', }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', + 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '23', + 'state': '2022-12-07T16:00:00+00:00', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -265,51 +175,74 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hours_priced_equal_or_lower', + 'unique_id': '12345_today_energy_hours_priced_equal_or_lower', 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Highest price - today', - 'unit_of_measurement': '€/kWh', + 'friendly_name': 'Energy market price Hours priced equal or lower than current - today', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_max_price', + 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.55', + 'state': '23', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_lowest_price_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_lowest_price_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time of lowest price - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lowest_price_time', + 'unique_id': '12345_today_energy_lowest_price_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_lowest_price_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'device_class': 'timestamp', + 'friendly_name': 'Energy market price Time of lowest price - today', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_lowest_price_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-12-07T02:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_max_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -338,52 +271,170 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'max_price', + 'unique_id': '12345_today_energy_max_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas] +# name: test_sensor[sensor.energyzero_today_energy_max_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Gas market price Current hour', - 'state_class': , - 'unit_of_measurement': '€/m³', + 'friendly_name': 'Energy market price Highest price - today', + 'unit_of_measurement': '€/kWh', }), 'context': , - 'entity_id': 'sensor.energyzero_today_gas_current_hour_price', + 'entity_id': 'sensor.energyzero_today_energy_max_price', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.47', + 'state': '0.55', }) # --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas].1 +# name: test_sensor[sensor.energyzero_today_energy_min_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_min_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lowest price - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'min_price', + 'unique_id': '12345_today_energy_min_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_min_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Lowest price - today', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_min_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.26', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_next_hour_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_next_hour_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next hour', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_hour_price', + 'unique_id': '12345_today_energy_next_hour_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_next_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Next hour', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_next_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.55', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_percentage_of_max-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_percentage_of_max', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current percentage of highest price - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'percentage_of_max', + 'unique_id': '12345_today_energy_percentage_of_max', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_percentage_of_max-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Current percentage of highest price - today', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_percentage_of_max', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '89.09', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -414,32 +465,71 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', + 'unique_id': '12345_today_gas_current_hour_price', 'unit_of_measurement': '€/m³', }) # --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ +# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Gas market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/m³', }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Gas market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'context': , + 'entity_id': 'sensor.energyzero_today_gas_current_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.47', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_next_hour_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_gas_next_hour_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next hour', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_hour_price', + 'unique_id': '12345_today_gas_next_hour_price', + 'unit_of_measurement': '€/m³', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_next_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Gas market price Next hour', + 'unit_of_measurement': '€/m³', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_gas_next_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.47', }) # --- diff --git a/tests/components/energyzero/test_config_flow.py b/tests/components/energyzero/test_config_flow.py index a9fe8534fd5..09884ff4cf6 100644 --- a/tests/components/energyzero/test_config_flow.py +++ b/tests/components/energyzero/test_config_flow.py @@ -2,18 +2,17 @@ from unittest.mock import MagicMock -from syrupy.assertion import SnapshotAssertion - from homeassistant.components.energyzero.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + async def test_full_user_flow( hass: HomeAssistant, mock_setup_entry: MagicMock, - snapshot: SnapshotAssertion, ) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( @@ -30,6 +29,22 @@ async def test_full_user_flow( ) assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result2.get("title") == "EnergyZero" + assert result2.get("data") == {} assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_single_instance( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test abort when setting up a duplicate entry.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" diff --git a/tests/components/energyzero/test_diagnostics.py b/tests/components/energyzero/test_diagnostics.py index f4408ded05d..198f21822c7 100644 --- a/tests/components/energyzero/test_diagnostics.py +++ b/tests/components/energyzero/test_diagnostics.py @@ -1,53 +1,54 @@ """Tests for the diagnostics data provided by the EnergyZero integration.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from energyzero import EnergyZeroNoDataError +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.components.energyzero.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator pytestmark = pytest.mark.freeze_time("2022-12-07 15:00:00") -async def test_diagnostics( +async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - init_integration: MockConfigEntry, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test diagnostics.""" - assert ( - await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - == snapshot + """Test the EnergyZero entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry ) + assert result == snapshot + async def test_diagnostics_no_gas_today( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_energyzero: MagicMock, init_integration: MockConfigEntry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics, no gas sensors available.""" - await async_setup_component(hass, "homeassistant", {}) mock_energyzero.gas_prices.side_effect = EnergyZeroNoDataError - await hass.services.async_call( - "homeassistant", - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: ["sensor.energyzero_today_gas_current_hour_price"]}, - blocking=True, - ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( diff --git a/tests/components/energyzero/test_init.py b/tests/components/energyzero/test_init.py index 287157026f4..f8e7e75e902 100644 --- a/tests/components/energyzero/test_init.py +++ b/tests/components/energyzero/test_init.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock, patch from energyzero import EnergyZeroConnectionError import pytest -from homeassistant.components.energyzero.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -26,7 +25,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py index 5c4700c21f1..d952ac77515 100644 --- a/tests/components/energyzero/test_sensor.py +++ b/tests/components/energyzero/test_sensor.py @@ -1,96 +1,49 @@ """Tests for the sensors provided by the EnergyZero integration.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from energyzero import EnergyZeroNoDataError +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from syrupy.filters import props -from homeassistant.components.energyzero.const import DOMAIN -from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.components.energyzero.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform pytestmark = [pytest.mark.freeze_time("2022-12-07 15:00:00")] -@pytest.mark.parametrize( - ("entity_id", "entity_unique_id", "device_identifier"), - [ - ( - "sensor.energyzero_today_energy_current_hour_price", - "today_energy_current_hour_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_average_price", - "today_energy_average_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_max_price", - "today_energy_max_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_highest_price_time", - "today_energy_highest_price_time", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_hours_priced_equal_or_lower", - "today_energy_hours_priced_equal_or_lower", - "today_energy", - ), - ( - "sensor.energyzero_today_gas_current_hour_price", - "today_gas_current_hour_price", - "today_gas", - ), - ], -) async def test_sensor( hass: HomeAssistant, - init_integration: MockConfigEntry, - device_registry: dr.DeviceRegistry, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, - entity_id: str, - entity_unique_id: str, - device_identifier: str, ) -> None: """Test the EnergyZero - Energy sensors.""" - entry_id = init_integration.entry_id - assert (state := hass.states.get(entity_id)) - assert state == snapshot - assert (entity_entry := entity_registry.async_get(entity_id)) - assert entity_entry == snapshot(exclude=props("unique_id")) - assert entity_entry.unique_id == f"{entry_id}_{entity_unique_id}" + with patch("homeassistant.components.energyzero.PLATFORMS", ["sensor"]): + await setup_integration(hass, mock_config_entry) - assert entity_entry.device_id - assert (device_entry := device_registry.async_get(entity_entry.device_id)) - assert device_entry == snapshot(exclude=props("identifiers")) - assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_{device_identifier}")} + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.usefixtures("init_integration") -async def test_no_gas_today(hass: HomeAssistant, mock_energyzero: MagicMock) -> None: +async def test_no_gas_today( + hass: HomeAssistant, + mock_energyzero: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: """Test the EnergyZero - No gas sensors available.""" - await async_setup_component(hass, "homeassistant", {}) - mock_energyzero.gas_prices.side_effect = EnergyZeroNoDataError - await hass.services.async_call( - "homeassistant", - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: ["sensor.energyzero_today_gas_current_hour_price"]}, - blocking=True, - ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("sensor.energyzero_today_gas_current_hour_price")) diff --git a/tests/components/enigma2/conftest.py b/tests/components/enigma2/conftest.py index a53d1494e9a..a16ef69979b 100644 --- a/tests/components/enigma2/conftest.py +++ b/tests/components/enigma2/conftest.py @@ -1,6 +1,10 @@ """Test the Enigma2 config flow.""" -from openwebif.api import OpenWebIfServiceEvent, OpenWebIfStatus +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from openwebif.api import OpenWebIfDevice, OpenWebIfServiceEvent, OpenWebIfStatus +import pytest from homeassistant.components.enigma2.const import ( CONF_DEEP_STANDBY, @@ -10,6 +14,7 @@ from homeassistant.components.enigma2.const import ( DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, + DOMAIN, ) from homeassistant.const import ( CONF_HOST, @@ -20,6 +25,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) +from tests.common import MockConfigEntry, load_json_object_fixture + MAC_ADDRESS = "12:34:56:78:90:ab" TEST_REQUIRED = { @@ -45,42 +52,41 @@ EXPECTED_OPTIONS = { } -class MockDevice: - """A mock Enigma2 device.""" +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, data=TEST_REQUIRED, unique_id="12:34:56:78:90:ab" + ) - mac_address: str | None = "12:34:56:78:90:ab" - _base = "http://1.1.1.1" - def __init__(self) -> None: - """Initialize the mock Enigma2 device.""" - self.status = OpenWebIfStatus(currservice=OpenWebIfServiceEvent()) +@pytest.fixture +def openwebif_device_mock() -> Generator[AsyncMock]: + """Mock a OpenWebIf device.""" - async def _call_api(self, url: str) -> dict | None: - if url.endswith("/api/about"): - return { - "info": { - "ifaces": [ - { - "mac": self.mac_address, - } - ], - "model": "Mock Enigma2", - "brand": "Enigma2", - } - } - return None - - def get_version(self) -> str | None: - """Return the version.""" - return None - - async def get_about(self) -> dict: - """Get mock about endpoint.""" - return await self._call_api("/api/about") - - async def get_all_bouquets(self) -> dict: - """Get all bouquets.""" - return { + with ( + patch( + "homeassistant.components.enigma2.coordinator.OpenWebIfDevice", + spec=OpenWebIfDevice, + ) as openwebif_device_mock, + patch( + "homeassistant.components.enigma2.config_flow.OpenWebIfDevice", + new=openwebif_device_mock, + ), + ): + device = openwebif_device_mock.return_value + device.status = OpenWebIfStatus(currservice=OpenWebIfServiceEvent()) + device.turn_off_to_deep = False + device.sources = {"Test": "1"} + device.source_list = list(device.sources.keys()) + device.picon_url = "file:///" + device.get_about.return_value = load_json_object_fixture( + "device_about.json", DOMAIN + ) + device.get_status_info.return_value = load_json_object_fixture( + "device_statusinfo_on.json", DOMAIN + ) + device.get_all_bouquets.return_value = { "bouquets": [ [ '1:7:1:0:0:0:0:0:0:0:FROM BOUQUET "userbouquet.favourites.tv" ORDER BY bouquet', @@ -88,9 +94,4 @@ class MockDevice: ] ] } - - async def update(self) -> None: - """Mock update.""" - - async def close(self): - """Mock close.""" + yield device diff --git a/tests/components/enigma2/fixtures/device_about.json b/tests/components/enigma2/fixtures/device_about.json new file mode 100644 index 00000000000..5b992fa1bd5 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_about.json @@ -0,0 +1,158 @@ +{ + "info": { + "brand": "GigaBlue", + "model": "UHD QUAD 4K", + "boxtype": "gbquad4k", + "machinebuild": "gb7252", + "lcd": 1, + "grabpip": 1, + "chipset": "bcm7252s", + "mem1": "906132 kB", + "mem2": "616396 kB", + "mem3": "616396 kB frei / 906132 kB insgesamt", + "uptime": "46d 15:47", + "webifver": "OWIF 2.2.0", + "imagedistro": "openatv", + "friendlyimagedistro": "openATV", + "oever": "OE-Alliance 5.5", + "imagever": "7.5.20241101", + "enigmaver": "2024-10-31", + "driverdate": "20200723", + "kernelver": "4.1.20", + "fp_version": 0, + "friendlychipsetdescription": "Chipsatz", + "friendlychipsettext": "Broadcom 7252s", + "tuners": [ + { + "name": "Tuner A", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner B", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner C", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner D", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner E", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner F", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner G", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner H", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner I", + "type": "GIGA DVB-T2/C NIM (TT2L10) (DVB-T2)", + "rec": "", + "live": "", + "stream": "" + } + ], + "ifaces": [ + { + "name": "eth0", + "friendlynic": "Broadcom Gigabit Ethernet", + "linkspeed": "1 GBit/s", + "mac": "12:34:56:78:90:ab", + "dhcp": true, + "ipv4method": "DHCP", + "ip": "192.168.1.100", + "mask": "255.255.255.0", + "v4prefix": 23, + "gw": "192.168.1.1", + "ipv6": "2003::2/64", + "ipmethod": "SL-AAC", + "firstpublic": "2003::2" + } + ], + "hdd": [ + { + "model": "ATA(ST2000LM015-2E81)", + "capacity": "1.8 TB", + "labelled_capacity": "2.0 TB", + "free": "22.5 GB", + "mount": "/media/hdd", + "friendlycapacity": "22.5 GB frei / 1.8 TB (2.0 TB) insgesamt" + } + ], + "shares": [ + { + "name": "NAS", + "method": "autofs", + "type": "SMBv2.0", + "mode": "r/w", + "path": "//192.168.1.2/NAS", + "host": "192.168.1.2", + "ipaddress": null, + "friendlyaddress": "192.168.1.2" + } + ], + "transcoding": true, + "EX": "", + "streams": [], + "timerpipzap": false, + "allow_duplicate": false, + "timermargins": true, + "textinputsupport": true + }, + "service": { + "result": false, + "name": "", + "namespace": "", + "aspect": 0, + "provider": "", + "width": 0, + "height": 0, + "apid": 0, + "vpid": 0, + "pcrpid": 0, + "pmtpid": 0, + "txtpid": "N/A", + "tsid": 0, + "onid": 0, + "sid": 0, + "ref": "", + "iswidescreen": false, + "bqref": "", + "bqname": "" + } +} diff --git a/tests/components/enigma2/fixtures/device_about_without_mac.json b/tests/components/enigma2/fixtures/device_about_without_mac.json new file mode 100644 index 00000000000..02a84edcac2 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_about_without_mac.json @@ -0,0 +1,158 @@ +{ + "info": { + "brand": "GigaBlue", + "model": "UHD QUAD 4K", + "boxtype": "gbquad4k", + "machinebuild": "gb7252", + "lcd": 1, + "grabpip": 1, + "chipset": "bcm7252s", + "mem1": "906132 kB", + "mem2": "616396 kB", + "mem3": "616396 kB frei / 906132 kB insgesamt", + "uptime": "46d 15:47", + "webifver": "OWIF 2.2.0", + "imagedistro": "openatv", + "friendlyimagedistro": "openATV", + "oever": "OE-Alliance 5.5", + "imagever": "7.5.20241101", + "enigmaver": "2024-10-31", + "driverdate": "20200723", + "kernelver": "4.1.20", + "fp_version": 0, + "friendlychipsetdescription": "Chipsatz", + "friendlychipsettext": "Broadcom 7252s", + "tuners": [ + { + "name": "Tuner A", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner B", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner C", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner D", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner E", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner F", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner G", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner H", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner I", + "type": "GIGA DVB-T2/C NIM (TT2L10) (DVB-T2)", + "rec": "", + "live": "", + "stream": "" + } + ], + "ifaces": [ + { + "name": "eth0", + "friendlynic": "Broadcom Gigabit Ethernet", + "linkspeed": "1 GBit/s", + "mac": null, + "dhcp": true, + "ipv4method": "DHCP", + "ip": "192.168.1.100", + "mask": "255.255.255.0", + "v4prefix": 23, + "gw": "192.168.1.1", + "ipv6": "2003::2/64", + "ipmethod": "SL-AAC", + "firstpublic": "2003::2" + } + ], + "hdd": [ + { + "model": "ATA(ST2000LM015-2E81)", + "capacity": "1.8 TB", + "labelled_capacity": "2.0 TB", + "free": "22.5 GB", + "mount": "/media/hdd", + "friendlycapacity": "22.5 GB frei / 1.8 TB (2.0 TB) insgesamt" + } + ], + "shares": [ + { + "name": "NAS", + "method": "autofs", + "type": "SMBv2.0", + "mode": "r/w", + "path": "//192.168.1.2/NAS", + "host": "192.168.1.2", + "ipaddress": null, + "friendlyaddress": "192.168.1.2" + } + ], + "transcoding": true, + "EX": "", + "streams": [], + "timerpipzap": false, + "allow_duplicate": false, + "timermargins": true, + "textinputsupport": true + }, + "service": { + "result": false, + "name": "", + "namespace": "", + "aspect": 0, + "provider": "", + "width": 0, + "height": 0, + "apid": 0, + "vpid": 0, + "pcrpid": 0, + "pmtpid": 0, + "txtpid": "N/A", + "tsid": 0, + "onid": 0, + "sid": 0, + "ref": "", + "iswidescreen": false, + "bqref": "", + "bqname": "" + } +} diff --git a/tests/components/enigma2/fixtures/device_statusinfo_on.json b/tests/components/enigma2/fixtures/device_statusinfo_on.json new file mode 100644 index 00000000000..0c8701c7b74 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_statusinfo_on.json @@ -0,0 +1,20 @@ +{ + "volume": 100, + "muted": false, + "transcoding": true, + "currservice_filename": "", + "currservice_id": 38835, + "currservice_name": "Flucht aus Saudi-Arabien", + "currservice_serviceref": "1:0:19:2BA2:3F2:1:C00000:0:0:0:", + "currservice_begin": "16:30", + "currservice_begin_timestamp": 1734622200, + "currservice_end": "17:15", + "currservice_end_timestamp": 1734624900, + "currservice_description": "Ein M\u00e4dchen k\u00e4mpft um die Freiheit", + "currservice_station": "ZDFinfo HD", + "currservice_fulldescription": "Flucht aus Saudi-Arabien\n16:30 - 17:15\n\nSaudi-Arabien / Australien 2019\nIm streng islamischen K\u00f6nigreich Saudi-Arabien haben Frauen immer noch wenig Rechte. Wenn sie ein selbstbestimmtes Leben f\u00fchren wollen, bleibt ihnen h\u00e4ufig nur die Flucht.\n\nDie 18-j\u00e4hrige Rahaf Mohammed al-Qunun ist eine solche junge Frau, die riskiert hat, dem m\u00e4nnlich gepr\u00e4gten Vormundschaftssystem zu entfliehen. Doch der saudische Staat und Rahafs Familie verfolgen die Abtr\u00fcnnige sogar bis ins Ausland.\nHD-Produktion", + "inStandby": "false", + "isRecording": "false", + "Streaming_list": "", + "isStreaming": "false" +} diff --git a/tests/components/enigma2/fixtures/device_statusinfo_standby.json b/tests/components/enigma2/fixtures/device_statusinfo_standby.json new file mode 100644 index 00000000000..18cc19f5901 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_statusinfo_standby.json @@ -0,0 +1,16 @@ +{ + "volume": 100, + "muted": true, + "transcoding": true, + "currservice_filename": "", + "currservice_id": -1, + "currservice_name": "N/A", + "currservice_begin": "", + "currservice_end": "", + "currservice_description": "", + "currservice_fulldescription": "N/A", + "inStandby": "true", + "isRecording": "false", + "Streaming_list": "", + "isStreaming": "false" +} diff --git a/tests/components/enigma2/test_config_flow.py b/tests/components/enigma2/test_config_flow.py index 8d32da42baf..1445048f0c1 100644 --- a/tests/components/enigma2/test_config_flow.py +++ b/tests/components/enigma2/test_config_flow.py @@ -1,19 +1,19 @@ """Test the Enigma2 config flow.""" from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock from aiohttp.client_exceptions import ClientError from openwebif.error import InvalidAuthError import pytest -from homeassistant import config_entries from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import TEST_FULL, TEST_REQUIRED, MockDevice +from .conftest import TEST_FULL, TEST_REQUIRED from tests.common import MockConfigEntry @@ -22,42 +22,35 @@ from tests.common import MockConfigEntry async def user_flow(hass: HomeAssistant) -> str: """Return a user-initiated flow after filling in host info.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == FlowResultType.FORM assert result["errors"] is None return result["flow_id"] +@pytest.mark.usefixtures("openwebif_device_mock") @pytest.mark.parametrize( ("test_config"), [(TEST_FULL), (TEST_REQUIRED)], ) -async def test_form_user( - hass: HomeAssistant, user_flow: str, test_config: dict[str, Any] -) -> None: +async def test_form_user(hass: HomeAssistant, test_config: dict[str, Any]) -> None: """Test a successful user initiated flow.""" - with ( - patch( - "openwebif.api.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ), - patch( - "homeassistant.components.enigma2.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure(user_flow, test_config) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], test_config + ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == test_config[CONF_HOST] assert result["data"] == test_config - assert len(mock_setup_entry.mock_calls) == 1 - @pytest.mark.parametrize( - ("exception", "error_type"), + ("side_effect", "error_value"), [ (InvalidAuthError, "invalid_auth"), (ClientError, "cannot_connect"), @@ -65,46 +58,87 @@ async def test_form_user( ], ) async def test_form_user_errors( - hass: HomeAssistant, user_flow, exception: Exception, error_type: str + hass: HomeAssistant, + openwebif_device_mock: AsyncMock, + side_effect: Exception, + error_value: str, ) -> None: """Test we handle errors.""" - with patch( - "homeassistant.components.enigma2.config_flow.OpenWebIfDevice.__new__", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure(user_flow, TEST_FULL) + + openwebif_device_mock.get_about.side_effect = side_effect + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_FULL + ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER - assert result["errors"] == {"base": error_type} + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {"base": error_value} + + openwebif_device_mock.get_about.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_FULL, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_FULL[CONF_HOST] + assert result["data"] == TEST_FULL + assert result["result"].unique_id == openwebif_device_mock.mac_address -async def test_options_flow(hass: HomeAssistant, user_flow: str) -> None: +@pytest.mark.usefixtures("openwebif_device_mock") +async def test_duplicate_host( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that a duplicate host aborts the config flow.""" + mock_config_entry.add_to_hass(hass) + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + result2 = await hass.config_entries.flow.async_configure( + result2["flow_id"], TEST_FULL + ) + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.usefixtures("openwebif_device_mock") +async def test_options_flow(hass: HomeAssistant) -> None: """Test the form options.""" - with patch( - "openwebif.api.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ): - entry = MockConfigEntry(domain=DOMAIN, data=TEST_FULL, options={}, entry_id="1") - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = MockConfigEntry(domain=DOMAIN, data=TEST_FULL, options={}, entry_id="1") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.LOADED - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={"source_bouquet": "Favourites (TV)"} - ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"source_bouquet": "Favourites (TV)"} + ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert entry.options == {"source_bouquet": "Favourites (TV)"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert entry.options == {"source_bouquet": "Favourites (TV)"} - await hass.async_block_till_done() + await hass.async_block_till_done() - assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/enigma2/test_init.py b/tests/components/enigma2/test_init.py index ab19c2ce51a..a3f68cd0902 100644 --- a/tests/components/enigma2/test_init.py +++ b/tests/components/enigma2/test_init.py @@ -1,32 +1,45 @@ """Test the Enigma2 integration init.""" -from unittest.mock import patch +from unittest.mock import AsyncMock + +import pytest from homeassistant.components.enigma2.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr -from .conftest import TEST_REQUIRED, MockDevice +from .conftest import TEST_REQUIRED -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture +async def test_device_without_mac_address( + hass: HomeAssistant, + openwebif_device_mock: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that a device gets successfully registered when the device doesn't report a MAC address.""" + openwebif_device_mock.get_about.return_value = load_json_object_fixture( + "device_about_without_mac.json", DOMAIN + ) + entry = MockConfigEntry( + domain=DOMAIN, data=TEST_REQUIRED, title="name", unique_id="123456" + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.unique_id == "123456" + assert device_registry.async_get_device({(DOMAIN, entry.unique_id)}) is not None + + +@pytest.mark.usefixtures("openwebif_device_mock") async def test_unload_entry(hass: HomeAssistant) -> None: """Test successful unload of entry.""" - with ( - patch( - "homeassistant.components.enigma2.coordinator.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ), - patch( - "homeassistant.components.enigma2.media_player.async_setup_entry", - return_value=True, - ), - ): - entry = MockConfigEntry(domain=DOMAIN, data=TEST_REQUIRED, title="name") - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = MockConfigEntry(domain=DOMAIN, data=TEST_REQUIRED, title="name") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/enigma2/test_media_player.py b/tests/components/enigma2/test_media_player.py new file mode 100644 index 00000000000..dd1dcb66cb6 --- /dev/null +++ b/tests/components/enigma2/test_media_player.py @@ -0,0 +1,305 @@ +"""Tests for the media player module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from openwebif.api import OpenWebIfServiceEvent, OpenWebIfStatus +from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption +import pytest + +from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.components.enigma2.media_player import ATTR_MEDIA_CURRENTLY_RECORDING +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + MediaPlayerState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, +) +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) + + +@pytest.mark.parametrize( + ("deep_standby", "powerstate"), + [(False, PowerState.STANDBY), (True, PowerState.DEEP_STANDBY)], +) +async def test_turn_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + deep_standby: bool, + powerstate: PowerState, +) -> None: + """Test turning off the media player.""" + openwebif_device_mock.turn_off_to_deep = deep_standby + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.set_powerstate.assert_awaited_once_with(powerstate) + + +async def test_turn_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test turning on the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.turn_on.assert_awaited_once() + + +async def test_set_volume_level( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test setting the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_MEDIA_VOLUME_LEVEL: 0.2}, + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(20) + + +async def test_volume_up( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test increasing the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(SetVolumeOption.UP) + + +async def test_volume_down( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test decreasing the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_DOWN, + {ATTR_ENTITY_ID: "media_player.1_1_1_1"}, + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(SetVolumeOption.DOWN) + + +@pytest.mark.parametrize( + ("service", "remote_code"), + [ + (SERVICE_MEDIA_STOP, RemoteControlCodes.STOP), + (SERVICE_MEDIA_PLAY, RemoteControlCodes.PLAY), + (SERVICE_MEDIA_PAUSE, RemoteControlCodes.PAUSE), + (SERVICE_MEDIA_NEXT_TRACK, RemoteControlCodes.CHANNEL_UP), + (SERVICE_MEDIA_PREVIOUS_TRACK, RemoteControlCodes.CHANNEL_DOWN), + ], +) +async def test_remote_control_actions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + service: str, + remote_code: RemoteControlCodes, +) -> None: + """Test media stop.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.1_1_1_1"}, + ) + + openwebif_device_mock.send_remote_control_action.assert_awaited_once_with( + remote_code + ) + + +@pytest.mark.parametrize("mute", [False, True]) +async def test_volume_mute( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + mute: bool, +) -> None: + """Test mute.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_MEDIA_VOLUME_MUTED: mute}, + ) + + openwebif_device_mock.toggle_mute.assert_awaited_once() + + +async def test_select_source( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test media previous track.""" + openwebif_device_mock.return_value.sources = {"Test": "1"} + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_INPUT_SOURCE: "Test"}, + ) + + openwebif_device_mock.zap.assert_awaited_once_with("1") + + +async def test_update_data_standby( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test data handling.""" + + openwebif_device_mock.get_status_info.return_value = load_json_object_fixture( + "device_statusinfo_standby.json", DOMAIN + ) + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=True + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + ATTR_MEDIA_CURRENTLY_RECORDING + not in hass.states.get("media_player.1_1_1_1").attributes + ) + assert hass.states.get("media_player.1_1_1_1").state == MediaPlayerState.OFF + + +async def test_update_volume( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test volume data handling.""" + + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=False, volume=100 + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("media_player.1_1_1_1").attributes[ATTR_MEDIA_VOLUME_LEVEL] + > 0.99 + ) + + +async def test_update_volume_none( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test volume data handling.""" + + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=False, volume=None + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + ATTR_MEDIA_VOLUME_LEVEL + not in hass.states.get("media_player.1_1_1_1").attributes + ) diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 541b6f96e19..b860d49aa6b 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -6,6 +6,8 @@ from unittest.mock import AsyncMock, Mock, patch import jwt from pyenphase import ( + EnvoyACBPower, + EnvoyBatteryAggregate, EnvoyData, EnvoyEncharge, EnvoyEnchargeAggregate, @@ -172,6 +174,8 @@ def _load_json_2_production_data( mocked_data.system_production_phases[sub_item] = EnvoySystemProduction( **item_data ) + if item := json_fixture["data"].get("acb_power"): + mocked_data.acb_power = EnvoyACBPower(**item) def _load_json_2_meter_data( @@ -245,6 +249,8 @@ def _load_json_2_encharge_enpower_data( mocked_data.dry_contact_settings[sub_item] = EnvoyDryContactSettings( **item_data ) + if item := json_fixture["data"].get("battery_aggregate"): + mocked_data.battery_aggregate = EnvoyBatteryAggregate(**item) def _load_json_2_raw_data(mocked_data: EnvoyData, json_fixture: dict[str, Any]) -> None: diff --git a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json new file mode 100644 index 00000000000..618b40027b8 --- /dev/null +++ b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json @@ -0,0 +1,274 @@ +{ + "serial_number": "1234", + "firmware": "7.6.358", + "part_number": "800-00654-r08", + "envoy_model": "Envoy, phases: 3, phase mode: three, net-consumption CT, production CT", + "supported_features": 1759, + "phase_mode": "three", + "phase_count": 3, + "active_phase_count": 0, + "ct_meter_count": 2, + "consumption_meter_type": "net-consumption", + "production_meter_type": "production", + "storage_meter_type": null, + "data": { + "encharge_inventory": { + "123456": { + "admin_state": 6, + "admin_state_str": "ENCHG_STATE_READY", + "bmu_firmware_version": "2.1.16", + "comm_level_2_4_ghz": 4, + "comm_level_sub_ghz": 4, + "communicating": true, + "dc_switch_off": false, + "encharge_capacity": 3500, + "encharge_revision": 2, + "firmware_loaded_date": 1714736645, + "firmware_version": "2.6.6618_rel/22.11", + "installed_date": 1714736645, + "last_report_date": 1714804173, + "led_status": 17, + "max_cell_temp": 16, + "operating": true, + "part_number": "830-01760-r46", + "percent_full": 54, + "serial_number": "122327081322", + "temperature": 16, + "temperature_unit": "C", + "zigbee_dongle_fw_version": "100F" + } + }, + "encharge_power": { + "123456": { + "apparent_power_mva": 105, + "real_power_mw": 105, + "soc": 54 + } + }, + "encharge_aggregate": { + "available_energy": 1890, + "backup_reserve": 0, + "state_of_charge": 54, + "reserve_state_of_charge": 0, + "configured_reserve_state_of_charge": 0, + "max_available_capacity": 3500 + }, + "enpower": null, + "acb_power": { + "power": 260, + "charge_wh": 930, + "state_of_charge": 25, + "state": "discharging", + "batteries": 3 + }, + "battery_aggregate": { + "available_energy": 2820, + "state_of_charge": 39, + "max_available_capacity": 7220 + }, + "system_consumption": { + "watt_hours_lifetime": 1234, + "watt_hours_last_7_days": 1234, + "watt_hours_today": 1234, + "watts_now": 1234 + }, + "system_production": { + "watt_hours_lifetime": 1234, + "watt_hours_last_7_days": 1234, + "watt_hours_today": 1234, + "watts_now": 1234 + }, + "system_consumption_phases": null, + "system_production_phases": null, + "system_net_consumption": { + "watt_hours_lifetime": 4321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 2341 + }, + "system_net_consumption_phases": null, + "ctmeter_production": { + "eid": "100000010", + "timestamp": 1708006110, + "energy_delivered": 11234, + "energy_received": 12345, + "active_power": 100, + "power_factor": 0.11, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance", "power-on-unused-phase"] + }, + "ctmeter_consumption": { + "eid": "100000020", + "timestamp": 1708006120, + "energy_delivered": 21234, + "energy_received": 22345, + "active_power": 101, + "power_factor": 0.21, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "ctmeter_storage": null, + "ctmeter_production_phases": { + "L1": { + "eid": "100000011", + "timestamp": 1708006111, + "energy_delivered": 112341, + "energy_received": 123451, + "active_power": 20, + "power_factor": 0.12, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance"] + }, + "L2": { + "eid": "100000012", + "timestamp": 1708006112, + "energy_delivered": 112342, + "energy_received": 123452, + "active_power": 30, + "power_factor": 0.13, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["power-on-unused-phase"] + }, + "L3": { + "eid": "100000013", + "timestamp": 1708006113, + "energy_delivered": 112343, + "energy_received": 123453, + "active_power": 50, + "power_factor": 0.14, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeter_consumption_phases": { + "L1": { + "eid": "100000021", + "timestamp": 1708006121, + "energy_delivered": 212341, + "energy_received": 223451, + "active_power": 21, + "power_factor": 0.22, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000022", + "timestamp": 1708006122, + "energy_delivered": 212342, + "energy_received": 223452, + "active_power": 31, + "power_factor": 0.23, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000023", + "timestamp": 1708006123, + "energy_delivered": 212343, + "energy_received": 223453, + "active_power": 51, + "power_factor": 0.24, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeter_storage_phases": null, + "dry_contact_status": {}, + "dry_contact_settings": {}, + "inverters": { + "1": { + "serial_number": "1", + "last_report_date": 1, + "last_report_watts": 1, + "max_report_watts": 1 + } + }, + "tariff": { + "currency": { + "code": "EUR" + }, + "logger": "mylogger", + "date": "1714749724", + "storage_settings": { + "mode": "self-consumption", + "operation_mode_sub_type": "", + "reserved_soc": 0.0, + "very_low_soc": 5, + "charge_from_grid": true, + "date": "1714749724" + }, + "single_rate": { + "rate": 0.0, + "sell": 0.0 + }, + "seasons": [ + { + "id": "all_year_long", + "start": "1/1", + "days": [ + { + "id": "all_days", + "days": "Mon,Tue,Wed,Thu,Fri,Sat,Sun", + "must_charge_start": 0, + "must_charge_duration": 0, + "must_charge_mode": "CP", + "enable_discharge_to_grid": false, + "periods": [ + { + "id": "period_1", + "start": 0, + "rate": 0.0 + } + ] + } + ], + "tiers": [] + } + ], + "seasons_sell": [] + }, + "raw": { + "varies_by": "firmware_version" + } + } +} diff --git a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr index f936a9db76e..e9bf8378d79 100644 --- a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr @@ -255,7 +255,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:transmission-tower', + 'original_icon': None, 'original_name': 'Grid status', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -269,7 +269,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Enpower 654321 Grid status', - 'icon': 'mdi:transmission-tower', }), 'context': , 'entity_id': 'binary_sensor.enpower_654321_grid_status', diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 76835098f27..4254ffe961a 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -88,7 +88,7 @@ }), }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -101,7 +101,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Envoy <> Current power production', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'kW', }), @@ -140,7 +139,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -153,7 +152,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production today', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'kWh', }), @@ -190,7 +188,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -203,7 +201,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': 'kWh', }), 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', @@ -241,7 +238,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -254,7 +251,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'MWh', }), @@ -321,7 +317,7 @@ 'options': dict({ }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -334,7 +330,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'W', }), @@ -365,7 +360,7 @@ 'options': dict({ }), 'original_device_class': 'timestamp', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -522,7 +517,7 @@ }), }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -535,7 +530,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Envoy <> Current power production', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'kW', }), @@ -574,7 +568,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -587,7 +581,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production today', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'kWh', }), @@ -624,7 +617,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -637,7 +630,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': 'kWh', }), 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', @@ -675,7 +667,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -688,7 +680,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'MWh', }), @@ -755,7 +746,7 @@ 'options': dict({ }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -768,7 +759,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'W', }), @@ -799,7 +789,7 @@ 'options': dict({ }), 'original_device_class': 'timestamp', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -996,7 +986,7 @@ }), }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1009,7 +999,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Envoy <> Current power production', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'kW', }), @@ -1048,7 +1037,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1061,7 +1050,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production today', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'kWh', }), @@ -1098,7 +1086,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1111,7 +1099,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': 'kWh', }), 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', @@ -1149,7 +1136,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1162,7 +1149,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'MWh', }), @@ -1229,7 +1215,7 @@ 'options': dict({ }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1242,7 +1228,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'W', }), @@ -1273,7 +1258,7 @@ 'options': dict({ }), 'original_device_class': 'timestamp', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index c43325a639d..0f251b5e859 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -30,7 +30,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -45,7 +45,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -86,7 +85,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -101,7 +100,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -143,7 +141,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -158,7 +156,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -201,7 +198,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -216,7 +213,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -253,7 +249,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -268,7 +264,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -303,7 +298,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -318,7 +313,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -359,7 +353,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -374,7 +368,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -417,7 +410,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -432,7 +425,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -475,7 +467,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -490,7 +482,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -533,7 +524,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -548,7 +539,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -589,7 +579,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -604,7 +594,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -646,7 +635,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -661,7 +650,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -702,7 +690,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -717,7 +705,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -759,7 +746,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -774,7 +761,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -814,7 +800,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -829,7 +815,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -869,7 +854,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -884,7 +869,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -927,7 +911,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -942,7 +926,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -985,7 +968,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1000,7 +983,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1043,7 +1025,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1058,7 +1040,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1101,7 +1082,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1116,7 +1097,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1159,7 +1139,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1174,7 +1154,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1197,7 +1176,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -1209,7 +1188,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1223,7 +1202,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -1244,7 +1222,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -1256,7 +1234,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1270,7 +1248,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -1297,7 +1274,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -1309,7 +1286,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1324,7 +1301,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -1356,7 +1332,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -1368,7 +1344,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1383,7 +1359,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -1429,7 +1404,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1444,7 +1419,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1484,7 +1458,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1499,7 +1473,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -1538,7 +1511,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1553,7 +1526,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -1595,7 +1567,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1610,7 +1582,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1653,7 +1624,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1668,7 +1639,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1711,7 +1681,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1726,7 +1696,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1763,7 +1732,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1778,7 +1747,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1813,7 +1781,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1828,7 +1796,4779 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01T00:00:01+00:00', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.acb_1234_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.acb_1234_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_acb_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.acb_1234_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'ACB 1234 Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.acb_1234_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.acb_1234_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'discharging', + 'idle', + 'charging', + 'full', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.acb_1234_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'acb_battery_state', + 'unique_id': '1234_acb_battery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.acb_1234_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'ACB 1234 Battery state', + 'options': list([ + 'discharging', + 'idle', + 'charging', + 'full', + ]), + }), + 'context': , + 'entity_id': 'sensor.acb_1234_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'discharging', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.acb_1234_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.acb_1234_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_acb_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.acb_1234_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'ACB 1234 Power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.acb_1234_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '260', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.encharge_123456_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.encharge_123456_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456_apparent_power_mva', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.encharge_123456_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Encharge 123456 Apparent power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.encharge_123456_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.105', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.encharge_123456_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.encharge_123456_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.encharge_123456_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Encharge 123456 Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.encharge_123456_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.encharge_123456_last_reported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.encharge_123456_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last reported', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '123456_last_reported', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.encharge_123456_last_reported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Encharge 123456 Last reported', + }), + 'context': , + 'entity_id': 'sensor.encharge_123456_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-05-04T06:29:33+00:00', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.encharge_123456_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.encharge_123456_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456_real_power_mw', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.encharge_123456_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Encharge 123456 Power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.encharge_123456_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.105', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.encharge_123456_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.encharge_123456_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.encharge_123456_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Encharge 123456 Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.encharge_123456_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_aggregated_available_battery_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_aggregated_available_battery_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aggregated available battery energy', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'aggregated_available_energy', + 'unique_id': '1234_aggregated_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_aggregated_available_battery_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Envoy 1234 Aggregated available battery energy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_aggregated_available_battery_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2820', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_aggregated_battery_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_aggregated_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aggregated Battery capacity', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'aggregated_max_capacity', + 'unique_id': '1234_aggregated_max_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_aggregated_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Envoy 1234 Aggregated Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_aggregated_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7220', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_aggregated_battery_soc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_aggregated_battery_soc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aggregated battery soc', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'aggregated_soc', + 'unique_id': '1234_aggregated_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_aggregated_battery_soc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Envoy 1234 Aggregated battery soc', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_aggregated_battery_soc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_available_acb_battery_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_available_acb_battery_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Available ACB battery energy', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'acb_available_energy', + 'unique_id': '1234_acb_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_available_acb_battery_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Envoy 1234 Available ACB battery energy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_available_acb_battery_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '930', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_available_battery_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_available_battery_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Available battery energy', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'available_energy', + 'unique_id': '1234_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_available_battery_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Available battery energy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_available_battery_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1890', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_balanced_net_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'balanced net power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption', + 'unique_id': '1234_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_balanced_net_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.341', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Envoy 1234 Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_battery_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_capacity', + 'unique_id': '1234_max_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3500', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_net_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current net power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_consumption', + 'unique_id': '1234_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_net_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current net power consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.101', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_net_power_consumption_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current net power consumption l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_consumption_phase', + 'unique_id': '1234_net_consumption_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_net_power_consumption_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current net power consumption l1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.021', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_net_power_consumption_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current net power consumption l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_consumption_phase', + 'unique_id': '1234_net_consumption_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_net_power_consumption_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current net power consumption l2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.031', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_net_power_consumption_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current net power consumption l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_consumption_phase', + 'unique_id': '1234_net_consumption_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_net_power_consumption_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current net power consumption l3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.051', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_power_consumption', + 'unique_id': '1234_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current power consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_power_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_power_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current power production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_power_production', + 'unique_id': '1234_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_current_power_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current power production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_power_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_energy_consumption_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_energy_consumption_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumption last seven days', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'seven_days_consumption', + 'unique_id': '1234_seven_days_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_energy_consumption_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Energy consumption last seven days', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_consumption_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_energy_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_energy_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumption today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_consumption', + 'unique_id': '1234_daily_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_energy_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Energy consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_energy_production_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_energy_production_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy production last seven days', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'seven_days_production', + 'unique_id': '1234_seven_days_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_energy_production_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Energy production last seven days', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_production_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_energy_production_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_energy_production_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_production', + 'unique_id': '1234_daily_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_energy_production_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Energy production today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_production_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_frequency', + 'unique_id': '1234_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency net consumption CT', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.2', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_frequency_phase', + 'unique_id': '1234_frequency_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.2', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_frequency_phase', + 'unique_id': '1234_frequency_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.2', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_frequency_phase', + 'unique_id': '1234_frequency_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.2', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency', + 'unique_id': '1234_production_ct_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_frequency_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime balanced net energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption', + 'unique_id': '1234_lifetime_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.321', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_consumption', + 'unique_id': '1234_lifetime_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001234', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_energy_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_energy_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_production', + 'unique_id': '1234_lifetime_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_energy_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime energy production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_energy_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001234', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime net energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_consumption', + 'unique_id': '1234_lifetime_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.021234', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime net energy consumption l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_consumption_phase', + 'unique_id': '1234_lifetime_net_consumption_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.212341', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime net energy consumption l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_consumption_phase', + 'unique_id': '1234_lifetime_net_consumption_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.212342', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime net energy consumption l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_consumption_phase', + 'unique_id': '1234_lifetime_net_consumption_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.212343', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime net energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_production', + 'unique_id': '1234_lifetime_net_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.022345', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_production_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime net energy production l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_production_phase', + 'unique_id': '1234_lifetime_net_production_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_production_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.223451', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_production_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime net energy production l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_production_phase', + 'unique_id': '1234_lifetime_net_production_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_production_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.223452', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_production_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime net energy production l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_production_phase', + 'unique_id': '1234_lifetime_net_production_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_lifetime_net_energy_production_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.223453', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter status flags active net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_status_flags', + 'unique_id': '1234_net_consumption_ct_status_flags', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter status flags active net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_status_flags_phase', + 'unique_id': '1234_net_consumption_ct_status_flags_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter status flags active net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_status_flags_phase', + 'unique_id': '1234_net_consumption_ct_status_flags_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter status flags active net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_status_flags_phase', + 'unique_id': '1234_net_consumption_ct_status_flags_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter status flags active production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_status_flags', + 'unique_id': '1234_production_ct_status_flags', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active production CT', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter status flags active production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_status_flags_phase', + 'unique_id': '1234_production_ct_status_flags_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter status flags active production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_status_flags_phase', + 'unique_id': '1234_production_ct_status_flags_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter status flags active production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_status_flags_phase', + 'unique_id': '1234_production_ct_status_flags_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Metering status net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_metering_status', + 'unique_id': '1234_net_consumption_ct_metering_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status net consumption CT', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Metering status net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_metering_status_phase', + 'unique_id': '1234_net_consumption_ct_metering_status_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Metering status net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_metering_status_phase', + 'unique_id': '1234_net_consumption_ct_metering_status_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Metering status net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_metering_status_phase', + 'unique_id': '1234_net_consumption_ct_metering_status_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Metering status production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_metering_status', + 'unique_id': '1234_production_ct_metering_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status production CT', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Metering status production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_metering_status_phase', + 'unique_id': '1234_production_ct_metering_status_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status production CT l1', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Metering status production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_metering_status_phase', + 'unique_id': '1234_production_ct_metering_status_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status production CT l2', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Metering status production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_metering_status_phase', + 'unique_id': '1234_production_ct_metering_status_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_metering_status_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status production CT l3', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_net_consumption_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Net consumption CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current', + 'unique_id': '1234_net_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_net_consumption_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_net_consumption_ct_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Net consumption CT current l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_net_consumption_ct_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_net_consumption_ct_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Net consumption CT current l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_net_consumption_ct_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_net_consumption_ct_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Net consumption CT current l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_net_consumption_ct_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Powerfactor net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor', + 'unique_id': '1234_net_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.21', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Powerfactor net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.22', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Powerfactor net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.23', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Powerfactor net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.24', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'powerfactor production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor', + 'unique_id': '1234_production_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.11', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Powerfactor production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Powerfactor production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.13', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Powerfactor production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.14', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_production_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Production CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current', + 'unique_id': '1234_production_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_production_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_production_ct_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Production CT current l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_production_ct_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_production_ct_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Production CT current l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_production_ct_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_production_ct_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Production CT current l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_production_ct_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_reserve_battery_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_reserve_battery_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reserve battery energy', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reserve_energy', + 'unique_id': '1234_reserve_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_reserve_battery_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Reserve battery energy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_reserve_battery_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_reserve_battery_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_reserve_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reserve battery level', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reserve_soc', + 'unique_id': '1234_reserve_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_reserve_battery_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Envoy 1234 Reserve battery level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_reserve_battery_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_voltage', + 'unique_id': '1234_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage net consumption CT', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '112', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_voltage_phase', + 'unique_id': '1234_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '112', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_voltage_phase', + 'unique_id': '1234_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '112', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_voltage_phase', + 'unique_id': '1234_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '112', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage', + 'unique_id': '1234_production_ct_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_voltage_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_last_reported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last reported', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '1_last_reported', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_last_reported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Inverter 1 Last reported', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -2100,7 +6840,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2115,7 +6855,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Available battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2157,7 +6896,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2172,7 +6911,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2207,7 +6945,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2222,7 +6960,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Battery', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -2256,7 +6993,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2271,7 +7008,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Battery capacity', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2313,7 +7049,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2328,7 +7064,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2371,7 +7106,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2386,7 +7121,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2429,7 +7163,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2444,7 +7178,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2487,7 +7220,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2502,7 +7235,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2545,7 +7277,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2560,7 +7292,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2603,7 +7334,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2618,7 +7349,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2659,7 +7389,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2674,7 +7404,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2716,7 +7445,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2731,7 +7460,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2772,7 +7500,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2787,7 +7515,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2829,7 +7556,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2844,7 +7571,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2884,7 +7610,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2899,7 +7625,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2939,7 +7664,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2954,7 +7679,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2994,7 +7718,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3009,7 +7733,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3049,7 +7772,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3064,7 +7787,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3104,7 +7826,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3119,7 +7841,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3159,7 +7880,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3174,7 +7895,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3214,7 +7934,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3229,7 +7949,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3269,7 +7988,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3284,7 +8003,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3327,7 +8045,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3342,7 +8060,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3385,7 +8102,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3400,7 +8117,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3443,7 +8159,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3458,7 +8174,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3501,7 +8216,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3516,7 +8231,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3559,7 +8273,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3574,7 +8288,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3617,7 +8330,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3632,7 +8345,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3675,7 +8387,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3690,7 +8402,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3733,7 +8444,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3748,7 +8459,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3791,7 +8501,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3806,7 +8516,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3849,7 +8558,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3864,7 +8573,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3907,7 +8615,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3922,7 +8630,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3945,7 +8652,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -3957,7 +8664,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3971,7 +8678,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -3992,7 +8698,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4004,7 +8710,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4018,7 +8724,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', @@ -4039,7 +8744,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -4051,7 +8756,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4065,7 +8770,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', @@ -4086,7 +8790,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -4098,7 +8802,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4112,7 +8816,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', @@ -4133,7 +8836,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4145,7 +8848,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4159,7 +8862,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -4180,7 +8882,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4192,7 +8894,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4206,7 +8908,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', @@ -4227,7 +8928,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -4239,7 +8940,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4253,7 +8954,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', @@ -4274,7 +8974,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -4286,7 +8986,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4300,7 +9000,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', @@ -4327,7 +9026,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4339,7 +9038,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4354,7 +9053,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4386,7 +9084,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4398,7 +9096,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4413,7 +9111,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4445,7 +9142,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -4457,7 +9154,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4472,7 +9169,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4504,7 +9200,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -4516,7 +9212,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4531,7 +9227,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4563,7 +9258,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4575,7 +9270,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4590,7 +9285,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4622,7 +9316,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4634,7 +9328,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4649,7 +9343,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4681,7 +9374,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -4693,7 +9386,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4708,7 +9401,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4740,7 +9432,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -4752,7 +9444,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4767,7 +9459,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4813,7 +9504,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4828,7 +9519,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4871,7 +9561,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4886,7 +9576,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4929,7 +9618,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4944,7 +9633,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4987,7 +9675,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5002,7 +9690,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5042,7 +9729,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5057,7 +9744,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5096,7 +9782,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5111,7 +9797,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5150,7 +9835,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5165,7 +9850,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5204,7 +9888,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5219,7 +9903,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5258,7 +9941,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5273,7 +9956,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5312,7 +9994,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5327,7 +10009,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5366,7 +10047,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5381,7 +10062,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5420,7 +10100,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5435,7 +10115,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5477,7 +10156,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5492,7 +10171,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5535,7 +10213,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5550,7 +10228,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5593,7 +10270,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5608,7 +10285,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5651,7 +10327,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5666,7 +10342,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5701,7 +10376,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5716,7 +10391,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Reserve battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -5750,7 +10424,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5765,7 +10439,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Reserve battery level', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -5807,7 +10480,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5822,7 +10495,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5865,7 +10537,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5880,7 +10552,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5923,7 +10594,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5938,7 +10609,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5981,7 +10651,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5996,7 +10666,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6039,7 +10708,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6054,7 +10723,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6097,7 +10765,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6112,7 +10780,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6155,7 +10822,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6170,7 +10837,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6213,7 +10879,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6228,7 +10894,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6265,7 +10930,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6280,7 +10945,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6315,7 +10979,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6330,7 +10994,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -6697,7 +11360,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6712,7 +11375,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Available battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -6754,7 +11416,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6769,7 +11431,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6812,7 +11473,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6827,7 +11488,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6870,7 +11530,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6885,7 +11545,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6928,7 +11587,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6943,7 +11602,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6978,7 +11636,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6993,7 +11651,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Battery', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -7027,7 +11684,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7042,7 +11699,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Battery capacity', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -7084,7 +11740,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7099,7 +11755,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7142,7 +11797,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7157,7 +11812,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7200,7 +11854,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7215,7 +11869,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7258,7 +11911,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7273,7 +11926,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7316,7 +11968,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7331,7 +11983,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7374,7 +12025,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7389,7 +12040,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7432,7 +12082,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7447,7 +12097,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7490,7 +12139,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7505,7 +12154,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7548,7 +12196,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7563,7 +12211,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7606,7 +12253,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7621,7 +12268,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7664,7 +12310,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7679,7 +12325,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7722,7 +12367,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7737,7 +12382,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7780,7 +12424,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7795,7 +12439,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7838,7 +12481,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7853,7 +12496,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7896,7 +12538,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7911,7 +12553,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7954,7 +12595,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7969,7 +12610,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8010,7 +12650,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8025,7 +12665,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -8065,7 +12704,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8080,7 +12719,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -8120,7 +12758,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8135,7 +12773,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -8175,7 +12812,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8190,7 +12827,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -8232,7 +12868,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8247,7 +12883,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8290,7 +12925,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8305,7 +12940,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8348,7 +12982,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8363,7 +12997,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8406,7 +13039,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8421,7 +13054,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8462,7 +13094,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8477,7 +13109,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -8517,7 +13148,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8532,7 +13163,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -8572,7 +13202,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8587,7 +13217,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -8627,7 +13256,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8642,7 +13271,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -8684,7 +13312,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8699,7 +13327,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8742,7 +13369,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8757,7 +13384,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8800,7 +13426,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8815,7 +13441,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8858,7 +13483,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8873,7 +13498,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8913,7 +13537,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8928,7 +13552,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8968,7 +13591,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8983,7 +13606,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9023,7 +13645,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9038,7 +13660,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9078,7 +13699,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9093,7 +13714,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9133,7 +13753,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9148,7 +13768,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9188,7 +13807,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9203,7 +13822,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9243,7 +13861,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9258,7 +13876,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9298,7 +13915,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9313,7 +13930,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9353,7 +13969,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9368,7 +13984,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9408,7 +14023,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9423,7 +14038,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9463,7 +14077,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9478,7 +14092,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9518,7 +14131,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9533,7 +14146,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9576,7 +14188,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9591,7 +14203,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9634,7 +14245,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9649,7 +14260,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9692,7 +14302,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9707,7 +14317,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9750,7 +14359,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9765,7 +14374,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9808,7 +14416,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9823,7 +14431,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9866,7 +14473,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9881,7 +14488,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9924,7 +14530,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9939,7 +14545,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9982,7 +14587,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9997,7 +14602,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10040,7 +14644,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10055,7 +14659,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10098,7 +14701,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10113,7 +14716,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10156,7 +14758,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10171,7 +14773,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10214,7 +14815,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10229,7 +14830,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10272,7 +14872,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10287,7 +14887,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10330,7 +14929,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10345,7 +14944,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10388,7 +14986,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10403,7 +15001,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10446,7 +15043,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10461,7 +15058,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10504,7 +15100,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10519,7 +15115,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10562,7 +15157,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10577,7 +15172,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10620,7 +15214,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10635,7 +15229,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10678,7 +15271,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10693,7 +15286,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10736,7 +15328,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10751,7 +15343,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10794,7 +15385,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10809,7 +15400,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10852,7 +15442,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10867,7 +15457,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10910,7 +15499,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10925,7 +15514,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10968,7 +15556,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10983,7 +15571,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11026,7 +15613,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11041,7 +15628,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11084,7 +15670,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11099,7 +15685,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11142,7 +15727,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11157,7 +15742,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11180,7 +15764,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -11192,7 +15776,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11206,7 +15790,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -11227,7 +15810,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -11239,7 +15822,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11253,7 +15836,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', @@ -11274,7 +15856,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -11286,7 +15868,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11300,7 +15882,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', @@ -11321,7 +15902,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -11333,7 +15914,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11347,7 +15928,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', @@ -11368,7 +15948,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -11380,7 +15960,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11394,7 +15974,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -11415,7 +15994,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -11427,7 +16006,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11441,7 +16020,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', @@ -11462,7 +16040,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -11474,7 +16052,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11488,7 +16066,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', @@ -11509,7 +16086,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -11521,7 +16098,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11535,7 +16112,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', @@ -11556,7 +16132,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct', 'has_entity_name': True, 'hidden_by': None, @@ -11568,7 +16144,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11582,7 +16158,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct', @@ -11603,7 +16178,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -11615,7 +16190,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11629,7 +16204,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l1', @@ -11650,7 +16224,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -11662,7 +16236,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11676,7 +16250,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l2', @@ -11697,7 +16270,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -11709,7 +16282,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11723,7 +16296,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l3', @@ -11750,7 +16322,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -11762,7 +16334,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11777,7 +16349,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -11809,7 +16380,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -11821,7 +16392,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11836,7 +16407,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -11868,7 +16438,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -11880,7 +16450,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11895,7 +16465,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -11927,7 +16496,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -11939,7 +16508,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11954,7 +16523,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -11986,7 +16554,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -11998,7 +16566,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12013,7 +16581,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -12045,7 +16612,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -12057,7 +16624,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12072,7 +16639,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -12104,7 +16670,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -12116,7 +16682,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12131,7 +16697,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -12163,7 +16728,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -12175,7 +16740,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12190,7 +16755,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -12222,7 +16786,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct', 'has_entity_name': True, 'hidden_by': None, @@ -12234,7 +16798,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12249,7 +16813,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -12281,7 +16844,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -12293,7 +16856,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12308,7 +16871,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -12340,7 +16902,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -12352,7 +16914,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12367,7 +16929,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -12399,7 +16960,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -12411,7 +16972,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12426,7 +16987,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -12472,7 +17032,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12487,7 +17047,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12530,7 +17089,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12545,7 +17104,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12588,7 +17146,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12603,7 +17161,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12646,7 +17203,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12661,7 +17218,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12701,7 +17257,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12716,7 +17272,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -12755,7 +17310,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12770,7 +17325,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -12809,7 +17363,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12824,7 +17378,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -12863,7 +17416,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12878,7 +17431,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -12917,7 +17469,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12932,7 +17484,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -12971,7 +17522,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12986,7 +17537,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -13025,7 +17575,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13040,7 +17590,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -13079,7 +17628,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13094,7 +17643,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -13133,7 +17681,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13148,7 +17696,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -13187,7 +17734,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13202,7 +17749,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -13241,7 +17787,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13256,7 +17802,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -13295,7 +17840,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13310,7 +17855,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -13352,7 +17896,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13367,7 +17911,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13410,7 +17953,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13425,7 +17968,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13468,7 +18010,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13483,7 +18025,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13526,7 +18067,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13541,7 +18082,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13576,7 +18116,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13591,7 +18131,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Reserve battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13625,7 +18164,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13640,7 +18179,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Reserve battery level', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -13682,7 +18220,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13697,7 +18235,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13740,7 +18277,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13755,7 +18292,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13798,7 +18334,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13813,7 +18349,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13856,7 +18391,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13871,7 +18406,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13914,7 +18448,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13929,7 +18463,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13972,7 +18505,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13987,7 +18520,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14030,7 +18562,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14045,7 +18577,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14088,7 +18619,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14103,7 +18634,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14146,7 +18676,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14161,7 +18691,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14204,7 +18733,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14219,7 +18748,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14262,7 +18790,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14277,7 +18805,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14320,7 +18847,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14335,7 +18862,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14378,7 +18904,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14393,7 +18919,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14436,7 +18961,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14451,7 +18976,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14494,7 +19018,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14509,7 +19033,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14552,7 +19075,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14567,7 +19090,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14604,7 +19126,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14619,7 +19141,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14654,7 +19175,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14669,7 +19190,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -14710,7 +19230,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14725,7 +19245,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14768,7 +19287,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14783,7 +19302,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14826,7 +19344,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14841,7 +19359,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14884,7 +19401,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14899,7 +19416,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14942,7 +19458,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14957,7 +19473,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15000,7 +19515,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15015,7 +19530,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15058,7 +19572,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15073,7 +19587,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15116,7 +19629,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15131,7 +19644,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15174,7 +19686,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15189,7 +19701,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15232,7 +19743,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15247,7 +19758,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15290,7 +19800,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15305,7 +19815,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15348,7 +19857,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15363,7 +19872,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15406,7 +19914,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15421,7 +19929,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15464,7 +19971,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15479,7 +19986,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15522,7 +20028,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15537,7 +20043,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15580,7 +20085,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15595,7 +20100,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15636,7 +20140,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15651,7 +20155,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -15691,7 +20194,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15706,7 +20209,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -15746,7 +20248,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15761,7 +20263,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -15801,7 +20302,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15816,7 +20317,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -15858,7 +20358,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15873,7 +20373,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15916,7 +20415,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15931,7 +20430,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15974,7 +20472,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15989,7 +20487,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16032,7 +20529,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16047,7 +20544,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16088,7 +20584,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16103,7 +20599,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -16143,7 +20638,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16158,7 +20653,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -16198,7 +20692,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16213,7 +20707,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -16253,7 +20746,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16268,7 +20761,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -16310,7 +20802,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16325,7 +20817,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16368,7 +20859,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16383,7 +20874,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16426,7 +20916,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16441,7 +20931,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16484,7 +20973,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16499,7 +20988,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16539,7 +21027,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16554,7 +21042,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16594,7 +21081,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16609,7 +21096,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16649,7 +21135,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16664,7 +21150,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16704,7 +21189,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16719,7 +21204,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16759,7 +21243,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16774,7 +21258,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16814,7 +21297,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16829,7 +21312,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16869,7 +21351,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16884,7 +21366,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16924,7 +21405,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16939,7 +21420,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16982,7 +21462,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16997,7 +21477,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17040,7 +21519,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17055,7 +21534,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17098,7 +21576,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17113,7 +21591,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17156,7 +21633,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17171,7 +21648,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17214,7 +21690,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17229,7 +21705,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17272,7 +21747,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17287,7 +21762,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17330,7 +21804,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17345,7 +21819,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17388,7 +21861,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17403,7 +21876,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17446,7 +21918,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17461,7 +21933,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17504,7 +21975,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17519,7 +21990,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17562,7 +22032,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17577,7 +22047,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17620,7 +22089,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17635,7 +22104,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17678,7 +22146,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17693,7 +22161,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17736,7 +22203,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17751,7 +22218,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17794,7 +22260,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17809,7 +22275,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17852,7 +22317,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17867,7 +22332,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17910,7 +22374,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17925,7 +22389,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17968,7 +22431,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17983,7 +22446,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18026,7 +22488,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18041,7 +22503,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18084,7 +22545,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18099,7 +22560,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18122,7 +22582,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -18134,7 +22594,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18148,7 +22608,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -18169,7 +22628,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -18181,7 +22640,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18195,7 +22654,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', @@ -18216,7 +22674,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -18228,7 +22686,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18242,7 +22700,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', @@ -18263,7 +22720,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -18275,7 +22732,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18289,7 +22746,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', @@ -18310,7 +22766,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -18322,7 +22778,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18336,7 +22792,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -18357,7 +22812,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -18369,7 +22824,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18383,7 +22838,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', @@ -18404,7 +22858,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -18416,7 +22870,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18430,7 +22884,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', @@ -18451,7 +22904,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -18463,7 +22916,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18477,7 +22930,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', @@ -18504,7 +22956,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -18516,7 +22968,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18531,7 +22983,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -18563,7 +23014,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -18575,7 +23026,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18590,7 +23041,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -18622,7 +23072,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -18634,7 +23084,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18649,7 +23099,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -18681,7 +23130,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -18693,7 +23142,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18708,7 +23157,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -18740,7 +23188,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -18752,7 +23200,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18767,7 +23215,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -18799,7 +23246,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -18811,7 +23258,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18826,7 +23273,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -18858,7 +23304,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -18870,7 +23316,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18885,7 +23331,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -18917,7 +23362,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -18929,7 +23374,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18944,7 +23389,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -18990,7 +23434,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19005,7 +23449,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19048,7 +23491,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19063,7 +23506,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19106,7 +23548,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19121,7 +23563,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19164,7 +23605,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19179,7 +23620,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19219,7 +23659,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19234,7 +23674,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -19273,7 +23712,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19288,7 +23727,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -19327,7 +23765,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19342,7 +23780,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -19381,7 +23818,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19396,7 +23833,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -19435,7 +23871,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19450,7 +23886,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -19489,7 +23924,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19504,7 +23939,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -19543,7 +23977,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19558,7 +23992,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -19597,7 +24030,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19612,7 +24045,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -19654,7 +24086,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19669,7 +24101,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19712,7 +24143,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19727,7 +24158,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19770,7 +24200,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19785,7 +24215,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19828,7 +24257,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19843,7 +24272,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19886,7 +24314,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19901,7 +24329,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19944,7 +24371,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19959,7 +24386,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20002,7 +24428,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20017,7 +24443,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20060,7 +24485,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20075,7 +24500,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20118,7 +24542,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20133,7 +24557,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20176,7 +24599,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20191,7 +24614,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20234,7 +24656,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20249,7 +24671,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20292,7 +24713,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20307,7 +24728,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20344,7 +24764,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20359,7 +24779,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20394,7 +24813,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20409,7 +24828,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -20450,7 +24868,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20465,7 +24883,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20508,7 +24925,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20523,7 +24940,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20564,7 +24980,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20579,7 +24995,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -20621,7 +25036,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20636,7 +25051,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20676,7 +25090,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20691,7 +25105,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20734,7 +25147,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20749,7 +25162,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20792,7 +25204,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20807,7 +25219,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20830,7 +25241,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -20842,7 +25253,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20856,7 +25267,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -20883,7 +25293,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -20895,7 +25305,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20910,7 +25320,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -20953,7 +25362,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20968,7 +25377,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -21010,7 +25418,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21025,7 +25433,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21068,7 +25475,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21083,7 +25490,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21120,7 +25526,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21135,7 +25541,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21170,7 +25575,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21185,7 +25590,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', diff --git a/tests/components/enphase_envoy/snapshots/test_switch.ambr b/tests/components/enphase_envoy/snapshots/test_switch.ambr index 46123c03cec..a022e476d5c 100644 --- a/tests/components/enphase_envoy/snapshots/test_switch.ambr +++ b/tests/components/enphase_envoy/snapshots/test_switch.ambr @@ -165,7 +165,7 @@ 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC1_relay_status', 'unit_of_measurement': None, }) @@ -211,7 +211,7 @@ 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC2_relay_status', 'unit_of_measurement': None, }) @@ -257,7 +257,7 @@ 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC3_relay_status', 'unit_of_measurement': None, }) diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index 44e2e680d5f..a3da14b3835 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock from pyenphase import EnvoyAuthenticationError, EnvoyError import pytest -from homeassistant.components import zeroconf from homeassistant.components.enphase_envoy.const import ( DOMAIN, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, @@ -19,6 +18,7 @@ from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import setup_integration @@ -49,6 +49,7 @@ async def test_form( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Envoy 1234" + assert result["result"].unique_id == "1234" assert result["data"] == { CONF_HOST: "1.1.1.1", CONF_NAME: "Envoy 1234", @@ -80,6 +81,7 @@ async def test_user_no_serial_number( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Envoy" + assert result["result"].unique_id is None assert result["data"] == { CONF_HOST: "1.1.1.1", CONF_NAME: "Envoy", @@ -88,49 +90,29 @@ async def test_user_no_serial_number( } -async def test_form_invalid_auth( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test we handle invalid auth.""" - mock_envoy.authenticate.side_effect = EnvoyAuthenticationError( - "fail authentication" - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - @pytest.mark.parametrize( ("exception", "error"), [ + (EnvoyAuthenticationError("fail authentication"), "invalid_auth"), (EnvoyError, "cannot_connect"), + (Exception, "unknown"), (ValueError, "unknown"), ], ) -async def test_form_cannot_connect( +async def test_form_errors( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_envoy: AsyncMock, exception: Exception, error: str, ) -> None: - """Test we handle cannot connect error.""" + """Test we handle form errors.""" mock_envoy.setup.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -142,41 +124,8 @@ async def test_form_cannot_connect( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - -def _get_schema_default(schema, key_name): - """Iterate schema to find a key.""" - for schema_key in schema: - if schema_key == key_name: - return schema_key.default() - raise KeyError(f"{key_name} not found in schema") - - -async def test_zeroconf_pre_token_firmware( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test we can setup from zeroconf.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serialnum": "1234", "protovers": "3.0.0"}, - type="mock_type", - ), - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - assert ( - _get_schema_default(result["data_schema"].schema, CONF_USERNAME) == "installer" - ) - + mock_envoy.setup.side_effect = None + # mock successful authentication and update of credentials result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -186,38 +135,50 @@ async def test_zeroconf_pre_token_firmware( }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Envoy 1234" - assert result["result"].unique_id == "1234" - assert result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_NAME: "Envoy 1234", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } -async def test_zeroconf_token_firmware( +def _get_schema_default(schema, key_name): + """Iterate schema to find a key.""" + for schema_key in schema: + if schema_key == key_name: + return schema_key.default() + raise KeyError(f"{key_name} not found in schema") + + +@pytest.mark.parametrize( + ("version", "schema_username"), + [ + ("7.0.0", ""), + ("3.0.0", "installer"), + ], +) +async def test_zeroconf( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_envoy: AsyncMock, + version: str, + schema_username: str, ) -> None: """Test we can setup from zeroconf.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={"serialnum": "1234", "protovers": "7.0.0"}, + properties={"serialnum": "1234", "protovers": version}, type="mock_type", ), ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert _get_schema_default(result["data_schema"].schema, CONF_USERNAME) == "" + assert ( + _get_schema_default(result["data_schema"].schema, CONF_USERNAME) + == schema_username + ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -312,7 +273,7 @@ async def test_zeroconf_serial_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -340,7 +301,7 @@ async def test_zeroconf_serial_already_exists_ignores_ipv6( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -369,7 +330,7 @@ async def test_zeroconf_host_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", @@ -402,7 +363,7 @@ async def test_zero_conf_while_form( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", @@ -435,7 +396,7 @@ async def test_zero_conf_second_envoy_while_form( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -473,119 +434,6 @@ async def test_zero_conf_second_envoy_while_form( assert result4["type"] is FlowResultType.ABORT -async def test_zero_conf_malformed_serial_property( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf properties.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - with pytest.raises(KeyError) as ex: - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serilnum": "1234", "protovers": "7.1.2"}, - type="mock_type", - ), - ) - assert "serialnum" in str(ex.value) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - assert result["type"] is FlowResultType.ABORT - - -async def test_zero_conf_malformed_serial( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf properties.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serialnum": "12%4", "protovers": "7.1.2"}, - type="mock_type", - ), - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Envoy 12%4" - - -async def test_zero_conf_malformed_fw_property( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf property.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serialnum": "1234", "protvers": "7.1.2"}, - type="mock_type", - ), - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert config_entry.data[CONF_HOST] == "1.1.1.1" - assert config_entry.unique_id == "1234" - assert config_entry.title == "Envoy 1234" - - async def test_zero_conf_old_blank_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -607,7 +455,7 @@ async def test_zero_conf_old_blank_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], hostname="mock_hostname", @@ -625,6 +473,88 @@ async def test_zero_conf_old_blank_entry( assert entry.title == "Envoy 1234" +async def test_zero_conf_old_blank_entry_standard_title( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, +) -> None: + """Test re-using old blank entry was Envoy as title.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_NAME: "unknown", + }, + unique_id=None, + title="Envoy", + ) + entry.add_to_hass(hass) + # test if shorthand title Envoy gets serial appended + hass.config_entries.async_update_entry(entry, title="Envoy") + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"serialnum": "1234", "protovers": "7.1.2"}, + type="mock_type", + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.unique_id == "1234" + assert entry.title == "Envoy 1234" + + +async def test_zero_conf_old_blank_entry_user_title( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, +) -> None: + """Test re-using old blank entry with user title.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_NAME: "unknown", + }, + unique_id=None, + title="Envoy", + ) + entry.add_to_hass(hass) + # set user title on entry + hass.config_entries.async_update_entry(entry, title="Envoy Backyard") + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"serialnum": "1234", "protovers": "7.1.2"}, + type="mock_type", + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.unique_id == "1234" + assert entry.title == "Envoy Backyard" + + async def test_reauth( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -634,6 +564,8 @@ async def test_reauth( """Test we reauth auth.""" await setup_integration(hass, config_entry) result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 2b35aaff5e9..620bd654aca 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -1,6 +1,8 @@ """Test Enphase Envoy runtime.""" -from unittest.mock import AsyncMock, patch +from datetime import timedelta +import logging +from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory from jwt import encode @@ -15,7 +17,10 @@ from homeassistant.components.enphase_envoy.const import ( OPTION_DISABLE_KEEP_ALIVE, Platform, ) -from homeassistant.components.enphase_envoy.coordinator import SCAN_INTERVAL +from homeassistant.components.enphase_envoy.coordinator import ( + FIRMWARE_REFRESH_INTERVAL, + SCAN_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -263,7 +268,7 @@ async def test_config_different_unique_id( domain=DOMAIN, entry_id="45a36e55aaddb2007c5f6602e0c38e72", title="Envoy 1234", - unique_id=4321, + unique_id="4321", data={ CONF_HOST: "1.1.1.1", CONF_NAME: "Envoy 1234", @@ -346,8 +351,10 @@ async def test_option_change_reload( await setup_integration(hass, config_entry) await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED + # By default neither option is available + assert config_entry.options == {} - # option change will take care of COV of init::async_reload_entry + # option change will also take care of COV of init::async_reload_entry hass.config_entries.async_update_entry( config_entry, options={ @@ -355,8 +362,102 @@ async def test_option_change_reload( OPTION_DISABLE_KEEP_ALIVE: True, }, ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED assert config_entry.options == { OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: False, OPTION_DISABLE_KEEP_ALIVE: True, } + # flip em + hass.config_entries.async_update_entry( + config_entry, + options={ + OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True, + OPTION_DISABLE_KEEP_ALIVE: False, + }, + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options == { + OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True, + OPTION_DISABLE_KEEP_ALIVE: False, + } + + +def mock_envoy_setup(mock_envoy: AsyncMock): + """Mock envoy.setup.""" + mock_envoy.firmware = "9.9.9999" + + +@patch( + "homeassistant.components.enphase_envoy.coordinator.SCAN_INTERVAL", + timedelta(days=1), +) +@respx.mock +async def test_coordinator_firmware_refresh( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator scheduled firmware check.""" + await setup_integration(hass, config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Move time to next firmware check moment + # SCAN_INTERVAL is patched to 1 day to disable it's firmware detection + mock_envoy.setup.reset_mock() + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_envoy.setup.assert_called_once_with() + mock_envoy.setup.reset_mock() + + envoy = config_entry.runtime_data.envoy + assert envoy.firmware == "7.6.175" + + caplog.set_level(logging.WARNING) + + with patch( + "homeassistant.components.enphase_envoy.Envoy.setup", + MagicMock(return_value=mock_envoy_setup(mock_envoy)), + ): + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + "Envoy firmware changed from: 7.6.175 to: 9.9.9999, reloading config entry Envoy 1234" + in caplog.text + ) + envoy = config_entry.runtime_data.envoy + assert envoy.firmware == "9.9.9999" + + +@respx.mock +async def test_coordinator_firmware_refresh_with_envoy_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator scheduled firmware check.""" + await setup_integration(hass, config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + caplog.set_level(logging.DEBUG) + logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( + logging.DEBUG + ) + + mock_envoy.setup.side_effect = EnvoyError + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Error reading firmware:" in caplog.text diff --git a/tests/components/enphase_envoy/test_select.py b/tests/components/enphase_envoy/test_select.py index 071dbcb2fe2..9b3a63d1e23 100644 --- a/tests/components/enphase_envoy/test_select.py +++ b/tests/components/enphase_envoy/test_select.py @@ -226,3 +226,28 @@ async def test_select_storage_modes( mock_envoy.set_storage_mode.assert_called_once_with( REVERSE_STORAGE_MODE_MAP[current_state] ) + + +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +async def test_select_storage_modes_if_none( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, +) -> None: + """Test select platform entity storage mode when tariff storage_mode is none.""" + mock_envoy.data.tariff.storage_settings.mode = None + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode" + + assert (entity_state := hass.states.get(test_entity)) + assert entity_state.state == "unknown" diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index 784dfe54073..89f28c74514 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -31,6 +31,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat "envoy_metered_batt_relay", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", + "envoy_acb_batt", ], indirect=["mock_envoy"], ) @@ -65,6 +66,7 @@ PRODUCTION_NAMES: tuple[str, ...] = ( "envoy_metered_batt_relay", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", + "envoy_acb_batt", ], indirect=["mock_envoy"], ) @@ -154,6 +156,7 @@ CONSUMPTION_NAMES: tuple[str, ...] = ( "envoy_eu_batt", "envoy_metered_batt_relay", "envoy_nobatt_metered_3p", + "envoy_acb_batt", ], indirect=["mock_envoy"], ) @@ -197,6 +200,7 @@ NET_CONSUMPTION_NAMES: tuple[str, ...] = ( "envoy_metered_batt_relay", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", + "envoy_acb_batt", ], indirect=["mock_envoy"], ) @@ -803,6 +807,7 @@ async def test_sensor_inverter_disabled_by_integration( ("mock_envoy"), [ "envoy_metered_batt_relay", + "envoy_acb_batt", ], indirect=["mock_envoy"], ) @@ -873,6 +878,7 @@ async def test_sensor_encharge_enpower_data( ("mock_envoy"), [ "envoy_metered_batt_relay", + "envoy_acb_batt", ], indirect=["mock_envoy"], ) @@ -930,6 +936,101 @@ async def test_sensor_encharge_power_data( ) +ACB_POWER_INT_NAMES: tuple[str, ...] = ( + "power", + "battery", +) +ACB_POWER_STR_NAMES: tuple[str, ...] = ("battery_state",) + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_acb_batt", + ], + indirect=["mock_envoy"], +) +async def test_sensor_acb_power_data( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, +) -> None: + """Test enphase_envoy acb battery power entities values.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + sn = mock_envoy.serial_number + ENTITY_BASE: str = f"{Platform.SENSOR}.acb_{sn}" + + data = mock_envoy.data.acb_power + ACB_POWER_INT_TARGETS: tuple[int, ...] = ( + data.power, + data.state_of_charge, + ) + ACB_POWER_STR_TARGETS: tuple[int, ...] = (data.state,) + + for name, target in list( + zip(ACB_POWER_INT_NAMES, ACB_POWER_INT_TARGETS, strict=False) + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert int(entity_state.state) == target + + for name, target in list( + zip(ACB_POWER_STR_NAMES, ACB_POWER_STR_TARGETS, strict=False) + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert entity_state.state == target + + +AGGREGATED_BATTERY_NAMES: tuple[str, ...] = ( + "aggregated_battery_soc", + "aggregated_available_battery_energy", + "aggregated_battery_capacity", +) +AGGREGATED_ACB_BATTERY_NAMES: tuple[str, ...] = ("available_acb_battery_energy",) + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_acb_batt", + ], + indirect=["mock_envoy"], +) +async def test_sensor_aggegated_battery_data( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, +) -> None: + """Test enphase_envoy aggregated batteries entities values.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + sn = mock_envoy.serial_number + ENTITY_BASE: str = f"{Platform.SENSOR}.envoy_{sn}" + + data = mock_envoy.data.battery_aggregate + AGGREGATED_TARGETS: tuple[int, ...] = ( + data.state_of_charge, + data.available_energy, + data.max_available_capacity, + ) + + for name, target in list( + zip(AGGREGATED_BATTERY_NAMES, AGGREGATED_TARGETS, strict=False) + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert int(entity_state.state) == target + + data = mock_envoy.data.acb_power + AGGREGATED_ACB_TARGETS: tuple[int, ...] = (data.charge_wh,) + for name, target in list( + zip(AGGREGATED_ACB_BATTERY_NAMES, AGGREGATED_ACB_TARGETS, strict=False) + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert int(entity_state.state) == target + + def integration_disabled_entities( entity_registry: er.EntityRegistry, config_entry: MockConfigEntry ) -> list[str]: diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index 98993be37d0..77d315f096d 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -4,7 +4,6 @@ from __future__ import annotations from aioesphomeapi import APIClient, APIVersion, BluetoothProxyFeature, DeviceInfo from bleak.exc import BleakError -from bleak_esphome.backend.cache import ESPHomeBluetoothCache from bleak_esphome.backend.client import ESPHomeClient, ESPHomeClientData from bleak_esphome.backend.device import ESPHomeBluetoothDevice from bleak_esphome.backend.scanner import ESPHomeScanner @@ -27,7 +26,6 @@ async def client_data_fixture( connector = HaBluetoothConnector(ESPHomeClientData, ESP_MAC_ADDRESS, lambda: True) return ESPHomeClientData( bluetooth_device=ESPHomeBluetoothDevice(ESP_NAME, ESP_MAC_ADDRESS), - cache=ESPHomeBluetoothCache(), client=mock_client, device_info=DeviceInfo( mac_address=ESP_MAC_ADDRESS, diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index e8344e50161..5ca333df1e2 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable from dataclasses import replace import io import socket -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import wave from aioesphomeapi import ( @@ -42,6 +42,10 @@ from homeassistant.components.esphome.assist_satellite import ( VoiceAssistantUDPServer, ) from homeassistant.components.media_source import PlayMedia +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, intent as intent_helper @@ -1473,3 +1477,194 @@ async def test_get_set_configuration( # Device should have been updated assert satellite.async_get_configuration() == updated_config + + +async def test_wake_word_select( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test wake word select.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]), + AssistSatelliteWakeWord("hey_mycroft", "Hey Mycroft", ["en"]), + ], + active_wake_words=["hey_jarvis"], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + # Wrap mock so we can tell when it's done + configuration_set = asyncio.Event() + + async def wrapper(*args, **kwargs): + # Update device config because entity will request it after update + device_config.active_wake_words = kwargs["active_wake_words"] + configuration_set.set() + + mock_client.set_voice_assistant_configuration = AsyncMock(side_effect=wrapper) + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + assert satellite.async_get_configuration().active_wake_words == ["hey_jarvis"] + + # Active wake word should be selected + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == "Hey Jarvis" + + # Changing the select should set the active wake word + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {"entity_id": "select.test_wake_word", "option": "Okay Nabu"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == "Okay Nabu" + + # Wait for device config to be updated + async with asyncio.timeout(1): + await configuration_set.wait() + + # Satellite config should have been updated + assert satellite.async_get_configuration().active_wake_words == ["okay_nabu"] + + +async def test_wake_word_select_no_wake_words( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test wake word select is unavailable when there are no available wake word.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[], + active_wake_words=[], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + assert not satellite.async_get_configuration().available_wake_words + + # Select should be unavailable + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_wake_word_select_zero_max_wake_words( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test wake word select is unavailable max wake words is zero.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + ], + active_wake_words=[], + max_active_wake_words=0, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + assert satellite.async_get_configuration().max_active_wake_words == 0 + + # Select should be unavailable + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_wake_word_select_no_active_wake_words( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test wake word select uses first available wake word if none are active.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]), + ], + active_wake_words=[], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + assert not satellite.async_get_configuration().active_wake_words + + # First available wake word should be selected + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == "Okay Nabu" diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index 46858c5826b..31d9fcd34f9 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -1,5 +1,7 @@ """Test the ESPHome bluetooth integration.""" +from unittest.mock import patch + from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant @@ -44,3 +46,22 @@ async def test_bluetooth_connect_with_legacy_adv( await hass.async_block_till_done() scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") assert scanner.scanning is True + + +async def test_bluetooth_cleanup_on_remove_entry( + hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice +) -> None: + """Test bluetooth is cleaned up on entry removal.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner.connectable is True + await hass.config_entries.async_unload( + mock_bluetooth_entry_with_raw_adv.entry.entry_id + ) + + with patch("homeassistant.components.esphome.async_remove_scanner") as remove_mock: + await hass.config_entries.async_remove( + mock_bluetooth_entry_with_raw_adv.entry.entry_id + ) + await hass.async_block_till_done() + + remove_mock.assert_called_once_with(hass, scanner.source) diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 189b86fc5fd..2a5013444dd 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -484,3 +484,36 @@ async def test_climate_entity_attributes( assert state is not None assert state.state == HVACMode.COOL assert state.attributes == snapshot(name="climate-entity-attributes") + + +async def test_climate_entity_attribute_current_temperature_unsupported( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a climate entity with current temperature unsupported.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + unique_id="my_climate", + supports_current_temperature=False, + ) + ] + states = [ + ClimateState( + key=1, + current_temperature=30, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("climate.test_myclimate") + assert state is not None + assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 0a389969c78..65dab4c516f 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -18,7 +18,6 @@ import aiohttp import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.esphome import dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, @@ -30,8 +29,10 @@ from homeassistant.components.esphome.const import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import VALID_NOISE_PSK @@ -126,7 +127,7 @@ async def test_user_sets_unique_id( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test that the user flow sets the unique id.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -205,7 +206,7 @@ async def test_user_causes_zeroconf_to_abort( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test that the user flow sets the unique id and aborts the zeroconf flow.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -568,7 +569,7 @@ async def test_discovery_initiation( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test discovery importing works.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", @@ -601,7 +602,7 @@ async def test_discovery_no_mac( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -629,7 +630,7 @@ async def test_discovery_already_configured( entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -650,7 +651,7 @@ async def test_discovery_duplicate_data( hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None ) -> None: """Test discovery aborts if same mDNS packet arrives.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", @@ -685,7 +686,7 @@ async def test_discovery_updates_unique_id( entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1056,7 +1057,7 @@ async def test_discovery_dhcp_updates_host( ) entry.add_to_hass(hass) - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.184", hostname="test8266", macaddress="1122334455aa", @@ -1083,7 +1084,7 @@ async def test_discovery_dhcp_no_changes( mock_client.device_info = AsyncMock(return_value=DeviceInfo(name="test8266")) - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.183", hostname="test8266", macaddress="000000000000", @@ -1132,7 +1133,7 @@ async def test_zeroconf_encryption_key_via_dashboard( mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1198,7 +1199,7 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard with api_encryption property set.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1264,7 +1265,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard( mock_setup_entry: None, ) -> None: """Test encryption key not retrieved from dashboard.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 7f275fff4f2..8e4f37079d1 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -20,9 +20,7 @@ from homeassistant.components.light import ( ATTR_FLASH, ATTR_HS_COLOR, ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MAX_MIREDS, ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_MIN_MIREDS, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -1379,9 +1377,6 @@ async def test_light_color_temp( assert state.state == STATE_ON attributes = state.attributes - assert attributes[ATTR_MIN_MIREDS] == 153 - assert attributes[ATTR_MAX_MIREDS] == 370 - assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 await hass.services.async_call( @@ -1454,9 +1449,6 @@ async def test_light_color_temp_no_mireds_set( assert state.state == STATE_ON attributes = state.attributes - assert attributes[ATTR_MIN_MIREDS] is None - assert attributes[ATTR_MAX_MIREDS] is None - assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 0 assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 0 await hass.services.async_call( @@ -1558,8 +1550,6 @@ async def test_light_color_temp_legacy( assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] - assert attributes[ATTR_MIN_MIREDS] == 153 - assert attributes[ATTR_MAX_MIREDS] == 370 assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 4b322c8744e..6fbd3726f64 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -21,7 +21,6 @@ from aioesphomeapi import ( import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, @@ -37,6 +36,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice @@ -598,7 +598,7 @@ async def test_connection_aborted_wrong_device( mock_client.disconnect = AsyncMock() caplog.clear() # Make sure discovery triggers a reconnect - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.184", hostname="test", macaddress="1122334455aa", diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index fbe30afd042..6ae1260a89d 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -9,7 +9,7 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -38,6 +38,16 @@ async def test_vad_sensitivity_select( assert state.state == "default" +async def test_wake_word_select( + hass: HomeAssistant, + mock_voice_assistant_v1_entry, +) -> None: + """Test that wake word select is unavailable initially.""" + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + async def test_select_generic_entity( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry ) -> None: diff --git a/tests/components/ezviz/__init__.py b/tests/components/ezviz/__init__.py index 78bbee0b0ad..1d4911e9785 100644 --- a/tests/components/ezviz/__init__.py +++ b/tests/components/ezviz/__init__.py @@ -1,102 +1,13 @@ """Tests for the EZVIZ integration.""" -from unittest.mock import _patch, patch - -from homeassistant.components.ezviz.const import ( - ATTR_SERIAL, - ATTR_TYPE_CAMERA, - ATTR_TYPE_CLOUD, - CONF_FFMPEG_ARGUMENTS, - CONF_RFSESSION_ID, - CONF_SESSION_ID, - DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_TIMEOUT, - DOMAIN, -) -from homeassistant.const import ( - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_TYPE, - CONF_URL, - CONF_USERNAME, -) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -ENTRY_CONFIG = { - CONF_SESSION_ID: "test-username", - CONF_RFSESSION_ID: "test-password", - CONF_URL: "apiieu.ezvizlife.com", - CONF_TYPE: ATTR_TYPE_CLOUD, -} -ENTRY_OPTIONS = { - CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, - CONF_TIMEOUT: DEFAULT_TIMEOUT, -} - -USER_INPUT_VALIDATE = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_URL: "apiieu.ezvizlife.com", -} - -USER_INPUT = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_URL: "apiieu.ezvizlife.com", - CONF_TYPE: ATTR_TYPE_CLOUD, -} - -USER_INPUT_CAMERA_VALIDATE = { - ATTR_SERIAL: "C666666", - CONF_PASSWORD: "test-password", - CONF_USERNAME: "test-username", -} - -USER_INPUT_CAMERA = { - CONF_PASSWORD: "test-password", - CONF_USERNAME: "test-username", - CONF_TYPE: ATTR_TYPE_CAMERA, -} - -DISCOVERY_INFO = { - ATTR_SERIAL: "C666666", - CONF_USERNAME: None, - CONF_PASSWORD: None, - CONF_IP_ADDRESS: "127.0.0.1", -} - -TEST = { - CONF_USERNAME: None, - CONF_PASSWORD: None, - CONF_IP_ADDRESS: "127.0.0.1", -} - -API_LOGIN_RETURN_VALIDATE = { - CONF_SESSION_ID: "fake_token", - CONF_RFSESSION_ID: "fake_rf_token", - CONF_URL: "apiieu.ezvizlife.com", - CONF_TYPE: ATTR_TYPE_CLOUD, -} - - -def patch_async_setup_entry() -> _patch: - """Patch async_setup_entry.""" - return patch( - "homeassistant.components.ezviz.async_setup_entry", - return_value=True, - ) - - -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Set up the EZVIZ integration in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG, options=ENTRY_OPTIONS) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - - return entry diff --git a/tests/components/ezviz/conftest.py b/tests/components/ezviz/conftest.py index 171cfffc2fc..fab8111b171 100644 --- a/tests/components/ezviz/conftest.py +++ b/tests/components/ezviz/conftest.py @@ -1,19 +1,30 @@ """Define pytest.fixtures available for all tests.""" from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from pyezviz import EzvizClient -from pyezviz.test_cam_rtsp import TestRTSPAuth import pytest +from homeassistant.components.ezviz import ( + ATTR_TYPE_CAMERA, + ATTR_TYPE_CLOUD, + CONF_RFSESSION_ID, + CONF_SESSION_ID, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_TYPE, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -ezviz_login_token_return = { - "session_id": "fake_token", - "rf_session_id": "fake_rf_token", - "api_url": "apiieu.ezvizlife.com", -} +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.ezviz.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock @pytest.fixture(autouse=True) @@ -23,40 +34,67 @@ def mock_ffmpeg(hass: HomeAssistant) -> None: @pytest.fixture -def ezviz_test_rtsp_config_flow() -> Generator[MagicMock]: +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + title="test-username", + data={ + CONF_SESSION_ID: "test-username", + CONF_RFSESSION_ID: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + }, + ) + + +@pytest.fixture +def mock_camera_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="C666666", + title="Camera 1", + data={ + CONF_TYPE: ATTR_TYPE_CAMERA, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + +@pytest.fixture +def mock_ezviz_client() -> Generator[AsyncMock]: + """Mock the EzvizAPI for easier testing.""" + with ( + patch( + "homeassistant.components.ezviz.EzvizClient", autospec=True + ) as mock_ezviz, + patch("homeassistant.components.ezviz.config_flow.EzvizClient", new=mock_ezviz), + ): + instance = mock_ezviz.return_value + + instance.login.return_value = { + "session_id": "fake_token", + "rf_session_id": "fake_rf_token", + "api_url": "apiieu.ezvizlife.com", + } + instance.get_detection_sensibility.return_value = True + + yield instance + + +@pytest.fixture +def mock_test_rtsp_auth() -> Generator[MagicMock]: """Mock the EzvizApi for easier testing.""" with ( - patch.object(TestRTSPAuth, "main", return_value=True), patch( "homeassistant.components.ezviz.config_flow.TestRTSPAuth" ) as mock_ezviz_test_rtsp, ): - instance = mock_ezviz_test_rtsp.return_value = TestRTSPAuth( - "test-ip", - "test-username", - "test-password", - ) + instance = mock_ezviz_test_rtsp.return_value - instance.main = MagicMock(return_value=True) + instance.main.return_value = True - yield mock_ezviz_test_rtsp - - -@pytest.fixture -def ezviz_config_flow() -> Generator[MagicMock]: - """Mock the EzvizAPI for easier config flow testing.""" - with ( - patch.object(EzvizClient, "login", return_value=True), - patch("homeassistant.components.ezviz.config_flow.EzvizClient") as mock_ezviz, - ): - instance = mock_ezviz.return_value = EzvizClient( - "test-username", - "test-password", - "local.host", - "1", - ) - - instance.login = MagicMock(return_value=ezviz_login_token_return) - instance.get_detection_sensibility = MagicMock(return_value=True) - - yield mock_ezviz + yield instance diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 63499996c89..ff538b31edb 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -1,11 +1,9 @@ """Test the EZVIZ config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock from pyezviz.exceptions import ( - AuthTestResultFailed, EzvizAuthVerificationCode, - HTTPError, InvalidHost, InvalidURL, PyEzvizError, @@ -15,7 +13,10 @@ import pytest from homeassistant.components.ezviz.const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, + ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -33,20 +34,14 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import ( - API_LOGIN_RETURN_VALIDATE, - DISCOVERY_INFO, - USER_INPUT_VALIDATE, - init_integration, - patch_async_setup_entry, -) +from . import setup_integration -from tests.common import MockConfigEntry, start_reauth_flow +from tests.common import MockConfigEntry -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_user_form(hass: HomeAssistant) -> None: - """Test the user initiated form.""" +@pytest.mark.usefixtures("mock_ezviz_client") +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -55,28 +50,32 @@ async def test_user_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" - assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } + assert result["result"].unique_id == "test-username" assert len(mock_setup_entry.mock_calls) == 1 - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured_account" - -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_user_custom_url(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_ezviz_client") +async def test_user_custom_url( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test custom url step.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -95,45 +94,30 @@ async def test_user_custom_url(hass: HomeAssistant) -> None: assert result["step_id"] == "user_custom_url" assert result["errors"] == {} - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "test-user"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == API_LOGIN_RETURN_VALIDATE - - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_async_step_reauth(hass: HomeAssistant) -> None: - """Test the reauth step.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } assert len(mock_setup_entry.mock_calls) == 1 - new_entry = hass.config_entries.async_entries(DOMAIN)[0] - result = await start_reauth_flow(hass, new_entry) + +@pytest.mark.usefixtures("mock_ezviz_client", "mock_setup_entry") +async def test_async_step_reauth( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the reauth step.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} @@ -145,19 +129,26 @@ async def test_async_step_reauth(hass: HomeAssistant) -> None: CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" +@pytest.mark.usefixtures("mock_ezviz_client") async def test_step_discovery_abort_if_cloud_account_missing( - hass: HomeAssistant, + hass: HomeAssistant, mock_test_rtsp_auth: AsyncMock ) -> None: """Test discovery and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_INTEGRATION_DISCOVERY}, data=DISCOVERY_INFO + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -170,45 +161,52 @@ async def test_step_discovery_abort_if_cloud_account_missing( CONF_PASSWORD: "test-pass", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "ezviz_cloud_account_missing" -async def test_step_reauth_abort_if_cloud_account_missing(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_ezviz_client", "mock_test_rtsp_auth") +async def test_step_reauth_abort_if_cloud_account_missing( + hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry +) -> None: """Test reauth and confirm step, abort if cloud account was removed.""" - entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT_VALIDATE) - entry.add_to_hass(hass) + mock_camera_config_entry.add_to_hass(hass) - result = await entry.start_reauth_flow(hass) + result = await mock_camera_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "ezviz_cloud_account_missing" -@pytest.mark.usefixtures("ezviz_config_flow", "ezviz_test_rtsp_config_flow") -async def test_async_step_integration_discovery(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_ezviz_client", "mock_test_rtsp_auth", "mock_setup_entry") +async def test_async_step_integration_discovery( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test discovery and confirm step.""" - with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): - await init_integration(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_INTEGRATION_DISCOVERY}, data=DISCOVERY_INFO + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" assert result["errors"] == {} - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-pass", + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { @@ -216,40 +214,103 @@ async def test_async_step_integration_discovery(hass: HomeAssistant) -> None: CONF_TYPE: ATTR_TYPE_CAMERA, CONF_USERNAME: "test-user", } - - assert len(mock_setup_entry.mock_calls) == 1 + assert result["result"].unique_id == "C666666" -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test updating options.""" - with patch_async_setup_entry() as mock_setup_entry: - entry = await init_integration(hass) + await setup_integration(hass, mock_config_entry) - assert entry.options[CONF_FFMPEG_ARGUMENTS] == DEFAULT_FFMPEG_ARGUMENTS - assert entry.options[CONF_TIMEOUT] == DEFAULT_TIMEOUT + assert mock_config_entry.options[CONF_FFMPEG_ARGUMENTS] == DEFAULT_FFMPEG_ARGUMENTS + assert mock_config_entry.options[CONF_TIMEOUT] == DEFAULT_TIMEOUT - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] is None - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_FFMPEG_ARGUMENTS: "/H.264", CONF_TIMEOUT: 25}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_FFMPEG_ARGUMENTS: "/H.264", CONF_TIMEOUT: 25}, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_FFMPEG_ARGUMENTS] == "/H.264" assert result["data"][CONF_TIMEOUT] == 25 + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "cannot_connect"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test the full flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_ezviz_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } + assert result["result"].unique_id == "test-username" + assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_form_exception( - hass: HomeAssistant, ezviz_config_flow: MagicMock +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_unknown_exception( + hass: HomeAssistant, mock_ezviz_client: AsyncMock ) -> None: - """Test we handle exception on user form.""" + """Test the full flow.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -257,223 +318,53 @@ async def test_user_form_exception( assert result["step_id"] == "user" assert result["errors"] == {} - ezviz_config_flow.side_effect = PyEzvizError + mock_ezviz_client.login.side_effect = Exception result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT_VALIDATE, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = InvalidURL - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_host"} - - ezviz_config_flow.side_effect = EzvizAuthVerificationCode - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "mfa_required"} - - ezviz_config_flow.side_effect = HTTPError - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unknown" -async def test_discover_exception_step1( +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "cannot_connect"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_user_custom_url_errors( hass: HomeAssistant, - ezviz_config_flow: MagicMock, + mock_ezviz_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, ) -> None: - """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): - await init_integration(hass) + """Test the full flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_INTEGRATION_DISCOVERY}, - data={ATTR_SERIAL: "C66666", CONF_IP_ADDRESS: "test-ip"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {} - - # Test Step 1 - ezviz_config_flow.side_effect = PyEzvizError - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = InvalidURL - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} - - ezviz_config_flow.side_effect = HTTPError - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = EzvizAuthVerificationCode - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "mfa_required"} - - ezviz_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_discover_exception_step3( - hass: HomeAssistant, ezviz_test_rtsp_config_flow: MagicMock -) -> None: - """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): - await init_integration(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_INTEGRATION_DISCOVERY}, - data={ATTR_SERIAL: "C66666", CONF_IP_ADDRESS: "test-ip"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {} - - # Test Step 3 - ezviz_test_rtsp_config_flow.side_effect = AuthTestResultFailed - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_test_rtsp_config_flow.side_effect = InvalidHost - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} - - ezviz_test_rtsp_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -async def test_user_custom_url_exception( - hass: HomeAssistant, ezviz_config_flow: MagicMock -) -> None: - """Test we handle unexpected exception.""" - ezviz_config_flow.side_effect = PyEzvizError() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", CONF_URL: CONF_CUSTOMIZE, }, ) @@ -489,56 +380,33 @@ async def test_user_custom_url_exception( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "invalid_auth"} + assert result["errors"] == {"base": error} - ezviz_config_flow.side_effect = InvalidURL + mock_ezviz_client.login.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_URL: "test-user"}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "invalid_host"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } + assert result["result"].unique_id == "test-username" - ezviz_config_flow.side_effect = HTTPError - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "test-user"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = EzvizAuthVerificationCode - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "test-user"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "mfa_required"} - - ezviz_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "test-user"}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" + assert len(mock_setup_entry.mock_calls) == 1 -async def test_async_step_reauth_exception( - hass: HomeAssistant, ezviz_config_flow: MagicMock +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_custom_url_unknown_exception( + hass: HomeAssistant, mock_ezviz_client: AsyncMock ) -> None: - """Test the reauth step exceptions.""" + """Test the full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -547,26 +415,210 @@ async def test_async_step_reauth_exception( assert result["step_id"] == "user" assert result["errors"] == {} - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - await hass.async_block_till_done() + mock_ezviz_client.login.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: CONF_CUSTOMIZE, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user_custom_url" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_already_configured( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the flow when the account is already configured.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_account" + + +async def test_async_step_integration_discovery_duplicate( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_test_rtsp_auth: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_camera_config_entry: MockConfigEntry, +) -> None: + """Test discovery and confirm step.""" + mock_config_entry.add_to_hass(hass) + mock_camera_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "invalid_host"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_camera_errors( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_test_rtsp_auth: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test the camera flow with errors.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": error} + + mock_ezviz_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + assert result["title"] == "C666666" + assert result["data"] == { + CONF_TYPE: ATTR_TYPE_CAMERA, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert result["result"].unique_id == "C666666" - assert len(mock_setup_entry.mock_calls) == 1 - new_entry = hass.config_entries.async_entries(DOMAIN)[0] - result = await start_reauth_flow(hass, new_entry) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_camera_unknown_error( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_test_rtsp_auth: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the camera flow with errors.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "invalid_host"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test the reauth step.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - ezviz_config_flow.side_effect = InvalidURL() + mock_ezviz_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -574,13 +626,12 @@ async def test_async_step_reauth_exception( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"] == {"base": error} + + mock_ezviz_client.login.side_effect = None - ezviz_config_flow.side_effect = InvalidHost() result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -588,49 +639,33 @@ async def test_async_step_reauth_exception( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_host"} - - ezviz_config_flow.side_effect = EzvizAuthVerificationCode() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "mfa_required"} - - ezviz_config_flow.side_effect = PyEzvizError() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = Exception() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_unknown_exception( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauth step.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unknown" diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index a7dc544a97a..90061ec60a1 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -1,10 +1,7 @@ """Tests for fan platforms.""" -from unittest.mock import patch - import pytest -from homeassistant.components import fan from homeassistant.components.fan import ( ATTR_PRESET_MODE, ATTR_PRESET_MODES, @@ -14,25 +11,13 @@ from homeassistant.components.fan import ( FanEntityFeature, NotValidPresetModeError, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component from .common import MockFan -from tests.common import ( - MockConfigEntry, - MockModule, - MockPlatform, - help_test_all, - import_and_test_deprecated_constant_enum, - mock_integration, - mock_platform, - setup_test_component_platform, -) +from tests.common import setup_test_component_platform class BaseFan(FanEntity): @@ -164,317 +149,3 @@ async def test_preset_mode_validation( with pytest.raises(NotValidPresetModeError) as exc: await test_fan._valid_preset_mode_or_raise("invalid") assert exc.value.translation_key == "not_valid_preset_mode" - - -def test_all() -> None: - """Test module.__all__ is correctly set.""" - help_test_all(fan) - - -@pytest.mark.parametrize(("enum"), list(fan.FanEntityFeature)) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: fan.FanEntityFeature, -) -> None: - """Test deprecated constants.""" - if not FanEntityFeature.TURN_OFF and not FanEntityFeature.TURN_ON: - import_and_test_deprecated_constant_enum( - caplog, fan, enum, "SUPPORT_", "2025.1" - ) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockFan(FanEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockFan() - assert entity.supported_features is FanEntityFeature(1) - assert "MockFan" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "FanEntityFeature.SET_SPEED" in caplog.text - caplog.clear() - assert entity.supported_features is FanEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text - - -async def test_warning_not_implemented_turn_on_off_feature( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None -) -> None: - """Test adding feature flag and warn if missing when methods are set.""" - - called = [] - - class MockFanEntityTest(MockFan): - """Mock Fan device.""" - - def turn_on( - self, - percentage: int | None = None, - preset_mode: str | None = None, - ) -> None: - """Turn on.""" - called.append("turn_on") - - def turn_off(self) -> None: - """Turn off.""" - called.append("turn_off") - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_setup_entry_fan_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test fan platform via config entry.""" - async_add_entities([MockFanEntityTest(name="test", entity_id="fan.test")]) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - ), - built_in=False, - ) - mock_platform( - hass, - "test.fan", - MockPlatform(async_setup_entry=async_setup_entry_fan_platform), - ) - - with patch.object( - MockFanEntityTest, "__module__", "tests.custom_components.fan.test_init" - ): - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state is not None - - assert ( - "Entity fan.test (.MockFanEntityTest'>) " - "does not set FanEntityFeature.TURN_OFF but implements the turn_off method. Please report it to the author of the 'test' custom integration" - in caplog.text - ) - assert ( - "Entity fan.test (.MockFanEntityTest'>) " - "does not set FanEntityFeature.TURN_ON but implements the turn_on method. Please report it to the author of the 'test' custom integration" - in caplog.text - ) - - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON, - { - "entity_id": "fan.test", - }, - blocking=True, - ) - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_OFF, - { - "entity_id": "fan.test", - }, - blocking=True, - ) - - assert len(called) == 2 - assert "turn_on" in called - assert "turn_off" in called - - -async def test_no_warning_implemented_turn_on_off_feature( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None -) -> None: - """Test no warning when feature flags are set.""" - - class MockFanEntityTest(MockFan): - """Mock Fan device.""" - - _attr_supported_features = ( - FanEntityFeature.DIRECTION - | FanEntityFeature.OSCILLATE - | FanEntityFeature.SET_SPEED - | FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_setup_entry_fan_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test fan platform via config entry.""" - async_add_entities([MockFanEntityTest(name="test", entity_id="fan.test")]) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - ), - built_in=False, - ) - mock_platform( - hass, - "test.fan", - MockPlatform(async_setup_entry=async_setup_entry_fan_platform), - ) - - with patch.object( - MockFanEntityTest, "__module__", "tests.custom_components.fan.test_init" - ): - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state is not None - - assert "does not set FanEntityFeature.TURN_OFF" not in caplog.text - assert "does not set FanEntityFeature.TURN_ON" not in caplog.text - - -async def test_no_warning_integration_has_migrated( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None -) -> None: - """Test no warning when integration migrated using `_enable_turn_on_off_backwards_compatibility`.""" - - class MockFanEntityTest(MockFan): - """Mock Fan device.""" - - _enable_turn_on_off_backwards_compatibility = False - _attr_supported_features = ( - FanEntityFeature.DIRECTION - | FanEntityFeature.OSCILLATE - | FanEntityFeature.SET_SPEED - | FanEntityFeature.PRESET_MODE - ) - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_setup_entry_fan_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test fan platform via config entry.""" - async_add_entities([MockFanEntityTest(name="test", entity_id="fan.test")]) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - ), - built_in=False, - ) - mock_platform( - hass, - "test.fan", - MockPlatform(async_setup_entry=async_setup_entry_fan_platform), - ) - - with patch.object( - MockFanEntityTest, "__module__", "tests.custom_components.fan.test_init" - ): - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state is not None - - assert "does not set FanEntityFeature.TURN_OFF" not in caplog.text - assert "does not set FanEntityFeature.TURN_ON" not in caplog.text - - -async def test_no_warning_integration_implement_feature_flags( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None -) -> None: - """Test no warning when integration uses the correct feature flags.""" - - class MockFanEntityTest(MockFan): - """Mock Fan device.""" - - _attr_supported_features = ( - FanEntityFeature.DIRECTION - | FanEntityFeature.OSCILLATE - | FanEntityFeature.SET_SPEED - | FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_setup_entry_fan_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test fan platform via config entry.""" - async_add_entities([MockFanEntityTest(name="test", entity_id="fan.test")]) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - ), - built_in=False, - ) - mock_platform( - hass, - "test.fan", - MockPlatform(async_setup_entry=async_setup_entry_fan_platform), - ) - - with patch.object( - MockFanEntityTest, "__module__", "tests.custom_components.fan.test_init" - ): - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("fan.test") - assert state is not None - - assert "does not set FanEntityFeature.TURN_OFF" not in caplog.text - assert "does not set FanEntityFeature.TURN_ON" not in caplog.text diff --git a/tests/components/feedreader/test_config_flow.py b/tests/components/feedreader/test_config_flow.py index e801227293c..c9fc89179db 100644 --- a/tests/components/feedreader/test_config_flow.py +++ b/tests/components/feedreader/test_config_flow.py @@ -5,7 +5,6 @@ import urllib import pytest -from homeassistant.components.feedreader import CONF_URLS from homeassistant.components.feedreader.const import ( CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, @@ -13,10 +12,8 @@ from homeassistant.components.feedreader.const import ( ) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_URL -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from . import create_mock_entry from .const import FEED_TITLE, URL, VALID_CONFIG_DEFAULT @@ -95,65 +92,6 @@ async def test_user_errors( assert result["options"][CONF_MAX_ENTRIES] == DEFAULT_MAX_ENTRIES -@pytest.mark.parametrize( - ("data", "expected_data", "expected_options"), - [ - ({CONF_URLS: [URL]}, {CONF_URL: URL}, {CONF_MAX_ENTRIES: DEFAULT_MAX_ENTRIES}), - ( - {CONF_URLS: [URL], CONF_MAX_ENTRIES: 5}, - {CONF_URL: URL}, - {CONF_MAX_ENTRIES: 5}, - ), - ], -) -async def test_import( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - data, - expected_data, - expected_options, - feedparser, - setup_entry, -) -> None: - """Test starting an import flow.""" - config_entries = hass.config_entries.async_entries(DOMAIN) - assert not config_entries - - assert await async_setup_component(hass, DOMAIN, {DOMAIN: data}) - - config_entries = hass.config_entries.async_entries(DOMAIN) - assert config_entries - assert len(config_entries) == 1 - assert config_entries[0].title == FEED_TITLE - assert config_entries[0].data == expected_data - assert config_entries[0].options == expected_options - - assert issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_feedreader" - ) - - -async def test_import_errors( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - feedparser, - setup_entry, - feed_one_event, -) -> None: - """Test starting an import flow which results in an URL error.""" - config_entries = hass.config_entries.async_entries(DOMAIN) - assert not config_entries - - # raise URLError - feedparser.side_effect = urllib.error.URLError("Test") - feedparser.return_value = None - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_URLS: [URL]}}) - assert issue_registry.async_get_issue( - DOMAIN, - "import_yaml_error_feedreader_url_error_http_some_rss_local_rss_feed_xml", - ) - - async def test_reconfigure(hass: HomeAssistant, feedparser) -> None: """Test starting a reconfigure flow.""" entry = create_mock_entry(VALID_CONFIG_DEFAULT) diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index bc7a66dc86e..9a2575bf591 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -11,6 +11,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.feedreader.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.util.dt as dt_util @@ -52,6 +53,23 @@ async def test_setup( assert not events +async def test_setup_error( + hass: HomeAssistant, + feed_one_event, +) -> None: + """Test setup error.""" + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get" + ) as feedreader: + feedreader.side_effect = urllib.error.URLError("Test") + feedreader.return_value = feed_one_event + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.SETUP_RETRY + + async def test_storage_data_writing( hass: HomeAssistant, events: list[Event], diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 1976a8f310b..583c44a41e6 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -129,6 +129,62 @@ def mock_light() -> Mock: return light +@pytest.fixture +def mock_thermostat() -> Mock: + """Fixture for a thermostat.""" + climate = Mock() + climate.fibaro_id = 4 + climate.parent_fibaro_id = 0 + climate.name = "Test climate" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.thermostatDanfoss" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = {"setThermostatMode": 1} + climate.supported_features = {} + climate.has_supported_thermostat_modes = True + climate.supported_thermostat_modes = ["Off", "Heat", "CustomerSpecific"] + climate.has_operating_mode = False + climate.has_thermostat_mode = True + climate.thermostat_mode = "CustomerSpecific" + value_mock = Mock() + value_mock.has_value = True + value_mock.int_value.return_value = 20 + climate.value = value_mock + return climate + + +@pytest.fixture +def mock_thermostat_with_operating_mode() -> Mock: + """Fixture for a thermostat.""" + climate = Mock() + climate.fibaro_id = 4 + climate.parent_fibaro_id = 0 + climate.name = "Test climate" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.thermostatDanfoss" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = {"setOperationMode": 1} + climate.supported_features = {} + climate.has_supported_operating_modes = True + climate.supported_operating_modes = [0, 1, 15] + climate.has_operating_mode = True + climate.operating_mode = 15 + climate.has_thermostat_mode = False + value_mock = Mock() + value_mock.has_value = True + value_mock.int_value.return_value = 20 + climate.value = value_mock + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/fibaro/test_climate.py b/tests/components/fibaro/test_climate.py new file mode 100644 index 00000000000..31022e19a08 --- /dev/null +++ b/tests/components/fibaro/test_climate.py @@ -0,0 +1,134 @@ +"""Test the Fibaro climate platform.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.climate import ClimateEntityFeature, HVACMode +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_climate_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat: Mock, + mock_room: Mock, +) -> None: + """Test that the climate creates an entity.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + entry = entity_registry.async_get("climate.room_1_test_climate_4") + assert entry + assert entry.unique_id == "hc2_111111.4" + assert entry.original_name == "Room 1 Test climate" + assert entry.supported_features == ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE + ) + + +async def test_hvac_mode_preset( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat: Mock, + mock_room: Mock, +) -> None: + """Test that the climate state is auto when a preset is selected.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_4") + assert state.state == HVACMode.AUTO + assert state.attributes["preset_mode"] == "CustomerSpecific" + + +async def test_hvac_mode_heat( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat: Mock, + mock_room: Mock, +) -> None: + """Test that the preset mode is None if a hvac mode is active.""" + + # Arrange + mock_thermostat.thermostat_mode = "Heat" + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_4") + assert state.state == HVACMode.HEAT + assert state.attributes["preset_mode"] is None + + +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat: Mock, + mock_room: Mock, +) -> None: + """Test that set_hvac_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.room_1_test_climate_4", "hvac_mode": HVACMode.HEAT}, + blocking=True, + ) + + # Assert + mock_thermostat.execute_action.assert_called_once() + + +async def test_hvac_mode_with_operation_mode_support( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_with_operating_mode: Mock, + mock_room: Mock, +) -> None: + """Test that operating mode works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat_with_operating_mode] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_4") + assert state.state == HVACMode.AUTO diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index cc7c9fa0570..6ce17261bfc 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -44,6 +44,7 @@ def mock_flexit_bacnet() -> Generator[AsyncMock]: ): flexit_bacnet.serial_number = "0000-0001" flexit_bacnet.device_name = "Device Name" + flexit_bacnet.model = "S4 RER" flexit_bacnet.room_temperature = 19.0 flexit_bacnet.air_temp_setpoint_away = 18.0 flexit_bacnet.air_temp_setpoint_home = 22.0 @@ -68,16 +69,16 @@ def mock_flexit_bacnet() -> Generator[AsyncMock]: flexit_bacnet.electric_heater = True # Mock fan setpoints - flexit_bacnet.fan_setpoint_extract_air_fire = 10 - flexit_bacnet.fan_setpoint_supply_air_fire = 20 - flexit_bacnet.fan_setpoint_extract_air_away = 30 - flexit_bacnet.fan_setpoint_supply_air_away = 40 - flexit_bacnet.fan_setpoint_extract_air_home = 50 - flexit_bacnet.fan_setpoint_supply_air_home = 60 - flexit_bacnet.fan_setpoint_extract_air_high = 70 - flexit_bacnet.fan_setpoint_supply_air_high = 80 - flexit_bacnet.fan_setpoint_extract_air_cooker = 90 - flexit_bacnet.fan_setpoint_supply_air_cooker = 100 + flexit_bacnet.fan_setpoint_extract_air_fire = 56 + flexit_bacnet.fan_setpoint_supply_air_fire = 77 + flexit_bacnet.fan_setpoint_extract_air_away = 40 + flexit_bacnet.fan_setpoint_supply_air_away = 42 + flexit_bacnet.fan_setpoint_extract_air_home = 70 + flexit_bacnet.fan_setpoint_supply_air_home = 74 + flexit_bacnet.fan_setpoint_extract_air_high = 100 + flexit_bacnet.fan_setpoint_supply_air_high = 100 + flexit_bacnet.fan_setpoint_extract_air_cooker = 50 + flexit_bacnet.fan_setpoint_supply_air_cooker = 70 yield flexit_bacnet diff --git a/tests/components/flexit_bacnet/snapshots/test_number.ambr b/tests/components/flexit_bacnet/snapshots/test_number.ambr index c4fb1e7c434..78eefd08345 100644 --- a/tests/components/flexit_bacnet/snapshots/test_number.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_number.ambr @@ -5,8 +5,8 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 100, - 'min': 0, + 'max': 70, + 'min': 30, 'mode': , 'step': 1, }), @@ -42,8 +42,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Device Name Away extract fan setpoint', - 'max': 100, - 'min': 0, + 'max': 70, + 'min': 30, 'mode': , 'step': 1, 'unit_of_measurement': '%', @@ -53,7 +53,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '40', }) # --- # name: test_numbers[number.device_name_away_supply_fan_setpoint-entry] @@ -62,8 +62,8 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 100, - 'min': 0, + 'max': 74, + 'min': 30, 'mode': , 'step': 1, }), @@ -99,8 +99,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Device Name Away supply fan setpoint', - 'max': 100, - 'min': 0, + 'max': 74, + 'min': 30, 'mode': , 'step': 1, 'unit_of_measurement': '%', @@ -110,7 +110,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '40', + 'state': '42', }) # --- # name: test_numbers[number.device_name_cooker_hood_extract_fan_setpoint-entry] @@ -120,7 +120,7 @@ 'area_id': None, 'capabilities': dict({ 'max': 100, - 'min': 0, + 'min': 30, 'mode': , 'step': 1, }), @@ -157,7 +157,7 @@ 'device_class': 'power_factor', 'friendly_name': 'Device Name Cooker hood extract fan setpoint', 'max': 100, - 'min': 0, + 'min': 30, 'mode': , 'step': 1, 'unit_of_measurement': '%', @@ -167,7 +167,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '90', + 'state': '50', }) # --- # name: test_numbers[number.device_name_cooker_hood_supply_fan_setpoint-entry] @@ -177,7 +177,7 @@ 'area_id': None, 'capabilities': dict({ 'max': 100, - 'min': 0, + 'min': 30, 'mode': , 'step': 1, }), @@ -214,7 +214,7 @@ 'device_class': 'power_factor', 'friendly_name': 'Device Name Cooker hood supply fan setpoint', 'max': 100, - 'min': 0, + 'min': 30, 'mode': , 'step': 1, 'unit_of_measurement': '%', @@ -224,7 +224,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '70', }) # --- # name: test_numbers[number.device_name_fireplace_extract_fan_setpoint-entry] @@ -234,7 +234,7 @@ 'area_id': None, 'capabilities': dict({ 'max': 100, - 'min': 0, + 'min': 30, 'mode': , 'step': 1, }), @@ -271,7 +271,7 @@ 'device_class': 'power_factor', 'friendly_name': 'Device Name Fireplace extract fan setpoint', 'max': 100, - 'min': 0, + 'min': 30, 'mode': , 'step': 1, 'unit_of_measurement': '%', @@ -281,7 +281,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '10', + 'state': '56', }) # --- # name: test_numbers[number.device_name_fireplace_supply_fan_setpoint-entry] @@ -291,7 +291,7 @@ 'area_id': None, 'capabilities': dict({ 'max': 100, - 'min': 0, + 'min': 30, 'mode': , 'step': 1, }), @@ -328,7 +328,7 @@ 'device_class': 'power_factor', 'friendly_name': 'Device Name Fireplace supply fan setpoint', 'max': 100, - 'min': 0, + 'min': 30, 'mode': , 'step': 1, 'unit_of_measurement': '%', @@ -338,7 +338,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '20', + 'state': '77', }) # --- # name: test_numbers[number.device_name_high_extract_fan_setpoint-entry] @@ -348,7 +348,7 @@ 'area_id': None, 'capabilities': dict({ 'max': 100, - 'min': 0, + 'min': 70, 'mode': , 'step': 1, }), @@ -385,7 +385,7 @@ 'device_class': 'power_factor', 'friendly_name': 'Device Name High extract fan setpoint', 'max': 100, - 'min': 0, + 'min': 70, 'mode': , 'step': 1, 'unit_of_measurement': '%', @@ -395,7 +395,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '70', + 'state': '100', }) # --- # name: test_numbers[number.device_name_high_supply_fan_setpoint-entry] @@ -405,7 +405,7 @@ 'area_id': None, 'capabilities': dict({ 'max': 100, - 'min': 0, + 'min': 74, 'mode': , 'step': 1, }), @@ -442,7 +442,7 @@ 'device_class': 'power_factor', 'friendly_name': 'Device Name High supply fan setpoint', 'max': 100, - 'min': 0, + 'min': 74, 'mode': , 'step': 1, 'unit_of_measurement': '%', @@ -452,7 +452,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '80', + 'state': '100', }) # --- # name: test_numbers[number.device_name_home_extract_fan_setpoint-entry] @@ -462,7 +462,7 @@ 'area_id': None, 'capabilities': dict({ 'max': 100, - 'min': 0, + 'min': 40, 'mode': , 'step': 1, }), @@ -499,7 +499,7 @@ 'device_class': 'power_factor', 'friendly_name': 'Device Name Home extract fan setpoint', 'max': 100, - 'min': 0, + 'min': 40, 'mode': , 'step': 1, 'unit_of_measurement': '%', @@ -509,7 +509,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '70', }) # --- # name: test_numbers[number.device_name_home_supply_fan_setpoint-entry] @@ -519,7 +519,7 @@ 'area_id': None, 'capabilities': dict({ 'max': 100, - 'min': 0, + 'min': 42, 'mode': , 'step': 1, }), @@ -556,7 +556,7 @@ 'device_class': 'power_factor', 'friendly_name': 'Device Name Home supply fan setpoint', 'max': 100, - 'min': 0, + 'min': 42, 'mode': , 'step': 1, 'unit_of_measurement': '%', @@ -566,6 +566,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '60', + 'state': '74', }) # --- diff --git a/tests/components/flexit_bacnet/test_number.py b/tests/components/flexit_bacnet/test_number.py index ad49908fa96..f566b623f12 100644 --- a/tests/components/flexit_bacnet/test_number.py +++ b/tests/components/flexit_bacnet/test_number.py @@ -64,21 +64,21 @@ async def test_numbers_implementation( assert len(mocked_method.mock_calls) == 1 assert hass.states.get(ENTITY_ID).state == "60" - mock_flexit_bacnet.fan_setpoint_supply_air_fire = 10 + mock_flexit_bacnet.fan_setpoint_supply_air_fire = 40 await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: ENTITY_ID, - ATTR_VALUE: 10, + ATTR_VALUE: 40, }, blocking=True, ) mocked_method = getattr(mock_flexit_bacnet, "set_fan_setpoint_supply_air_fire") assert len(mocked_method.mock_calls) == 2 - assert hass.states.get(ENTITY_ID).state == "10" + assert hass.states.get(ENTITY_ID).state == "40" # Error recovery, when setting the value mock_flexit_bacnet.set_fan_setpoint_supply_air_fire.side_effect = DecodingError @@ -89,7 +89,7 @@ async def test_numbers_implementation( SERVICE_SET_VALUE, { ATTR_ENTITY_ID: ENTITY_ID, - ATTR_VALUE: 10, + ATTR_VALUE: 40, }, blocking=True, ) diff --git a/tests/components/flick_electric/__init__.py b/tests/components/flick_electric/__init__.py index 7ba25e6c180..36936cad047 100644 --- a/tests/components/flick_electric/__init__.py +++ b/tests/components/flick_electric/__init__.py @@ -1 +1,51 @@ """Tests for the Flick Electric integration.""" + +from pyflick.types import FlickPrice + +from homeassistant.components.flick_electric.const import ( + CONF_ACCOUNT_ID, + CONF_SUPPLY_NODE_REF, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +CONF = { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_ACCOUNT_ID: "1234", + CONF_SUPPLY_NODE_REF: "123", +} + + +def _mock_flick_price(): + return FlickPrice( + { + "cost": "0.25", + "quantity": "1.0", + "status": "final", + "start_at": "2024-01-01T00:00:00Z", + "end_at": "2024-01-01T00:00:00Z", + "type": "flat", + "components": [ + { + "charge_method": "kwh", + "charge_setter": "network", + "value": "1.00", + "single_unit_price": "1.00", + "quantity": "1.0", + "unit_code": "NZD", + "charge_per": "kwh", + "flow_direction": "import", + }, + { + "charge_method": "kwh", + "charge_setter": "nonsupported", + "value": "1.00", + "single_unit_price": "1.00", + "quantity": "1.0", + "unit_code": "NZD", + "charge_per": "kwh", + "flow_direction": "import", + }, + ], + } + ) diff --git a/tests/components/flick_electric/test_config_flow.py b/tests/components/flick_electric/test_config_flow.py index 85a6495d3c5..7ac605f1c8c 100644 --- a/tests/components/flick_electric/test_config_flow.py +++ b/tests/components/flick_electric/test_config_flow.py @@ -3,29 +3,37 @@ from unittest.mock import patch from pyflick.authentication import AuthException +from pyflick.types import APIException from homeassistant import config_entries -from homeassistant.components.flick_electric.const import DOMAIN +from homeassistant.components.flick_electric.const import ( + CONF_ACCOUNT_ID, + CONF_SUPPLY_NODE_REF, + DOMAIN, +) from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from . import CONF, _mock_flick_price -CONF = {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"} +from tests.common import MockConfigEntry async def _flow_submit(hass: HomeAssistant) -> ConfigFlowResult: return await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=CONF, + data={ + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, ) async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" + """Test we get the form with only one, with no account picker.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -38,6 +46,21 @@ async def test_form(hass: HomeAssistant) -> None: "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", return_value="123456789abcdef", ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + } + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), patch( "homeassistant.components.flick_electric.async_setup_entry", return_value=True, @@ -45,29 +68,293 @@ async def test_form(hass: HomeAssistant) -> None: ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - CONF, + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Flick Electric: test-username" + assert result2["title"] == "123 Fake St" assert result2["data"] == CONF + assert result2["result"].unique_id == "1234" assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_duplicate_login(hass: HomeAssistant) -> None: - """Test uniqueness of username.""" +async def test_form_multi_account(hass: HomeAssistant) -> None: + """Test the form when multiple accounts are available.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + patch( + "homeassistant.components.flick_electric.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" + assert len(mock_setup_entry.mock_calls) == 0 + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"account_id": "5678"}, + ) + + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "456 Fake St" + assert result3["data"] == { + **CONF, + CONF_SUPPLY_NODE_REF: "456", + CONF_ACCOUNT_ID: "5678", + } + assert result3["result"].unique_id == "5678" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_token(hass: HomeAssistant) -> None: + """Test reauth flow when username/password is wrong.""" entry = MockConfigEntry( domain=DOMAIN, - data=CONF, - title="Flick Electric: test-username", - unique_id="flick_electric_test-username", + data={**CONF}, + title="123 Fake St", + unique_id="1234", + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + side_effect=AuthException, + ), + ): + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + assert result["step_id"] == "user" + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_update_entry", + return_value=True, + ) as mock_update_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert len(mock_update_entry.mock_calls) > 0 + + +async def test_form_reauth_migrate(hass: HomeAssistant) -> None: + """Test reauth flow for v1 with single account.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + title="123 Fake St", + unique_id="test-username", + version=1, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + ): + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.version == 2 + assert entry.unique_id == "1234" + assert entry.data == CONF + + +async def test_form_reauth_migrate_multi_account(hass: HomeAssistant) -> None: + """Test the form when multiple accounts are available.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + title="123 Fake St", + unique_id="test-username", + version=1, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + ): + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_account" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"account_id": "5678"}, + ) + + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + assert entry.version == 2 + assert entry.unique_id == "5678" + assert entry.data == { + **CONF, + CONF_ACCOUNT_ID: "5678", + CONF_SUPPLY_NODE_REF: "456", + } + + +async def test_form_duplicate_account(hass: HomeAssistant) -> None: + """Test uniqueness for account_id.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={**CONF, CONF_ACCOUNT_ID: "1234", CONF_SUPPLY_NODE_REF: "123"}, + title="123 Fake St", + unique_id="1234", + version=2, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + } + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), ): result = await _flow_submit(hass) @@ -109,3 +396,280 @@ async def test_form_generic_exception(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "unknown"} + + +async def test_form_select_account_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle connection errors for select account.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + side_effect=APIException, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"account_id": "5678"}, + ) + + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "select_account" + assert result3["errors"] == {"base": "cannot_connect"} + + +async def test_form_select_account_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle auth errors for select account.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + side_effect=AuthException, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + side_effect=AuthException, + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + side_effect=AuthException, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"account_id": "5678"}, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "no_permissions" + + +async def test_form_select_account_failed_to_connect(hass: HomeAssistant) -> None: + """Test we handle connection errors for select account.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + side_effect=AuthException, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + side_effect=APIException, + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + side_effect=APIException, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"account_id": "5678"}, + ) + + assert result3["type"] is FlowResultType.FORM + assert result3["errors"] == {"base": "cannot_connect"} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + patch( + "homeassistant.components.flick_electric.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {"account_id": "5678"}, + ) + + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == "456 Fake St" + assert result4["data"] == { + **CONF, + CONF_SUPPLY_NODE_REF: "456", + CONF_ACCOUNT_ID: "5678", + } + assert result4["result"].unique_id == "5678" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_select_account_no_accounts(hass: HomeAssistant) -> None: + """Test we handle connection errors for select account.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "closed", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + ], + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "no_accounts" diff --git a/tests/components/flick_electric/test_init.py b/tests/components/flick_electric/test_init.py new file mode 100644 index 00000000000..e022b6e03bc --- /dev/null +++ b/tests/components/flick_electric/test_init.py @@ -0,0 +1,135 @@ +"""Test the Flick Electric config flow.""" + +from unittest.mock import patch + +from pyflick.authentication import AuthException + +from homeassistant.components.flick_electric.const import CONF_ACCOUNT_ID, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import CONF, _mock_flick_price + +from tests.common import MockConfigEntry + + +async def test_init_auth_failure_triggers_auth(hass: HomeAssistant) -> None: + """Test reauth flow is triggered when username/password is wrong.""" + with ( + patch( + "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", + side_effect=AuthException, + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={**CONF}, + title="123 Fake St", + unique_id="1234", + version=2, + ) + entry.add_to_hass(hass) + + # Ensure setup fails + assert not await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR + + # Ensure reauth flow is triggered + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 1 + + +async def test_init_migration_single_account(hass: HomeAssistant) -> None: + """Test migration with single account.""" + with ( + patch( + "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + } + ], + ), + patch( + "homeassistant.components.flick_electric.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + title=CONF_USERNAME, + unique_id=CONF_USERNAME, + version=1, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 0 + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 2 + assert entry.unique_id == CONF[CONF_ACCOUNT_ID] + assert entry.data == CONF + + +async def test_init_migration_multi_account_reauth(hass: HomeAssistant) -> None: + """Test migration triggers reauth with multiple accounts.""" + with ( + patch( + "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + patch( + "homeassistant.components.flick_electric.FlickAPI.getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "active", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + { + "id": "5678", + "status": "active", + "address": "456 Fake St", + "main_consumer": {"supply_node_ref": "456"}, + }, + ], + ), + patch( + "homeassistant.components.flick_electric.FlickAPI.getPricing", + return_value=_mock_flick_price(), + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + title=CONF_USERNAME, + unique_id=CONF_USERNAME, + version=1, + ) + entry.add_to_hass(hass) + + # ensure setup fails + assert not await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.MIGRATION_ERROR + await hass.async_block_till_done() + + # Ensure reauth flow is triggered + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 1 diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index ab0e8a556c4..f7dc30db240 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -1164,7 +1164,7 @@ async def test_flux_with_multiple_lights( assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376] -async def test_flux_with_mired( +async def test_flux_with_temp( hass: HomeAssistant, mock_light_entities: list[MockLight], ) -> None: @@ -1224,7 +1224,7 @@ async def test_flux_with_mired( async_fire_time_changed(hass, test_time) await hass.async_block_till_done() call = turn_on_calls[-1] - assert call.data[light.ATTR_COLOR_TEMP] == 269 + assert call.data[light.ATTR_COLOR_TEMP_KELVIN] == 3708 async def test_flux_with_rgb( diff --git a/tests/components/flux_led/__init__.py b/tests/components/flux_led/__init__.py index d1cb892d548..c8bd0bb192c 100644 --- a/tests/components/flux_led/__init__.py +++ b/tests/components/flux_led/__init__.py @@ -24,12 +24,12 @@ from flux_led.protocol import ( ) from flux_led.scanner import FluxLEDDiscovery -from homeassistant.components import dhcp from homeassistant.components.flux_led.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -49,7 +49,7 @@ SHORT_MAC_ADDRESS = "DDEEFF" DEFAULT_ENTRY_TITLE = f"{MODEL_DESCRIPTION} {SHORT_MAC_ADDRESS}" -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=MODEL, ip=IP_ADDRESS, macaddress=format_mac(MAC_ADDRESS).replace(":", ""), diff --git a/tests/components/flux_led/test_config_flow.py b/tests/components/flux_led/test_config_flow.py index 4332cb69f02..f486d27244e 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.flux_led.config_flow import FluxLedConfigFlow from homeassistant.components.flux_led.const import ( CONF_CUSTOM_EFFECT_COLORS, @@ -27,6 +26,7 @@ from homeassistant.components.flux_led.const import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( DEFAULT_ENTRY_TITLE, @@ -424,7 +424,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=IP_ADDRESS, macaddress="000000000000", diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index c12776eb552..a881bc2ea27 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -41,7 +41,7 @@ from homeassistant.components.flux_led.light import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, @@ -777,12 +777,12 @@ async def test_rgb_cct_light(hass: HomeAssistant) -> None: assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "color_temp" assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "rgb"] - assert attributes[ATTR_COLOR_TEMP] == 200 + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 5000 await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 370}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 2702}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(2702, 128) @@ -1003,7 +1003,7 @@ async def test_rgbw_light_warm_white(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6493}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(6493, 255) @@ -1012,7 +1012,7 @@ async def test_rgbw_light_warm_white(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154, ATTR_BRIGHTNESS: 255}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6493, ATTR_BRIGHTNESS: 255}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(6493, 255) @@ -1021,7 +1021,7 @@ async def test_rgbw_light_warm_white(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 290}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 3448}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(3448, 255) @@ -1241,7 +1241,7 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6493}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(6493, 255) @@ -1250,7 +1250,7 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154, ATTR_BRIGHTNESS: 255}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6493, ATTR_BRIGHTNESS: 255}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(6493, 255) @@ -1259,7 +1259,7 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 290}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 3448}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(3448, 255) @@ -1316,7 +1316,7 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 170}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 5882}, blocking=True, ) bulb.async_set_white_temp.assert_called_with(5882, MIN_CCT_BRIGHTNESS) diff --git a/tests/components/forecast_solar/test_config_flow.py b/tests/components/forecast_solar/test_config_flow.py index abaad402e1b..8fffb5096bc 100644 --- a/tests/components/forecast_solar/test_config_flow.py +++ b/tests/components/forecast_solar/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock +import pytest + from homeassistant.components.forecast_solar.const import ( CONF_AZIMUTH, CONF_DAMPING_EVENING, @@ -25,10 +27,10 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_NAME: "Name", @@ -40,13 +42,16 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == "Name" - assert result2.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Name" + assert config_entry.unique_id is None + assert config_entry.data == { CONF_LATITUDE: 52.42, CONF_LONGITUDE: 4.42, } - assert result2.get("options") == { + assert config_entry.options == { CONF_AZIMUTH: 142, CONF_DECLINATION: 42, CONF_MODULES_POWER: 4242, @@ -55,9 +60,9 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_setup_entry") async def test_options_flow_invalid_api( hass: HomeAssistant, - mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test options config flow when API key is invalid.""" @@ -67,10 +72,10 @@ async def test_options_flow_invalid_api( result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "init" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_API_KEY: "solarPOWER!", @@ -84,27 +89,11 @@ async def test_options_flow_invalid_api( ) await hass.async_block_till_done() - assert result2.get("type") is FlowResultType.FORM - assert result2["errors"] == {CONF_API_KEY: "invalid_api_key"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} - -async def test_options_flow( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test config flow options.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "init" - - # With the API key - result2 = await hass.config_entries.options.async_configure( + # Ensure we can recover from this error + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_API_KEY: "SolarForecast150", @@ -118,8 +107,8 @@ async def test_options_flow( ) await hass.async_block_till_done() - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { CONF_API_KEY: "SolarForecast150", CONF_DECLINATION: 21, CONF_AZIMUTH: 22, @@ -130,9 +119,9 @@ async def test_options_flow( } -async def test_options_flow_without_key( +@pytest.mark.usefixtures("mock_setup_entry") +async def test_options_flow( hass: HomeAssistant, - mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test config flow options.""" @@ -142,11 +131,53 @@ async def test_options_flow_without_key( result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "init" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # With the API key + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "SolarForecast150", + CONF_DECLINATION: 21, + CONF_AZIMUTH: 22, + CONF_MODULES_POWER: 2122, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, + CONF_INVERTER_SIZE: 2000, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_API_KEY: "SolarForecast150", + CONF_DECLINATION: 21, + CONF_AZIMUTH: 22, + CONF_MODULES_POWER: 2122, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, + CONF_INVERTER_SIZE: 2000, + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_options_flow_without_key( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow options.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" # Without the API key - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_DECLINATION: 21, @@ -159,8 +190,8 @@ async def test_options_flow_without_key( ) await hass.async_block_till_done() - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { CONF_API_KEY: None, CONF_DECLINATION: 21, CONF_AZIMUTH: 22, diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index 076fffef59b..8bf5de31da2 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.components import zeroconf from homeassistant.components.forked_daapd.const import ( CONF_LIBRESPOT_JAVA_PORT, CONF_MAX_PLAYLISTS, @@ -19,6 +18,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -109,7 +109,7 @@ async def test_zeroconf_updates_title( MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "different host"}).add_to_hass(hass) config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.1"), ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", @@ -146,7 +146,7 @@ async def test_config_flow_no_websocket( async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: """Test that an invalid zeroconf entry doesn't work.""" # test with no discovery properties - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -161,7 +161,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with forked-daapd version < 27 - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -176,7 +176,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with verbose mtd-version from Firefly - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -191,7 +191,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with svn mtd-version from Firefly - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -209,7 +209,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: async def test_config_flow_zeroconf_valid(hass: HomeAssistant) -> None: """Test that a valid zeroconf entry works.""" - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.1"), ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", diff --git a/tests/components/foscam/conftest.py b/tests/components/foscam/conftest.py new file mode 100644 index 00000000000..6ff5a0b5af5 --- /dev/null +++ b/tests/components/foscam/conftest.py @@ -0,0 +1,66 @@ +"""Common stuff for Foscam tests.""" + +from libpyfoscam.foscam import ( + ERROR_FOSCAM_AUTH, + ERROR_FOSCAM_CMD, + ERROR_FOSCAM_UNAVAILABLE, + ERROR_FOSCAM_UNKNOWN, +) + +from homeassistant.components.foscam import config_flow + +from .const import ( + CAMERA_MAC, + CAMERA_NAME, + INVALID_RESPONSE_CONFIG, + OPERATOR_CONFIG, + VALID_CONFIG, +) + + +def setup_mock_foscam_camera(mock_foscam_camera): + """Mock FoscamCamera simulating behaviour using a base valid config.""" + + def configure_mock_on_init(host, port, user, passwd, verbose=False): + product_all_info_rc = 0 + dev_info_rc = 0 + dev_info_data = {} + + if ( + host != VALID_CONFIG[config_flow.CONF_HOST] + or port != VALID_CONFIG[config_flow.CONF_PORT] + ): + product_all_info_rc = dev_info_rc = ERROR_FOSCAM_UNAVAILABLE + + elif ( + user + not in [ + VALID_CONFIG[config_flow.CONF_USERNAME], + OPERATOR_CONFIG[config_flow.CONF_USERNAME], + INVALID_RESPONSE_CONFIG[config_flow.CONF_USERNAME], + ] + or passwd != VALID_CONFIG[config_flow.CONF_PASSWORD] + ): + product_all_info_rc = dev_info_rc = ERROR_FOSCAM_AUTH + + elif user == INVALID_RESPONSE_CONFIG[config_flow.CONF_USERNAME]: + product_all_info_rc = dev_info_rc = ERROR_FOSCAM_UNKNOWN + + elif user == OPERATOR_CONFIG[config_flow.CONF_USERNAME]: + dev_info_rc = ERROR_FOSCAM_CMD + + else: + dev_info_data["devName"] = CAMERA_NAME + dev_info_data["mac"] = CAMERA_MAC + dev_info_data["productName"] = "Foscam Product" + dev_info_data["firmwareVer"] = "1.2.3" + dev_info_data["hardwareVer"] = "4.5.6" + + mock_foscam_camera.get_product_all_info.return_value = (product_all_info_rc, {}) + mock_foscam_camera.get_dev_info.return_value = (dev_info_rc, dev_info_data) + mock_foscam_camera.get_port_info.return_value = (dev_info_rc, {}) + mock_foscam_camera.is_asleep.return_value = (0, True) + + return mock_foscam_camera + + mock_foscam_camera.side_effect = configure_mock_on_init diff --git a/tests/components/foscam/const.py b/tests/components/foscam/const.py new file mode 100644 index 00000000000..2f00dad5932 --- /dev/null +++ b/tests/components/foscam/const.py @@ -0,0 +1,21 @@ +"""Constants for Foscam tests.""" + +from homeassistant.components.foscam import config_flow + +VALID_CONFIG = { + config_flow.CONF_HOST: "10.0.0.2", + config_flow.CONF_PORT: 88, + config_flow.CONF_USERNAME: "admin", + config_flow.CONF_PASSWORD: "1234", + config_flow.CONF_STREAM: "Main", + config_flow.CONF_RTSP_PORT: 554, +} +OPERATOR_CONFIG = { + config_flow.CONF_USERNAME: "operator", +} +INVALID_RESPONSE_CONFIG = { + config_flow.CONF_USERNAME: "interr", +} +CAMERA_NAME = "Mocked Foscam Camera" +CAMERA_MAC = "C0:C1:D0:F4:B4:D4" +ENTRY_ID = "123ABC" diff --git a/tests/components/foscam/test_config_flow.py b/tests/components/foscam/test_config_flow.py index 9c0a07aa67c..235a2741992 100644 --- a/tests/components/foscam/test_config_flow.py +++ b/tests/components/foscam/test_config_flow.py @@ -2,80 +2,16 @@ from unittest.mock import patch -from libpyfoscam.foscam import ( - ERROR_FOSCAM_AUTH, - ERROR_FOSCAM_CMD, - ERROR_FOSCAM_UNAVAILABLE, - ERROR_FOSCAM_UNKNOWN, -) - from homeassistant import config_entries from homeassistant.components.foscam import config_flow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import setup_mock_foscam_camera +from .const import CAMERA_NAME, INVALID_RESPONSE_CONFIG, VALID_CONFIG + from tests.common import MockConfigEntry -VALID_CONFIG = { - config_flow.CONF_HOST: "10.0.0.2", - config_flow.CONF_PORT: 88, - config_flow.CONF_USERNAME: "admin", - config_flow.CONF_PASSWORD: "1234", - config_flow.CONF_STREAM: "Main", - config_flow.CONF_RTSP_PORT: 554, -} -OPERATOR_CONFIG = { - config_flow.CONF_USERNAME: "operator", -} -INVALID_RESPONSE_CONFIG = { - config_flow.CONF_USERNAME: "interr", -} -CAMERA_NAME = "Mocked Foscam Camera" -CAMERA_MAC = "C0:C1:D0:F4:B4:D4" - - -def setup_mock_foscam_camera(mock_foscam_camera): - """Mock FoscamCamera simulating behaviour using a base valid config.""" - - def configure_mock_on_init(host, port, user, passwd, verbose=False): - product_all_info_rc = 0 - dev_info_rc = 0 - dev_info_data = {} - - if ( - host != VALID_CONFIG[config_flow.CONF_HOST] - or port != VALID_CONFIG[config_flow.CONF_PORT] - ): - product_all_info_rc = dev_info_rc = ERROR_FOSCAM_UNAVAILABLE - - elif ( - user - not in [ - VALID_CONFIG[config_flow.CONF_USERNAME], - OPERATOR_CONFIG[config_flow.CONF_USERNAME], - INVALID_RESPONSE_CONFIG[config_flow.CONF_USERNAME], - ] - or passwd != VALID_CONFIG[config_flow.CONF_PASSWORD] - ): - product_all_info_rc = dev_info_rc = ERROR_FOSCAM_AUTH - - elif user == INVALID_RESPONSE_CONFIG[config_flow.CONF_USERNAME]: - product_all_info_rc = dev_info_rc = ERROR_FOSCAM_UNKNOWN - - elif user == OPERATOR_CONFIG[config_flow.CONF_USERNAME]: - dev_info_rc = ERROR_FOSCAM_CMD - - else: - dev_info_data["devName"] = CAMERA_NAME - dev_info_data["mac"] = CAMERA_MAC - - mock_foscam_camera.get_product_all_info.return_value = (product_all_info_rc, {}) - mock_foscam_camera.get_dev_info.return_value = (dev_info_rc, dev_info_data) - - return mock_foscam_camera - - mock_foscam_camera.side_effect = configure_mock_on_init - async def test_user_valid(hass: HomeAssistant) -> None: """Test valid config from user input.""" diff --git a/tests/components/foscam/test_init.py b/tests/components/foscam/test_init.py new file mode 100644 index 00000000000..0b82ed3b02a --- /dev/null +++ b/tests/components/foscam/test_init.py @@ -0,0 +1,118 @@ +"""Test the Foscam component.""" + +from unittest.mock import patch + +from homeassistant.components.foscam import DOMAIN, config_flow +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_mock_foscam_camera +from .const import ENTRY_ID, VALID_CONFIG + +from tests.common import MockConfigEntry + + +async def test_unique_id_new_entry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test unique ID for a newly added device is correct.""" + entry = MockConfigEntry( + domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID + ) + entry.add_to_hass(hass) + + with ( + # Mock a valid camera instance" + patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera, + ): + setup_mock_foscam_camera(mock_foscam_camera) + assert await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + # Test that unique_id remains the same. + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{ENTRY_ID}_sleep_switch" + ) + entity_new = entity_registry.async_get(entity_id) + assert entity_new.unique_id == f"{ENTRY_ID}_sleep_switch" + + +async def test_switch_unique_id_migration_ok( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the unique ID for a sleep switch is migrated to the new format.""" + entry = MockConfigEntry( + domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=1 + ) + entry.add_to_hass(hass) + + entity_before = entity_registry.async_get_or_create( + SWITCH_DOMAIN, DOMAIN, "sleep_switch", config_entry=entry + ) + assert entity_before.unique_id == "sleep_switch" + + # Update config entry with version 2 + entry = MockConfigEntry( + domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=2 + ) + entry.add_to_hass(hass) + + with ( + # Mock a valid camera instance" + patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera, + ): + setup_mock_foscam_camera(mock_foscam_camera) + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + entity_id_new = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{ENTRY_ID}_sleep_switch" + ) + assert hass.states.get(entity_id_new) + entity_after = entity_registry.async_get(entity_id_new) + assert entity_after.previous_unique_id == "sleep_switch" + assert entity_after.unique_id == f"{ENTRY_ID}_sleep_switch" + + +async def test_unique_id_migration_not_needed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the unique ID for a sleep switch is not executed if already in right format.""" + entry = MockConfigEntry( + domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID + ) + entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + SWITCH_DOMAIN, DOMAIN, f"{ENTRY_ID}_sleep_switch", config_entry=entry + ) + + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{ENTRY_ID}_sleep_switch" + ) + entity_before = entity_registry.async_get(entity_id) + assert entity_before.unique_id == f"{ENTRY_ID}_sleep_switch" + + with ( + # Mock a valid camera instance" + patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera, + patch( + "homeassistant.components.foscam.async_migrate_entry", + return_value=True, + ), + ): + setup_mock_foscam_camera(mock_foscam_camera) + assert await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + # Test that unique_id remains the same. + assert hass.states.get(entity_id) + entity_after = entity_registry.async_get(entity_id) + assert entity_after.unique_id == entity_before.unique_id diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 2fe4e1b77de..e6adae572f3 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -1,7 +1,7 @@ """Test helpers for Freebox.""" import json -from unittest.mock import AsyncMock, PropertyMock, patch +from unittest.mock import AsyncMock, patch from freebox_api.exceptions import HttpRequestError import pytest @@ -36,16 +36,6 @@ def mock_path(): yield -@pytest.fixture(autouse=True) -def enable_all_entities(): - """Make sure all entities are enabled.""" - with patch( - "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", - PropertyMock(return_value=True), - ): - yield - - @pytest.fixture def mock_device_registry_devices( hass: HomeAssistant, device_registry: dr.DeviceRegistry diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index 4950ef27e5f..a56f3ed0789 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -4,6 +4,7 @@ from copy import deepcopy from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, @@ -45,6 +46,7 @@ async def test_raid_array_degraded( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_home( hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock ) -> None: diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py index ca9e9c12937..50dd2f8c14e 100644 --- a/tests/components/freebox/test_config_flow.py +++ b/tests/components/freebox/test_config_flow.py @@ -9,18 +9,18 @@ from freebox_api.exceptions import ( InvalidTokenError, ) -from homeassistant.components import zeroconf from homeassistant.components.freebox.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import MOCK_HOST, MOCK_PORT from tests.common import MockConfigEntry -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.254"), ip_addresses=[ip_address("192.168.0.254")], port=80, diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index acd96879b1e..1e292ed22bb 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -1,8 +1,6 @@ """Common stuff for Fritz!Tools tests.""" -from homeassistant.components import ssdp from homeassistant.components.fritz.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.const import ( CONF_DEVICES, CONF_HOST, @@ -11,6 +9,11 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) ATTR_HOST = "host" ATTR_NEW_SERIAL_NUMBER = "NewSerialNumber" @@ -941,7 +944,7 @@ MOCK_DEVICE_INFO = { ATTR_HOST: MOCK_HOST, ATTR_NEW_SERIAL_NUMBER: MOCK_SERIAL_NUMBER, } -MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"https://{MOCK_IPS['fritz.box']}:12345/test", diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 84f1b240b88..f4c4229af74 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -10,7 +10,6 @@ from fritzconnection.core.exceptions import ( ) import pytest -from homeassistant.components import ssdp from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -33,6 +32,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import ( MOCK_FIRMWARE_INFO, @@ -644,7 +648,7 @@ async def test_ssdp_already_in_progress_host( MOCK_NO_UNIQUE_ID = dataclasses.replace(MOCK_SSDP_DATA) MOCK_NO_UNIQUE_ID.upnp = MOCK_NO_UNIQUE_ID.upnp.copy() - del MOCK_NO_UNIQUE_ID.upnp[ssdp.ATTR_UPNP_UDN] + del MOCK_NO_UNIQUE_ID.upnp[ATTR_UPNP_UDN] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_UNIQUE_ID ) @@ -745,13 +749,13 @@ async def test_ssdp_ipv6_link_local(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[fe80::1ff:fe23:4567:890a]:12345/test", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "fake_name", - ssdp.ATTR_UPNP_UDN: "uuid:only-a-test", + ATTR_UPNP_FRIENDLY_NAME: "fake_name", + ATTR_UPNP_UDN: "uuid:only-a-test", }, ), ) diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index 0df6d0b2ea9..0c8a7996898 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -9,13 +9,16 @@ from pyfritzhome import LoginError import pytest from requests.exceptions import HTTPError -from homeassistant.components import ssdp from homeassistant.components.fritzbox.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import CONF_FAKE_NAME, MOCK_CONFIG @@ -23,7 +26,7 @@ from tests.common import MockConfigEntry MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] MOCK_SSDP_DATA = { - "ip4_valid": ssdp.SsdpServiceInfo( + "ip4_valid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://10.0.0.1:12345/test", @@ -32,7 +35,7 @@ MOCK_SSDP_DATA = { ATTR_UPNP_UDN: "uuid:only-a-test", }, ), - "ip6_valid": ssdp.SsdpServiceInfo( + "ip6_valid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[1234::1]:12345/test", @@ -41,7 +44,7 @@ MOCK_SSDP_DATA = { ATTR_UPNP_UDN: "uuid:only-a-test", }, ), - "ip6_invalid": ssdp.SsdpServiceInfo( + "ip6_invalid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[fe80::1%1]:12345/test", @@ -264,7 +267,7 @@ async def test_reconfigure_failed(hass: HomeAssistant, fritz: Mock) -> None: async def test_ssdp( hass: HomeAssistant, fritz: Mock, - test_data: ssdp.SsdpServiceInfo, + test_data: SsdpServiceInfo, expected_result: str, ) -> None: """Test starting a flow from discovery.""" diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 57b22490ed0..8445e6b6a79 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -3,20 +3,16 @@ from __future__ import annotations from collections.abc import Callable -from datetime import timedelta import json from typing import Any -from freezegun.api import FrozenDateTimeFactory - from homeassistant.components.fronius.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker MOCK_HOST = "http://fronius" @@ -115,24 +111,3 @@ def mock_responses( f"{host}/solar_api/v1/GetOhmPilotRealtimeData.cgi?Scope=System", text=_load(f"{fixture_set}/GetOhmPilotRealtimeData.json", "fronius"), ) - - -async def enable_all_entities( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - config_entry_id: str, - time_till_next_update: timedelta, -) -> None: - """Enable all entities for a config entry and fast forward time to receive data.""" - registry = er.async_get(hass) - entities = er.async_entries_for_config_entry(registry, config_entry_id) - for entry in [ - entry - for entry in entities - if entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - ]: - registry.async_update_entity(entry.entity_id, disabled_by=None) - await hass.async_block_till_done() - freezer.tick(time_till_next_update) - async_fire_time_changed(hass) - await hass.async_block_till_done() diff --git a/tests/components/fronius/snapshots/test_sensor.ambr b/tests/components/fronius/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..81770893273 --- /dev/null +++ b/tests/components/fronius/snapshots/test_sensor.ambr @@ -0,0 +1,9984 @@ +# serializer version: 1 +# name: test_gen24[sensor.inverter_name_ac_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac', + 'unique_id': '12345678-current_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter name AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1589', + }) +# --- +# name: test_gen24[sensor.inverter_name_ac_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_ac_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_ac', + 'unique_id': '12345678-power_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_ac_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter name AC power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_ac_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.3204', + }) +# --- +# name: test_gen24[sensor.inverter_name_ac_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac', + 'unique_id': '12345678-voltage_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter name AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '234.9168', + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc', + 'unique_id': '12345678-current_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter name DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0783', + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_current_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_dc_current_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc_2', + 'unique_id': '12345678-current_dc_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_current_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter name DC current 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_dc_current_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0754', + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': '12345678-voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter name DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '411.3811', + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_voltage_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_dc_voltage_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc_2', + 'unique_id': '12345678-voltage_dc_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_dc_voltage_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter name DC voltage 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_dc_voltage_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '403.4312', + }) +# --- +# name: test_gen24[sensor.inverter_name_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inverter_name_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '12345678-error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.inverter_name_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inverter name Error code', + }), + 'context': , + 'entity_id': 'sensor.inverter_name_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24[sensor.inverter_name_error_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'ac_voltage_too_high', + 'ac_voltage_too_low', + 'ac_frequency_too_high', + 'ac_frequency_too_low', + 'ac_grid_outside_permissible_limits', + 'stand_alone_operation_detected', + 'rcmu_error', + 'arc_detection_triggered', + 'overcurrent_ac', + 'overcurrent_dc', + 'dc_module_over_temperature', + 'ac_module_over_temperature', + 'no_power_fed_in_despite_closed_relay', + 'pv_output_too_low_for_feeding_energy_into_the_grid', + 'low_pv_voltage_dc_input_voltage_too_low', + 'intermediate_circuit_voltage_too_high', + 'dc_input_voltage_mppt_1_too_high', + 'polarity_of_dc_strings_reversed', + 'dc_input_voltage_mppt_2_too_high', + 'current_sensor_calibration_timeout', + 'ac_current_sensor_error', + 'interrupt_check_fail', + 'overtemperature_in_connection_area', + 'fan_1_error', + 'fan_2_error', + 'no_communication_with_power_stage_set', + 'ac_module_temperature_sensor_faulty_l1', + 'ac_module_temperature_sensor_faulty_l2', + 'dc_component_measured_in_grid_too_high', + 'fixed_voltage_mode_out_of_range', + 'safety_cut_out_triggered', + 'no_communication_between_power_stage_and_control_system', + 'hardware_id_problem', + 'unique_id_conflict', + 'no_communication_with_hybrid_manager', + 'hid_range_error', + 'possible_hardware_fault', + 'software_problem', + 'functional_incompatibility_between_pc_boards', + 'power_stage_set_problem', + 'intermediate_circuit_voltage_too_low_or_asymmetric', + 'compatibility_error_invalid_power_stage_configuration', + 'insulation_fault', + 'neutral_conductor_not_connected', + 'guard_cannot_be_found', + 'memory_error_detected', + 'communication', + 'insulation_error_on_solar_panels', + 'no_energy_fed_into_grid_past_24_hours', + 'no_communication_with_filter', + 'no_communication_with_storage_unit', + 'power_derating_due_to_high_temperature', + 'internal_dsp_malfunction', + 'no_energy_fed_by_mppt1_past_24_hours', + 'dc_low_string_1', + 'dc_low_string_2', + 'derating_caused_by_over_frequency', + 'arc_detector_switched_off', + 'grid_voltage_dependent_power_reduction_active', + 'can_bus_full', + 'ac_module_temperature_sensor_faulty_l3', + 'dc_module_temperature_sensor_faulty', + 'internal_processor_status', + 'eeprom_reinitialised', + 'initialisation_error_usb_flash_drive_not_supported', + 'initialisation_error_usb_stick_over_current', + 'no_usb_flash_drive_connected', + 'update_file_not_recognised_or_missing', + 'update_file_does_not_match_device', + 'write_or_read_error_occurred', + 'file_could_not_be_opened', + 'log_file_cannot_be_saved', + 'initialisation_error_file_system_error_on_usb', + 'error_during_logging_data_recording', + 'error_during_update_process', + 'update_file_corrupt', + 'time_lost', + 'real_time_clock_communication_error', + 'real_time_clock_in_emergency_mode', + 'real_time_clock_hardware_error', + 'internal_hardware_error', + 'emergency_power_derating_activated', + 'different_power_limitation_in_hardware_modules', + 'storage_unit_not_available', + 'software_update_invalid_country_setup', + 'pmc_power_stage_set_not_available', + 'invalid_device_type', + 'insulation_measurement_triggered', + 'inverter_settings_changed_restart_required', + 'wired_shut_down_triggered', + 'grid_frequency_exceeded_limit_reconnecting', + 'mains_voltage_dependent_power_reduction', + 'too_little_dc_power_for_feed_in_operation', + 'inverter_required_setup_values_not_received', + 'dc_connection_inverter_battery_interrupted', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inverter_name_error_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_message', + 'unique_id': '12345678-error_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.inverter_name_error_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Inverter name Error message', + 'options': list([ + 'no_error', + 'ac_voltage_too_high', + 'ac_voltage_too_low', + 'ac_frequency_too_high', + 'ac_frequency_too_low', + 'ac_grid_outside_permissible_limits', + 'stand_alone_operation_detected', + 'rcmu_error', + 'arc_detection_triggered', + 'overcurrent_ac', + 'overcurrent_dc', + 'dc_module_over_temperature', + 'ac_module_over_temperature', + 'no_power_fed_in_despite_closed_relay', + 'pv_output_too_low_for_feeding_energy_into_the_grid', + 'low_pv_voltage_dc_input_voltage_too_low', + 'intermediate_circuit_voltage_too_high', + 'dc_input_voltage_mppt_1_too_high', + 'polarity_of_dc_strings_reversed', + 'dc_input_voltage_mppt_2_too_high', + 'current_sensor_calibration_timeout', + 'ac_current_sensor_error', + 'interrupt_check_fail', + 'overtemperature_in_connection_area', + 'fan_1_error', + 'fan_2_error', + 'no_communication_with_power_stage_set', + 'ac_module_temperature_sensor_faulty_l1', + 'ac_module_temperature_sensor_faulty_l2', + 'dc_component_measured_in_grid_too_high', + 'fixed_voltage_mode_out_of_range', + 'safety_cut_out_triggered', + 'no_communication_between_power_stage_and_control_system', + 'hardware_id_problem', + 'unique_id_conflict', + 'no_communication_with_hybrid_manager', + 'hid_range_error', + 'possible_hardware_fault', + 'software_problem', + 'functional_incompatibility_between_pc_boards', + 'power_stage_set_problem', + 'intermediate_circuit_voltage_too_low_or_asymmetric', + 'compatibility_error_invalid_power_stage_configuration', + 'insulation_fault', + 'neutral_conductor_not_connected', + 'guard_cannot_be_found', + 'memory_error_detected', + 'communication', + 'insulation_error_on_solar_panels', + 'no_energy_fed_into_grid_past_24_hours', + 'no_communication_with_filter', + 'no_communication_with_storage_unit', + 'power_derating_due_to_high_temperature', + 'internal_dsp_malfunction', + 'no_energy_fed_by_mppt1_past_24_hours', + 'dc_low_string_1', + 'dc_low_string_2', + 'derating_caused_by_over_frequency', + 'arc_detector_switched_off', + 'grid_voltage_dependent_power_reduction_active', + 'can_bus_full', + 'ac_module_temperature_sensor_faulty_l3', + 'dc_module_temperature_sensor_faulty', + 'internal_processor_status', + 'eeprom_reinitialised', + 'initialisation_error_usb_flash_drive_not_supported', + 'initialisation_error_usb_stick_over_current', + 'no_usb_flash_drive_connected', + 'update_file_not_recognised_or_missing', + 'update_file_does_not_match_device', + 'write_or_read_error_occurred', + 'file_could_not_be_opened', + 'log_file_cannot_be_saved', + 'initialisation_error_file_system_error_on_usb', + 'error_during_logging_data_recording', + 'error_during_update_process', + 'update_file_corrupt', + 'time_lost', + 'real_time_clock_communication_error', + 'real_time_clock_in_emergency_mode', + 'real_time_clock_hardware_error', + 'internal_hardware_error', + 'emergency_power_derating_activated', + 'different_power_limitation_in_hardware_modules', + 'storage_unit_not_available', + 'software_update_invalid_country_setup', + 'pmc_power_stage_set_not_available', + 'invalid_device_type', + 'insulation_measurement_triggered', + 'inverter_settings_changed_restart_required', + 'wired_shut_down_triggered', + 'grid_frequency_exceeded_limit_reconnecting', + 'mains_voltage_dependent_power_reduction', + 'too_little_dc_power_for_feed_in_operation', + 'inverter_required_setup_values_not_received', + 'dc_connection_inverter_battery_interrupted', + ]), + }), + 'context': , + 'entity_id': 'sensor.inverter_name_error_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- +# name: test_gen24[sensor.inverter_name_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_ac', + 'unique_id': '12345678-frequency_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter name Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9917', + }) +# --- +# name: test_gen24[sensor.inverter_name_inverter_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inverter_name_inverter_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Inverter state', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_state', + 'unique_id': '12345678-inverter_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.inverter_name_inverter_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inverter name Inverter state', + }), + 'context': , + 'entity_id': 'sensor.inverter_name_inverter_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Running', + }) +# --- +# name: test_gen24[sensor.inverter_name_status_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inverter_name_status_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_code', + 'unique_id': '12345678-status_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.inverter_name_status_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inverter name Status code', + }), + 'context': , + 'entity_id': 'sensor.inverter_name_status_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_gen24[sensor.inverter_name_status_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inverter_name_status_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_message', + 'unique_id': '12345678-status_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.inverter_name_status_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Inverter name Status message', + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'context': , + 'entity_id': 'sensor.inverter_name_status_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_gen24[sensor.inverter_name_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_name_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': '12345678-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.inverter_name_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter name Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_name_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1530193.42', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent', + 'unique_id': '1234567890-power_apparent', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '868.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_1', + 'unique_id': '1234567890-power_apparent_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '243.3', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_2', + 'unique_id': '1234567890-power_apparent_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '323.4', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_3', + 'unique_id': '1234567890-power_apparent_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_apparent_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '301.2', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_1', + 'unique_id': '1234567890-current_ac_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.145', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_2', + 'unique_id': '1234567890-current_ac_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.33', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_3', + 'unique_id': '1234567890-current_ac_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_current_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.825', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_frequency_phase_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_frequency_phase_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency phase average', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_phase_average', + 'unique_id': '1234567890-frequency_phase_average', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_frequency_phase_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Smart Meter TS 65A-3 Frequency phase average', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_frequency_phase_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_meter_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter location', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location', + 'unique_id': '1234567890-meter_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_meter_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Meter location', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_meter_location_description-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location_description', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter location description', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location_description', + 'unique_id': '1234567890-meter_location_description', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_meter_location_description-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Smart Meter TS 65A-3 Meter location description', + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location_description', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'feed_in', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor', + 'unique_id': '1234567890-power_factor', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.828', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_1', + 'unique_id': '1234567890-power_factor_phase_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 1', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.441', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_2', + 'unique_id': '1234567890-power_factor_phase_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 2', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.934', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_3', + 'unique_id': '1234567890-power_factor_phase_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_power_factor_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 3', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.832', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reactive energy consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_ac_consumed', + 'unique_id': '1234567890-energy_reactive_ac_consumed', + 'unit_of_measurement': 'varh', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Reactive energy consumed', + 'state_class': , + 'unit_of_measurement': 'varh', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88221.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_energy_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reactive energy produced', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_ac_produced', + 'unique_id': '1234567890-energy_reactive_ac_produced', + 'unit_of_measurement': 'varh', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Reactive energy produced', + 'state_class': , + 'unit_of_measurement': 'varh', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1989125.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive', + 'unique_id': '1234567890-power_reactive', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-517.4', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_1', + 'unique_id': '1234567890-power_reactive_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-218.6', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_2', + 'unique_id': '1234567890-power_reactive_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-132.8', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_3', + 'unique_id': '1234567890-power_reactive_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_reactive_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-166.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_consumed', + 'unique_id': '1234567890-energy_real_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2013105.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_minus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_minus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy minus', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_ac_minus', + 'unique_id': '1234567890-energy_real_ac_minus', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_minus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy minus', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_minus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3863340.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_plus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_plus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy plus', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_ac_plus', + 'unique_id': '1234567890-energy_real_ac_plus', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_plus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy plus', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_plus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2013105.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy produced', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_produced', + 'unique_id': '1234567890-energy_real_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3863340.0', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real', + 'unique_id': '1234567890-power_real', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '653.1', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_1', + 'unique_id': '1234567890-power_real_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '106.8', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_2', + 'unique_id': '1234567890-power_real_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '294.9', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_3', + 'unique_id': '1234567890-power_real_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_real_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '251.3', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_1', + 'unique_id': '1234567890-voltage_ac_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '235.9', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1-2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_12', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_12', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 1-2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '408.7', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_2', + 'unique_id': '1234567890-voltage_ac_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '236.1', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_2_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2-3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_23', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_23', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_2_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 2-3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '409.6', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_3', + 'unique_id': '1234567890-voltage_ac_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '236.9', + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_3_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3-1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_31', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_31', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.smart_meter_ts_65a_3_voltage_phase_3_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 3-1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '409.4', + }) +# --- +# name: test_gen24[sensor.solarnet_meter_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter mode', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_mode', + 'unique_id': 'solar_net_123.4567890-power_flow-meter_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24[sensor.solarnet_meter_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Meter mode', + }), + 'context': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'meter', + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '658.4', + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid export', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_export', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_export', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid import', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_import', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_import', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_grid_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '658.4', + }) +# --- +# name: test_gen24[sensor.solarnet_power_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-695.6827', + }) +# --- +# name: test_gen24[sensor.solarnet_power_load_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_consumed', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_load_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '695.6827', + }) +# --- +# name: test_gen24[sensor.solarnet_power_load_generated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_generated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load generated', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_generated', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load_generated', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_load_generated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24[sensor.solarnet_power_photovoltaics-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power photovoltaics', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_photovoltaics', + 'unique_id': 'solar_net_123.4567890-power_flow-power_photovoltaics', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_power_photovoltaics-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power photovoltaics', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62.9481', + }) +# --- +# name: test_gen24[sensor.solarnet_relative_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative autonomy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_autonomy', + 'unique_id': 'solar_net_123.4567890-power_flow-relative_autonomy', + 'unit_of_measurement': '%', + }) +# --- +# name: test_gen24[sensor.solarnet_relative_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative autonomy', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.3592', + }) +# --- +# name: test_gen24[sensor.solarnet_relative_self_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative self consumption', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_self_consumption', + 'unique_id': 'solar_net_123.4567890-power_flow-relative_self_consumption', + 'unit_of_measurement': '%', + }) +# --- +# name: test_gen24[sensor.solarnet_relative_self_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative self consumption', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_gen24[sensor.solarnet_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': 'solar_net_123.4567890-power_flow-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24[sensor.solarnet_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarNet Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1530193.42', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.byd_battery_box_premium_hv_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc', + 'unique_id': 'P030T020Z2001234567 -current_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'BYD Battery-Box Premium HV DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.byd_battery_box_premium_hv_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': 'P030T020Z2001234567 -voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'BYD Battery-Box Premium HV DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_designed_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_designed_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Designed capacity', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'capacity_designed', + 'unique_id': 'P030T020Z2001234567 -capacity_designed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_designed_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BYD Battery-Box Premium HV Designed capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_designed_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16588', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_maximum_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_maximum_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Maximum capacity', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'capacity_maximum', + 'unique_id': 'P030T020Z2001234567 -capacity_maximum', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_maximum_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BYD Battery-Box Premium HV Maximum capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_maximum_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16588', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_state_of_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.byd_battery_box_premium_hv_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_charge', + 'unique_id': 'P030T020Z2001234567 -state_of_charge', + 'unit_of_measurement': '%', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'BYD Battery-Box Premium HV State of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.6', + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.byd_battery_box_premium_hv_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_cell', + 'unique_id': 'P030T020Z2001234567 -temperature_cell', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.byd_battery_box_premium_hv_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BYD Battery-Box Premium HV Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.byd_battery_box_premium_hv_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.5', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac', + 'unique_id': '12345678-current_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gen24 Storage AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1087', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_ac_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_ac', + 'unique_id': '12345678-power_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Gen24 Storage AC power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_ac_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '250.9093', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac', + 'unique_id': '12345678-voltage_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Gen24 Storage AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '227.354', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc', + 'unique_id': '12345678-current_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gen24 Storage DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3952', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_current_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_dc_current_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc_2', + 'unique_id': '12345678-current_dc_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_current_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gen24 Storage DC current 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_dc_current_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3564', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': '12345678-voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Gen24 Storage DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '419.1009', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_voltage_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_dc_voltage_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc_2', + 'unique_id': '12345678-voltage_dc_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_dc_voltage_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Gen24 Storage DC voltage 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_dc_voltage_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '318.8103', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gen24_storage_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '12345678-error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gen24 Storage Error code', + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_error_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'ac_voltage_too_high', + 'ac_voltage_too_low', + 'ac_frequency_too_high', + 'ac_frequency_too_low', + 'ac_grid_outside_permissible_limits', + 'stand_alone_operation_detected', + 'rcmu_error', + 'arc_detection_triggered', + 'overcurrent_ac', + 'overcurrent_dc', + 'dc_module_over_temperature', + 'ac_module_over_temperature', + 'no_power_fed_in_despite_closed_relay', + 'pv_output_too_low_for_feeding_energy_into_the_grid', + 'low_pv_voltage_dc_input_voltage_too_low', + 'intermediate_circuit_voltage_too_high', + 'dc_input_voltage_mppt_1_too_high', + 'polarity_of_dc_strings_reversed', + 'dc_input_voltage_mppt_2_too_high', + 'current_sensor_calibration_timeout', + 'ac_current_sensor_error', + 'interrupt_check_fail', + 'overtemperature_in_connection_area', + 'fan_1_error', + 'fan_2_error', + 'no_communication_with_power_stage_set', + 'ac_module_temperature_sensor_faulty_l1', + 'ac_module_temperature_sensor_faulty_l2', + 'dc_component_measured_in_grid_too_high', + 'fixed_voltage_mode_out_of_range', + 'safety_cut_out_triggered', + 'no_communication_between_power_stage_and_control_system', + 'hardware_id_problem', + 'unique_id_conflict', + 'no_communication_with_hybrid_manager', + 'hid_range_error', + 'possible_hardware_fault', + 'software_problem', + 'functional_incompatibility_between_pc_boards', + 'power_stage_set_problem', + 'intermediate_circuit_voltage_too_low_or_asymmetric', + 'compatibility_error_invalid_power_stage_configuration', + 'insulation_fault', + 'neutral_conductor_not_connected', + 'guard_cannot_be_found', + 'memory_error_detected', + 'communication', + 'insulation_error_on_solar_panels', + 'no_energy_fed_into_grid_past_24_hours', + 'no_communication_with_filter', + 'no_communication_with_storage_unit', + 'power_derating_due_to_high_temperature', + 'internal_dsp_malfunction', + 'no_energy_fed_by_mppt1_past_24_hours', + 'dc_low_string_1', + 'dc_low_string_2', + 'derating_caused_by_over_frequency', + 'arc_detector_switched_off', + 'grid_voltage_dependent_power_reduction_active', + 'can_bus_full', + 'ac_module_temperature_sensor_faulty_l3', + 'dc_module_temperature_sensor_faulty', + 'internal_processor_status', + 'eeprom_reinitialised', + 'initialisation_error_usb_flash_drive_not_supported', + 'initialisation_error_usb_stick_over_current', + 'no_usb_flash_drive_connected', + 'update_file_not_recognised_or_missing', + 'update_file_does_not_match_device', + 'write_or_read_error_occurred', + 'file_could_not_be_opened', + 'log_file_cannot_be_saved', + 'initialisation_error_file_system_error_on_usb', + 'error_during_logging_data_recording', + 'error_during_update_process', + 'update_file_corrupt', + 'time_lost', + 'real_time_clock_communication_error', + 'real_time_clock_in_emergency_mode', + 'real_time_clock_hardware_error', + 'internal_hardware_error', + 'emergency_power_derating_activated', + 'different_power_limitation_in_hardware_modules', + 'storage_unit_not_available', + 'software_update_invalid_country_setup', + 'pmc_power_stage_set_not_available', + 'invalid_device_type', + 'insulation_measurement_triggered', + 'inverter_settings_changed_restart_required', + 'wired_shut_down_triggered', + 'grid_frequency_exceeded_limit_reconnecting', + 'mains_voltage_dependent_power_reduction', + 'too_little_dc_power_for_feed_in_operation', + 'inverter_required_setup_values_not_received', + 'dc_connection_inverter_battery_interrupted', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gen24_storage_error_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_message', + 'unique_id': '12345678-error_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_error_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gen24 Storage Error message', + 'options': list([ + 'no_error', + 'ac_voltage_too_high', + 'ac_voltage_too_low', + 'ac_frequency_too_high', + 'ac_frequency_too_low', + 'ac_grid_outside_permissible_limits', + 'stand_alone_operation_detected', + 'rcmu_error', + 'arc_detection_triggered', + 'overcurrent_ac', + 'overcurrent_dc', + 'dc_module_over_temperature', + 'ac_module_over_temperature', + 'no_power_fed_in_despite_closed_relay', + 'pv_output_too_low_for_feeding_energy_into_the_grid', + 'low_pv_voltage_dc_input_voltage_too_low', + 'intermediate_circuit_voltage_too_high', + 'dc_input_voltage_mppt_1_too_high', + 'polarity_of_dc_strings_reversed', + 'dc_input_voltage_mppt_2_too_high', + 'current_sensor_calibration_timeout', + 'ac_current_sensor_error', + 'interrupt_check_fail', + 'overtemperature_in_connection_area', + 'fan_1_error', + 'fan_2_error', + 'no_communication_with_power_stage_set', + 'ac_module_temperature_sensor_faulty_l1', + 'ac_module_temperature_sensor_faulty_l2', + 'dc_component_measured_in_grid_too_high', + 'fixed_voltage_mode_out_of_range', + 'safety_cut_out_triggered', + 'no_communication_between_power_stage_and_control_system', + 'hardware_id_problem', + 'unique_id_conflict', + 'no_communication_with_hybrid_manager', + 'hid_range_error', + 'possible_hardware_fault', + 'software_problem', + 'functional_incompatibility_between_pc_boards', + 'power_stage_set_problem', + 'intermediate_circuit_voltage_too_low_or_asymmetric', + 'compatibility_error_invalid_power_stage_configuration', + 'insulation_fault', + 'neutral_conductor_not_connected', + 'guard_cannot_be_found', + 'memory_error_detected', + 'communication', + 'insulation_error_on_solar_panels', + 'no_energy_fed_into_grid_past_24_hours', + 'no_communication_with_filter', + 'no_communication_with_storage_unit', + 'power_derating_due_to_high_temperature', + 'internal_dsp_malfunction', + 'no_energy_fed_by_mppt1_past_24_hours', + 'dc_low_string_1', + 'dc_low_string_2', + 'derating_caused_by_over_frequency', + 'arc_detector_switched_off', + 'grid_voltage_dependent_power_reduction_active', + 'can_bus_full', + 'ac_module_temperature_sensor_faulty_l3', + 'dc_module_temperature_sensor_faulty', + 'internal_processor_status', + 'eeprom_reinitialised', + 'initialisation_error_usb_flash_drive_not_supported', + 'initialisation_error_usb_stick_over_current', + 'no_usb_flash_drive_connected', + 'update_file_not_recognised_or_missing', + 'update_file_does_not_match_device', + 'write_or_read_error_occurred', + 'file_could_not_be_opened', + 'log_file_cannot_be_saved', + 'initialisation_error_file_system_error_on_usb', + 'error_during_logging_data_recording', + 'error_during_update_process', + 'update_file_corrupt', + 'time_lost', + 'real_time_clock_communication_error', + 'real_time_clock_in_emergency_mode', + 'real_time_clock_hardware_error', + 'internal_hardware_error', + 'emergency_power_derating_activated', + 'different_power_limitation_in_hardware_modules', + 'storage_unit_not_available', + 'software_update_invalid_country_setup', + 'pmc_power_stage_set_not_available', + 'invalid_device_type', + 'insulation_measurement_triggered', + 'inverter_settings_changed_restart_required', + 'wired_shut_down_triggered', + 'grid_frequency_exceeded_limit_reconnecting', + 'mains_voltage_dependent_power_reduction', + 'too_little_dc_power_for_feed_in_operation', + 'inverter_required_setup_values_not_received', + 'dc_connection_inverter_battery_interrupted', + ]), + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_error_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_ac', + 'unique_id': '12345678-frequency_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gen24 Storage Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9816', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_inverter_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gen24_storage_inverter_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Inverter state', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_state', + 'unique_id': '12345678-inverter_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_inverter_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gen24 Storage Inverter state', + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_inverter_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Running', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_status_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gen24_storage_status_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_code', + 'unique_id': '12345678-status_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_status_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gen24 Storage Status code', + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_status_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_status_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gen24_storage_status_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_message', + 'unique_id': '12345678-status_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_status_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gen24 Storage Status message', + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_status_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gen24_storage_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': '12345678-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.gen24_storage_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Gen24 Storage Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gen24_storage_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7512794.0117', + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohmpilot_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_ac_consumed', + 'unique_id': '23456789-energy_real_ac_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ohmpilot Energy consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohmpilot_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1233295.0', + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohmpilot_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_ac', + 'unique_id': '23456789-power_real_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Ohmpilot Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohmpilot_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_state_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ohmpilot_state_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'State code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_code', + 'unique_id': '23456789-state_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_state_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohmpilot State code', + }), + 'context': , + 'entity_id': 'sensor.ohmpilot_state_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_state_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'up_and_running', + 'keep_minimum_temperature', + 'legionella_protection', + 'critical_fault', + 'fault', + 'boost_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ohmpilot_state_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_message', + 'unique_id': '23456789-state_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_state_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Ohmpilot State message', + 'options': list([ + 'up_and_running', + 'keep_minimum_temperature', + 'legionella_protection', + 'critical_fault', + 'fault', + 'boost_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.ohmpilot_state_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'up_and_running', + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohmpilot_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_channel_1', + 'unique_id': '23456789-temperature_channel_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.ohmpilot_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Ohmpilot Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohmpilot_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.9', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent', + 'unique_id': '1234567890-power_apparent', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '821.9', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_1', + 'unique_id': '1234567890-power_apparent_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '319.5', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_2', + 'unique_id': '1234567890-power_apparent_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '383.9', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_apparent_phase_3', + 'unique_id': '1234567890-power_apparent_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_apparent_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter TS 65A-3 Apparent power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_apparent_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '118.4', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_1', + 'unique_id': '1234567890-current_ac_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.701', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_2', + 'unique_id': '1234567890-current_ac_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.832', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac_phase_3', + 'unique_id': '1234567890-current_ac_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_current_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Smart Meter TS 65A-3 Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_current_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.645', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_frequency_phase_average-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_frequency_phase_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency phase average', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_phase_average', + 'unique_id': '1234567890-frequency_phase_average', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_frequency_phase_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Smart Meter TS 65A-3 Frequency phase average', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_frequency_phase_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_meter_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter location', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location', + 'unique_id': '1234567890-meter_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_meter_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Meter location', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_meter_location_description-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location_description', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter location description', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location_description', + 'unique_id': '1234567890-meter_location_description', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_meter_location_description-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Smart Meter TS 65A-3 Meter location description', + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_meter_location_description', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'feed_in', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor', + 'unique_id': '1234567890-power_factor', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.698', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_1', + 'unique_id': '1234567890-power_factor_phase_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 1', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.995', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_2', + 'unique_id': '1234567890-power_factor_phase_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 2', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.389', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_factor_phase_3', + 'unique_id': '1234567890-power_factor_phase_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_power_factor_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Smart Meter TS 65A-3 Power factor phase 3', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_power_factor_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.163', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reactive energy consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_ac_consumed', + 'unique_id': '1234567890-energy_reactive_ac_consumed', + 'unit_of_measurement': 'varh', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Reactive energy consumed', + 'state_class': , + 'unit_of_measurement': 'varh', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5482.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_energy_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reactive energy produced', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_ac_produced', + 'unique_id': '1234567890-energy_reactive_ac_produced', + 'unit_of_measurement': 'varh', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Meter TS 65A-3 Reactive energy produced', + 'state_class': , + 'unit_of_measurement': 'varh', + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3266105.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive', + 'unique_id': '1234567890-power_reactive', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-501.5', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_1', + 'unique_id': '1234567890-power_reactive_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-31.3', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_2', + 'unique_id': '1234567890-power_reactive_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-353.4', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_3', + 'unique_id': '1234567890-power_reactive_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_reactive_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Smart Meter TS 65A-3 Reactive power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_reactive_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-116.7', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_consumed', + 'unique_id': '1234567890-energy_real_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1247204.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_minus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_minus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy minus', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_ac_minus', + 'unique_id': '1234567890-energy_real_ac_minus', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_minus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy minus', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_minus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1705128.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_plus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_plus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy plus', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_ac_plus', + 'unique_id': '1234567890-energy_real_ac_plus', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_plus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy plus', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_plus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1247204.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_produced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real energy produced', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_real_produced', + 'unique_id': '1234567890-energy_real_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Smart Meter TS 65A-3 Real energy produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1705128.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real', + 'unique_id': '1234567890-power_real', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '487.7', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_1', + 'unique_id': '1234567890-power_real_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '317.9', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_2', + 'unique_id': '1234567890-power_real_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real_phase_3', + 'unique_id': '1234567890-power_real_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_real_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter TS 65A-3 Real power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_real_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.6', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_1', + 'unique_id': '1234567890-voltage_ac_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.4', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1-2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_12', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_12', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 1-2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '396.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_2', + 'unique_id': '1234567890-voltage_ac_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '225.6', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_2_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2-3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_23', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_23', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_2_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 2-3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_2_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '393.0', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_3', + 'unique_id': '1234567890-voltage_ac_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '228.3', + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_3_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3-1', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac_phase_to_phase_31', + 'unique_id': '1234567890-voltage_ac_phase_to_phase_31', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.smart_meter_ts_65a_3_voltage_phase_3_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smart Meter TS 65A-3 Voltage phase 3-1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_ts_65a_3_voltage_phase_3_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '394.3', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_meter_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter mode', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_mode', + 'unique_id': 'solar_net_12345678-power_flow-meter_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_gen24_storage[sensor.solarnet_meter_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Meter mode', + }), + 'context': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bidirectional', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power battery', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_battery', + 'unique_id': 'solar_net_12345678-power_flow-power_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1591', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_battery_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power battery charge', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_battery_charge', + 'unique_id': 'solar_net_12345678-power_flow-power_battery_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power battery charge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_battery_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery_discharge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_battery_discharge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power battery discharge', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_battery_discharge', + 'unique_id': 'solar_net_12345678-power_flow-power_battery_discharge', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_battery_discharge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power battery discharge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_battery_discharge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1591', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid', + 'unique_id': 'solar_net_12345678-power_flow-power_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2274.9', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid export', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_export', + 'unique_id': 'solar_net_12345678-power_flow-power_grid_export', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid import', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_import', + 'unique_id': 'solar_net_12345678-power_flow-power_grid_import', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_grid_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2274.9', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load', + 'unique_id': 'solar_net_12345678-power_flow-power_load', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2459.3092', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_consumed', + 'unique_id': 'solar_net_12345678-power_flow-power_load_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2459.3092', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load_generated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_generated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load generated', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_generated', + 'unique_id': 'solar_net_12345678-power_flow-power_load_generated', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_load_generated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_photovoltaics-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power photovoltaics', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_photovoltaics', + 'unique_id': 'solar_net_12345678-power_flow-power_photovoltaics', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_power_photovoltaics-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power photovoltaics', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '216.4328', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_relative_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative autonomy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_autonomy', + 'unique_id': 'solar_net_12345678-power_flow-relative_autonomy', + 'unit_of_measurement': '%', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_relative_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative autonomy', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.4984', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_relative_self_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative self consumption', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_self_consumption', + 'unique_id': 'solar_net_12345678-power_flow-relative_self_consumption', + 'unit_of_measurement': '%', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_relative_self_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative self consumption', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_gen24_storage[sensor.solarnet_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': 'solar_net_12345678-power_flow-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_gen24_storage[sensor.solarnet_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarNet Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7512664.4042', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac', + 'unique_id': '234567-current_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Primo 3.0-1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.32', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_ac_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_ac', + 'unique_id': '234567-power_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Primo 3.0-1 AC power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_ac_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '296', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac', + 'unique_id': '234567-voltage_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Primo 3.0-1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '223.6', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc', + 'unique_id': '234567-current_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Primo 3.0-1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.97', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': '234567-voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Primo 3.0-1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '329.5', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_energy_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_energy_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy day', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_day', + 'unique_id': '234567-energy_day', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_energy_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 3.0-1 Energy day', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_energy_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14237', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_energy_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_energy_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy year', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_year', + 'unique_id': '234567-energy_year', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_energy_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 3.0-1 Energy year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_energy_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3596193.25', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_3_0_1_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '234567-error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 3.0-1 Error code', + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_error_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'ac_voltage_too_high', + 'ac_voltage_too_low', + 'ac_frequency_too_high', + 'ac_frequency_too_low', + 'ac_grid_outside_permissible_limits', + 'stand_alone_operation_detected', + 'rcmu_error', + 'arc_detection_triggered', + 'overcurrent_ac', + 'overcurrent_dc', + 'dc_module_over_temperature', + 'ac_module_over_temperature', + 'no_power_fed_in_despite_closed_relay', + 'pv_output_too_low_for_feeding_energy_into_the_grid', + 'low_pv_voltage_dc_input_voltage_too_low', + 'intermediate_circuit_voltage_too_high', + 'dc_input_voltage_mppt_1_too_high', + 'polarity_of_dc_strings_reversed', + 'dc_input_voltage_mppt_2_too_high', + 'current_sensor_calibration_timeout', + 'ac_current_sensor_error', + 'interrupt_check_fail', + 'overtemperature_in_connection_area', + 'fan_1_error', + 'fan_2_error', + 'no_communication_with_power_stage_set', + 'ac_module_temperature_sensor_faulty_l1', + 'ac_module_temperature_sensor_faulty_l2', + 'dc_component_measured_in_grid_too_high', + 'fixed_voltage_mode_out_of_range', + 'safety_cut_out_triggered', + 'no_communication_between_power_stage_and_control_system', + 'hardware_id_problem', + 'unique_id_conflict', + 'no_communication_with_hybrid_manager', + 'hid_range_error', + 'possible_hardware_fault', + 'software_problem', + 'functional_incompatibility_between_pc_boards', + 'power_stage_set_problem', + 'intermediate_circuit_voltage_too_low_or_asymmetric', + 'compatibility_error_invalid_power_stage_configuration', + 'insulation_fault', + 'neutral_conductor_not_connected', + 'guard_cannot_be_found', + 'memory_error_detected', + 'communication', + 'insulation_error_on_solar_panels', + 'no_energy_fed_into_grid_past_24_hours', + 'no_communication_with_filter', + 'no_communication_with_storage_unit', + 'power_derating_due_to_high_temperature', + 'internal_dsp_malfunction', + 'no_energy_fed_by_mppt1_past_24_hours', + 'dc_low_string_1', + 'dc_low_string_2', + 'derating_caused_by_over_frequency', + 'arc_detector_switched_off', + 'grid_voltage_dependent_power_reduction_active', + 'can_bus_full', + 'ac_module_temperature_sensor_faulty_l3', + 'dc_module_temperature_sensor_faulty', + 'internal_processor_status', + 'eeprom_reinitialised', + 'initialisation_error_usb_flash_drive_not_supported', + 'initialisation_error_usb_stick_over_current', + 'no_usb_flash_drive_connected', + 'update_file_not_recognised_or_missing', + 'update_file_does_not_match_device', + 'write_or_read_error_occurred', + 'file_could_not_be_opened', + 'log_file_cannot_be_saved', + 'initialisation_error_file_system_error_on_usb', + 'error_during_logging_data_recording', + 'error_during_update_process', + 'update_file_corrupt', + 'time_lost', + 'real_time_clock_communication_error', + 'real_time_clock_in_emergency_mode', + 'real_time_clock_hardware_error', + 'internal_hardware_error', + 'emergency_power_derating_activated', + 'different_power_limitation_in_hardware_modules', + 'storage_unit_not_available', + 'software_update_invalid_country_setup', + 'pmc_power_stage_set_not_available', + 'invalid_device_type', + 'insulation_measurement_triggered', + 'inverter_settings_changed_restart_required', + 'wired_shut_down_triggered', + 'grid_frequency_exceeded_limit_reconnecting', + 'mains_voltage_dependent_power_reduction', + 'too_little_dc_power_for_feed_in_operation', + 'inverter_required_setup_values_not_received', + 'dc_connection_inverter_battery_interrupted', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_3_0_1_error_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_message', + 'unique_id': '234567-error_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_error_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Primo 3.0-1 Error message', + 'options': list([ + 'no_error', + 'ac_voltage_too_high', + 'ac_voltage_too_low', + 'ac_frequency_too_high', + 'ac_frequency_too_low', + 'ac_grid_outside_permissible_limits', + 'stand_alone_operation_detected', + 'rcmu_error', + 'arc_detection_triggered', + 'overcurrent_ac', + 'overcurrent_dc', + 'dc_module_over_temperature', + 'ac_module_over_temperature', + 'no_power_fed_in_despite_closed_relay', + 'pv_output_too_low_for_feeding_energy_into_the_grid', + 'low_pv_voltage_dc_input_voltage_too_low', + 'intermediate_circuit_voltage_too_high', + 'dc_input_voltage_mppt_1_too_high', + 'polarity_of_dc_strings_reversed', + 'dc_input_voltage_mppt_2_too_high', + 'current_sensor_calibration_timeout', + 'ac_current_sensor_error', + 'interrupt_check_fail', + 'overtemperature_in_connection_area', + 'fan_1_error', + 'fan_2_error', + 'no_communication_with_power_stage_set', + 'ac_module_temperature_sensor_faulty_l1', + 'ac_module_temperature_sensor_faulty_l2', + 'dc_component_measured_in_grid_too_high', + 'fixed_voltage_mode_out_of_range', + 'safety_cut_out_triggered', + 'no_communication_between_power_stage_and_control_system', + 'hardware_id_problem', + 'unique_id_conflict', + 'no_communication_with_hybrid_manager', + 'hid_range_error', + 'possible_hardware_fault', + 'software_problem', + 'functional_incompatibility_between_pc_boards', + 'power_stage_set_problem', + 'intermediate_circuit_voltage_too_low_or_asymmetric', + 'compatibility_error_invalid_power_stage_configuration', + 'insulation_fault', + 'neutral_conductor_not_connected', + 'guard_cannot_be_found', + 'memory_error_detected', + 'communication', + 'insulation_error_on_solar_panels', + 'no_energy_fed_into_grid_past_24_hours', + 'no_communication_with_filter', + 'no_communication_with_storage_unit', + 'power_derating_due_to_high_temperature', + 'internal_dsp_malfunction', + 'no_energy_fed_by_mppt1_past_24_hours', + 'dc_low_string_1', + 'dc_low_string_2', + 'derating_caused_by_over_frequency', + 'arc_detector_switched_off', + 'grid_voltage_dependent_power_reduction_active', + 'can_bus_full', + 'ac_module_temperature_sensor_faulty_l3', + 'dc_module_temperature_sensor_faulty', + 'internal_processor_status', + 'eeprom_reinitialised', + 'initialisation_error_usb_flash_drive_not_supported', + 'initialisation_error_usb_stick_over_current', + 'no_usb_flash_drive_connected', + 'update_file_not_recognised_or_missing', + 'update_file_does_not_match_device', + 'write_or_read_error_occurred', + 'file_could_not_be_opened', + 'log_file_cannot_be_saved', + 'initialisation_error_file_system_error_on_usb', + 'error_during_logging_data_recording', + 'error_during_update_process', + 'update_file_corrupt', + 'time_lost', + 'real_time_clock_communication_error', + 'real_time_clock_in_emergency_mode', + 'real_time_clock_hardware_error', + 'internal_hardware_error', + 'emergency_power_derating_activated', + 'different_power_limitation_in_hardware_modules', + 'storage_unit_not_available', + 'software_update_invalid_country_setup', + 'pmc_power_stage_set_not_available', + 'invalid_device_type', + 'insulation_measurement_triggered', + 'inverter_settings_changed_restart_required', + 'wired_shut_down_triggered', + 'grid_frequency_exceeded_limit_reconnecting', + 'mains_voltage_dependent_power_reduction', + 'too_little_dc_power_for_feed_in_operation', + 'inverter_required_setup_values_not_received', + 'dc_connection_inverter_battery_interrupted', + ]), + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_error_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_ac', + 'unique_id': '234567-frequency_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Primo 3.0-1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.01', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_led_color-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_3_0_1_led_color', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED color', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led_color', + 'unique_id': '234567-led_color', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_led_color-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 3.0-1 LED color', + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_led_color', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_led_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_3_0_1_led_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED state', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led_state', + 'unique_id': '234567-led_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_led_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 3.0-1 LED state', + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_led_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_status_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_3_0_1_status_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_code', + 'unique_id': '234567-status_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_status_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 3.0-1 Status code', + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_status_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_status_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_3_0_1_status_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_message', + 'unique_id': '234567-status_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_status_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Primo 3.0-1 Status message', + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_status_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_3_0_1_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': '234567-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_3_0_1_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 3.0-1 Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_3_0_1_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5796010', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_ac', + 'unique_id': '123456-current_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Primo 5.0-1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.85', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_ac_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_ac', + 'unique_id': '123456-power_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Primo 5.0-1 AC power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_ac_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '862', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac', + 'unique_id': '123456-voltage_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Primo 5.0-1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '223.9', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_dc', + 'unique_id': '123456-current_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Primo 5.0-1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.23', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': '123456-voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Primo 5.0-1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '452.3', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_energy_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_energy_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy day', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_day', + 'unique_id': '123456-energy_day', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_energy_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 5.0-1 Energy day', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_energy_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22504', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_energy_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_energy_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy year', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_year', + 'unique_id': '123456-energy_year', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_energy_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 5.0-1 Energy year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_energy_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7532755.5', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_5_0_1_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '123456-error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 5.0-1 Error code', + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_error_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'ac_voltage_too_high', + 'ac_voltage_too_low', + 'ac_frequency_too_high', + 'ac_frequency_too_low', + 'ac_grid_outside_permissible_limits', + 'stand_alone_operation_detected', + 'rcmu_error', + 'arc_detection_triggered', + 'overcurrent_ac', + 'overcurrent_dc', + 'dc_module_over_temperature', + 'ac_module_over_temperature', + 'no_power_fed_in_despite_closed_relay', + 'pv_output_too_low_for_feeding_energy_into_the_grid', + 'low_pv_voltage_dc_input_voltage_too_low', + 'intermediate_circuit_voltage_too_high', + 'dc_input_voltage_mppt_1_too_high', + 'polarity_of_dc_strings_reversed', + 'dc_input_voltage_mppt_2_too_high', + 'current_sensor_calibration_timeout', + 'ac_current_sensor_error', + 'interrupt_check_fail', + 'overtemperature_in_connection_area', + 'fan_1_error', + 'fan_2_error', + 'no_communication_with_power_stage_set', + 'ac_module_temperature_sensor_faulty_l1', + 'ac_module_temperature_sensor_faulty_l2', + 'dc_component_measured_in_grid_too_high', + 'fixed_voltage_mode_out_of_range', + 'safety_cut_out_triggered', + 'no_communication_between_power_stage_and_control_system', + 'hardware_id_problem', + 'unique_id_conflict', + 'no_communication_with_hybrid_manager', + 'hid_range_error', + 'possible_hardware_fault', + 'software_problem', + 'functional_incompatibility_between_pc_boards', + 'power_stage_set_problem', + 'intermediate_circuit_voltage_too_low_or_asymmetric', + 'compatibility_error_invalid_power_stage_configuration', + 'insulation_fault', + 'neutral_conductor_not_connected', + 'guard_cannot_be_found', + 'memory_error_detected', + 'communication', + 'insulation_error_on_solar_panels', + 'no_energy_fed_into_grid_past_24_hours', + 'no_communication_with_filter', + 'no_communication_with_storage_unit', + 'power_derating_due_to_high_temperature', + 'internal_dsp_malfunction', + 'no_energy_fed_by_mppt1_past_24_hours', + 'dc_low_string_1', + 'dc_low_string_2', + 'derating_caused_by_over_frequency', + 'arc_detector_switched_off', + 'grid_voltage_dependent_power_reduction_active', + 'can_bus_full', + 'ac_module_temperature_sensor_faulty_l3', + 'dc_module_temperature_sensor_faulty', + 'internal_processor_status', + 'eeprom_reinitialised', + 'initialisation_error_usb_flash_drive_not_supported', + 'initialisation_error_usb_stick_over_current', + 'no_usb_flash_drive_connected', + 'update_file_not_recognised_or_missing', + 'update_file_does_not_match_device', + 'write_or_read_error_occurred', + 'file_could_not_be_opened', + 'log_file_cannot_be_saved', + 'initialisation_error_file_system_error_on_usb', + 'error_during_logging_data_recording', + 'error_during_update_process', + 'update_file_corrupt', + 'time_lost', + 'real_time_clock_communication_error', + 'real_time_clock_in_emergency_mode', + 'real_time_clock_hardware_error', + 'internal_hardware_error', + 'emergency_power_derating_activated', + 'different_power_limitation_in_hardware_modules', + 'storage_unit_not_available', + 'software_update_invalid_country_setup', + 'pmc_power_stage_set_not_available', + 'invalid_device_type', + 'insulation_measurement_triggered', + 'inverter_settings_changed_restart_required', + 'wired_shut_down_triggered', + 'grid_frequency_exceeded_limit_reconnecting', + 'mains_voltage_dependent_power_reduction', + 'too_little_dc_power_for_feed_in_operation', + 'inverter_required_setup_values_not_received', + 'dc_connection_inverter_battery_interrupted', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_5_0_1_error_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_message', + 'unique_id': '123456-error_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_error_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Primo 5.0-1 Error message', + 'options': list([ + 'no_error', + 'ac_voltage_too_high', + 'ac_voltage_too_low', + 'ac_frequency_too_high', + 'ac_frequency_too_low', + 'ac_grid_outside_permissible_limits', + 'stand_alone_operation_detected', + 'rcmu_error', + 'arc_detection_triggered', + 'overcurrent_ac', + 'overcurrent_dc', + 'dc_module_over_temperature', + 'ac_module_over_temperature', + 'no_power_fed_in_despite_closed_relay', + 'pv_output_too_low_for_feeding_energy_into_the_grid', + 'low_pv_voltage_dc_input_voltage_too_low', + 'intermediate_circuit_voltage_too_high', + 'dc_input_voltage_mppt_1_too_high', + 'polarity_of_dc_strings_reversed', + 'dc_input_voltage_mppt_2_too_high', + 'current_sensor_calibration_timeout', + 'ac_current_sensor_error', + 'interrupt_check_fail', + 'overtemperature_in_connection_area', + 'fan_1_error', + 'fan_2_error', + 'no_communication_with_power_stage_set', + 'ac_module_temperature_sensor_faulty_l1', + 'ac_module_temperature_sensor_faulty_l2', + 'dc_component_measured_in_grid_too_high', + 'fixed_voltage_mode_out_of_range', + 'safety_cut_out_triggered', + 'no_communication_between_power_stage_and_control_system', + 'hardware_id_problem', + 'unique_id_conflict', + 'no_communication_with_hybrid_manager', + 'hid_range_error', + 'possible_hardware_fault', + 'software_problem', + 'functional_incompatibility_between_pc_boards', + 'power_stage_set_problem', + 'intermediate_circuit_voltage_too_low_or_asymmetric', + 'compatibility_error_invalid_power_stage_configuration', + 'insulation_fault', + 'neutral_conductor_not_connected', + 'guard_cannot_be_found', + 'memory_error_detected', + 'communication', + 'insulation_error_on_solar_panels', + 'no_energy_fed_into_grid_past_24_hours', + 'no_communication_with_filter', + 'no_communication_with_storage_unit', + 'power_derating_due_to_high_temperature', + 'internal_dsp_malfunction', + 'no_energy_fed_by_mppt1_past_24_hours', + 'dc_low_string_1', + 'dc_low_string_2', + 'derating_caused_by_over_frequency', + 'arc_detector_switched_off', + 'grid_voltage_dependent_power_reduction_active', + 'can_bus_full', + 'ac_module_temperature_sensor_faulty_l3', + 'dc_module_temperature_sensor_faulty', + 'internal_processor_status', + 'eeprom_reinitialised', + 'initialisation_error_usb_flash_drive_not_supported', + 'initialisation_error_usb_stick_over_current', + 'no_usb_flash_drive_connected', + 'update_file_not_recognised_or_missing', + 'update_file_does_not_match_device', + 'write_or_read_error_occurred', + 'file_could_not_be_opened', + 'log_file_cannot_be_saved', + 'initialisation_error_file_system_error_on_usb', + 'error_during_logging_data_recording', + 'error_during_update_process', + 'update_file_corrupt', + 'time_lost', + 'real_time_clock_communication_error', + 'real_time_clock_in_emergency_mode', + 'real_time_clock_hardware_error', + 'internal_hardware_error', + 'emergency_power_derating_activated', + 'different_power_limitation_in_hardware_modules', + 'storage_unit_not_available', + 'software_update_invalid_country_setup', + 'pmc_power_stage_set_not_available', + 'invalid_device_type', + 'insulation_measurement_triggered', + 'inverter_settings_changed_restart_required', + 'wired_shut_down_triggered', + 'grid_frequency_exceeded_limit_reconnecting', + 'mains_voltage_dependent_power_reduction', + 'too_little_dc_power_for_feed_in_operation', + 'inverter_required_setup_values_not_received', + 'dc_connection_inverter_battery_interrupted', + ]), + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_error_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'frequency_ac', + 'unique_id': '123456-frequency_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Primo 5.0-1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_led_color-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_5_0_1_led_color', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED color', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led_color', + 'unique_id': '123456-led_color', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_led_color-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 5.0-1 LED color', + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_led_color', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_led_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_5_0_1_led_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED state', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led_state', + 'unique_id': '123456-led_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_led_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 5.0-1 LED state', + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_led_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_status_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_5_0_1_status_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status code', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_code', + 'unique_id': '123456-status_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_status_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Primo 5.0-1 Status code', + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_status_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_status_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.primo_5_0_1_status_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status message', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_message', + 'unique_id': '123456-status_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_status_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Primo 5.0-1 Status message', + 'options': list([ + 'startup', + 'running', + 'standby', + 'bootloading', + 'error', + 'idle', + 'ready', + 'sleeping', + ]), + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_status_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.primo_5_0_1_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': '123456-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.primo_5_0_1_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Primo 5.0-1 Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.primo_5_0_1_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17114940', + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_meter_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.s0_meter_at_inverter_1_meter_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter location', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location', + 'unique_id': 'solar_net_123.4567890:S0 Meter at inverter 1-meter_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_meter_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S0 Meter at inverter 1 Meter location', + }), + 'context': , + 'entity_id': 'sensor.s0_meter_at_inverter_1_meter_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_meter_location_description-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.s0_meter_at_inverter_1_meter_location_description', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter location description', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_location_description', + 'unique_id': 'solar_net_123.4567890:S0 Meter at inverter 1-meter_location_description', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_meter_location_description-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'S0 Meter at inverter 1 Meter location description', + 'options': list([ + 'feed_in', + 'consumption_path', + 'external_generator', + 'external_battery', + 'subload', + ]), + }), + 'context': , + 'entity_id': 'sensor.s0_meter_at_inverter_1_meter_location_description', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'consumption_path', + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_real_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.s0_meter_at_inverter_1_real_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Real power', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_real', + 'unique_id': 'solar_net_123.4567890:S0 Meter at inverter 1-power_real', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.s0_meter_at_inverter_1_real_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'S0 Meter at inverter 1 Real power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.s0_meter_at_inverter_1_real_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2216.7487', + }) +# --- +# name: test_primo_s0[sensor.solarnet_co2_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_co2_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CO₂ factor', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'co2_factor', + 'unique_id': '123.4567890-co2_factor', + 'unit_of_measurement': 'kg/kWh', + }) +# --- +# name: test_primo_s0[sensor.solarnet_co2_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet CO₂ factor', + 'state_class': , + 'unit_of_measurement': 'kg/kWh', + }), + 'context': , + 'entity_id': 'sensor.solarnet_co2_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.53', + }) +# --- +# name: test_primo_s0[sensor.solarnet_energy_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_energy_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy day', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_day', + 'unique_id': 'solar_net_123.4567890-power_flow-energy_day', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_energy_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarNet Energy day', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_energy_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36724', + }) +# --- +# name: test_primo_s0[sensor.solarnet_energy_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_energy_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy year', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_year', + 'unique_id': 'solar_net_123.4567890-power_flow-energy_year', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_energy_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarNet Energy year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_energy_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11128933.25', + }) +# --- +# name: test_primo_s0[sensor.solarnet_grid_export_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_grid_export_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Grid export tariff', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cash_factor', + 'unique_id': '123.4567890-cash_factor', + 'unit_of_measurement': 'BRL/kWh', + }) +# --- +# name: test_primo_s0[sensor.solarnet_grid_export_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Grid export tariff', + 'state_class': , + 'unit_of_measurement': 'BRL/kWh', + }), + 'context': , + 'entity_id': 'sensor.solarnet_grid_export_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_primo_s0[sensor.solarnet_grid_import_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_grid_import_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Grid import tariff', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'delivery_factor', + 'unique_id': '123.4567890-delivery_factor', + 'unit_of_measurement': 'BRL/kWh', + }) +# --- +# name: test_primo_s0[sensor.solarnet_grid_import_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Grid import tariff', + 'state_class': , + 'unit_of_measurement': 'BRL/kWh', + }), + 'context': , + 'entity_id': 'sensor.solarnet_grid_import_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_primo_s0[sensor.solarnet_meter_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meter mode', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_mode', + 'unique_id': 'solar_net_123.4567890-power_flow-meter_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_primo_s0[sensor.solarnet_meter_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Meter mode', + }), + 'context': , + 'entity_id': 'sensor.solarnet_meter_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'vague-meter', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '384.9349', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid export', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_export', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_export', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_grid_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power grid import', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_grid_import', + 'unique_id': 'solar_net_123.4567890-power_flow-power_grid_import', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_grid_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power grid import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_grid_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '384.9349', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2218.9349', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load consumed', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_consumed', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2218.9349', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load_generated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_load_generated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power load generated', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_load_generated', + 'unique_id': 'solar_net_123.4567890-power_flow-power_load_generated', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_load_generated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power load generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_load_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_photovoltaics-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power photovoltaics', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_photovoltaics', + 'unique_id': 'solar_net_123.4567890-power_flow-power_photovoltaics', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_power_photovoltaics-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarNet Power photovoltaics', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_power_photovoltaics', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1834', + }) +# --- +# name: test_primo_s0[sensor.solarnet_relative_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative autonomy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_autonomy', + 'unique_id': 'solar_net_123.4567890-power_flow-relative_autonomy', + 'unit_of_measurement': '%', + }) +# --- +# name: test_primo_s0[sensor.solarnet_relative_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative autonomy', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '82.6523', + }) +# --- +# name: test_primo_s0[sensor.solarnet_relative_self_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relative self consumption', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_self_consumption', + 'unique_id': 'solar_net_123.4567890-power_flow-relative_self_consumption', + 'unit_of_measurement': '%', + }) +# --- +# name: test_primo_s0[sensor.solarnet_relative_self_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarNet Relative self consumption', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarnet_relative_self_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_primo_s0[sensor.solarnet_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarnet_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'fronius', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': 'solar_net_123.4567890-power_flow-energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_primo_s0[sensor.solarnet_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarNet Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarnet_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22910919.5', + }) +# --- diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py index 1b9c41d5aa6..819f960c64b 100644 --- a/tests/components/fronius/test_config_flow.py +++ b/tests/components/fronius/test_config_flow.py @@ -6,11 +6,11 @@ from pyfronius import FroniusError import pytest from homeassistant import config_entries -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fronius.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import mock_responses @@ -44,43 +44,62 @@ MOCK_DHCP_DATA = DhcpServiceInfo( ) -async def test_form_with_logger(hass: HomeAssistant) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert not result["errors"] - - with ( - patch( - "pyfronius.Fronius.current_logger_info", - return_value=LOGGER_INFO_RETURN_VALUE, - ), - patch( - "homeassistant.components.fronius.async_setup_entry", - return_value=True, - ) as mock_setup_entry, +async def assert_finish_flow_with_logger(hass: HomeAssistant, flow_id: str) -> None: + """Assert finishing the flow with a logger device.""" + with patch( + "pyfronius.Fronius.current_logger_info", + return_value=LOGGER_INFO_RETURN_VALUE, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result = await hass.config_entries.flow.async_configure( + flow_id, { "host": "10.9.8.1", }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "SolarNet Datalogger at 10.9.8.1" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "SolarNet Datalogger at 10.9.8.1" + assert result["data"] == { "host": "10.9.8.1", "is_logger": True, } - assert len(mock_setup_entry.mock_calls) == 1 + assert result["result"].unique_id == "123.4567" + + +async def assert_abort_flow_with_logger( + hass: HomeAssistant, flow_id: str, reason: str +) -> config_entries.ConfigFlowResult: + """Assert the flow was aborted when a logger device responded.""" + with patch( + "pyfronius.Fronius.current_logger_info", + return_value=LOGGER_INFO_RETURN_VALUE, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + "host": "10.9.8.1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + return result + + +async def test_form_with_logger(hass: HomeAssistant) -> None: + """Test the basic flow with a logger device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + await assert_finish_flow_with_logger(hass, result["flow_id"]) async def test_form_with_inverter(hass: HomeAssistant) -> None: - """Test we get the form.""" + """Test the basic flow with a Gen24 device.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -96,10 +115,6 @@ async def test_form_with_inverter(hass: HomeAssistant) -> None: "pyfronius.Fronius.inverter_info", return_value=INVERTER_INFO_RETURN_VALUE, ), - patch( - "homeassistant.components.fronius.async_setup_entry", - return_value=True, - ) as mock_setup_entry, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -115,11 +130,21 @@ async def test_form_with_inverter(hass: HomeAssistant) -> None: "host": "10.9.1.1", "is_logger": False, } - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["result"].unique_id == "1234567" -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "inverter_side_effect", + [ + FroniusError, + None, # raises StopIteration through INVERTER_INFO_NONE + ], +) +async def test_form_cannot_connect( + hass: HomeAssistant, inverter_side_effect: type[FroniusError] | None +) -> None: """Test we handle cannot connect error.""" + INVERTER_INFO_NONE: dict[str, list] = {"inverters": []} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -131,34 +156,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ), patch( "pyfronius.Fronius.inverter_info", - side_effect=FroniusError, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_no_device(hass: HomeAssistant) -> None: - """Test we handle no device found error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch( - "pyfronius.Fronius.current_logger_info", - side_effect=FroniusError, - ), - patch( - "pyfronius.Fronius.inverter_info", - return_value={"inverters": []}, + side_effect=inverter_side_effect, + return_value=INVERTER_INFO_NONE, ), ): result2 = await hass.config_entries.flow.async_configure( @@ -170,6 +169,7 @@ async def test_form_no_device(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + await assert_finish_flow_with_logger(hass, result2["flow_id"]) async def test_form_unexpected(hass: HomeAssistant) -> None: @@ -191,13 +191,14 @@ async def test_form_unexpected(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} + await assert_finish_flow_with_logger(hass, result2["flow_id"]) async def test_form_already_existing(hass: HomeAssistant) -> None: """Test existing entry.""" MockConfigEntry( domain=DOMAIN, - unique_id="123.4567", + unique_id=LOGGER_INFO_RETURN_VALUE["unique_identifier"]["value"], data={CONF_HOST: "10.9.8.1", "is_logger": True}, ).add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -205,26 +206,15 @@ async def test_form_already_existing(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "pyfronius.Fronius.current_logger_info", - return_value=LOGGER_INFO_RETURN_VALUE, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "10.9.8.1", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" + await assert_abort_flow_with_logger( + hass, result["flow_id"], reason="already_configured" + ) -async def test_form_updates_host( +async def test_config_flow_already_configured( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: - """Test existing entry gets updated.""" + """Test existing entry doesn't get updated by config flow.""" old_host = "http://10.1.0.1" new_host = "http://10.1.0.2" entry = MockConfigEntry( @@ -247,26 +237,20 @@ async def test_form_updates_host( ) mock_responses(aioclient_mock, host=new_host) - with patch( - "homeassistant.components.fronius.async_unload_entry", - return_value=True, - ) as mock_unload_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": new_host, - }, - ) - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": new_host, + }, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" - mock_unload_entry.assert_called_with(hass, entry) entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].data == { - "host": new_host, + "host": old_host, # not updated from config flow - only from reconfigure flow "is_logger": True, } @@ -295,6 +279,7 @@ async def test_dhcp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> "host": MOCK_DHCP_DATA.ip, "is_logger": True, } + assert result["result"].unique_id == "123.4567" async def test_dhcp_already_configured( @@ -342,11 +327,13 @@ async def test_dhcp_invalid( async def test_reconfigure(hass: HomeAssistant) -> None: """Test reconfiguring an entry.""" + old_host = "http://10.1.0.1" + new_host = "http://10.1.0.2" entry = MockConfigEntry( domain=DOMAIN, unique_id="1234567", data={ - CONF_HOST: "10.1.2.3", + CONF_HOST: old_host, "is_logger": True, }, ) @@ -365,15 +352,11 @@ async def test_reconfigure(hass: HomeAssistant) -> None: "pyfronius.Fronius.inverter_info", return_value=INVERTER_INFO_RETURN_VALUE, ), - patch( - "homeassistant.components.fronius.async_setup_entry", - return_value=True, - ) as mock_setup_entry, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - "host": "10.9.1.1", + "host": new_host, }, ) await hass.async_block_till_done() @@ -381,17 +364,16 @@ async def test_reconfigure(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert entry.data == { - "host": "10.9.1.1", + "host": new_host, "is_logger": False, } - assert len(mock_setup_entry.mock_calls) == 1 async def test_reconfigure_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id="123.4567890", + unique_id=LOGGER_INFO_RETURN_VALUE["unique_identifier"]["value"], data={ CONF_HOST: "10.1.2.3", "is_logger": True, @@ -421,12 +403,16 @@ async def test_reconfigure_cannot_connect(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + await assert_abort_flow_with_logger( + hass, result2["flow_id"], reason="reconfigure_successful" + ) + async def test_reconfigure_unexpected(hass: HomeAssistant) -> None: """Test we handle unexpected error.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id="123.4567890", + unique_id=LOGGER_INFO_RETURN_VALUE["unique_identifier"]["value"], data={ CONF_HOST: "10.1.2.3", "is_logger": True, @@ -450,12 +436,16 @@ async def test_reconfigure_unexpected(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} + await assert_abort_flow_with_logger( + hass, result2["flow_id"], reason="reconfigure_successful" + ) -async def test_reconfigure_already_configured(hass: HomeAssistant) -> None: - """Test reconfiguring an entry.""" + +async def test_reconfigure_to_different_device(hass: HomeAssistant) -> None: + """Test reconfiguring an entry to a different device.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id="123.4567890", + unique_id="999.9999999", data={ CONF_HOST: "10.1.2.3", "is_logger": True, @@ -467,68 +457,6 @@ async def test_reconfigure_already_configured(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - with ( - patch( - "pyfronius.Fronius.current_logger_info", - return_value=LOGGER_INFO_RETURN_VALUE, - ), - patch( - "pyfronius.Fronius.inverter_info", - return_value=INVERTER_INFO_RETURN_VALUE, - ), - patch( - "homeassistant.components.fronius.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - "host": "10.1.2.3", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unique_id_mismatch" - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_reconfigure_already_existing(hass: HomeAssistant) -> None: - """Test reconfiguring entry to already existing device.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="123.4567890", - data={ - CONF_HOST: "10.1.2.3", - "is_logger": True, - }, + await assert_abort_flow_with_logger( + hass, result["flow_id"], reason="unique_id_mismatch" ) - entry.add_to_hass(hass) - - entry_2_uid = "222.2222222" - entry_2 = MockConfigEntry( - domain=DOMAIN, - unique_id=entry_2_uid, - data={ - CONF_HOST: "10.2.2.2", - "is_logger": True, - }, - ) - entry_2.add_to_hass(hass) - - result = await entry.start_reconfigure_flow(hass) - with patch( - "pyfronius.Fronius.current_logger_info", - return_value={"unique_identifier": {"value": entry_2_uid}}, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "10.1.1.1", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "unique_id_mismatch" diff --git a/tests/components/fronius/test_coordinator.py b/tests/components/fronius/test_coordinator.py index 13a08bbe70e..fab2d509767 100644 --- a/tests/components/fronius/test_coordinator.py +++ b/tests/components/fronius/test_coordinator.py @@ -29,7 +29,7 @@ async def test_adaptive_update_interval( mock_inverter_data.reset_mock() freezer.tick(FroniusInverterUpdateCoordinator.default_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() @@ -38,13 +38,13 @@ async def test_adaptive_update_interval( # first 3 bad requests at default interval - 4th has different interval for _ in range(3): freezer.tick(FroniusInverterUpdateCoordinator.default_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_inverter_data.call_count == 3 mock_inverter_data.reset_mock() freezer.tick(FroniusInverterUpdateCoordinator.error_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_inverter_data.call_count == 1 mock_inverter_data.reset_mock() @@ -52,13 +52,13 @@ async def test_adaptive_update_interval( mock_inverter_data.side_effect = None # next successful request resets to default interval freezer.tick(FroniusInverterUpdateCoordinator.error_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() freezer.tick(FroniusInverterUpdateCoordinator.default_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() @@ -68,7 +68,7 @@ async def test_adaptive_update_interval( # first 3 requests at default interval - 4th has different interval for _ in range(3): freezer.tick(FroniusInverterUpdateCoordinator.default_interval) - async_fire_time_changed(hass, None) + async_fire_time_changed(hass) await hass.async_block_till_done() # BadStatusError does 3 silent retries for inverter endpoint * 3 request intervals = 9 assert mock_inverter_data.call_count == 9 diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py index 9d570785073..a950ed4e296 100644 --- a/tests/components/fronius/test_init.py +++ b/tests/components/fronius/test_init.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pyfronius import FroniusError from homeassistant.components.fronius.const import DOMAIN, SOLAR_NET_RESCAN_TIMER @@ -10,7 +11,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from . import mock_responses, setup_fronius_integration @@ -66,6 +66,7 @@ async def test_inverter_night_rescan( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test dynamic adding of an inverter discovered automatically after a Home Assistant reboot during the night.""" mock_responses(aioclient_mock, fixture_set="igplus_v2", night=True) @@ -78,9 +79,8 @@ async def test_inverter_night_rescan( # Switch to daytime mock_responses(aioclient_mock, fixture_set="igplus_v2", night=False) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER) - ) + freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER)) + async_fire_time_changed(hass) await hass.async_block_till_done() # We expect our inverter to be present now @@ -88,9 +88,8 @@ async def test_inverter_night_rescan( assert inverter_1.manufacturer == "Fronius" # After another re-scan we still only expect this inverter - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER * 2) - ) + freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER)) + async_fire_time_changed(hass) await hass.async_block_till_done() inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "203200")}) assert inverter_1.manufacturer == "Fronius" @@ -100,6 +99,7 @@ async def test_inverter_rescan_interruption( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test interruption of re-scan during runtime to process further.""" mock_responses(aioclient_mock, fixture_set="igplus_v2", night=True) @@ -115,9 +115,8 @@ async def test_inverter_rescan_interruption( "pyfronius.Fronius.inverter_info", side_effect=FroniusError, ): - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER) - ) + freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER)) + async_fire_time_changed(hass) await hass.async_block_till_done() # No increase of devices expected because of a FroniusError @@ -132,9 +131,8 @@ async def test_inverter_rescan_interruption( # Next re-scan will pick up the new inverter. Expect 2 devices now. mock_responses(aioclient_mock, fixture_set="igplus_v2", night=False) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER * 2) - ) + freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 04c25ce26f2..63f36705c8f 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -2,27 +2,29 @@ from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.fronius.coordinator import ( FroniusInverterUpdateCoordinator, - FroniusMeterUpdateCoordinator, FroniusPowerFlowUpdateCoordinator, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import enable_all_entities, mock_responses, setup_fronius_integration +from . import mock_responses, setup_fronius_integration -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_symo_inverter( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, ) -> None: """Test Fronius Symo inverter entities.""" @@ -32,16 +34,9 @@ async def test_symo_inverter( # Init at night mock_responses(aioclient_mock, night=True) - config_entry = await setup_fronius_integration(hass) + await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 22 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusInverterUpdateCoordinator.default_interval, - ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59 assert_state("sensor.symo_20_dc_current", 0) assert_state("sensor.symo_20_energy_day", 10828) assert_state("sensor.symo_20_total_energy", 44186900) @@ -54,14 +49,7 @@ async def test_symo_inverter( freezer.tick(FroniusInverterUpdateCoordinator.default_interval) async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 62 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusInverterUpdateCoordinator.default_interval, - ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 65 # 4 additional AC entities assert_state("sensor.symo_20_dc_current", 2.19) assert_state("sensor.symo_20_energy_day", 1113) @@ -104,6 +92,7 @@ async def test_symo_logger( assert_state("sensor.solarnet_grid_import_tariff", 0.15) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_symo_meter( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -117,16 +106,9 @@ async def test_symo_meter( assert state.state == str(expected_state) mock_responses(aioclient_mock) - config_entry = await setup_fronius_integration(hass) + await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 26 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusMeterUpdateCoordinator.default_interval, - ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 65 # states are rounded to 4 decimals assert_state("sensor.smart_meter_63a_current_phase_1", 7.755) assert_state("sensor.smart_meter_63a_current_phase_2", 6.68) @@ -206,6 +188,7 @@ async def test_symo_meter_forged( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_symo_power_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -220,16 +203,9 @@ async def test_symo_power_flow( # First test at night mock_responses(aioclient_mock, night=True) - config_entry = await setup_fronius_integration(hass) + await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 22 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusInverterUpdateCoordinator.default_interval, - ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59 # states are rounded to 4 decimals assert_state("sensor.solarnet_energy_day", 10828) assert_state("sensor.solarnet_total_energy", 44186900) @@ -247,7 +223,7 @@ async def test_symo_power_flow( async_fire_time_changed(hass) await hass.async_block_till_done() # 54 because power_flow `rel_SelfConsumption` and `P_PV` is not `null` anymore - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 61 assert_state("sensor.solarnet_energy_day", 1101.7001) assert_state("sensor.solarnet_total_energy", 44188000) assert_state("sensor.solarnet_energy_year", 25508788) @@ -266,7 +242,7 @@ async def test_symo_power_flow( freezer.tick(FroniusPowerFlowUpdateCoordinator.default_interval) async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 61 assert_state("sensor.solarnet_energy_day", 10828) assert_state("sensor.solarnet_total_energy", 44186900) assert_state("sensor.solarnet_energy_year", 25507686) @@ -277,10 +253,13 @@ async def test_symo_power_flow( assert_state("sensor.solarnet_relative_self_consumption", 0) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_gen24( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test Fronius Gen24 inverter entities.""" @@ -292,72 +271,10 @@ async def test_gen24( mock_responses(aioclient_mock, fixture_set="gen24") config_entry = await setup_fronius_integration(hass, is_logger=False) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusMeterUpdateCoordinator.default_interval, - ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 - # inverter 1 - assert_state("sensor.inverter_name_ac_current", 0.1589) - assert_state("sensor.inverter_name_dc_current_2", 0.0754) - assert_state("sensor.inverter_name_status_code", 7) - assert_state("sensor.inverter_name_status_message", "running") - assert_state("sensor.inverter_name_dc_current", 0.0783) - assert_state("sensor.inverter_name_dc_voltage_2", 403.4312) - assert_state("sensor.inverter_name_ac_power", 37.3204) - assert_state("sensor.inverter_name_error_code", 0) - assert_state("sensor.inverter_name_dc_voltage", 411.3811) - assert_state("sensor.inverter_name_total_energy", 1530193.42) - assert_state("sensor.inverter_name_inverter_state", "Running") - assert_state("sensor.inverter_name_ac_voltage", 234.9168) - assert_state("sensor.inverter_name_frequency", 49.9917) - # meter - assert_state("sensor.smart_meter_ts_65a_3_real_energy_produced", 3863340.0) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_consumed", 2013105.0) - assert_state("sensor.smart_meter_ts_65a_3_real_power", 653.1) - assert_state("sensor.smart_meter_ts_65a_3_frequency_phase_average", 49.9) - assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0) - assert_state("sensor.smart_meter_ts_65a_3_meter_location_description", "feed_in") - assert_state("sensor.smart_meter_ts_65a_3_power_factor", 0.828) - assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_consumed", 88221.0) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_minus", 3863340.0) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_2", 2.33) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_1", 235.9) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_1_2", 408.7) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_2", 294.9) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_plus", 2013105.0) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_2", 236.1) - assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_produced", 1989125.0) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_3", 236.9) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_1", 0.441) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_2_3", 409.6) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_3", 1.825) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_3", 0.832) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_1", 243.3) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_3_1", 409.4) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_2", 323.4) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_3", 301.2) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_1", 106.8) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_2", 0.934) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_3", 251.3) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_1", -218.6) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_2", -132.8) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_3", -166.0) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power", 868.0) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power", -517.4) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_1", 1.145) - # power_flow - assert_state("sensor.solarnet_power_grid", 658.4) - assert_state("sensor.solarnet_relative_self_consumption", 100.0) - assert_state("sensor.solarnet_power_photovoltaics", 62.9481) - assert_state("sensor.solarnet_power_load", -695.6827) - assert_state("sensor.solarnet_meter_mode", "meter") - assert_state("sensor.solarnet_relative_autonomy", 5.3592) - assert_state("sensor.solarnet_total_energy", 1530193.42) + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59 + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + assert_state("sensor.inverter_name_total_energy", 1530193.42) # Gen24 devices may report 0 for total energy while doing firmware updates. # This should yield "unknown" state instead of 0. mock_responses( @@ -375,11 +292,14 @@ async def test_gen24( assert_state("sensor.inverter_name_total_energy", "unknown") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_gen24_storage( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test Fronius Gen24 inverter with BYD battery and Ohmpilot entities.""" @@ -393,87 +313,8 @@ async def test_gen24_storage( hass, is_logger=False, unique_id="12345678" ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 37 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusMeterUpdateCoordinator.default_interval, - ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 72 - # inverter 1 - assert_state("sensor.gen24_storage_dc_current", 0.3952) - assert_state("sensor.gen24_storage_dc_voltage_2", 318.8103) - assert_state("sensor.gen24_storage_dc_current_2", 0.3564) - assert_state("sensor.gen24_storage_ac_current", 1.1087) - assert_state("sensor.gen24_storage_ac_power", 250.9093) - assert_state("sensor.gen24_storage_error_code", 0) - assert_state("sensor.gen24_storage_status_code", 7) - assert_state("sensor.gen24_storage_status_message", "running") - assert_state("sensor.gen24_storage_total_energy", 7512794.0117) - assert_state("sensor.gen24_storage_inverter_state", "Running") - assert_state("sensor.gen24_storage_dc_voltage", 419.1009) - assert_state("sensor.gen24_storage_ac_voltage", 227.354) - assert_state("sensor.gen24_storage_frequency", 49.9816) - # meter - assert_state("sensor.smart_meter_ts_65a_3_real_energy_produced", 1705128.0) - assert_state("sensor.smart_meter_ts_65a_3_real_power", 487.7) - assert_state("sensor.smart_meter_ts_65a_3_power_factor", 0.698) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_consumed", 1247204.0) - assert_state("sensor.smart_meter_ts_65a_3_frequency_phase_average", 49.9) - assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0) - assert_state("sensor.smart_meter_ts_65a_3_meter_location_description", "feed_in") - assert_state("sensor.smart_meter_ts_65a_3_reactive_power", -501.5) - assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_produced", 3266105.0) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_3", 19.6) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_3", 0.645) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_minus", 1705128.0) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_2", 383.9) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_1", 1.701) - assert_state("sensor.smart_meter_ts_65a_3_current_phase_2", 1.832) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_1", 319.5) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_1", 229.4) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_2", 150.0) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_3_1", 394.3) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_2", 225.6) - assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_consumed", 5482.0) - assert_state("sensor.smart_meter_ts_65a_3_real_energy_plus", 1247204.0) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_1", 0.995) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_3", 0.163) - assert_state("sensor.smart_meter_ts_65a_3_power_factor_phase_2", 0.389) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_1", -31.3) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_3", -116.7) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_1_2", 396.0) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_2_3", 393.0) - assert_state("sensor.smart_meter_ts_65a_3_reactive_power_phase_2", -353.4) - assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_1", 317.9) - assert_state("sensor.smart_meter_ts_65a_3_voltage_phase_3", 228.3) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power", 821.9) - assert_state("sensor.smart_meter_ts_65a_3_apparent_power_phase_3", 118.4) - # ohmpilot - assert_state("sensor.ohmpilot_energy_consumed", 1233295.0) - assert_state("sensor.ohmpilot_power", 0.0) - assert_state("sensor.ohmpilot_temperature", 38.9) - assert_state("sensor.ohmpilot_state_code", 0.0) - assert_state("sensor.ohmpilot_state_message", "up_and_running") - # power_flow - assert_state("sensor.solarnet_power_grid", 2274.9) - assert_state("sensor.solarnet_power_battery", 0.1591) - assert_state("sensor.solarnet_power_battery_charge", 0) - assert_state("sensor.solarnet_power_battery_discharge", 0.1591) - assert_state("sensor.solarnet_power_load", -2459.3092) - assert_state("sensor.solarnet_relative_self_consumption", 100.0) - assert_state("sensor.solarnet_power_photovoltaics", 216.4328) - assert_state("sensor.solarnet_relative_autonomy", 7.4984) - assert_state("sensor.solarnet_meter_mode", "bidirectional") - assert_state("sensor.solarnet_total_energy", 7512664.4042) - # storage - assert_state("sensor.byd_battery_box_premium_hv_dc_current", 0.0) - assert_state("sensor.byd_battery_box_premium_hv_state_of_charge", 4.6) - assert_state("sensor.byd_battery_box_premium_hv_maximum_capacity", 16588) - assert_state("sensor.byd_battery_box_premium_hv_temperature", 21.5) - assert_state("sensor.byd_battery_box_premium_hv_designed_capacity", 16588) - assert_state("sensor.byd_battery_box_premium_hv_dc_voltage", 0.0) + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 73 + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) # Devices solar_net = device_registry.async_get_device( @@ -507,11 +348,14 @@ async def test_gen24_storage( assert storage.name == "BYD Battery-Box Premium HV" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_primo_s0( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test Fronius Primo dual inverter with S0 meter entities.""" @@ -523,64 +367,8 @@ async def test_primo_s0( mock_responses(aioclient_mock, fixture_set="primo_s0", inverter_ids=[1, 2]) config_entry = await setup_fronius_integration(hass, is_logger=True) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 31 - await enable_all_entities( - hass, - freezer, - config_entry.entry_id, - FroniusMeterUpdateCoordinator.default_interval, - ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 47 - # logger - assert_state("sensor.solarnet_grid_export_tariff", 1) - assert_state("sensor.solarnet_co2_factor", 0.53) - assert_state("sensor.solarnet_grid_import_tariff", 1) - # inverter 1 - assert_state("sensor.primo_5_0_1_total_energy", 17114940) - assert_state("sensor.primo_5_0_1_energy_day", 22504) - assert_state("sensor.primo_5_0_1_dc_voltage", 452.3) - assert_state("sensor.primo_5_0_1_ac_power", 862) - assert_state("sensor.primo_5_0_1_error_code", 0) - assert_state("sensor.primo_5_0_1_dc_current", 4.23) - assert_state("sensor.primo_5_0_1_status_code", 7) - assert_state("sensor.primo_5_0_1_status_message", "running") - assert_state("sensor.primo_5_0_1_energy_year", 7532755.5) - assert_state("sensor.primo_5_0_1_ac_current", 3.85) - assert_state("sensor.primo_5_0_1_ac_voltage", 223.9) - assert_state("sensor.primo_5_0_1_frequency", 60) - assert_state("sensor.primo_5_0_1_led_color", 2) - assert_state("sensor.primo_5_0_1_led_state", 0) - # inverter 2 - assert_state("sensor.primo_3_0_1_total_energy", 5796010) - assert_state("sensor.primo_3_0_1_energy_day", 14237) - assert_state("sensor.primo_3_0_1_dc_voltage", 329.5) - assert_state("sensor.primo_3_0_1_ac_power", 296) - assert_state("sensor.primo_3_0_1_error_code", 0) - assert_state("sensor.primo_3_0_1_dc_current", 0.97) - assert_state("sensor.primo_3_0_1_status_code", 7) - assert_state("sensor.primo_3_0_1_status_message", "running") - assert_state("sensor.primo_3_0_1_energy_year", 3596193.25) - assert_state("sensor.primo_3_0_1_ac_current", 1.32) - assert_state("sensor.primo_3_0_1_ac_voltage", 223.6) - assert_state("sensor.primo_3_0_1_frequency", 60.01) - assert_state("sensor.primo_3_0_1_led_color", 2) - assert_state("sensor.primo_3_0_1_led_state", 0) - # meter - assert_state("sensor.s0_meter_at_inverter_1_meter_location", 1) - assert_state( - "sensor.s0_meter_at_inverter_1_meter_location_description", "consumption_path" - ) - assert_state("sensor.s0_meter_at_inverter_1_real_power", -2216.7487) - # power_flow - assert_state("sensor.solarnet_power_load", -2218.9349) - assert_state("sensor.solarnet_meter_mode", "vague-meter") - assert_state("sensor.solarnet_power_photovoltaics", 1834) - assert_state("sensor.solarnet_power_grid", 384.9349) - assert_state("sensor.solarnet_relative_self_consumption", 100) - assert_state("sensor.solarnet_relative_autonomy", 82.6523) - assert_state("sensor.solarnet_total_energy", 22910919.5) - assert_state("sensor.solarnet_energy_day", 36724) - assert_state("sensor.solarnet_energy_year", 11128933.25) + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 49 + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) # Devices solar_net = device_registry.async_get_device( diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 5006adedd77..5a682277176 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -166,7 +166,7 @@ async def test_frontend_and_static(mock_http_client: TestClient) -> None: text = await resp.text() # Test we can retrieve frontend.js - frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9_-]{11}.js)", text) + frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9_-]{16}.js)", text) assert frontendjs is not None, text resp = await mock_http_client.get(frontendjs.groups(0)[0]) @@ -689,7 +689,7 @@ async def test_auth_authorize(mock_http_client: TestClient) -> None: # Test we can retrieve authorize.js authorizejs = re.search( - r"(?P\/frontend_latest\/authorize.[A-Za-z0-9_-]{11}.js)", text + r"(?P\/frontend_latest\/authorize.[A-Za-z0-9_-]{16}.js)", text ) assert authorizejs is not None, text diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index c92cf897fe6..f60e9ad557e 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -6,7 +6,6 @@ from afsapi import ConnectionError, InvalidPinException, NotImplementedException import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.frontier_silicon.const import ( CONF_WEBFSAPI_URL, DEFAULT_PIN, @@ -15,13 +14,14 @@ from homeassistant.components.frontier_silicon.const import ( from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", ssdp_st="mock_st", @@ -30,7 +30,7 @@ MOCK_DISCOVERY = ssdp.SsdpServiceInfo( upnp={"SPEAKER-NAME": "Speaker Name"}, ) -INVALID_MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +INVALID_MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", ssdp_st="mock_st", diff --git a/tests/components/fujitsu_fglair/conftest.py b/tests/components/fujitsu_fglair/conftest.py index 5974adbeb0d..71a11557b44 100644 --- a/tests/components/fujitsu_fglair/conftest.py +++ b/tests/components/fujitsu_fglair/conftest.py @@ -1,6 +1,6 @@ """Common fixtures for the Fujitsu HVAC (based on Ayla IOT) tests.""" -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator from unittest.mock import AsyncMock, create_autospec, patch from ayla_iot_unofficial import AylaApi @@ -12,7 +12,8 @@ from homeassistant.components.fujitsu_fglair.const import ( DOMAIN, REGION_DEFAULT, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -33,6 +34,12 @@ TEST_PROPERTY_VALUES = { } +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -78,6 +85,24 @@ def mock_config_entry(request: pytest.FixtureRequest) -> MockConfigEntry: ) +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + platforms: list[Platform], + mock_config_entry: MockConfigEntry, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + mock_config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch("homeassistant.components.fujitsu_fglair.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + def _create_device(serial_number: str) -> AsyncMock: dev = AsyncMock(spec=FujitsuHVAC) dev.device_serial_number = serial_number @@ -109,6 +134,7 @@ def _create_device(serial_number: str) -> AsyncMock: dev.temperature_range = [18.0, 26.0] dev.sensed_temp = 22.0 dev.set_temp = 21.0 + dev.outdoor_temperature = 5.0 return dev diff --git a/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..89738cc4a66 --- /dev/null +++ b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_entities[sensor.testserial123_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testserial123_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'fujitsu_fglair', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fglair_outside_temp', + 'unique_id': 'testserial123_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.testserial123_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'testserial123 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testserial123_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_entities[sensor.testserial345_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testserial345_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'fujitsu_fglair', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fglair_outside_temp', + 'unique_id': 'testserial345_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.testserial345_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'testserial345 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testserial345_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- diff --git a/tests/components/fujitsu_fglair/test_climate.py b/tests/components/fujitsu_fglair/test_climate.py index daddc83a871..676ff97f26a 100644 --- a/tests/components/fujitsu_fglair/test_climate.py +++ b/tests/components/fujitsu_fglair/test_climate.py @@ -1,7 +1,9 @@ """Test for the climate entities of Fujitsu HVAC.""" +from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from homeassistant.components.climate import ( @@ -23,24 +25,32 @@ from homeassistant.components.fujitsu_fglair.climate import ( HA_TO_FUJI_HVAC, HA_TO_FUJI_SWING, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import entity_id, setup_integration +from . import entity_id from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + async def test_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_ayla_api: AsyncMock, mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], ) -> None: """Test that coordinator returns the data we expect after the first refresh.""" - await setup_integration(hass, mock_config_entry) + assert await integration_setup() + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -51,9 +61,10 @@ async def test_set_attributes( mock_ayla_api: AsyncMock, mock_devices: list[AsyncMock], mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], ) -> None: """Test that setting the attributes calls the correct functions on the device.""" - await setup_integration(hass, mock_config_entry) + assert await integration_setup() await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/fujitsu_fglair/test_init.py b/tests/components/fujitsu_fglair/test_init.py index af51b222c19..a69610c416d 100644 --- a/tests/components/fujitsu_fglair/test_init.py +++ b/tests/components/fujitsu_fglair/test_init.py @@ -7,6 +7,7 @@ from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.climate import HVACMode from homeassistant.components.fujitsu_fglair.const import ( API_REFRESH, API_TIMEOUT, @@ -17,14 +18,9 @@ from homeassistant.components.fujitsu_fglair.const import ( REGION_EU, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - STATE_UNAVAILABLE, - Platform, -) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, entity_registry as er +from homeassistant.helpers import aiohttp_client from . import entity_id, setup_integration from .conftest import TEST_PASSWORD, TEST_USERNAME @@ -129,6 +125,26 @@ async def test_device_auth_failure( assert hass.states.get(entity_id(mock_devices[1])).state == STATE_UNAVAILABLE +async def test_device_offline( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ayla_api: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_devices: list[AsyncMock], +) -> None: + """Test entities become unavailable if device if offline.""" + await setup_integration(hass, mock_config_entry) + + mock_ayla_api.async_get_devices.return_value[0].is_online.return_value = False + + freezer.tick(API_REFRESH) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id(mock_devices[0])).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id(mock_devices[1])).state == HVACMode.COOL + + async def test_token_expired( hass: HomeAssistant, mock_ayla_api: AsyncMock, @@ -166,36 +182,3 @@ async def test_startup_exception( await setup_integration(hass, mock_config_entry) assert len(hass.states.async_all()) == 0 - - -async def test_one_device_disabled( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - freezer: FrozenDateTimeFactory, - mock_devices: list[AsyncMock], - mock_ayla_api: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that coordinator only updates devices that are currently listening.""" - await setup_integration(hass, mock_config_entry) - - for d in mock_devices: - d.async_update.assert_called_once() - d.reset_mock() - - entity = entity_registry.async_get( - entity_registry.async_get_entity_id( - Platform.CLIMATE, DOMAIN, mock_devices[0].device_serial_number - ) - ) - entity_registry.async_update_entity( - entity.entity_id, disabled_by=er.RegistryEntryDisabler.USER - ) - await hass.async_block_till_done() - freezer.tick(API_REFRESH) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == len(mock_devices) - 1 - mock_devices[0].async_update.assert_not_called() - mock_devices[1].async_update.assert_called_once() diff --git a/tests/components/fujitsu_fglair/test_sensor.py b/tests/components/fujitsu_fglair/test_sensor.py new file mode 100644 index 00000000000..e3f6109a2e8 --- /dev/null +++ b/tests/components/fujitsu_fglair/test_sensor.py @@ -0,0 +1,33 @@ +"""Test for the sensor platform entity of the fujitsu_fglair component.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ayla_api: AsyncMock, + mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test that coordinator returns the data we expect after the first refresh.""" + assert await integration_setup() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/fully_kiosk/test_config_flow.py b/tests/components/fully_kiosk/test_config_flow.py index 873fb2c6796..4ce393a417d 100644 --- a/tests/components/fully_kiosk/test_config_flow.py +++ b/tests/components/fully_kiosk/test_config_flow.py @@ -6,7 +6,6 @@ from aiohttp.client_exceptions import ClientConnectorError from fullykiosk import FullyKioskError import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fully_kiosk.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_MQTT, SOURCE_USER from homeassistant.const import ( @@ -18,6 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index 600fc46608c..ca5662714a0 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -1,6 +1,9 @@ { "battery_level": 80, - "low_battery": true, + "fertilisation": { + "was_repotted": true + }, + "low_battery": false, "last_updated": "2023-01-10 10:10:00", "light": 2, "light_status": 3, @@ -10,7 +13,7 @@ "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E2", - "sensor_update_available": false, + "sensor_update_available": true, "sw_version": "1.0", "status": 1, "online": true, diff --git a/tests/components/fyta/fixtures/plant_status2.json b/tests/components/fyta/fixtures/plant_status2.json index c39e2ac8685..bf90ab1e50d 100644 --- a/tests/components/fyta/fixtures/plant_status2.json +++ b/tests/components/fyta/fixtures/plant_status2.json @@ -1,5 +1,8 @@ { "battery_level": 80, + "fertilisation": { + "was_repotted": true + }, "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json index 58e3e1b86a0..2bedd196fe1 100644 --- a/tests/components/fyta/fixtures/plant_status3.json +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -1,5 +1,8 @@ { "battery_level": 80, + "fertilisation": { + "was_repotted": false + }, "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, diff --git a/tests/components/fyta/snapshots/test_binary_sensor.ambr b/tests/components/fyta/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..c90db22bc7f --- /dev/null +++ b/tests/components/fyta/snapshots/test_binary_sensor.ambr @@ -0,0 +1,741 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.gummibaum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Gummibaum Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_light_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_light_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_light', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_light_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Light notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_light_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_nutrition_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_nutrition_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nutrition notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_nutrition', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_nutrition', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_nutrition_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Nutrition notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_nutrition_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_productive_plant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gummibaum_productive_plant', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Productive plant', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'productive_plant', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-productive_plant', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_productive_plant-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Productive plant', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_productive_plant', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_repotted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gummibaum_repotted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Repotted', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'repotted', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-repotted', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_repotted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Repotted', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_repotted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_temperature_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_temperature_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_temperature', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_temperature_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Temperature notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_temperature_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-sensor_update_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'Gummibaum Update', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_water_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_water_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_water', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_water_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Water notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_water_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Kakaobaum Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_light_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_light_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_light', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_light_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Light notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_light_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_nutrition_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_nutrition_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nutrition notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_nutrition', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_nutrition', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_nutrition_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Nutrition notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_nutrition_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_productive_plant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kakaobaum_productive_plant', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Productive plant', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'productive_plant', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-productive_plant', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_productive_plant-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Productive plant', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_productive_plant', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_repotted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kakaobaum_repotted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Repotted', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'repotted', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-repotted', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_repotted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Repotted', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_repotted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_temperature_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_temperature_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_temperature', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_temperature_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Temperature notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_temperature_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-sensor_update_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'Kakaobaum Update', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_water_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_water_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_water', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_water_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Water notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_water_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index eb19797e5b1..b4da0238db0 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -31,7 +31,7 @@ 'last_updated': '2023-01-10T10:10:00', 'light': 2.0, 'light_status': 3, - 'low_battery': True, + 'low_battery': False, 'moisture': 61.0, 'moisture_status': 3, 'name': 'Gummibaum', @@ -46,14 +46,14 @@ 'plant_origin_path': '', 'plant_thumb_path': '', 'productive_plant': False, - 'repotted': False, + 'repotted': True, 'salinity': 1.0, 'salinity_status': 4, 'scientific_name': 'Ficus elastica', 'sensor_available': True, 'sensor_id': 'FD:1D:B7:E3:D0:E2', 'sensor_status': 0, - 'sensor_update_available': False, + 'sensor_update_available': True, 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, @@ -81,7 +81,7 @@ 'plant_origin_path': '', 'plant_thumb_path': '', 'productive_plant': False, - 'repotted': False, + 'repotted': True, 'salinity': 1.0, 'salinity_status': 4, 'scientific_name': 'Theobroma cacao', diff --git a/tests/components/fyta/snapshots/test_sensor.ambr b/tests/components/fyta/snapshots/test_sensor.ambr index ef583dd28a6..8b75579f557 100644 --- a/tests/components/fyta/snapshots/test_sensor.ambr +++ b/tests/components/fyta/snapshots/test_sensor.ambr @@ -50,6 +50,53 @@ 'state': '80.0', }) # --- +# name: test_all_entities[sensor.gummibaum_last_fertilized-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gummibaum_last_fertilized', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last fertilized', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_fertilised', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-fertilise_last', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.gummibaum_last_fertilized-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Gummibaum Last fertilized', + }), + 'context': , + 'entity_id': 'sensor.gummibaum_last_fertilized', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[sensor.gummibaum_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -279,6 +326,117 @@ 'state': 'perfect', }) # --- +# name: test_all_entities[sensor.gummibaum_next_fertilization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gummibaum_next_fertilization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next fertilization', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_fertilisation', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-fertilise_next', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.gummibaum_next_fertilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Gummibaum Next fertilization', + }), + 'context': , + 'entity_id': 'sensor.gummibaum_next_fertilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.gummibaum_nutrients_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_data', + 'too_low', + 'low', + 'perfect', + 'high', + 'too_high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gummibaum_nutrients_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nutrients state', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nutrients_status', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-nutrients_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.gummibaum_nutrients_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gummibaum Nutrients state', + 'options': list([ + 'no_data', + 'too_low', + 'low', + 'perfect', + 'high', + 'too_high', + ]), + }), + 'context': , + 'entity_id': 'sensor.gummibaum_nutrients_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'perfect', + }) +# --- # name: test_all_entities[sensor.gummibaum_ph-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -716,6 +874,53 @@ 'state': '80.0', }) # --- +# name: test_all_entities[sensor.kakaobaum_last_fertilized-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kakaobaum_last_fertilized', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last fertilized', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_fertilised', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-fertilise_last', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kakaobaum_last_fertilized-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Kakaobaum Last fertilized', + }), + 'context': , + 'entity_id': 'sensor.kakaobaum_last_fertilized', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[sensor.kakaobaum_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -945,6 +1150,117 @@ 'state': 'perfect', }) # --- +# name: test_all_entities[sensor.kakaobaum_next_fertilization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kakaobaum_next_fertilization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next fertilization', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_fertilisation', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-fertilise_next', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kakaobaum_next_fertilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Kakaobaum Next fertilization', + }), + 'context': , + 'entity_id': 'sensor.kakaobaum_next_fertilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.kakaobaum_nutrients_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_data', + 'too_low', + 'low', + 'perfect', + 'high', + 'too_high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kakaobaum_nutrients_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nutrients state', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nutrients_status', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-nutrients_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kakaobaum_nutrients_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Kakaobaum Nutrients state', + 'options': list([ + 'no_data', + 'too_low', + 'low', + 'perfect', + 'high', + 'too_high', + ]), + }), + 'context': , + 'entity_id': 'sensor.kakaobaum_nutrients_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'perfect', + }) +# --- # name: test_all_entities[sensor.kakaobaum_ph-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/fyta/test_binary_sensor.py b/tests/components/fyta/test_binary_sensor.py new file mode 100644 index 00000000000..9d6a4ae3b0e --- /dev/null +++ b/tests/components/fyta/test_binary_sensor.py @@ -0,0 +1,95 @@ +"""Test the Home Assistant fyta binary sensor module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError +from fyta_cli.fyta_models import Plant +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "exception", + [ + FytaConnectionError, + FytaPlantError, + ], +) +async def test_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + mock_fyta_connector.update_all_plants.side_effect = exception + + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("binary_sensor.gummibaum_repotted").state == STATE_UNAVAILABLE + ) + + +async def test_add_remove_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entities are added and old are removed.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert hass.states.get("binary_sensor.gummibaum_repotted").state == STATE_ON + + plants: dict[int, Plant] = { + 0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.kakaobaum_repotted") is None + assert hass.states.get("binary_sensor.tomatenpflanze_repotted").state == STATE_OFF diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py index e47b78aa893..d1e6e326737 100644 --- a/tests/components/fyta/test_config_flow.py +++ b/tests/components/fyta/test_config_flow.py @@ -14,12 +14,33 @@ from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ACCESS_TOKEN, EXPIRATION, PASSWORD, USERNAME from tests.common import MockConfigEntry +async def user_step( + hass: HomeAssistant, flow_id: str, mock_setup_entry: AsyncMock +) -> None: + """Test user step (helper function).""" + + result = await hass.config_entries.flow.async_configure( + flow_id, {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"] == { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_EXPIRATION: EXPIRATION, + } + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_flow( hass: HomeAssistant, mock_fyta_connector: AsyncMock, mock_setup_entry: AsyncMock ) -> None: @@ -31,20 +52,7 @@ async def test_user_flow( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == USERNAME - assert result2["data"] == { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_EXPIRATION: EXPIRATION, - } - assert len(mock_setup_entry.mock_calls) == 1 + await user_step(hass, result["flow_id"], mock_setup_entry) @pytest.mark.parametrize( @@ -190,3 +198,27 @@ async def test_reauth( assert entry.data[CONF_PASSWORD] == "other_password" assert entry.data[CONF_ACCESS_TOKEN] == ACCESS_TOKEN assert entry.data[CONF_EXPIRATION] == EXPIRATION + + +async def test_dhcp_discovery( + hass: HomeAssistant, mock_fyta_connector: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test DHCP discovery flow.""" + + service_info = DhcpServiceInfo( + hostname="FYTA HUB", + ip="1.2.3.4", + macaddress="aabbccddeeff", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=service_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + await user_step(hass, result["flow_id"], mock_setup_entry) diff --git a/tests/components/garages_amsterdam/__init__.py b/tests/components/garages_amsterdam/__init__.py index ff430c0e7b2..f721506b9b0 100644 --- a/tests/components/garages_amsterdam/__init__.py +++ b/tests/components/garages_amsterdam/__init__.py @@ -1 +1,12 @@ """Tests for the Garages Amsterdam integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/garages_amsterdam/conftest.py b/tests/components/garages_amsterdam/conftest.py index 8d7eb8752b0..93190d1d1ee 100644 --- a/tests/components/garages_amsterdam/conftest.py +++ b/tests/components/garages_amsterdam/conftest.py @@ -1,7 +1,10 @@ """Fixtures for Garages Amsterdam integration tests.""" -from unittest.mock import Mock, patch +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch +from odp_amsterdam import Garage, GarageCategory, VehicleType import pytest from homeassistant.components.garages_amsterdam.const import DOMAIN @@ -9,40 +12,74 @@ from homeassistant.components.garages_amsterdam.const import DOMAIN from tests.common import MockConfigEntry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override setup entry.""" + with patch( + "homeassistant.components.garages_amsterdam.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_garages_amsterdam() -> Generator[AsyncMock]: + """Mock garages_amsterdam garages.""" + with ( + patch( + "homeassistant.components.garages_amsterdam.ODPAmsterdam", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.garages_amsterdam.config_flow.ODPAmsterdam", + new=mock_client, + ), + ): + client = mock_client.return_value + client.all_garages.return_value = [ + Garage( + garage_id="test-id-1", + garage_name="IJDok", + vehicle=VehicleType.CAR, + category=GarageCategory.GARAGE, + state="ok", + free_space_short=100, + free_space_long=10, + short_capacity=120, + long_capacity=60, + availability_pct=50.5, + longitude=1.111111, + latitude=2.222222, + updated_at=datetime(2023, 2, 23, 13, 44, 48, tzinfo=UTC), + ), + Garage( + garage_id="test-id-2", + garage_name="Arena", + vehicle=VehicleType.CAR, + category=GarageCategory.GARAGE, + state="error", + free_space_short=200, + free_space_long=None, + short_capacity=240, + long_capacity=None, + availability_pct=83.3, + longitude=3.333333, + latitude=4.444444, + updated_at=datetime(2023, 2, 23, 13, 44, 48, tzinfo=UTC), + ), + ] + yield client + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( title="monitor", domain=DOMAIN, - data={}, + data={ + "garage_name": "IJDok", + }, unique_id="unique_thingy", version=1, ) - - -@pytest.fixture(autouse=True) -def mock_garages_amsterdam(): - """Mock garages_amsterdam garages.""" - with patch( - "odp_amsterdam.ODPAmsterdam.all_garages", - return_value=[ - Mock( - garage_name="IJDok", - free_space_short=100, - free_space_long=10, - short_capacity=120, - long_capacity=60, - state="ok", - ), - Mock( - garage_name="Arena", - free_space_short=200, - free_space_long=20, - short_capacity=240, - long_capacity=80, - state="error", - ), - ], - ) as mock_get_garages: - yield mock_get_garages diff --git a/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..5f6511090ee --- /dev/null +++ b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_all_binary_sensors[binary_sensor.ijdok_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ijdok_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'garages_amsterdam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': 'IJDok-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensors[binary_sensor.ijdok_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by municipality of Amsterdam', + 'device_class': 'problem', + 'friendly_name': 'IJDok State', + }), + 'context': , + 'entity_id': 'binary_sensor.ijdok_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/garages_amsterdam/snapshots/test_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..2c579631bae --- /dev/null +++ b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr @@ -0,0 +1,199 @@ +# serializer version: 1 +# name: test_all_sensors[sensor.ijdok_long_parking_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ijdok_long_parking_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Long parking capacity', + 'platform': 'garages_amsterdam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'long_capacity', + 'unique_id': 'IJDok-long_capacity', + 'unit_of_measurement': 'cars', + }) +# --- +# name: test_all_sensors[sensor.ijdok_long_parking_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by municipality of Amsterdam', + 'friendly_name': 'IJDok Long parking capacity', + 'unit_of_measurement': 'cars', + }), + 'context': , + 'entity_id': 'sensor.ijdok_long_parking_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_sensors[sensor.ijdok_long_parking_free_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ijdok_long_parking_free_space', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Long parking free space', + 'platform': 'garages_amsterdam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'free_space_long', + 'unique_id': 'IJDok-free_space_long', + 'unit_of_measurement': 'cars', + }) +# --- +# name: test_all_sensors[sensor.ijdok_long_parking_free_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by municipality of Amsterdam', + 'friendly_name': 'IJDok Long parking free space', + 'state_class': , + 'unit_of_measurement': 'cars', + }), + 'context': , + 'entity_id': 'sensor.ijdok_long_parking_free_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_sensors[sensor.ijdok_short_parking_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ijdok_short_parking_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Short parking capacity', + 'platform': 'garages_amsterdam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'short_capacity', + 'unique_id': 'IJDok-short_capacity', + 'unit_of_measurement': 'cars', + }) +# --- +# name: test_all_sensors[sensor.ijdok_short_parking_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by municipality of Amsterdam', + 'friendly_name': 'IJDok Short parking capacity', + 'unit_of_measurement': 'cars', + }), + 'context': , + 'entity_id': 'sensor.ijdok_short_parking_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_all_sensors[sensor.ijdok_short_parking_free_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ijdok_short_parking_free_space', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Short parking free space', + 'platform': 'garages_amsterdam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'free_space_short', + 'unique_id': 'IJDok-free_space_short', + 'unit_of_measurement': 'cars', + }) +# --- +# name: test_all_sensors[sensor.ijdok_short_parking_free_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by municipality of Amsterdam', + 'friendly_name': 'IJDok Short parking free space', + 'state_class': , + 'unit_of_measurement': 'cars', + }), + 'context': , + 'entity_id': 'sensor.ijdok_short_parking_free_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/garages_amsterdam/test_binary_sensor.py b/tests/components/garages_amsterdam/test_binary_sensor.py new file mode 100644 index 00000000000..b7d0333f7e3 --- /dev/null +++ b/tests/components/garages_amsterdam/test_binary_sensor.py @@ -0,0 +1,31 @@ +"""Tests the binary sensors provided by the Garages Amsterdam integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import snapshot_platform + + +async def test_all_binary_sensors( + hass: HomeAssistant, + mock_garages_amsterdam: AsyncMock, + mock_config_entry: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all binary sensors.""" + with patch( + "homeassistant.components.garages_amsterdam.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/garages_amsterdam/test_config_flow.py b/tests/components/garages_amsterdam/test_config_flow.py index 9c5b10f9ecc..68950c96cf0 100644 --- a/tests/components/garages_amsterdam/test_config_flow.py +++ b/tests/components/garages_amsterdam/test_config_flow.py @@ -1,39 +1,40 @@ """Test the Garages Amsterdam config flow.""" from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from aiohttp import ClientResponseError import pytest -from homeassistant import config_entries from homeassistant.components.garages_amsterdam.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -async def test_full_user_flow(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_full_user_flow( + hass: HomeAssistant, + mock_garages_amsterdam: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert not result.get("errors") - with patch( - "homeassistant.components.garages_amsterdam.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"garage_name": "IJDok"}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"garage_name": "IJDok"}, + ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == "IJDok" - assert "result" in result2 - assert result2["result"].unique_id == "IJDok" + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "IJDok" + assert result.get("data") == {"garage_name": "IJDok"} + assert len(mock_garages_amsterdam.all_garages.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -50,14 +51,14 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: async def test_error_handling( side_effect: Exception, reason: str, hass: HomeAssistant ) -> None: - """Test we get the form.""" + """Test error handling in the config flow.""" with patch( "homeassistant.components.garages_amsterdam.config_flow.ODPAmsterdam.all_garages", side_effect=side_effect, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == reason diff --git a/tests/components/garages_amsterdam/test_sensor.py b/tests/components/garages_amsterdam/test_sensor.py new file mode 100644 index 00000000000..bc36401ea47 --- /dev/null +++ b/tests/components/garages_amsterdam/test_sensor.py @@ -0,0 +1,31 @@ +"""Tests the sensors provided by the Garages Amsterdam integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import snapshot_platform + + +async def test_all_sensors( + hass: HomeAssistant, + mock_garages_amsterdam: AsyncMock, + mock_config_entry: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all sensors.""" + with patch( + "homeassistant.components.garages_amsterdam.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index 69e6cc6b696..96cdfe41d0d 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Generator from io import BytesIO -from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from PIL import Image import pytest @@ -54,11 +54,15 @@ def fakeimgbytes_gif() -> bytes: @pytest.fixture def fakeimg_png(fakeimgbytes_png: bytes) -> Generator[None]: """Set up respx to respond to test url with fake image bytes.""" - respx.get("http://127.0.0.1/testurl/1", name="fake_img").respond( + respx.get("http://127.0.0.1/testurl/1", name="fake_img1").respond( + stream=fakeimgbytes_png + ) + respx.get("http://127.0.0.1/testurl/2", name="fake_img2").respond( stream=fakeimgbytes_png ) yield - respx.pop("fake_img") + respx.pop("fake_img1") + respx.pop("fake_img2") @pytest.fixture @@ -71,24 +75,37 @@ def fakeimg_gif(fakeimgbytes_gif: bytes) -> Generator[None]: respx.pop("fake_img") -@pytest.fixture(scope="package") -def mock_create_stream() -> _patch[MagicMock]: +@pytest.fixture(name="mock_create_stream") +def mock_create_stream(hass: HomeAssistant) -> Generator[AsyncMock]: """Mock create stream.""" - mock_stream = Mock() + mock_stream = MagicMock() + mock_stream.hass = hass mock_provider = Mock() mock_provider.part_recv = AsyncMock() mock_provider.part_recv.return_value = True mock_stream.add_provider.return_value = mock_provider mock_stream.start = AsyncMock() mock_stream.stop = AsyncMock() - return patch( + mock_stream.endpoint_url.return_value = "http://127.0.0.1/nothing" + with patch( "homeassistant.components.generic.config_flow.create_stream", return_value=mock_stream, - ) + ) as mock_create_stream: + yield mock_create_stream @pytest.fixture -async def user_flow(hass: HomeAssistant) -> ConfigFlowResult: +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setup entry.""" + with patch( + "homeassistant.components.generic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="user_flow") +async def user_flow_fixture(hass: HomeAssistant) -> ConfigFlowResult: """Initiate a user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -124,8 +141,8 @@ def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: return entry -@pytest.fixture -async def setup_entry( +@pytest.fixture(name="setup_entry") +async def setup_entry_fixture( hass: HomeAssistant, config_entry: MockConfigEntry ) -> MockConfigEntry: """Set up a config entry ready to be used in tests.""" diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index a882ca4cd8d..19af6cd7a09 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator import contextlib import errno from http import HTTPStatus @@ -13,7 +14,6 @@ import httpx import pytest import respx -from homeassistant import config_entries from homeassistant.components.camera import async_get_image from homeassistant.components.generic.config_flow import slug from homeassistant.components.generic.const import ( @@ -29,8 +29,7 @@ from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, ) -from homeassistant.components.stream.worker import StreamWorkerError -from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, @@ -45,7 +44,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator TESTDATA = { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", @@ -69,39 +68,47 @@ TESTDATA_YAML = { @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_form( hass: HomeAssistant, fakeimgbytes_png: bytes, hass_client: ClientSessionGenerator, user_flow: ConfigFlowResult, mock_create_stream: _patch[MagicMock], + mock_setup_entry: _patch[MagicMock], + hass_ws_client: WebSocketGenerator, ) -> None: """Test the form with a normal set of settings.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - with ( - mock_create_stream as mock_setup, - patch( - "homeassistant.components.generic.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" - client = await hass_client() - preview_url = result1["description_placeholders"]["preview_url"] - # Check the preview image works. - resp = await client.get(preview_url) - assert resp.status == HTTPStatus.OK - assert await resp.read() == fakeimgbytes_png - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) - await hass.async_block_till_done() + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + + # HA should now be serving a WS connection for a preview stream. + ws_client = await hass_ws_client() + flow_id = user_flow["flow_id"] + await ws_client.send_json_auto_id( + { + "type": "generic_camera/start_preview", + "flow_id": flow_id, + }, + ) + json = await ws_client.receive_json() + + client = await hass_client() + still_preview_url = json["event"]["attributes"]["still_url"] + # Check the preview image works. + resp = await client.get(still_preview_url) + assert resp.status == HTTPStatus.OK + assert await resp.read() == fakeimgbytes_png + + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -110,46 +117,37 @@ async def test_form( CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", - CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, CONF_CONTENT_TYPE: "image/png", - CONF_FRAMERATE: 5, + CONF_FRAMERATE: 5.0, CONF_VERIFY_SSL: False, } - await hass.async_block_till_done() # Check that the preview image is disabled after. - resp = await client.get(preview_url) + resp = await client.get(still_preview_url) assert resp.status == HTTPStatus.NOT_FOUND - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 @respx.mock @pytest.mark.usefixtures("fakeimg_png") async def test_form_only_stillimage( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user wants still images only.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - await hass.async_block_till_done() - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -157,9 +155,8 @@ async def test_form_only_stillimage( CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", - CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, CONF_CONTENT_TYPE: "image/png", - CONF_FRAMERATE: 5, + CONF_FRAMERATE: 5.0, CONF_VERIFY_SSL: False, } @@ -167,21 +164,19 @@ async def test_form_only_stillimage( @respx.mock -async def test_form_reject_still_preview( +@pytest.mark.usefixtures("fakeimg_png") +async def test_form_reject_preview( hass: HomeAssistant, - fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock], user_flow: ConfigFlowResult, ) -> None: - """Test we go back to the config screen if the user rejects the still preview.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - with mock_create_stream: - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + """Test we go back to the config screen if the user rejects the preview.""" + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" + assert result1["step_id"] == "user_confirm" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: False}, @@ -197,6 +192,7 @@ async def test_form_still_preview_cam_off( mock_create_stream: _patch[MagicMock], user_flow: ConfigFlowResult, hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, ) -> None: """Test camera errors are triggered during preview.""" with ( @@ -204,65 +200,80 @@ async def test_form_still_preview_cam_off( "homeassistant.components.generic.camera.GenericCamera.is_on", new_callable=PropertyMock(return_value=False), ), - mock_create_stream, ): result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, ) assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" - preview_url = result1["description_placeholders"]["preview_url"] + assert result1["step_id"] == "user_confirm" + + # HA should now be serving a WS connection for a preview stream. + ws_client = await hass_ws_client() + flow_id = user_flow["flow_id"] + await ws_client.send_json_auto_id( + { + "type": "generic_camera/start_preview", + "flow_id": flow_id, + }, + ) + json = await ws_client.receive_json() + + client = await hass_client() + still_preview_url = json["event"]["attributes"]["still_url"] # Try to view the image, should be unavailable. client = await hass_client() - resp = await client.get(preview_url) + resp = await client.get(still_preview_url) assert resp.status == HTTPStatus.SERVICE_UNAVAILABLE @respx.mock @pytest.mark.usefixtures("fakeimg_gif") async def test_form_only_stillimage_gif( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user wants a gif.""" data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) - await hass.async_block_till_done() + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["options"][CONF_CONTENT_TYPE] == "image/gif" @respx.mock async def test_form_only_svg_whitespace( - hass: HomeAssistant, fakeimgbytes_svg: bytes, user_flow: ConfigFlowResult + hass: HomeAssistant, + fakeimgbytes_svg: bytes, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if svg starts with whitespace, issue #68889.""" fakeimgbytes_wspace_svg = bytes(" \n ", encoding="utf-8") + fakeimgbytes_svg respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_wspace_svg) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY @@ -279,7 +290,7 @@ async def test_form_only_svg_whitespace( ], ) async def test_form_only_still_sample( - hass: HomeAssistant, user_flow: ConfigFlowResult, image_file + hass: HomeAssistant, user_flow: ConfigFlowResult, image_file, mock_setup_entry ) -> None: """Test various sample images #69037.""" image_path = os.path.join(os.path.dirname(__file__), image_file) @@ -287,18 +298,17 @@ async def test_form_only_still_sample( respx.get("http://127.0.0.1/testurl/1").respond(stream=image_bytes) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) - await hass.async_block_till_done() + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY @@ -310,13 +320,13 @@ async def test_form_only_still_sample( ( "http://localhost:812{{3}}/static/icons/favicon-apple-180x180.png", "http://localhost:8123/static/icons/favicon-apple-180x180.png", - "user_confirm_still", + "user_confirm", None, ), ( "{% if 1 %}https://bla{% else %}https://yo{% endif %}", "https://bla/", - "user_confirm_still", + "user_confirm", None, ), ( @@ -339,10 +349,11 @@ async def test_form_only_still_sample( ), ], ) -async def test_still_template( +async def test_form_still_template( hass: HomeAssistant, user_flow: ConfigFlowResult, fakeimgbytes_png: bytes, + mock_setup_entry: Generator[AsyncMock], template, url, expected_result, @@ -356,12 +367,11 @@ async def test_still_template( data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) data[CONF_STILL_IMAGE_URL] = template - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["step_id"] == expected_result assert result2.get("errors") == expected_errors @@ -372,24 +382,19 @@ async def test_form_rtsp_mode( hass: HomeAssistant, user_flow: ConfigFlowResult, mock_create_stream: _patch[MagicMock], + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user enters a stream url.""" data = TESTDATA.copy() data[CONF_RTSP_TRANSPORT] = "tcp" data[CONF_STREAM_SOURCE] = "rtsp://127.0.0.1/testurl/2" - with ( - mock_create_stream as mock_setup, - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - ): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], data - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure(user_flow["flow_id"], data) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -399,15 +404,11 @@ async def test_form_rtsp_mode( CONF_RTSP_TRANSPORT: "tcp", CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", - CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, CONF_CONTENT_TYPE: "image/png", - CONF_FRAMERATE: 5, + CONF_FRAMERATE: 5.0, CONF_VERIFY_SSL: False, } - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - async def test_form_only_stream( hass: HomeAssistant, @@ -419,33 +420,34 @@ async def test_form_only_stream( data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) data[CONF_STREAM_SOURCE] = "rtsp://user:pass@127.0.0.1/testurl/2" - with mock_create_stream as mock_setup: - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.CREATE_ENTRY - assert result1["title"] == "127_0_0_1" - assert result1["options"] == { + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + + assert result1["type"] is FlowResultType.FORM + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + + assert result2["title"] == "127_0_0_1" + assert result2["options"] == { CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_STREAM_SOURCE: "rtsp://user:pass@127.0.0.1/testurl/2", CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", - CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, CONF_CONTENT_TYPE: "image/jpeg", - CONF_FRAMERATE: 5, + CONF_FRAMERATE: 5.0, CONF_VERIFY_SSL: False, } - await hass.async_block_till_done() - with patch( "homeassistant.components.camera._async_get_stream_image", return_value=fakeimgbytes_jpg, ): image_obj = await async_get_image(hass, "camera.127_0_0_1") assert image_obj.content == fakeimgbytes_jpg - assert len(mock_setup.mock_calls) == 1 async def test_form_still_and_stream_not_provided( @@ -503,16 +505,11 @@ async def test_form_image_http_exceptions( mock_create_stream: _patch[MagicMock], ) -> None: """Test we handle image http exceptions.""" - respx.get("http://127.0.0.1/testurl/1").side_effect = [ - side_effect, - ] - - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - await hass.async_block_till_done() + respx.get("http://127.0.0.1/testurl/1").side_effect = [side_effect] + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == expected_message @@ -526,12 +523,10 @@ async def test_form_stream_invalidimage( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=b"invalid") - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "invalid_still_image"} @@ -545,12 +540,10 @@ async def test_form_stream_invalidimage2( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=None) - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "unable_still_load_no_image"} @@ -564,12 +557,10 @@ async def test_form_stream_invalidimage3( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=bytes([0xFF])) - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "invalid_still_image"} @@ -578,21 +569,22 @@ async def test_form_stream_invalidimage3( @respx.mock @pytest.mark.usefixtures("fakeimg_png") async def test_form_stream_timeout( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_create_stream: _patch[MagicMock], ) -> None: """Test we handle invalid auth.""" - with patch( - "homeassistant.components.generic.config_flow.create_stream" - ) as create_stream: - create_stream.return_value.start = AsyncMock() - create_stream.return_value.add_provider.return_value.part_recv = AsyncMock() - create_stream.return_value.add_provider.return_value.part_recv.return_value = ( - False - ) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + mock_create_stream.return_value.start = AsyncMock() + mock_create_stream.return_value.stop = AsyncMock() + mock_create_stream.return_value.hass = hass + mock_create_stream.return_value.add_provider.return_value.part_recv = AsyncMock() + mock_create_stream.return_value.add_provider.return_value.part_recv.return_value = ( + False + ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"stream_source": "timeout"} @@ -639,29 +631,10 @@ async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None: @respx.mock @pytest.mark.usefixtures("fakeimg_png") -async def test_form_stream_worker_error( +async def test_form_stream_permission_error( hass: HomeAssistant, user_flow: ConfigFlowResult -) -> None: - """Test we handle a StreamWorkerError and pass the message through.""" - with patch( - "homeassistant.components.generic.config_flow.create_stream", - side_effect=StreamWorkerError("Some message"), - ): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"stream_source": "unknown_with_details"} - assert result2["description_placeholders"] == {"error": "Some message"} - - -@respx.mock -async def test_form_stream_permission_error( - hass: HomeAssistant, fakeimgbytes_png: bytes, user_flow: ConfigFlowResult ) -> None: """Test we handle permission error.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) with patch( "homeassistant.components.generic.config_flow.create_stream", side_effect=PermissionError(), @@ -697,7 +670,7 @@ async def test_form_no_route_to_host( async def test_form_stream_io_error( hass: HomeAssistant, user_flow: ConfigFlowResult ) -> None: - """Test we handle no io error when setting up stream.""" + """Test we handle an io error when setting up stream.""" with patch( "homeassistant.components.generic.config_flow.create_stream", side_effect=OSError(errno.EIO, "Input/output error"), @@ -728,85 +701,73 @@ async def test_form_oserror(hass: HomeAssistant, user_flow: ConfigFlowResult) -> @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_options_template_error( - hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock] + hass: HomeAssistant, + mock_create_stream: _patch[MagicMock], + config_entry: MockConfigEntry, ) -> None: """Test the options flow with a template error.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) - - mock_entry = MockConfigEntry( - title="Test Camera", - domain=DOMAIN, - data={}, - options=TESTDATA, - ) - - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(mock_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" # try updating the still image url data = TESTDATA.copy() data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/2" - with mock_create_stream: - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input=data, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "confirm_still" + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_confirm" - result2a = await hass.config_entries.options.async_configure( - result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} - ) - assert result2a["type"] is FlowResultType.CREATE_ENTRY + result2a = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} + ) + assert result2a["type"] is FlowResultType.CREATE_ENTRY - result3 = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "init" + result3 = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "init" - # verify that an invalid template reports the correct UI error. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/{{1/0}}" - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], - user_input=data, - ) - assert result4.get("type") is FlowResultType.FORM - assert result4["errors"] == {"still_image_url": "template_error"} + # verify that an invalid template reports the correct UI error. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/{{1/0}}" + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input=data, + ) + assert result4.get("type") is FlowResultType.FORM + assert result4["errors"] == {"still_image_url": "template_error"} - # verify that an invalid template reports the correct UI error. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "http://127.0.0.2/testurl/{{1/0}}" - result5 = await hass.config_entries.options.async_configure( - result4["flow_id"], - user_input=data, - ) + # verify that an invalid template reports the correct UI error. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "http://127.0.0.2/testurl/{{1/0}}" + result5 = await hass.config_entries.options.async_configure( + result4["flow_id"], + user_input=data, + ) - assert result5.get("type") is FlowResultType.FORM - assert result5["errors"] == {"stream_source": "template_error"} + assert result5.get("type") is FlowResultType.FORM + assert result5["errors"] == {"stream_source": "template_error"} - # verify that an relative stream url is rejected. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "relative/stream.mjpeg" - result6 = await hass.config_entries.options.async_configure( - result5["flow_id"], - user_input=data, - ) - assert result6.get("type") is FlowResultType.FORM - assert result6["errors"] == {"stream_source": "relative_url"} + # verify that an relative stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "relative/stream.mjpeg" + result6 = await hass.config_entries.options.async_configure( + result5["flow_id"], + user_input=data, + ) + assert result6.get("type") is FlowResultType.FORM + assert result6["errors"] == {"stream_source": "relative_url"} - # verify that an malformed stream url is rejected. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "http://example.com:45:56" - result7 = await hass.config_entries.options.async_configure( - result6["flow_id"], - user_input=data, - ) + # verify that an malformed stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "http://example.com:45:56" + result7 = await hass.config_entries.options.async_configure( + result6["flow_id"], + user_input=data, + ) assert result7.get("type") is FlowResultType.FORM assert result7["errors"] == {"stream_source": "malformed_url"} @@ -826,11 +787,13 @@ async def test_slug(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> No @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_options_only_stream( - hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock] + hass: HomeAssistant, + mock_setup_entry: _patch[MagicMock], + mock_create_stream: _patch[MagicMock], ) -> None: """Test the options flow without a still_image_url.""" - respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) @@ -842,20 +805,18 @@ async def test_options_only_stream( ) mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() result = await hass.config_entries.options.async_init(mock_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" # try updating the config options - with mock_create_stream: - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input=data, - ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "confirm_still" + assert result2["step_id"] == "user_confirm" result3 = await hass.config_entries.options.async_configure( result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} @@ -864,41 +825,54 @@ async def test_options_only_stream( assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" -@pytest.mark.usefixtures("fakeimg_png") -async def test_unload_entry(hass: HomeAssistant) -> None: - """Test unloading the generic IP Camera entry.""" - mock_entry = MockConfigEntry(domain=DOMAIN, options=TESTDATA) - mock_entry.add_to_hass(hass) +async def test_options_still_and_stream_not_provided( + hass: HomeAssistant, + mock_setup_entry: _patch[MagicMock], +) -> None: + """Test we show a suitable error if neither still or stream URL are provided.""" + data = TESTDATA.copy() - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_reload_on_title_change(hass: HomeAssistant) -> None: - """Test the integration gets reloaded when the title is updated.""" - - test_data = TESTDATA_OPTIONS - test_data[CONF_CONTENT_TYPE] = "image/png" mock_entry = MockConfigEntry( - domain=DOMAIN, unique_id="54321", options=test_data, title="My Title" + title="Test Camera", + domain=DOMAIN, + data={}, + options=data, ) mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.LOADED - assert hass.states.get("camera.my_title").attributes["friendly_name"] == "My Title" - hass.config_entries.async_update_entry(mock_entry, title="New Title") - assert mock_entry.title == "New Title" - await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" - assert hass.states.get("camera.my_title").attributes["friendly_name"] == "New Title" + data.pop(CONF_STILL_IMAGE_URL) + data.pop(CONF_STREAM_SOURCE) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "no_still_image_or_stream_url"} + + +@respx.mock +@pytest.mark.usefixtures("fakeimg_png") +async def test_options_permission_error( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test we handle a PermissionError and pass the message through.""" + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + with patch( + "homeassistant.components.generic.config_flow.create_stream", + side_effect=PermissionError("Some message"), + ): + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + TESTDATA, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"stream_source": "stream_not_permitted"} async def test_migrate_existing_ids( @@ -936,52 +910,58 @@ async def test_migrate_existing_ids( @respx.mock @pytest.mark.usefixtures("fakeimg_png") -async def test_use_wallclock_as_timestamps_option( - hass: HomeAssistant, mock_create_stream: _patch[MagicMock] +async def test_options_use_wallclock_as_timestamps( + hass: HomeAssistant, + mock_create_stream: _patch[MagicMock], + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + fakeimgbytes_png: bytes, + config_entry: MockConfigEntry, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test the use_wallclock_as_timestamps option flow.""" - mock_entry = MockConfigEntry( - title="Test Camera", - domain=DOMAIN, - data={}, - options=TESTDATA, - ) - - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init( - mock_entry.entry_id, context={"show_advanced_options": True} + config_entry.entry_id, context={"show_advanced_options": True} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with ( - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - mock_create_stream, - ): - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, - ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + ) assert result2["type"] is FlowResultType.FORM + + ws_client = await hass_ws_client() + flow_id = result2["flow_id"] + await ws_client.send_json_auto_id( + { + "type": "generic_camera/start_preview", + "flow_id": flow_id, + "flow_type": "options_flow", + }, + ) + json = await ws_client.receive_json() + + client = await hass_client() + still_preview_url = json["event"]["attributes"]["still_url"] + # Check the preview image works. + resp = await client.get(still_preview_url) + assert resp.status == HTTPStatus.OK + assert await resp.read() == fakeimgbytes_png + # Test what happens if user rejects the preview result3 = await hass.config_entries.options.async_configure( result2["flow_id"], user_input={CONF_CONFIRMED_OK: False} ) assert result3["type"] is FlowResultType.FORM assert result3["step_id"] == "init" - with ( - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - mock_create_stream, - ): - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], - user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, - ) + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + ) assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "confirm_still" + assert result4["step_id"] == "user_confirm" result5 = await hass.config_entries.options.async_configure( result4["flow_id"], user_input={CONF_CONFIRMED_OK: True}, diff --git a/tests/components/generic/test_init.py b/tests/components/generic/test_init.py new file mode 100644 index 00000000000..faa00ee9144 --- /dev/null +++ b/tests/components/generic/test_init.py @@ -0,0 +1,37 @@ +"""Define tests for the generic (IP camera) integration.""" + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("fakeimg_png") +async def test_unload_entry(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: + """Test unloading the generic IP Camera entry.""" + assert setup_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(setup_entry.entry_id) + await hass.async_block_till_done() + assert setup_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_reload_on_title_change( + hass: HomeAssistant, setup_entry: MockConfigEntry +) -> None: + """Test the integration gets reloaded when the title is updated.""" + assert setup_entry.state is ConfigEntryState.LOADED + assert ( + hass.states.get("camera.test_camera").attributes["friendly_name"] + == "Test Camera" + ) + + hass.config_entries.async_update_entry(setup_entry, title="New Title") + assert setup_entry.title == "New Title" + await hass.async_block_till_done() + + assert ( + hass.states.get("camera.test_camera").attributes["friendly_name"] == "New Title" + ) diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 39435f154c4..8cbbdbb49d4 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -319,6 +319,20 @@ async def test_set_target_temp(hass: HomeAssistant) -> None: assert state.attributes.get("temperature") == 30.0 +@pytest.mark.usefixtures("setup_comp_2") +async def test_set_target_temp_change_preset(hass: HomeAssistant) -> None: + """Test the setting of the target temperature. + + Verify that preset is changed. + """ + await common.async_set_temperature(hass, 30) + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == PRESET_NONE + await common.async_set_temperature(hass, 20) + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == PRESET_COMFORT + + @pytest.mark.parametrize( ("preset", "temp"), [ diff --git a/tests/components/goalzero/__init__.py b/tests/components/goalzero/__init__.py index 30a7c92510e..7d86f638fc2 100644 --- a/tests/components/goalzero/__init__.py +++ b/tests/components/goalzero/__init__.py @@ -2,11 +2,11 @@ from unittest.mock import AsyncMock, patch -from homeassistant.components import dhcp from homeassistant.components.goalzero.const import DEFAULT_NAME, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -19,7 +19,7 @@ CONF_DATA = { CONF_NAME: DEFAULT_NAME, } -CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW = DhcpServiceInfo( ip=HOST, macaddress=format_mac("AA:BB:CC:DD:EE:FF").replace(":", ""), hostname="yeti", diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py index 25fb5922506..1e7e48437cd 100644 --- a/tests/components/gogogate2/test_config_flow.py +++ b/tests/components/gogogate2/test_config_flow.py @@ -8,7 +8,6 @@ from ismartgate.common import ApiError from ismartgate.const import GogoGate2ApiErrorCode from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.gogogate2.const import ( DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, @@ -23,6 +22,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import _mocked_ismartgate_closed_door_response @@ -105,13 +109,13 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -133,13 +137,13 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -158,13 +162,13 @@ async def test_form_homekit_ip_address_already_setup(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -177,13 +181,13 @@ async def test_form_homekit_ip_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -213,7 +217,7 @@ async def test_discovered_dhcp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" ), ) @@ -260,13 +264,13 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -276,7 +280,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" ), ) @@ -286,7 +290,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="00:00:00:00:00:00", hostname="mock_hostname" ), ) diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 6ce95a2bc17..305f30d99d4 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -751,6 +751,7 @@ async def test_unique_id_migration( old_unique_id, ) -> None: """Test that old unique id format is migrated to the new format that supports multiple accounts.""" + config_entry.add_to_hass(hass) # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, @@ -805,6 +806,7 @@ async def test_invalid_unique_id_cleanup( mock_calendars_yaml, ) -> None: """Test that old unique id format that is not actually unique is removed.""" + config_entry.add_to_hass(hass) # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 536a1440958..ad43e341968 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -20,7 +20,8 @@ from homeassistant.components.google.const import CONF_CALENDAR_ACCESS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF from homeassistant.core import HomeAssistant, State -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported +from homeassistant.setup import async_setup_component from homeassistant.util.dt import UTC, utcnow from .conftest import ( @@ -593,7 +594,7 @@ async def test_unsupported_create_event( aioclient_mock: AiohttpClientMocker, ) -> None: """Test create event service call is unsupported for virtual calendars.""" - + await async_setup_component(hass, "homeassistant", {}) mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) assert await component_setup() @@ -601,8 +602,12 @@ async def test_unsupported_create_event( start_datetime = datetime.datetime.now(tz=zoneinfo.ZoneInfo("America/Regina")) delta = datetime.timedelta(days=3, hours=3) end_datetime = start_datetime + delta + entity_id = "calendar.backyard_light" - with pytest.raises(HomeAssistantError, match="does not support this service"): + with pytest.raises( + ServiceNotSupported, + match=f"Entity {entity_id} does not support action google.create_event", + ): await hass.services.async_call( DOMAIN, "create_event", @@ -613,7 +618,7 @@ async def test_unsupported_create_event( "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, }, - target={"entity_id": "calendar.backyard_light"}, + target={"entity_id": entity_id}, blocking=True, ) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index ea30f89e0ef..2b0bfd82908 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -491,7 +491,7 @@ async def test_execute_request(hass_fixture, assistant_client, auth_header) -> N assert kitchen.attributes.get(light.ATTR_RGB_COLOR) == (255, 0, 0) bed = hass_fixture.states.get("light.bed_light") - assert bed.attributes.get(light.ATTR_COLOR_TEMP) == 212 + assert bed.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 4700 assert hass_fixture.states.get("switch.decorative_lights").state == "off" diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 0e6876cc901..a5451e5332d 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -316,7 +316,7 @@ async def test_sync_notifications(agents) -> None: config, "async_sync_notification", return_value=HTTPStatus.NO_CONTENT ) as mock: await config.async_sync_notification_all("1234", {}) - assert not agents or bool(mock.mock_calls) and agents + assert not agents or (bool(mock.mock_calls) and agents) @pytest.mark.parametrize( diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index f1b7108c348..3b43728988b 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -51,7 +51,7 @@ from homeassistant.setup import async_setup_component from . import BASIC_CONFIG, MockConfig -from tests.common import MockConfigEntry, async_capture_events +from tests.common import MockConfigEntry, MockEntityPlatform, async_capture_events REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" @@ -156,6 +156,7 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" @@ -301,6 +302,7 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = entity.entity_id light._attr_device_info = None light._attr_name = "Demo Light" @@ -396,22 +398,25 @@ async def test_query_message(hass: HomeAssistant) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" light.async_write_ha_state() light2 = DemoLight( - None, "Another Light", state=True, hs_color=(180, 75), ct=400, brightness=78 + None, "Another Light", state=True, hs_color=(180, 75), ct=2500, brightness=78 ) light2.hass = hass + light2.platform = MockEntityPlatform(hass) light2.entity_id = "light.another_light" light2._attr_device_info = None light2._attr_name = "Another Light" light2.async_write_ha_state() - light3 = DemoLight(None, "Color temp Light", state=True, ct=400, brightness=200) + light3 = DemoLight(None, "Color temp Light", state=True, ct=2500, brightness=200) light3.hass = hass + light3.platform = MockEntityPlatform(hass) light3.entity_id = "light.color_temp_light" light3._attr_device_info = None light3._attr_name = "Color temp Light" @@ -899,6 +904,7 @@ async def test_unavailable_state_does_sync(hass: HomeAssistant) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._available = False light._attr_device_info = None @@ -996,6 +1002,7 @@ async def test_device_class_switch( device_class=device_class, ) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "switch.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1046,6 +1053,7 @@ async def test_device_class_binary_sensor( None, "Demo Sensor", state=False, device_class=device_class ) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "binary_sensor.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1100,6 +1108,7 @@ async def test_device_class_cover( """Test that a cover entity syncs to the correct device type.""" sensor = DemoCover(None, hass, "Demo Sensor", device_class=device_class) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "cover.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1150,6 +1159,7 @@ async def test_device_media_player( """Test that a binary entity syncs to the correct device type.""" sensor = AbstractDemoPlayer("Demo", device_class=device_class) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "media_player.demo" sensor.async_write_ha_state() @@ -1441,6 +1451,7 @@ async def test_sync_message_recovery( hs_color=(180, 75), ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" @@ -1450,7 +1461,7 @@ async def test_sync_message_recovery( "light.bad_light", "on", { - "min_mireds": "badvalue", + "max_color_temp_kelvin": "badvalue", "supported_color_modes": ["color_temp"], }, ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 1e42edf8e7b..dafe85d97b2 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -77,7 +77,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State from homeassistant.core_config import async_process_ha_core_config -from homeassistant.util import color, dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import TemperatureConverter from . import BASIC_CONFIG, MockConfig @@ -431,7 +431,9 @@ async def test_dock_vacuum(hass: HomeAssistant) -> None: assert helpers.get_google_type(vacuum.DOMAIN, None) is not None assert trait.DockTrait.supported(vacuum.DOMAIN, 0, None, None) - trt = trait.DockTrait(hass, State("vacuum.bla", vacuum.STATE_IDLE), BASIC_CONFIG) + trt = trait.DockTrait( + hass, State("vacuum.bla", vacuum.VacuumActivity.IDLE), BASIC_CONFIG + ) assert trt.sync_attributes() == {} @@ -454,7 +456,7 @@ async def test_locate_vacuum(hass: HomeAssistant) -> None: hass, State( "vacuum.bla", - vacuum.STATE_IDLE, + vacuum.VacuumActivity.IDLE, {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.LOCATE}, ), BASIC_CONFIG, @@ -485,7 +487,7 @@ async def test_energystorage_vacuum(hass: HomeAssistant) -> None: hass, State( "vacuum.bla", - vacuum.STATE_DOCKED, + vacuum.VacuumActivity.DOCKED, { ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.BATTERY, ATTR_BATTERY_LEVEL: 100, @@ -511,7 +513,7 @@ async def test_energystorage_vacuum(hass: HomeAssistant) -> None: hass, State( "vacuum.bla", - vacuum.STATE_CLEANING, + vacuum.VacuumActivity.CLEANING, { ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.BATTERY, ATTR_BATTERY_LEVEL: 20, @@ -551,7 +553,7 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: hass, State( "vacuum.bla", - vacuum.STATE_PAUSED, + vacuum.VacuumActivity.PAUSED, {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.PAUSE}, ), BASIC_CONFIG, @@ -868,10 +870,10 @@ async def test_color_setting_temperature_light(hass: HomeAssistant) -> None: "light.bla", STATE_ON, { - light.ATTR_MIN_MIREDS: 200, + light.ATTR_MAX_COLOR_TEMP_KELVIN: 5000, light.ATTR_COLOR_MODE: "color_temp", - light.ATTR_COLOR_TEMP: 300, - light.ATTR_MAX_MIREDS: 500, + light.ATTR_COLOR_TEMP_KELVIN: 3333, + light.ATTR_MIN_COLOR_TEMP_KELVIN: 2000, "supported_color_modes": ["color_temp"], }, ), @@ -904,7 +906,7 @@ async def test_color_setting_temperature_light(hass: HomeAssistant) -> None: assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: "light.bla", - light.ATTR_COLOR_TEMP: color.color_temperature_kelvin_to_mired(2857), + light.ATTR_COLOR_TEMP_KELVIN: 2857, } @@ -922,9 +924,9 @@ async def test_color_light_temperature_light_bad_temp(hass: HomeAssistant) -> No "light.bla", STATE_ON, { - light.ATTR_MIN_MIREDS: 200, - light.ATTR_COLOR_TEMP: 0, - light.ATTR_MAX_MIREDS: 500, + light.ATTR_MAX_COLOR_TEMP_KELVIN: 5000, + light.ATTR_COLOR_TEMP_KELVIN: 0, + light.ATTR_MIN_COLOR_TEMP_KELVIN: 2000, }, ), BASIC_CONFIG, @@ -2206,7 +2208,7 @@ async def test_fan_speed_ordered( "ordered": True, "speeds": [ { - "speed_name": f"{idx+1}/{len(speeds)}", + "speed_name": f"{idx + 1}/{len(speeds)}", "speed_values": [{"lang": "en", "speed_synonym": x}], } for idx, x in enumerate(speeds) diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 4192a60513e..df0b11487d8 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -603,9 +603,9 @@ async def test_template_variables( hass, "hello", None, context, agent_id=mock_config_entry.entry_id ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert ( "The user name is Test User." in mock_model.mock_calls[0][2]["system_instruction"] diff --git a/tests/components/google_photos/test_services.py b/tests/components/google_photos/test_services.py index 381fb1c431f..e02253be887 100644 --- a/tests/components/google_photos/test_services.py +++ b/tests/components/google_photos/test_services.py @@ -74,24 +74,55 @@ def mock_upload_file( yield +@pytest.mark.parametrize( + ("media_items_result", "service_response"), + [ + ( + CreateMediaItemsResult( + new_media_item_results=[ + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-1"), + ) + ] + ), + [{"media_item_id": "new-media-item-id-1"}], + ), + ( + CreateMediaItemsResult( + new_media_item_results=[ + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-1"), + ), + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-2"), + ), + ] + ), + [ + {"media_item_id": "new-media-item-id-1"}, + {"media_item_id": "new-media-item-id-2"}, + ], + ), + ], +) @pytest.mark.usefixtures("setup_integration") async def test_upload_service( hass: HomeAssistant, config_entry: MockConfigEntry, mock_api: Mock, + media_items_result: CreateMediaItemsResult, + service_response: list[dict[str, str]], ) -> None: """Test service call to upload content.""" assert hass.services.has_service(DOMAIN, "upload") - mock_api.create_media_items.return_value = CreateMediaItemsResult( - new_media_item_results=[ - NewMediaItemResult( - upload_token="some-upload-token", - status=Status(code=200), - media_item=MediaItem(id="new-media-item-id-1"), - ) - ] - ) + mock_api.create_media_items.return_value = media_items_result response = await hass.services.async_call( DOMAIN, @@ -106,7 +137,7 @@ async def test_upload_service( ) assert response == { - "media_items": [{"media_item_id": "new-media-item-id-1"}], + "media_items": service_response, "album_id": "album-media-id-1", } diff --git a/tests/components/google_tasks/conftest.py b/tests/components/google_tasks/conftest.py index 7db78af6232..8f966800147 100644 --- a/tests/components/google_tasks/conftest.py +++ b/tests/components/google_tasks/conftest.py @@ -1,10 +1,12 @@ """Test fixtures for Google Tasks.""" from collections.abc import Awaitable, Callable +import json import time from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch +from httplib2 import Response import pytest from homeassistant.components.application_credentials import ( @@ -24,6 +26,26 @@ FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" FAKE_AUTH_IMPL = "conftest-imported-cred" +TASK_LIST = { + "id": "task-list-id-1", + "title": "My tasks", +} +LIST_TASK_LIST_RESPONSE = { + "items": [TASK_LIST], +} + +LIST_TASKS_RESPONSE_WATER = { + "items": [ + { + "id": "some-task-id", + "title": "Water", + "status": "needsAction", + "description": "Any size is ok", + "position": "00000000000000000001", + }, + ], +} + @pytest.fixture def platforms() -> list[Platform]: @@ -34,7 +56,7 @@ def platforms() -> list[Platform]: @pytest.fixture(name="expires_at") def mock_expires_at() -> int: """Fixture to set the oauth token expiration time.""" - return time.time() + 3600 + return time.time() + 86400 @pytest.fixture(name="token_entry") @@ -89,3 +111,31 @@ async def mock_integration_setup( return result return run + + +@pytest.fixture(name="api_responses") +def mock_api_responses() -> list[dict | list]: + """Fixture forcreate_response_object API responses to return during test.""" + return [] + + +def create_response_object(api_response: dict | list) -> tuple[Response, bytes]: + """Create an http response.""" + return ( + Response({"Content-Type": "application/json"}), + json.dumps(api_response).encode(), + ) + + +@pytest.fixture(name="response_handler") +def mock_response_handler(api_responses: list[dict | list]) -> list: + """Create a mock http2lib response handler.""" + return [create_response_object(api_response) for api_response in api_responses] + + +@pytest.fixture +def mock_http_response(response_handler: list | Callable) -> Mock: + """Fixture to fake out http2lib responses.""" + + with patch("httplib2.Http.request", side_effect=response_handler) as mock_response: + yield mock_response diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index 76611ba4a31..f32441354fc 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -15,7 +15,7 @@ ) # --- # name: test_create_todo_list_item[due].1 - '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}' + '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00+00:00", "notes": null}' # --- # name: test_create_todo_list_item[summary] tuple( @@ -137,7 +137,7 @@ ) # --- # name: test_partial_update[due_date].1 - '{"title": "Water", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}' + '{"title": "Water", "status": "needsAction", "due": "2023-11-18T00:00:00+00:00", "notes": null}' # --- # name: test_partial_update[empty_description] tuple( @@ -166,6 +166,33 @@ # name: test_partial_update_status[api_responses0].1 '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' # --- +# name: test_update_due_date[api_responses0-America/Regina] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_update_due_date[api_responses0-America/Regina].1 + '{"title": "Water", "status": "needsAction", "due": "2024-12-05T00:00:00+00:00", "notes": null}' +# --- +# name: test_update_due_date[api_responses0-Asia/Tokyo] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_update_due_date[api_responses0-Asia/Tokyo].1 + '{"title": "Water", "status": "needsAction", "due": "2024-12-05T00:00:00+00:00", "notes": null}' +# --- +# name: test_update_due_date[api_responses0-UTC] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_update_due_date[api_responses0-UTC].1 + '{"title": "Water", "status": "needsAction", "due": "2024-12-05T00:00:00+00:00", "notes": null}' +# --- # name: test_update_todo_list_item[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', diff --git a/tests/components/google_tasks/test_init.py b/tests/components/google_tasks/test_init.py index 1fe0e4a0c36..e93e0d9c643 100644 --- a/tests/components/google_tasks/test_init.py +++ b/tests/components/google_tasks/test_init.py @@ -2,8 +2,13 @@ from collections.abc import Awaitable, Callable import http +from http import HTTPStatus +import json import time +from unittest.mock import Mock +from aiohttp import ClientError +from httplib2 import Response import pytest from homeassistant.components.google_tasks import DOMAIN @@ -11,15 +16,21 @@ from homeassistant.components.google_tasks.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from .conftest import LIST_TASK_LIST_RESPONSE, LIST_TASKS_RESPONSE_WATER + from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.parametrize( + "api_responses", [[LIST_TASK_LIST_RESPONSE, LIST_TASKS_RESPONSE_WATER]] +) async def test_setup( hass: HomeAssistant, integration_setup: Callable[[], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, + mock_http_response: Mock, ) -> None: """Test successful setup and unload.""" assert config_entry.state is ConfigEntryState.NOT_LOADED @@ -34,13 +45,17 @@ async def test_setup( assert not hass.services.async_services().get(DOMAIN) -@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +@pytest.mark.parametrize("expires_at", [time.time() - 86400], ids=["expired"]) +@pytest.mark.parametrize( + "api_responses", [[LIST_TASK_LIST_RESPONSE, LIST_TASKS_RESPONSE_WATER]] +) async def test_expired_token_refresh_success( hass: HomeAssistant, integration_setup: Callable[[], Awaitable[bool]], aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, setup_credentials: None, + mock_http_response: Mock, ) -> None: """Test expired token is refreshed.""" @@ -50,8 +65,8 @@ async def test_expired_token_refresh_success( json={ "access_token": "updated-access-token", "refresh_token": "updated-refresh-token", - "expires_at": time.time() + 3600, - "expires_in": 3600, + "expires_at": time.time() + 86400, + "expires_in": 86400, }, ) @@ -59,24 +74,32 @@ async def test_expired_token_refresh_success( assert config_entry.state is ConfigEntryState.LOADED assert config_entry.data["token"]["access_token"] == "updated-access-token" - assert config_entry.data["token"]["expires_in"] == 3600 + assert config_entry.data["token"]["expires_in"] == 86400 @pytest.mark.parametrize( - ("expires_at", "status", "expected_state"), + ("expires_at", "status", "exc", "expected_state"), [ ( - time.time() - 3600, + time.time() - 86400, http.HTTPStatus.UNAUTHORIZED, + None, ConfigEntryState.SETUP_ERROR, ), ( - time.time() - 3600, + time.time() - 86400, http.HTTPStatus.INTERNAL_SERVER_ERROR, + None, + ConfigEntryState.SETUP_RETRY, + ), + ( + time.time() - 86400, + None, + ClientError("error"), ConfigEntryState.SETUP_RETRY, ), ], - ids=["unauthorized", "internal_server_error"], + ids=["unauthorized", "internal_server_error", "client_error"], ) async def test_expired_token_refresh_failure( hass: HomeAssistant, @@ -84,7 +107,8 @@ async def test_expired_token_refresh_failure( aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, setup_credentials: None, - status: http.HTTPStatus, + status: http.HTTPStatus | None, + exc: Exception | None, expected_state: ConfigEntryState, ) -> None: """Test failure while refreshing token with a transient error.""" @@ -93,8 +117,38 @@ async def test_expired_token_refresh_failure( aioclient_mock.post( OAUTH2_TOKEN, status=status, + exc=exc, ) await integration_setup() assert config_entry.state is expected_state + + +@pytest.mark.parametrize( + "response_handler", + [ + ([(Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR}), b"")]), + # First request succeeds, second request fails + ( + [ + ( + Response({"status": HTTPStatus.OK}), + json.dumps(LIST_TASK_LIST_RESPONSE), + ), + (Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR}), b""), + ] + ), + ], +) +async def test_setup_error( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Mock, + config_entry: MockConfigEntry, +) -> None: + """Test an error returned by the server when setting up the platform.""" + + assert not await integration_setup() + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index b0ee135d4a9..f28f1bb917e 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -4,12 +4,14 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus import json from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory from httplib2 import Response import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.google_tasks.coordinator import UPDATE_INTERVAL from homeassistant.components.todo import ( ATTR_DESCRIPTION, ATTR_DUE_DATE, @@ -19,20 +21,20 @@ from homeassistant.components.todo import ( DOMAIN as TODO_DOMAIN, TodoServices, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from .conftest import ( + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + create_response_object, +) + +from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator ENTITY_ID = "todo.my_tasks" -ITEM = { - "id": "task-list-id-1", - "title": "My tasks", -} -LIST_TASK_LIST_RESPONSE = { - "items": [ITEM], -} EMPTY_RESPONSE = {} LIST_TASKS_RESPONSE = { "items": [], @@ -49,17 +51,6 @@ ERROR_RESPONSE = { CONTENT_ID = "Content-ID" BOUNDARY = "batch_00972cc8-75bd-11ee-9692-0242ac110002" # Arbitrary uuid -LIST_TASKS_RESPONSE_WATER = { - "items": [ - { - "id": "some-task-id", - "title": "Water", - "status": "needsAction", - "description": "Any size is ok", - "position": "00000000000000000001", - }, - ], -} LIST_TASKS_RESPONSE_MULTIPLE = { "items": [ { @@ -149,20 +140,6 @@ async def ws_get_items( return get -@pytest.fixture(name="api_responses") -def mock_api_responses() -> list[dict | list]: - """Fixture for API responses to return during test.""" - return [] - - -def create_response_object(api_response: dict | list) -> tuple[Response, bytes]: - """Create an http response.""" - return ( - Response({"Content-Type": "application/json"}), - json.dumps(api_response).encode(), - ) - - def create_batch_response_object( content_ids: list[str], api_responses: list[dict | list | Response | None] ) -> tuple[Response, bytes]: @@ -225,20 +202,13 @@ def create_batch_response_handler( return _handler -@pytest.fixture(name="response_handler") -def mock_response_handler(api_responses: list[dict | list]) -> list: - """Create a mock http2lib response handler.""" - return [create_response_object(api_response) for api_response in api_responses] - - @pytest.fixture(autouse=True) -def mock_http_response(response_handler: list | Callable) -> Mock: - """Fixture to fake out http2lib responses.""" - - with patch("httplib2.Http.request", side_effect=response_handler) as mock_response: - yield mock_response +def setup_http_response(mock_http_response: Mock) -> None: + """Fixture to load the http response mock.""" + return +@pytest.mark.parametrize("timezone", ["America/Regina", "UTC", "Asia/Tokyo"]) @pytest.mark.parametrize( "api_responses", [ @@ -251,7 +221,7 @@ def mock_http_response(response_handler: list | Callable) -> Mock: "title": "Task 1", "status": "needsAction", "position": "0000000000000001", - "due": "2023-11-18T00:00:00+00:00", + "due": "2023-11-18T00:00:00Z", }, { "id": "task-2", @@ -271,8 +241,10 @@ async def test_get_items( integration_setup: Callable[[], Awaitable[bool]], hass_ws_client: WebSocketGenerator, ws_get_items: Callable[[], Awaitable[dict[str, str]]], + timezone: str, ) -> None: """Test getting todo list items.""" + await hass.config.async_set_time_zone(timezone) assert await integration_setup() @@ -300,29 +272,6 @@ async def test_get_items( assert state.state == "1" -@pytest.mark.parametrize( - "response_handler", - [ - ([(Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR}), b"")]), - ], -) -async def test_list_items_server_error( - hass: HomeAssistant, - setup_credentials: None, - integration_setup: Callable[[], Awaitable[bool]], - hass_ws_client: WebSocketGenerator, - ws_get_items: Callable[[], Awaitable[dict[str, str]]], -) -> None: - """Test an error returned by the server when setting up the platform.""" - - assert await integration_setup() - - await hass_ws_client(hass) - - state = hass.states.get("todo.my_tasks") - assert state is None - - @pytest.mark.parametrize( "api_responses", [ @@ -358,7 +307,9 @@ async def test_empty_todo_list( [ [ LIST_TASK_LIST_RESPONSE, - ERROR_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + ERROR_RESPONSE, # Fail after one update interval + LIST_TASKS_RESPONSE_WATER, ] ], ) @@ -366,18 +317,34 @@ async def test_task_items_error_response( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], - hass_ws_client: WebSocketGenerator, - ws_get_items: Callable[[], Awaitable[dict[str, str]]], + freezer: FrozenDateTimeFactory, ) -> None: - """Test an error while getting todo list items.""" + """Test an error while the entity updates getting a new list of todo list items.""" assert await integration_setup() - await hass_ws_client(hass) + # Test successful setup and first data fetch + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + # Next update fails + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("todo.my_tasks") assert state - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE + + # Next update succeeds + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" @pytest.mark.parametrize( @@ -484,6 +451,39 @@ async def test_update_todo_list_item( assert call.kwargs.get("body") == snapshot +@pytest.mark.parametrize("timezone", ["America/Regina", "UTC", "Asia/Tokyo"]) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) +async def test_update_due_date( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, + timezone: str, +) -> None: + """Test for updating the due date of a To-do item and timezone.""" + await hass.config.async_set_time_zone(timezone) + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + {ATTR_ITEM: "some-task-id", ATTR_DUE_DATE: "2024-12-5"}, + target={ATTR_ENTITY_ID: "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + @pytest.mark.parametrize( "api_responses", [ diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index af870a2136d..18d96e3a1c0 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -12,7 +12,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, async_fire_time_changed +from tests.common import ( + MockEntityPlatform, + assert_setup_component, + async_fire_time_changed, +) NAME = "foo" @@ -111,11 +115,12 @@ def fake_delay(hass: HomeAssistant, ha_delay: int) -> None: async_fire_time_changed(hass, shifted_time) -def test_name(requests_mock: requests_mock.Mocker) -> None: +def test_name(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: """Test the name.""" api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] + sensor.platform = MockEntityPlatform(hass) test_name = value["name"] assert test_name == sensor.name diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index af8556b5450..91604d663b3 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -12,7 +12,6 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, @@ -792,19 +791,19 @@ async def test_emulated_color_temp_group(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light_group", ATTR_COLOR_TEMP: 200}, + {ATTR_ENTITY_ID: "light.light_group", ATTR_COLOR_TEMP_KELVIN: 5000}, blocking=True, ) await hass.async_block_till_done() state = hass.states.get("light.test1") assert state.state == STATE_ON - assert state.attributes[ATTR_COLOR_TEMP] == 200 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 5000 assert ATTR_HS_COLOR in state.attributes state = hass.states.get("light.test2") assert state.state == STATE_ON - assert state.attributes[ATTR_COLOR_TEMP] == 200 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 5000 assert ATTR_HS_COLOR in state.attributes state = hass.states.get("light.test3") diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index bbf2d98b492..e3a01c05eca 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -161,7 +161,8 @@ async def test_send_message_with_data(hass: HomeAssistant, tmp_path: Path) -> No "data": {"hello": "world", "test": "message", "default": "default"}, }, ), - ] + ], + any_order=True, ) send_message_mock.reset_mock() diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index 6c06171a45f..5f0d54aaa0d 100644 --- a/tests/components/guardian/test_config_flow.py +++ b/tests/components/guardian/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch from aioguardian.errors import GuardianError import pytest -from homeassistant.components import dhcp, zeroconf from homeassistant.components.guardian import CONF_UID, DOMAIN from homeassistant.components.guardian.config_flow import ( async_get_pin_from_discovery_hostname, @@ -17,6 +16,8 @@ from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCO from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -82,7 +83,7 @@ async def test_step_user(hass: HomeAssistant, config: dict[str, Any]) -> None: @pytest.mark.usefixtures("setup_guardian") async def test_step_zeroconf(hass: HomeAssistant) -> None: """Test the zeroconf step.""" - zeroconf_data = zeroconf.ZeroconfServiceInfo( + zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], port=7777, @@ -112,7 +113,7 @@ async def test_step_zeroconf(hass: HomeAssistant) -> None: async def test_step_zeroconf_already_in_progress(hass: HomeAssistant) -> None: """Test the zeroconf step aborting because it's already in progress.""" - zeroconf_data = zeroconf.ZeroconfServiceInfo( + zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], port=7777, @@ -138,7 +139,7 @@ async def test_step_zeroconf_already_in_progress(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_guardian") async def test_step_dhcp(hass: HomeAssistant) -> None: """Test the dhcp step.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddeeff", @@ -164,7 +165,7 @@ async def test_step_dhcp(hass: HomeAssistant) -> None: async def test_step_dhcp_already_in_progress(hass: HomeAssistant) -> None: """Test the zeroconf step aborting because it's already in progress.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddeeff", @@ -193,7 +194,7 @@ async def test_step_dhcp_already_setup_match_mac(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddabcd", @@ -215,7 +216,7 @@ async def test_step_dhcp_already_setup_match_ip(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddabcd", diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 8d729f4358f..daf1c669463 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -1,68 +1,44 @@ """Tests for the habitica component.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch +from habiticalib import ( + BadRequestError, + HabiticaCastSkillResponse, + HabiticaContentResponse, + HabiticaErrorResponse, + HabiticaGroupMembersResponse, + HabiticaLoginResponse, + HabiticaQuestResponse, + HabiticaResponse, + HabiticaScoreResponse, + HabiticaSleepResponse, + HabiticaTaskOrderResponse, + HabiticaTaskResponse, + HabiticaTasksResponse, + HabiticaUserAnonymizedrResponse, + HabiticaUserResponse, + NotAuthorizedError, + NotFoundError, + TaskFilter, + TooManyRequestsError, +) import pytest -from yarl import URL from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_json_object_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry, load_fixture - -@pytest.fixture(autouse=True) -def disable_plumbum(): - """Disable plumbum in tests as it can cause the test suite to fail. - - plumbum can leave behind PlumbumTimeoutThreads - """ - with patch("plumbum.local"), patch("plumbum.colors"): - yield - - -def mock_called_with( - mock_client: AiohttpClientMocker, - method: str, - url: str, -) -> tuple | None: - """Assert request mock was called with json data.""" - - return next( - ( - call - for call in mock_client.mock_calls - if call[0].upper() == method.upper() and call[1] == URL(url) - ), - None, - ) - - -@pytest.fixture -def mock_habitica(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: - """Mock aiohttp requests.""" - - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", json=load_json_object_fixture("user.json", DOMAIN) - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - params={"type": "completedTodos"}, - json=load_json_object_fixture("completed_todos.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - json=load_json_object_fixture("tasks.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/content", - params={"language": "en"}, - json=load_json_object_fixture("content.json", DOMAIN), - ) - - return aioclient_mock +ERROR_RESPONSE = HabiticaErrorResponse(success=False, error="error", message="reason") +ERROR_NOT_AUTHORIZED = NotAuthorizedError(error=ERROR_RESPONSE, headers={}) +ERROR_NOT_FOUND = NotFoundError(error=ERROR_RESPONSE, headers={}) +ERROR_BAD_REQUEST = BadRequestError(error=ERROR_RESPONSE, headers={}) +ERROR_TOO_MANY_REQUESTS = TooManyRequestsError( + error=ERROR_RESPONSE, headers={"retry-after": 5} +) @pytest.fixture(name="config_entry") @@ -73,10 +49,10 @@ def mock_config_entry() -> MockConfigEntry: title="test-user", data={ CONF_URL: DEFAULT_URL, - CONF_API_USER: "test-api-user", - CONF_API_KEY: "test-api-key", + CONF_API_USER: "a380546a-94be-4b8e-8a0b-23e0d5c03303", + CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382", }, - unique_id="00000000-0000-0000-0000-000000000000", + unique_id="a380546a-94be-4b8e-8a0b-23e0d5c03303", ) @@ -84,3 +60,109 @@ def mock_config_entry() -> MockConfigEntry: async def set_tz(hass: HomeAssistant) -> None: """Fixture to set timezone.""" await hass.config.async_set_time_zone("Europe/Berlin") + + +def mock_get_tasks(task_type: TaskFilter | None = None) -> HabiticaTasksResponse: + """Load tasks fixtures.""" + + if task_type is TaskFilter.COMPLETED_TODOS: + return HabiticaTasksResponse.from_json( + load_fixture("completed_todos.json", DOMAIN) + ) + return HabiticaTasksResponse.from_json(load_fixture("tasks.json", DOMAIN)) + + +@pytest.fixture(name="habitica") +async def mock_habiticalib() -> Generator[AsyncMock]: + """Mock habiticalib.""" + + with ( + patch( + "homeassistant.components.habitica.Habitica", autospec=True + ) as mock_client, + patch( + "homeassistant.components.habitica.config_flow.Habitica", new=mock_client + ), + ): + client = mock_client.return_value + + client.login.return_value = HabiticaLoginResponse.from_json( + load_fixture("login.json", DOMAIN) + ) + + client.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture("user.json", DOMAIN) + ) + + client.cast_skill.return_value = HabiticaCastSkillResponse.from_json( + load_fixture("cast_skill_response.json", DOMAIN) + ) + client.toggle_sleep.return_value = HabiticaSleepResponse( + success=True, data=True + ) + client.update_score.return_value = HabiticaUserResponse.from_json( + load_fixture("score_with_drop.json", DOMAIN) + ) + client.get_group_members.return_value = HabiticaGroupMembersResponse.from_json( + load_fixture("party_members.json", DOMAIN) + ) + for func in ( + "leave_quest", + "reject_quest", + "cancel_quest", + "abort_quest", + "start_quest", + "accept_quest", + ): + getattr(client, func).return_value = HabiticaQuestResponse.from_json( + load_fixture("party_quest.json", DOMAIN) + ) + client.get_content.return_value = HabiticaContentResponse.from_json( + load_fixture("content.json", DOMAIN) + ) + client.get_tasks.side_effect = mock_get_tasks + client.update_score.return_value = HabiticaScoreResponse.from_json( + load_fixture("score_with_drop.json", DOMAIN) + ) + client.update_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + client.create_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + client.delete_task.return_value = HabiticaResponse.from_dict( + {"data": {}, "success": True} + ) + client.delete_completed_todos.return_value = HabiticaResponse.from_dict( + {"data": {}, "success": True} + ) + client.reorder_task.return_value = HabiticaTaskOrderResponse.from_dict( + {"data": [], "success": True} + ) + client.get_user_anonymized.return_value = ( + HabiticaUserAnonymizedrResponse.from_json( + load_fixture("anonymized.json", DOMAIN) + ) + ) + client.habitipy.return_value = { + "tasks": { + "user": { + "post": AsyncMock( + return_value={ + "text": "Use API from Home Assistant", + "type": "todo", + } + ) + } + } + } + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.habitica.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/habitica/fixtures/anonymized.json b/tests/components/habitica/fixtures/anonymized.json new file mode 100644 index 00000000000..e0251f4c1bc --- /dev/null +++ b/tests/components/habitica/fixtures/anonymized.json @@ -0,0 +1,661 @@ +{ + "success": true, + "data": { + "user": { + "auth": { + "timestamps": { + "created": "2024-10-10T15:57:01.106Z", + "loggedin": "2024-11-27T19:34:28.887Z", + "updated": "2024-11-27T20:05:19.047Z" + } + }, + "achievements": { + "ultimateGearSets": { + "healer": false, + "wizard": false, + "rogue": false, + "warrior": false + }, + "streak": 0, + "perfect": 2, + "quests": {}, + "completedTask": true, + "partyUp": true, + "snowball": 1, + "spookySparkles": 1, + "seafoam": 1, + "shinySeed": 1, + "createdTask": true + }, + "backer": {}, + "purchased": { + "ads": false, + "txnCount": 0, + "skin": {}, + "hair": {}, + "shirt": {}, + "background": { + "violet": true, + "blue": true, + "green": true, + "purple": true, + "red": true, + "yellow": true + } + }, + "flags": { + "tour": { + "intro": -1, + "classes": -1, + "stats": -1, + "tavern": -1, + "party": -1, + "guilds": -1, + "challenges": -1, + "market": -1, + "pets": -1, + "mounts": -1, + "hall": -1, + "equipment": -1, + "groupPlans": -1 + }, + "tutorial": { + "common": { + "habits": true, + "dailies": true, + "todos": true, + "rewards": true, + "party": true, + "pets": true, + "gems": true, + "skills": true, + "classes": true, + "tavern": true, + "equipment": true, + "items": true, + "mounts": true, + "inbox": true, + "stats": true + }, + "ios": { + "addTask": false, + "editTask": false, + "deleteTask": false, + "filterTask": false, + "groupPets": false, + "inviteParty": false, + "reorderTask": false + } + }, + "verifiedUsername": true, + "customizationsNotification": true, + "showTour": true, + "dropsEnabled": false, + "itemsEnabled": true, + "lastNewStuffRead": "", + "rewrite": true, + "classSelected": false, + "rebirthEnabled": false, + "levelDrops": {}, + "recaptureEmailsPhase": 0, + "weeklyRecapEmailsPhase": 0, + "lastWeeklyRecap": "2024-10-10T15:57:01.106Z", + "communityGuidelinesAccepted": true, + "cronCount": 6, + "welcomed": true, + "armoireEnabled": true, + "armoireOpened": false, + "armoireEmpty": false, + "cardReceived": false, + "warnedLowHealth": true, + "thirdPartyTools": "2024-11-27T19:32:18.826Z", + "newStuff": false + }, + "history": { + "exp": [ + { + "date": "2024-10-30T19:37:01.970Z", + "value": 24 + }, + { + "date": "2024-10-31T23:33:14.972Z", + "value": 48 + }, + { + "date": "2024-11-05T18:25:04.681Z", + "value": 66 + }, + { + "date": "2024-11-21T15:09:07.501Z", + "value": 66 + }, + { + "date": "2024-11-22T00:41:21.137Z", + "value": 66 + }, + { + "date": "2024-11-27T19:34:28.887Z", + "value": 66 + } + ], + "todos": [ + { + "date": "2024-10-30T19:37:01.970Z", + "value": -5 + }, + { + "date": "2024-10-31T23:33:14.972Z", + "value": -10.129783523135325 + }, + { + "date": "2024-11-05T18:25:04.681Z", + "value": -16.396221153338182 + }, + { + "date": "2024-11-21T15:09:07.501Z", + "value": -22.8326979965846 + }, + { + "date": "2024-11-22T00:41:21.137Z", + "value": -29.448636229365235 + }, + { + "date": "2024-11-27T19:34:28.887Z", + "value": -36.25425987861077 + } + ] + }, + "items": { + "gear": { + "equipped": { + "armor": "armor_base_0", + "head": "head_base_0", + "shield": "shield_base_0" + }, + "costume": { + "armor": "armor_base_0", + "head": "head_base_0", + "shield": "shield_base_0" + }, + "owned": { + "headAccessory_special_blackHeadband": true, + "headAccessory_special_blueHeadband": true, + "headAccessory_special_greenHeadband": true, + "headAccessory_special_pinkHeadband": true, + "headAccessory_special_redHeadband": true, + "headAccessory_special_whiteHeadband": true, + "headAccessory_special_yellowHeadband": true, + "eyewear_special_blackTopFrame": true, + "eyewear_special_blueTopFrame": true, + "eyewear_special_greenTopFrame": true, + "eyewear_special_pinkTopFrame": true, + "eyewear_special_redTopFrame": true, + "eyewear_special_whiteTopFrame": true, + "eyewear_special_yellowTopFrame": true, + "eyewear_special_blackHalfMoon": true, + "eyewear_special_blueHalfMoon": true, + "eyewear_special_greenHalfMoon": true, + "eyewear_special_pinkHalfMoon": true, + "eyewear_special_redHalfMoon": true, + "eyewear_special_whiteHalfMoon": true, + "eyewear_special_yellowHalfMoon": true, + "armor_special_bardRobes": true, + "head_special_bardHat": true + } + }, + "special": { + "snowball": 0, + "spookySparkles": 0, + "shinySeed": 0, + "seafoam": 0, + "valentine": 0, + "nye": 0, + "greeting": 0, + "greetingReceived": [], + "thankyou": 0, + "thankyouReceived": [], + "birthday": 0, + "birthdayReceived": [], + "congrats": 0, + "congratsReceived": [], + "getwell": 0, + "getwellReceived": [], + "goodluck": 0, + "goodluckReceived": [] + }, + "lastDrop": { + "count": 0, + "date": "2024-11-05T18:25:04.619Z" + }, + "currentPet": "", + "currentMount": "", + "pets": {}, + "eggs": { + "BearCub": 1, + "Cactus": 1, + "Wolf": 1, + "Dragon": 1 + }, + "hatchingPotions": { + "Skeleton": 1, + "Zombie": 1, + "RoyalPurple": 1 + }, + "food": { + "Candy_Red": 1, + "Chocolate": 1, + "Meat": 1, + "CottonCandyPink": 1 + }, + "mounts": {}, + "quests": { + "dustbunnies": 1 + } + }, + "party": { + "quest": { + "progress": { + "up": 0, + "down": 0, + "collectedItems": 0, + "collect": {} + }, + "RSVPNeeded": true, + "key": "dustbunnies", + "completed": null + }, + "order": "level", + "orderAscending": "ascending", + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "preferences": { + "hair": { + "color": "red", + "base": 3, + "bangs": 1, + "beard": 0, + "mustache": 0, + "flower": 1 + }, + "emailNotifications": { + "unsubscribeFromAll": false, + "newPM": true, + "kickedGroup": true, + "wonChallenge": true, + "giftedGems": true, + "giftedSubscription": true, + "invitedParty": true, + "invitedGuild": true, + "questStarted": true, + "invitedQuest": true, + "importantAnnouncements": true, + "weeklyRecaps": true, + "onboarding": true, + "majorUpdates": true, + "subscriptionReminders": true, + "contentRelease": true + }, + "pushNotifications": { + "unsubscribeFromAll": false, + "newPM": true, + "wonChallenge": true, + "giftedGems": true, + "giftedSubscription": true, + "invitedParty": true, + "invitedGuild": true, + "questStarted": true, + "invitedQuest": true, + "majorUpdates": true, + "mentionParty": true, + "mentionJoinedGuild": true, + "mentionUnjoinedGuild": true, + "partyActivity": true, + "contentRelease": true + }, + "suppressModals": { + "levelUp": false, + "hatchPet": false, + "raisePet": false, + "streak": false + }, + "tasks": { + "activeFilter": { + "habit": "all", + "daily": "all", + "todo": "remaining", + "reward": "all" + }, + "groupByChallenge": false, + "confirmScoreNotes": false, + "mirrorGroupTasks": [] + }, + "language": "de", + "dayStart": 0, + "size": "slim", + "hideHeader": false, + "skin": "915533", + "shirt": "blue", + "timezoneOffset": -60, + "sound": "rosstavoTheme", + "chair": "none", + "allocationMode": "flat", + "autoEquip": true, + "costume": false, + "dateFormat": "MM/dd/yyyy", + "sleep": false, + "stickyHeader": true, + "disableClasses": false, + "newTaskEdit": false, + "dailyDueDefaultView": false, + "advancedCollapsed": false, + "toolbarCollapsed": false, + "reverseChatOrder": false, + "developerMode": false, + "displayInviteToPartyWhenPartyIs1": true, + "webhooks": {}, + "improvementCategories": [], + "background": "violet", + "timezoneOffsetAtLastCron": -60 + }, + "stats": { + "buffs": { + "str": 0, + "int": 0, + "per": 0, + "con": 0, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "training": { + "int": 0, + "per": 0, + "str": 0, + "con": 0 + }, + "hp": 25.40000000000002, + "mp": 32, + "exp": 41, + "gp": 11.100978952781748, + "lvl": 2, + "class": "warrior", + "points": 2, + "str": 0, + "con": 0, + "int": 0, + "per": 0, + "toNextLevel": 50, + "maxHealth": 50, + "maxMP": 32 + }, + "inbox": { + "newMessages": 0, + "optOut": false, + "blocks": [], + "messages": {} + }, + "tasksOrder": { + "habits": ["30923acd-3b4c-486d-9ef3-c8f57cf56049"], + "dailys": ["6e53f1f5-a315-4edd-984d-8d762e4a08ef"], + "todos": ["e6e06dc6-c887-4b86-b175-b99cc2e20fdf"], + "rewards": ["2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9"] + }, + "_id": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "_v": 131, + "balance": 0, + "_subSignature": "NOT_RUNNING", + "challenges": [], + "guilds": [], + "loginIncentives": 6, + "invitesSent": 0, + "pinnedItemsOrder": [], + "lastCron": "2024-11-27T19:34:28.887Z", + "tags": [ + { + "id": "c1a35186-9895-4ac0-9cd7-49e7bb875695", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "53d1deb8-ed2b-4f94-bbfc-955e9e92aa98", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "29bf6a99-536f-446b-838f-a81d41e1ed4d", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "1b1297e7-4fd8-460a-b148-e92d7bcfa9a5", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "05e6cf40-48ea-415a-9b8b-e2ecad258ef6", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "fe53f179-59d8-4c28-9bf7-b9068ab552a4", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "c44e9e8c-4bff-42df-98d5-1a1a7b69eada", + "name": "tag", + "challenge": "challenge" + } + ], + "extra": {}, + "pushDevices": [], + "pinnedItems": [ + { + "path": "gear.flat.weapon_warrior_0", + "type": "marketGear" + }, + { + "path": "gear.flat.armor_warrior_1", + "type": "marketGear" + }, + { + "path": "gear.flat.shield_warrior_1", + "type": "marketGear" + }, + { + "path": "gear.flat.head_warrior_1", + "type": "marketGear" + }, + { + "path": "potion", + "type": "potion" + }, + { + "path": "armoire", + "type": "armoire" + } + ], + "unpinnedItems": [], + "id": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7" + }, + "tasks": [ + { + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "30923acd-3b4c-486d-9ef3-c8f57cf56049", + "up": true, + "down": false, + "counterUp": 0, + "counterDown": 0, + "frequency": "daily", + "history": [], + "type": "habit", + "text": "task text", + "notes": "task notes", + "tags": [], + "value": 0, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "reminders": [], + "createdAt": "2024-10-10T15:57:14.287Z", + "updatedAt": "2024-10-10T15:57:14.287Z", + "userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "id": "30923acd-3b4c-486d-9ef3-c8f57cf56049" + }, + { + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "e6e06dc6-c887-4b86-b175-b99cc2e20fdf", + "completed": false, + "collapseChecklist": false, + "type": "todo", + "text": "task text", + "notes": "task notes", + "tags": [], + "value": -6.418582324043852, + "priority": 1, + "attribute": "str", + "byHabitica": true, + "checklist": [], + "reminders": [], + "createdAt": "2024-10-10T15:57:14.290Z", + "updatedAt": "2024-11-27T19:34:29.001Z", + "userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "id": "e6e06dc6-c887-4b86-b175-b99cc2e20fdf" + }, + { + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9", + "type": "reward", + "text": "task text", + "notes": "task notes", + "tags": [], + "value": 10, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "reminders": [], + "createdAt": "2024-10-10T15:57:14.290Z", + "updatedAt": "2024-10-10T15:57:14.290Z", + "userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "id": "2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9" + }, + { + "repeat": { + "m": true, + "t": true, + "w": true, + "th": true, + "f": true, + "s": true, + "su": true + }, + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef", + "frequency": "weekly", + "everyX": 1, + "streak": 0, + "nextDue": [ + "2024-11-27T23:00:00.000Z", + "2024-11-28T23:00:00.000Z", + "2024-11-29T23:00:00.000Z", + "2024-12-01T23:00:00.000Z", + "2024-12-02T23:00:00.000Z", + "2024-12-03T23:00:00.000Z" + ], + "yesterDaily": true, + "history": [ + { + "date": 1730317021817, + "value": 1, + "isDue": true, + "completed": true + }, + { + "date": 1730417594890, + "value": 1.9747, + "isDue": true, + "completed": true + }, + { + "date": 1730831104730, + "value": 1.024043774264157, + "isDue": true, + "completed": false + }, + { + "date": 1732201747573, + "value": 0.049944135963563174, + "isDue": true, + "completed": false + }, + { + "date": 1732236081228, + "value": -0.9487768368544092, + "isDue": true, + "completed": false + }, + { + "date": 1732736068973, + "value": -1.973387732005249, + "isDue": true, + "completed": false + } + ], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "task text", + "notes": "task notes", + "tags": [], + "value": -1.973387732005249, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "startDate": "2024-10-09T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-10-10T15:57:14.304Z", + "updatedAt": "2024-11-27T19:34:29.001Z", + "userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "isDue": true, + "id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + } + ] + }, + "notifications": [ + { + "id": "35708dfc-74c6-4352-97d3-8aeddbb31d20", + "type": "NEW_CHAT_MESSAGE", + "data": { + "group": { + "id": "94cd398c-2240-4320-956e-6d345cf2c0de", + "name": "tests Party" + } + }, + "seen": false + } + ], + "userV": 131, + "appVersion": "5.29.2" +} diff --git a/tests/components/habitica/fixtures/cast_skill_response.json b/tests/components/habitica/fixtures/cast_skill_response.json new file mode 100644 index 00000000000..41880770394 --- /dev/null +++ b/tests/components/habitica/fixtures/cast_skill_response.json @@ -0,0 +1,216 @@ +{ + "success": true, + "data": { + "user": { + "api_user": "test-api-user", + "profile": { "name": "test-user" }, + "auth": { "local": { "username": "test-username" } }, + "stats": { + "buffs": { + "str": 26, + "int": 26, + "per": 26, + "con": 26, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "hp": 0, + "mp": 50.89999999999998, + "exp": 737, + "gp": 137.62587214609795, + "lvl": 38, + "class": "wizard", + "maxHealth": 50, + "maxMP": 166, + "toNextLevel": 880, + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 + }, + "preferences": { + "sleep": false, + "automaticAllocation": true, + "disableClasses": false, + "language": "en" + }, + "flags": { + "classSelected": true + }, + "tasksOrder": { + "rewards": ["5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"], + "todos": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "dailys": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + ], + "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] + }, + "party": { + "quest": { + "RSVPNeeded": true, + "key": "dustbunnies" + }, + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "tags": [ + { + "id": "8515e4ae-2f4b-455a-b4a4-8939e04b1bfd", + "name": "Arbeit" + }, + { + "id": "6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab", + "name": "Training" + }, + { + "id": "20409521-c096-447f-9a90-23e8da615710", + "name": "Gesundheit + Wohlbefinden" + }, + { + "id": "2ac458af-0833-4f3f-bf04-98a0c33ef60b", + "name": "Schule" + }, + { + "id": "1bcb1a0f-4d05-4087-8223-5ea779e258b0", + "name": "Teams" + }, + { + "id": "b2780f82-b3b5-49a3-a677-48f2c8c7e3bb", + "name": "Hausarbeiten" + }, + { + "id": "3450351f-1323-4c7e-9fd2-0cdff25b3ce0", + "name": "Kreativität" + } + ], + "needsCron": true, + "lastCron": "2024-09-21T22:01:55.586Z", + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "back_special_heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" + } + } + }, + "balance": 10 + }, + "task": { + "_id": "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "date": "2024-08-31T22:16:00.000Z", + "completed": false, + "collapseChecklist": false, + "checklist": [], + "type": "todo", + "text": "Rechnungen bezahlen", + "notes": "Strom- und Internetrechnungen rechtzeitig überweisen.", + "tags": [], + "value": 0, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "reminders": [ + { + "id": "91c09432-10ac-4a49-bd20-823081ec29ed", + "time": "2024-09-22T02:00:00.0000Z" + } + ], + "byHabitica": false, + "createdAt": "2024-09-21T22:17:19.513Z", + "updatedAt": "2024-09-21T22:19:35.576Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "id": "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "alias": "pay_bills" + } + }, + "notifications": [ + { + "type": "ITEM_RECEIVED", + "data": { + "icon": "notif_habitoween_base_pet", + "title": "Happy Habitoween!", + "text": "For this spooky celebration, you've received a Jack-O-Lantern Pet and an assortment of candy for your Pets!", + "destination": "/inventory/stable" + }, + "seen": false, + "id": "5af98f52-f72a-4540-bdeb-3ffc39b34196" + }, + { + "type": "ITEM_RECEIVED", + "data": { + "icon": "notif_harvestfeast_base_pet", + "title": "Happy Harvest Feast!", + "text": "Gobble gobble, you've received the Turkey Pet!", + "destination": "/inventory/stable" + }, + "seen": false, + "id": "1e4a1481-e7ca-42d1-9b3f-3f442bef9435" + }, + { + "type": "ITEM_RECEIVED", + "data": { + "title": "Happy New Year!", + "destination": "/inventory/equipment", + "icon": "notif_2013hat_nye", + "text": "Take on your resolutions with style in this Absurd Party Hat!" + }, + "seen": false, + "id": "61fe229b-feee-48b9-bb4d-9e4b9c088ab1" + }, + { + "type": "NEW_STUFF", + "data": { + "title": "SNAG YOUR FAVES FROM THE QUEST SHOP - NEW SELECTIONS ARE ON THE WAY!" + }, + "seen": true, + "id": "0decabad-57f8-4cb2-a158-ba7b44da890f" + }, + { + "id": "f6fb06bc-6e63-40e1-b8c8-76bd5889ef51", + "type": "NEW_CHAT_MESSAGE", + "data": { + "group": { + "id": "a8289328-c2ae-4007-9ef4-833b9ac90d37", + "name": "tr4nt0r_2's Party" + } + }, + "seen": false + }, + { + "type": "UNALLOCATED_STATS_POINTS", + "data": { + "points": 21 + }, + "seen": false, + "id": "a88ba092-2d4d-40f9-bf87-902aedf954fe" + } + ], + "userV": 677, + "appVersion": "5.32.5" +} diff --git a/tests/components/habitica/fixtures/content.json b/tests/components/habitica/fixtures/content.json index e8e14dead73..e26dbeb17cc 100644 --- a/tests/components/habitica/fixtures/content.json +++ b/tests/components/habitica/fixtures/content.json @@ -2,6 +2,88 @@ "success": true, "data": { "gear": { + "tree": { + "weapon": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "armor": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "head": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "shield": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "back": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "body": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "headAccessory": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "eyewear": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + } + }, "flat": { "weapon_warrior_5": { "text": "Ruby Sword", @@ -281,6 +363,300 @@ "per": 0 } } + }, + "achievements": {}, + "questSeriesAchievements": {}, + "animalColorAchievements": [], + "animalSetAchievements": {}, + "stableAchievements": {}, + "petSetCompleteAchievs": [], + "quests": { + "atom1": { + "text": "Angriff des Banalen, Teil 1: Abwasch-Katastrophe!", + "notes": "Du erreichst die Ufer des Waschbeckensees für eine wohlverdiente Auszeit ... Aber der See ist verschmutzt mit nicht abgespültem Geschirr! Wie ist das passiert? Wie auch immer, Du kannst den See jedenfalls nicht in diesem Zustand lassen. Es gibt nur eine Sache die Du tun kannst: Abspülen und den Ferienort retten! Dazu musst Du aber Seife für den Abwasch finden. Viel Seife ...", + "completion": "Nach gründlichem Schrubben ist das Geschirr sicher am Ufer gestapelt! Du trittst zurück und begutachtest stolz Deiner Hände Arbeit.", + "group": "questGroupAtom", + "prerequisite": { + "lvl": 15 + }, + "value": 4, + "lvl": 15, + "category": "unlockable", + "collect": { + "soapBars": { + "text": "Seifenstücke", + "count": 20 + } + }, + "drop": { + "items": [ + { + "type": "quests", + "key": "atom2", + "text": "Das Monster vom KochLess (Schriftrolle)", + "onlyOwner": true + } + ], + "gp": 7, + "exp": 50 + }, + "key": "atom1" + }, + "goldenknight1": { + "text": "Die goldene Ritterin, Teil 1: Ein ernstes Gespräch", + "notes": "Die goldene Ritterin ist Habiticanern mit ihrer Kritik ganz schön auf die Nerven gegangen. Nicht alle Tagesaufgaben erledigt? Eine negative Gewohnheit angeklickt? Sie nimmt dies zum Anlass Dich zu bedrängen, dass Du doch ihrem Beispiel folgen sollst. Sie ist das leuchtende Beispiel eines perfekten Habiticaners und Du bist nichts als ein Versager. Das ist ja mal gar nicht nett! Jeder macht Fehler. Man sollte deshalb nicht mit solcher Kritik drangsaliert werden. Vielleicht solltest Du einige Zeugenaussagen von verletzten Habiticanern zusammentragen und die Goldene Ritterin mal ordentlich zurechtweisen!", + "completion": "Schau Dir nur all diese Zeugenaussagen an! Bestimmt wird das reichen, um die Goldene Ritterin zu überzeugen. Nun musst Du sie nur noch finden.", + "group": "questGroupGoldenknight", + "value": 4, + "lvl": 40, + "category": "unlockable", + "collect": { + "testimony": { + "text": "Zeugenaussagen", + "count": 60 + } + }, + "drop": { + "items": [ + { + "type": "quests", + "key": "goldenknight2", + "text": "Die goldene Ritterin Teil 2: Die goldene Ritterin (Schriftrolle)", + "onlyOwner": true + } + ], + "gp": 15, + "exp": 120 + }, + "key": "goldenknight1" + }, + "dustbunnies": { + "text": "Die ungezähmten Staubmäuse", + "notes": "Es ist schon etwas her, seit Du hier drinnen das letzte Mal Staub gewischt hast, aber Du sorgst dich nicht allzusehr - ein Wenig Staub hat noch nie jemandem geschadet, oder? Erst, als Du Deine Hand in eine der staubigsten Ecken steckst und einen Biss spürst, erinnerst du dich an @InspectorCaracals Warnung: Harmlosen Staub zu lange in Ruhe zu lassen, verwandelt ihn in boshafte Staubmäuse! Du solltest sie besser besiegen, bevor sie ganz Habitica mit feinen Schmutzpartikeln bedecken!", + "group": "questGroupEarnable", + "completion": "Die Staubmäuse verschwinden in einer Rauch-, äh… Staubwolke. Als sich der Staub legt, siehst du dich um. Du hast vergessen, wie hübsch es hier doch aussieht, wenn es sauber ist. Du erkennst einen kleinen Haufen Gold, wo der Staub vorher war. Huch, du hattest dich schon gefragt, wo er abgeblieben war!", + "value": 1, + "category": "unlockable", + "boss": { + "name": "Ungezähmte Staubmäuse", + "hp": 100, + "str": 0.5, + "def": 1 + }, + "drop": { + "gp": 8, + "exp": 42 + }, + "key": "dustbunnies" + }, + "basilist": { + "text": "Der Basi-List", + "notes": "Da ist ein Aufruhr auf dem Marktplatz – es sieht ganz so aus, als ob man lieber in die andere Richtung rennen sollte. Da Du aber ein mutiger Abenteurer bist, rennst Du stattdessen darauf zu und entdeckst einen Basi-List, der sich aus einem Haufen unerledigter Aufgaben geformt hat! Alle umstehenden Habiticaner sind aus Angst vor der Länge des Basi-Lists gelähmt und können nicht anfangen zu arbeiten. Von irgendwo in der Nähe hörst Du @Arcosine schreien: \"Schnell! Erledige Deine To-Dos und Tagesaufgaben, um dem Monster die Zähne zu entfernen, bevor sich jemand am Papier schneidet!\" Greife schnell an, Abenteurer, und hake etwas ab - aber Vorsicht! Wenn Du irgendwelche Tagesaufgaben nicht erledigst, wird der Basi-List Dich und Deine Party angreifen!", + "group": "questGroupEarnable", + "completion": "Der Basi-List ist in Papierschnitzel zerfallen, die sanft in Regenbogenfarben schimmern. \"Puh!\" sagt @Arcosine. \"Gut, dass ihr gerade hier wart!\" Du fühlst Dich erfahrener als vorher und sammelst ein paar verstreute Goldstücke zwischen den Papierstücken auf.", + "goldValue": 100, + "category": "unlockable", + "unlockCondition": { + "condition": "party invite", + "text": "Lade Freunde ein" + }, + "boss": { + "name": "Der Basi-List", + "hp": 100, + "str": 0.5, + "def": 1 + }, + "drop": { + "gp": 8, + "exp": 42 + }, + "key": "basilist" + } + }, + "questsByLevel": {}, + "userCanOwnQuestCategories": [], + "itemList": { + "weapon": { + "localeKey": "weapon", + "isEquipment": true + }, + "armor": { + "localeKey": "armor", + "isEquipment": true + }, + "head": { + "localeKey": "headgear", + "isEquipment": true + }, + "shield": { + "localeKey": "offhand", + "isEquipment": true + }, + "back": { + "localeKey": "back", + "isEquipment": true + }, + "body": { + "localeKey": "body", + "isEquipment": true + }, + "headAccessory": { + "localeKey": "headAccessory", + "isEquipment": true + }, + "eyewear": { + "localeKey": "eyewear", + "isEquipment": true + }, + "hatchingPotions": { + "localeKey": "hatchingPotion", + "isEquipment": false + }, + "premiumHatchingPotions": { + "localeKey": "hatchingPotion", + "isEquipment": false + }, + "eggs": { + "localeKey": "eggSingular", + "isEquipment": false + }, + "quests": { + "localeKey": "quest", + "isEquipment": false + }, + "food": { + "localeKey": "foodTextThe", + "isEquipment": false + }, + "Saddle": { + "localeKey": "foodSaddleText", + "isEquipment": false + }, + "bundles": { + "localeKey": "discountBundle", + "isEquipment": false + } + }, + "spells": { + "wizard": {}, + "warrior": {}, + "rogue": {}, + "healer": {}, + "special": {} + }, + "officialPinnedItems": [], + "audioThemes": [], + "classes": [], + "gearTypes": [], + "cardTypes": {}, + "special": {}, + "dropEggs": {}, + "questEggs": {}, + "eggs": { + "Wolf": { + "text": "Wolfsjunges", + "mountText": "Wolfs-Reittier", + "adjective": "ein treues", + "value": 3, + "key": "Wolf", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein treues Wolfsjunges schlüpfen kann." + }, + "TigerCub": { + "text": "Tigerjunges", + "mountText": "Tiger-Reittier", + "adjective": "ein wildes", + "value": 3, + "key": "TigerCub", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein wildes Tigerjunges schlüpfen kann." + }, + "PandaCub": { + "text": "Pandajunges", + "mountText": "Panda-Reittier", + "adjective": "ein sanftes", + "value": 3, + "key": "PandaCub", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein sanftes Pandajunges schlüpfen kann." + } + }, + "dropHatchingPotions": {}, + "premiumHatchingPotions": {}, + "wackyHatchingPotions": {}, + "hatchingPotions": { + "Base": { + "value": 2, + "key": "Base", + "text": "Normales", + "notes": "Gieße dies über ein Ei und es wird ein Normales Haustier daraus schlüpfen.", + "premium": false, + "limited": false + }, + "White": { + "value": 2, + "key": "White", + "text": "Weißes", + "notes": "Gieße dies über ein Ei und es wird ein Weißes Haustier daraus schlüpfen.", + "premium": false, + "limited": false + }, + "Desert": { + "value": 2, + "key": "Desert", + "text": "Wüstenfarbenes", + "notes": "Gieße dies über ein Ei und es wird ein Wüstenfarbenes Haustier daraus schlüpfen.", + "premium": false, + "limited": false + } + }, + "pets": {}, + "premiumPets": {}, + "questPets": {}, + "specialPets": {}, + "wackyPets": {}, + "petInfo": {}, + "mounts": {}, + "premiumMounts": {}, + "questMounts": {}, + "specialMounts": {}, + "mountInfo": {}, + "food": { + "Meat": { + "text": "Fleisch", + "textA": "Fleisch", + "textThe": "das Fleisch", + "target": "Base", + "value": 1, + "key": "Meat", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Milk": { + "text": "Milch", + "textA": "Milch", + "textThe": "die Milch", + "target": "White", + "value": 1, + "key": "Milk", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Potatoe": { + "text": "Kartoffel", + "textA": "eine Kartoffel", + "textThe": "die Kartoffel", + "target": "Desert", + "value": 1, + "key": "Potatoe", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Saddle": { + "sellWarningNote": "Hey! Das ist ein sehr nützlicher Gegenstand! Weißt Du, wie Du den Sattel mit Deinen Haustieren nutzt?", + "text": "Magischer Sattel", + "value": 5, + "notes": "Lässt eines Deiner Haustiere augenblicklich zum Reittier heranwachsen.", + "canDrop": false, + "key": "Saddle" + } } }, "appVersion": "5.29.2" diff --git a/tests/components/habitica/fixtures/duedate_fixture_9.json b/tests/components/habitica/fixtures/duedate_fixture_9.json new file mode 100644 index 00000000000..f908ad0deae --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_9.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "weekly", + "everyX": 1, + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": false + }, + "streak": 1, + "nextDue": ["2024-09-20T22:00:00.000Z", "2024-09-27T22:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-09-25T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/login.json b/tests/components/habitica/fixtures/login.json new file mode 100644 index 00000000000..f0c598b3cee --- /dev/null +++ b/tests/components/habitica/fixtures/login.json @@ -0,0 +1,10 @@ +{ + "success": true, + "data": { + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "apiToken": "cd0e5985-17de-4b4f-849e-5d506c5e4382", + "newUser": false, + "username": "test-username" + }, + "appVersion": "5.30.0" +} diff --git a/tests/components/habitica/fixtures/party_quest.json b/tests/components/habitica/fixtures/party_quest.json new file mode 100644 index 00000000000..c140a4525bc --- /dev/null +++ b/tests/components/habitica/fixtures/party_quest.json @@ -0,0 +1,31 @@ +{ + "success": true, + "data": { + "progress": { + "collect": {}, + "hp": 100 + }, + "key": "dustbunnies", + "active": true, + "leader": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "members": { + "a380546a-94be-4b8e-8a0b-23e0d5c03303": true + }, + "extra": {} + }, + "notifications": [ + { + "id": "3f59313f-6d7c-4fff-a8c4-3b153d828c6f", + "type": "NEW_CHAT_MESSAGE", + "data": { + "group": { + "id": "94cd398c-2240-4320-956e-6d345cf2c0de", + "name": "tests Party" + } + }, + "seen": false + } + ], + "userV": 287, + "appVersion": "5.29.2" +} diff --git a/tests/components/habitica/fixtures/task.json b/tests/components/habitica/fixtures/task.json new file mode 100644 index 00000000000..2816c368198 --- /dev/null +++ b/tests/components/habitica/fixtures/task.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": { + "_id": "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "date": "2024-09-27T22:17:00.000Z", + "completed": false, + "collapseChecklist": false, + "checklist": [], + "type": "todo", + "text": "Buch zu Ende lesen", + "notes": "Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.", + "tags": [], + "value": 0, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "reminders": [], + "byHabitica": false, + "createdAt": "2024-09-21T22:17:57.816Z", + "updatedAt": "2024-09-21T22:17:57.816Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "id": "88de7cd9-af2b-49ce-9afd-bf941d87336b" + }, + "notifications": [ + { + "type": "ITEM_RECEIVED", + "data": { + "icon": "notif_orca_mount", + "title": "Orcas for Summer Splash!", + "text": "To celebrate Summer Splash, we've given you an Orca Mount!", + "destination": "stable" + }, + "seen": true, + "id": "b7a85df1-06ed-4ab1-b56d-43418fc6a5e5" + }, + { + "type": "UNALLOCATED_STATS_POINTS", + "data": { + "points": 2 + }, + "seen": true, + "id": "bc3f8a69-231f-4eb1-ba48-a00b6c0e0f37" + } + ], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 7784b9c7f49..cf6e3864675 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -233,13 +233,20 @@ "startDate": "2024-07-06T22:00:00.000Z", "daysOfMonth": [], "weeksOfMonth": [], - "checklist": [], + "checklist": [ + { + "completed": false, + "id": "c8662c16-8cd3-4104-a3b2-b1e54f61b8ca", + "text": "Checklist-item1" + } + ], "reminders": [], "createdAt": "2024-07-07T17:51:53.268Z", "updatedAt": "2024-09-21T22:24:20.154Z", "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", "isDue": true, - "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "alias": "alias_zahnseide_benutzen" }, { "_id": "f2c85972-1a19-4426-bc6d-ce3337b9d99f", @@ -386,11 +393,17 @@ "history": [], "completed": false, "collapseChecklist": false, - "checklist": [], + "checklist": [ + { + "completed": true, + "id": "c8662c16-8cd3-4104-a3b2-b1e54f61b8ca", + "text": "Checklist-item1" + } + ], "type": "daily", "text": "Fitnessstudio besuchen", "notes": "Ein einstündiges Workout im Fitnessstudio absolvieren.", - "tags": ["51076966-2970-4b40-b6ba-d58c6a756dd7"], + "tags": ["6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab"], "value": 0, "priority": 2, "attribute": "str", @@ -416,7 +429,10 @@ "type": "todo", "text": "Buch zu Ende lesen", "notes": "Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.", - "tags": [], + "tags": [ + "20409521-c096-447f-9a90-23e8da615710", + "8515e4ae-2f4b-455a-b4a4-8939e04b1bfd" + ], "value": 0, "priority": 1, "attribute": "str", @@ -532,6 +548,106 @@ "updatedAt": "2024-07-07T17:51:53.266Z", "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", "id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + }, + { + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": true + }, + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef", + "frequency": "monthly", + "everyX": 1, + "streak": 1, + "nextDue": [ + "2024-12-14T23:00:00.000Z", + "2025-01-18T23:00:00.000Z", + "2025-02-15T23:00:00.000Z", + "2025-03-15T23:00:00.000Z", + "2025-04-19T23:00:00.000Z", + "2025-05-17T23:00:00.000Z" + ], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Arbeite an einem kreativen Projekt", + "notes": "Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!", + "tags": [], + "value": -0.9215181434950852, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "startDate": "2024-09-20T23:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [3], + "checklist": [], + "reminders": [], + "createdAt": "2024-10-10T15:57:14.304Z", + "updatedAt": "2024-11-27T23:47:29.986Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + }, + { + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": false + }, + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "7d92278b-9361-4854-83b6-0a66b57dce20", + "frequency": "weekly", + "everyX": 1, + "streak": 1, + "nextDue": [ + "2024-12-14T23:00:00.000Z", + "2025-01-18T23:00:00.000Z", + "2025-02-15T23:00:00.000Z", + "2025-03-15T23:00:00.000Z", + "2025-04-19T23:00:00.000Z", + "2025-05-17T23:00:00.000Z" + ], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Lerne eine neue Programmiersprache", + "notes": "Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.", + "tags": [], + "value": -0.9215181434950852, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "startDate": "2024-09-20T23:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-10-10T15:57:14.304Z", + "updatedAt": "2024-11-27T23:47:29.986Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "7d92278b-9361-4854-83b6-0a66b57dce20" } ], "notifications": [ diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index a498de910ef..255d9c7c3b5 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -55,7 +55,8 @@ "e97659e0-2c42-4599-a7bb-00282adc410d", "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", "f2c85972-1a19-4426-bc6d-ce3337b9d99f", - "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1" + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef" ], "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] }, @@ -66,6 +67,36 @@ }, "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" }, + "tags": [ + { + "id": "8515e4ae-2f4b-455a-b4a4-8939e04b1bfd", + "name": "Arbeit" + }, + { + "id": "6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab", + "name": "Training" + }, + { + "id": "20409521-c096-447f-9a90-23e8da615710", + "name": "Gesundheit + Wohlbefinden" + }, + { + "id": "2ac458af-0833-4f3f-bf04-98a0c33ef60b", + "name": "Schule" + }, + { + "id": "1bcb1a0f-4d05-4087-8223-5ea779e258b0", + "name": "Teams" + }, + { + "id": "b2780f82-b3b5-49a3-a677-48f2c8c7e3bb", + "name": "Hausarbeiten" + }, + { + "id": "3450351f-1323-4c7e-9fd2-0cdff25b3ce0", + "name": "Kreativität" + } + ], "needsCron": true, "lastCron": "2024-09-21T22:01:55.586Z", "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", @@ -81,6 +112,36 @@ "eyewear": "eyewear_armoire_plagueDoctorMask", "body": "body_special_aetherAmulet" } + }, + "quests": { + "atom1": 1, + "goldenknight1": 0, + "dustbunnies": 1, + "basilist": 0 + }, + "food": { + "Saddle": 2, + "Meat": 0, + "Milk": 1, + "Potatoe": 2 + }, + "hatchingPotions": { + "Base": 2, + "White": 0, + "Desert": 1 + }, + "eggs": { + "Wolf": 1, + "TigerCub": 0, + "PandaCub": 2 + } + }, + "balance": 10, + "purchased": { + "plan": { + "consecutive": { + "trinkets": 0 + } } } } diff --git a/tests/components/habitica/fixtures/user_no_party.json b/tests/components/habitica/fixtures/user_no_party.json new file mode 100644 index 00000000000..1c58dde6f50 --- /dev/null +++ b/tests/components/habitica/fixtures/user_no_party.json @@ -0,0 +1,86 @@ +{ + "success": true, + "data": { + "api_user": "test-api-user", + "profile": { "name": "test-user" }, + "auth": { "local": { "username": "test-username" } }, + "stats": { + "buffs": { + "str": 26, + "int": 26, + "per": 26, + "con": 26, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "hp": 0, + "mp": 50.89999999999998, + "exp": 737, + "gp": 137.62587214609795, + "lvl": 38, + "class": "wizard", + "maxHealth": 50, + "maxMP": 166, + "toNextLevel": 880, + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 + }, + "preferences": { + "sleep": false, + "automaticAllocation": true, + "disableClasses": false, + "language": "en" + }, + "flags": { + "classSelected": true + }, + "tasksOrder": { + "rewards": ["5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"], + "todos": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "dailys": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1" + ], + "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] + }, + "party": { + "quest": { + "RSVPNeeded": true, + "key": "dustbunnies" + } + }, + "needsCron": true, + "lastCron": "2024-09-21T22:01:55.586Z", + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "back_special_heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" + } + } + } + } +} diff --git a/tests/components/habitica/snapshots/test_binary_sensor.ambr b/tests/components/habitica/snapshots/test_binary_sensor.ambr index c18f8f551c9..0a4076a6135 100644 --- a/tests/components/habitica/snapshots/test_binary_sensor.ambr +++ b/tests/components/habitica/snapshots/test_binary_sensor.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_pending_quest', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_pending_quest', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/habitica/snapshots/test_button.ambr b/tests/components/habitica/snapshots/test_button.ambr index c8f92650874..76a0198d5b2 100644 --- a/tests/components/habitica/snapshots/test_button.ambr +++ b/tests/components/habitica/snapshots/test_button.ambr @@ -27,8 +27,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', 'unit_of_measurement': None, }) # --- @@ -73,8 +73,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_heal_all', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_heal_all', 'unit_of_measurement': None, }) # --- @@ -120,8 +120,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', 'unit_of_measurement': None, }) # --- @@ -167,8 +167,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_heal', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_heal', 'unit_of_measurement': None, }) # --- @@ -214,8 +214,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_protect_aura', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_protect_aura', 'unit_of_measurement': None, }) # --- @@ -261,8 +261,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_revive', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', 'unit_of_measurement': None, }) # --- @@ -307,8 +307,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_brightness', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_brightness', 'unit_of_measurement': None, }) # --- @@ -354,8 +354,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', 'unit_of_measurement': None, }) # --- @@ -400,8 +400,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', 'unit_of_measurement': None, }) # --- @@ -446,8 +446,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', 'unit_of_measurement': None, }) # --- @@ -493,8 +493,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_revive', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', 'unit_of_measurement': None, }) # --- @@ -539,8 +539,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', 'unit_of_measurement': None, }) # --- @@ -585,8 +585,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_stealth', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_stealth', 'unit_of_measurement': None, }) # --- @@ -632,8 +632,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_tools_of_trade', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_tools_of_trade', 'unit_of_measurement': None, }) # --- @@ -679,8 +679,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', 'unit_of_measurement': None, }) # --- @@ -725,8 +725,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', 'unit_of_measurement': None, }) # --- @@ -772,8 +772,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_defensive_stance', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_defensive_stance', 'unit_of_measurement': None, }) # --- @@ -819,8 +819,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_intimidate', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_intimidate', 'unit_of_measurement': None, }) # --- @@ -866,8 +866,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_revive', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', 'unit_of_measurement': None, }) # --- @@ -912,8 +912,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', 'unit_of_measurement': None, }) # --- @@ -958,8 +958,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_valorous_presence', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_valorous_presence', 'unit_of_measurement': None, }) # --- @@ -1005,8 +1005,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', 'unit_of_measurement': None, }) # --- @@ -1051,8 +1051,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', 'unit_of_measurement': None, }) # --- @@ -1098,8 +1098,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_frost', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_frost', 'unit_of_measurement': None, }) # --- @@ -1145,8 +1145,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_earth', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_earth', 'unit_of_measurement': None, }) # --- @@ -1192,8 +1192,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_mpheal', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mpheal', 'unit_of_measurement': None, }) # --- @@ -1239,8 +1239,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_revive', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', 'unit_of_measurement': None, }) # --- @@ -1285,8 +1285,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index c2f9c8e83c9..8be45ccc0fd 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -1,5 +1,21 @@ # serializer version: 1 -# name: test_api_events[calendar.test_user_dailies] +# name: test_api_events[date range in the past-calendar.test_user_dailies] + list([ + ]) +# --- +# name: test_api_events[date range in the past-calendar.test_user_daily_reminders] + list([ + ]) +# --- +# name: test_api_events[date range in the past-calendar.test_user_to_do_reminders] + list([ + ]) +# --- +# name: test_api_events[date range in the past-calendar.test_user_to_do_s] + list([ + ]) +# --- +# name: test_api_events[default date range-calendar.test_user_dailies] list([ dict({ 'description': 'Klicke um Deinen Terminplan festzulegen!', @@ -577,7 +593,7 @@ }), ]) # --- -# name: test_api_events[calendar.test_user_daily_reminders] +# name: test_api_events[default date range-calendar.test_user_daily_reminders] list([ dict({ 'description': 'Klicke um Deinen Terminplan festzulegen!', @@ -819,7 +835,7 @@ }), ]) # --- -# name: test_api_events[calendar.test_user_to_do_reminders] +# name: test_api_events[default date range-calendar.test_user_to_do_reminders] list([ dict({ 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.', @@ -837,7 +853,7 @@ }), ]) # --- -# name: test_api_events[calendar.test_user_to_do_s] +# name: test_api_events[default date range-calendar.test_user_to_do_s] list([ dict({ 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.', @@ -912,7 +928,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_dailys', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_dailys', 'unit_of_measurement': None, }) # --- @@ -965,7 +981,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_daily_reminders', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_daily_reminders', 'unit_of_measurement': None, }) # --- @@ -1017,7 +1033,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_todo_reminders', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todo_reminders', 'unit_of_measurement': None, }) # --- @@ -1069,7 +1085,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_todos', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todos', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..b4304e33ec8 --- /dev/null +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -0,0 +1,1029 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry_data': dict({ + 'api_user': 'a380546a-94be-4b8e-8a0b-23e0d5c03303', + 'url': 'https://habitica.com', + }), + 'habitica_data': dict({ + 'tasks': list([ + dict({ + 'Type': 'habit', + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': '2024-10-10T15:57:14.287000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': False, + 'everyX': None, + 'frequency': 'daily', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '30923acd-3b4c-486d-9ef3-c8f57cf56049', + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'task notes', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'task text', + 'up': True, + 'updatedAt': '2024-10-10T15:57:14.287000+00:00', + 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': 'todo', + 'alias': None, + 'attribute': 'str', + 'byHabitica': True, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.290000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': 'e6e06dc6-c887-4b86-b175-b99cc2e20fdf', + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'task notes', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'task text', + 'up': None, + 'updatedAt': '2024-11-27T19:34:29.001000+00:00', + 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', + 'value': -6.418582324043852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': 'reward', + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.290000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': '2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9', + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'task notes', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'task text', + 'up': None, + 'updatedAt': '2024-10-10T15:57:14.290000+00:00', + 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', + 'value': 10.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': 'daily', + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.304000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'weekly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': '2024-10-30T19:37:01.817000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': True, + 'date': '2024-10-31T23:33:14.890000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.9747, + }), + dict({ + 'completed': False, + 'date': '2024-11-05T18:25:04.730000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.024043774264157, + }), + dict({ + 'completed': False, + 'date': '2024-11-21T15:09:07.573000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.049944135963563174, + }), + dict({ + 'completed': False, + 'date': '2024-11-22T00:41:21.228000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9487768368544092, + }), + dict({ + 'completed': False, + 'date': '2024-11-27T19:34:28.973000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.973387732005249, + }), + ]), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', + 'isDue': True, + 'nextDue': list([ + '2024-11-27T23:00:00+00:00', + '2024-11-28T23:00:00+00:00', + '2024-11-29T23:00:00+00:00', + '2024-12-01T23:00:00+00:00', + '2024-12-02T23:00:00+00:00', + '2024-12-03T23:00:00+00:00', + ]), + 'notes': 'task notes', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': '2024-10-09T22:00:00+00:00', + 'streak': 0, + 'tags': list([ + ]), + 'text': 'task text', + 'up': None, + 'updatedAt': '2024-11-27T19:34:29.001000+00:00', + 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', + 'value': -1.973387732005249, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + ]), + 'user': dict({ + 'achievements': dict({ + 'backToBasics': None, + 'boneCollector': None, + 'challenges': list([ + ]), + 'completedTask': True, + 'createdTask': True, + 'dustDevil': None, + 'fedPet': None, + 'goodAsGold': None, + 'hatchedPet': None, + 'joinedChallenge': None, + 'joinedGuild': None, + 'partyUp': None, + 'perfect': 2, + 'primedForPainting': None, + 'purchasedEquipment': None, + 'quests': dict({ + 'atom1': None, + 'atom2': None, + 'atom3': None, + 'bewilder': None, + 'burnout': None, + 'dilatory': None, + 'dilatory_derby': None, + 'dysheartener': None, + 'evilsanta': None, + 'evilsanta2': None, + 'gryphon': None, + 'harpy': None, + 'stressbeast': None, + 'vice1': None, + 'vice3': None, + }), + 'seeingRed': None, + 'shadyCustomer': None, + 'streak': 0, + 'tickledPink': None, + 'ultimateGearSets': dict({ + 'healer': False, + 'rogue': False, + 'warrior': False, + 'wizard': False, + }), + 'violetsAreBlue': None, + }), + 'auth': dict({ + 'apple': None, + 'facebook': None, + 'google': None, + 'local': dict({ + 'email': None, + 'has_password': None, + 'lowerCaseUsername': None, + 'username': None, + }), + 'timestamps': dict({ + 'created': '2024-10-10T15:57:01.106000+00:00', + 'loggedin': '2024-11-27T19:34:28.887000+00:00', + 'updated': '2024-11-27T20:05:19.047000+00:00', + }), + }), + 'backer': dict({ + 'npc': None, + 'tier': None, + 'tokensApplied': None, + }), + 'balance': 0.0, + 'challenges': list([ + ]), + 'contributor': dict({ + 'contributions': None, + 'level': None, + 'text': None, + }), + 'extra': dict({ + }), + 'flags': dict({ + 'armoireEmpty': False, + 'armoireEnabled': True, + 'armoireOpened': False, + 'cardReceived': False, + 'chatRevoked': None, + 'chatShadowMuted': None, + 'classSelected': False, + 'communityGuidelinesAccepted': True, + 'cronCount': 6, + 'customizationsNotification': True, + 'dropsEnabled': False, + 'itemsEnabled': True, + 'lastFreeRebirth': None, + 'lastNewStuffRead': '', + 'lastWeeklyRecap': '2024-10-10T15:57:01.106000+00:00', + 'lastWeeklyRecapDiscriminator': None, + 'levelDrops': dict({ + }), + 'mathUpdates': None, + 'newStuff': False, + 'onboardingEmailsPhase': None, + 'rebirthEnabled': False, + 'recaptureEmailsPhase': 0, + 'rewrite': True, + 'showTour': True, + 'thirdPartyTools': '2024-11-27T19:32:18.826000+00:00', + 'tour': dict({ + 'challenges': -1, + 'classes': -1, + 'equipment': -1, + 'groupPlans': -1, + 'guilds': -1, + 'hall': -1, + 'intro': -1, + 'market': -1, + 'mounts': -1, + 'party': -1, + 'pets': -1, + 'stats': -1, + 'tavern': -1, + }), + 'tutorial': dict({ + 'common': dict({ + 'classes': True, + 'dailies': True, + 'equipment': True, + 'gems': True, + 'habits': True, + 'inbox': True, + 'items': True, + 'mounts': True, + 'party': True, + 'pets': True, + 'rewards': True, + 'skills': True, + 'stats': True, + 'tavern': True, + 'todos': True, + }), + 'ios': dict({ + 'addTask': False, + 'deleteTask': False, + 'editTask': False, + 'filterTask': False, + 'groupPets': False, + 'inviteParty': False, + 'reorderTask': False, + }), + }), + 'verifiedUsername': True, + 'warnedLowHealth': True, + 'weeklyRecapEmailsPhase': 0, + 'welcomed': True, + }), + 'guilds': list([ + ]), + 'history': dict({ + 'exp': list([ + dict({ + 'completed': None, + 'date': '2024-10-30T19:37:01.970000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 24.0, + }), + dict({ + 'completed': None, + 'date': '2024-10-31T23:33:14.972000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 48.0, + }), + dict({ + 'completed': None, + 'date': '2024-11-05T18:25:04.681000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 66.0, + }), + dict({ + 'completed': None, + 'date': '2024-11-21T15:09:07.501000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 66.0, + }), + dict({ + 'completed': None, + 'date': '2024-11-22T00:41:21.137000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 66.0, + }), + dict({ + 'completed': None, + 'date': '2024-11-27T19:34:28.887000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 66.0, + }), + ]), + 'todos': list([ + dict({ + 'completed': None, + 'date': '2024-10-30T19:37:01.970000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -5.0, + }), + dict({ + 'completed': None, + 'date': '2024-10-31T23:33:14.972000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -10.129783523135325, + }), + dict({ + 'completed': None, + 'date': '2024-11-05T18:25:04.681000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -16.396221153338182, + }), + dict({ + 'completed': None, + 'date': '2024-11-21T15:09:07.501000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -22.8326979965846, + }), + dict({ + 'completed': None, + 'date': '2024-11-22T00:41:21.137000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -29.448636229365235, + }), + dict({ + 'completed': None, + 'date': '2024-11-27T19:34:28.887000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -36.25425987861077, + }), + ]), + }), + 'id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', + 'inbox': dict({ + 'blocks': list([ + ]), + 'messages': dict({ + }), + 'newMessages': 0, + 'optOut': False, + }), + 'invitations': dict({ + 'guilds': list([ + ]), + 'parties': list([ + ]), + 'party': dict({ + }), + }), + 'invitesSent': 0, + 'items': dict({ + 'currentMount': '', + 'currentPet': '', + 'eggs': dict({ + 'BearCub': 1, + 'Cactus': 1, + 'Dragon': 1, + 'Wolf': 1, + }), + 'food': dict({ + 'Candy_Red': 1, + 'Chocolate': 1, + 'CottonCandyPink': 1, + 'Meat': 1, + }), + 'gear': dict({ + 'costume': dict({ + 'armor': 'armor_base_0', + 'back': None, + 'body': None, + 'eyewear': None, + 'head': 'head_base_0', + 'headAccessory': None, + 'shield': 'shield_base_0', + 'weapon': None, + }), + 'equipped': dict({ + 'armor': 'armor_base_0', + 'back': None, + 'body': None, + 'eyewear': None, + 'head': 'head_base_0', + 'headAccessory': None, + 'shield': 'shield_base_0', + 'weapon': None, + }), + 'owned': dict({ + 'armor_special_bardRobes': True, + 'eyewear_special_blackHalfMoon': True, + 'eyewear_special_blackTopFrame': True, + 'eyewear_special_blueHalfMoon': True, + 'eyewear_special_blueTopFrame': True, + 'eyewear_special_greenHalfMoon': True, + 'eyewear_special_greenTopFrame': True, + 'eyewear_special_pinkHalfMoon': True, + 'eyewear_special_pinkTopFrame': True, + 'eyewear_special_redHalfMoon': True, + 'eyewear_special_redTopFrame': True, + 'eyewear_special_whiteHalfMoon': True, + 'eyewear_special_whiteTopFrame': True, + 'eyewear_special_yellowHalfMoon': True, + 'eyewear_special_yellowTopFrame': True, + 'headAccessory_special_blackHeadband': True, + 'headAccessory_special_blueHeadband': True, + 'headAccessory_special_greenHeadband': True, + 'headAccessory_special_pinkHeadband': True, + 'headAccessory_special_redHeadband': True, + 'headAccessory_special_whiteHeadband': True, + 'headAccessory_special_yellowHeadband': True, + 'head_special_bardHat': True, + }), + }), + 'hatchingPotions': dict({ + 'RoyalPurple': 1, + 'Skeleton': 1, + 'Zombie': 1, + }), + 'lastDrop': dict({ + 'count': 0, + 'date': '2024-11-05T18:25:04.619000+00:00', + }), + 'mounts': dict({ + }), + 'pets': dict({ + }), + 'quests': dict({ + 'dustbunnies': 1, + }), + 'special': dict({ + 'birthday': 0, + 'birthdayReceived': list([ + ]), + 'congrats': 0, + 'congratsReceived': list([ + ]), + 'getwell': 0, + 'getwellReceived': list([ + ]), + 'goodluck': 0, + 'goodluckReceived': list([ + ]), + 'greeting': 0, + 'greetingReceived': list([ + ]), + 'nye': 0, + 'nyeReceived': list([ + ]), + 'seafoam': 0, + 'shinySeed': 0, + 'snowball': 0, + 'spookySparkles': 0, + 'thankyou': 0, + 'thankyouReceived': list([ + ]), + 'valentine': 0, + 'valentineReceived': list([ + ]), + }), + }), + 'lastCron': '2024-11-27T19:34:28.887000+00:00', + 'loginIncentives': 6, + 'needsCron': None, + 'newMessages': dict({ + }), + 'notifications': list([ + ]), + 'party': dict({ + '_id': '94cd398c-2240-4320-956e-6d345cf2c0de', + 'order': 'level', + 'orderAscending': 'ascending', + 'quest': dict({ + 'RSVPNeeded': True, + 'completed': None, + 'key': 'dustbunnies', + 'progress': dict({ + 'collect': dict({ + }), + 'collectedItems': 0, + 'down': 0.0, + 'up': 0.0, + }), + }), + }), + 'permissions': dict({ + 'challengeAdmin': None, + 'coupons': None, + 'fullAccess': None, + 'moderator': None, + 'news': None, + 'userSupport': None, + }), + 'pinnedItems': list([ + dict({ + 'Type': 'marketGear', + 'path': 'gear.flat.weapon_warrior_0', + }), + dict({ + 'Type': 'marketGear', + 'path': 'gear.flat.armor_warrior_1', + }), + dict({ + 'Type': 'marketGear', + 'path': 'gear.flat.shield_warrior_1', + }), + dict({ + 'Type': 'marketGear', + 'path': 'gear.flat.head_warrior_1', + }), + dict({ + 'Type': 'potion', + 'path': 'potion', + }), + dict({ + 'Type': 'armoire', + 'path': 'armoire', + }), + ]), + 'pinnedItemsOrder': list([ + ]), + 'preferences': dict({ + 'advancedCollapsed': False, + 'allocationMode': 'flat', + 'autoEquip': True, + 'automaticAllocation': None, + 'background': 'violet', + 'chair': 'none', + 'costume': False, + 'dailyDueDefaultView': False, + 'dateFormat': 'MM/dd/yyyy', + 'dayStart': 0, + 'developerMode': False, + 'disableClasses': False, + 'displayInviteToPartyWhenPartyIs1': True, + 'emailNotifications': dict({ + 'contentRelease': True, + 'giftedGems': True, + 'giftedSubscription': True, + 'importantAnnouncements': True, + 'invitedGuild': True, + 'invitedParty': True, + 'invitedQuest': True, + 'kickedGroup': True, + 'majorUpdates': True, + 'newPM': True, + 'onboarding': True, + 'questStarted': True, + 'subscriptionReminders': True, + 'unsubscribeFromAll': False, + 'weeklyRecaps': True, + 'wonChallenge': True, + }), + 'hair': dict({ + 'bangs': 1, + 'base': 3, + 'beard': 0, + 'color': 'red', + 'flower': 1, + 'mustache': 0, + }), + 'hideHeader': False, + 'improvementCategories': list([ + ]), + 'language': 'de', + 'newTaskEdit': False, + 'pushNotifications': dict({ + 'contentRelease': True, + 'giftedGems': True, + 'giftedSubscription': True, + 'invitedGuild': True, + 'invitedParty': True, + 'invitedQuest': True, + 'majorUpdates': True, + 'mentionJoinedGuild': True, + 'mentionParty': True, + 'mentionUnjoinedGuild': True, + 'newPM': True, + 'partyActivity': True, + 'questStarted': True, + 'unsubscribeFromAll': False, + 'wonChallenge': True, + }), + 'reverseChatOrder': False, + 'shirt': 'blue', + 'size': 'slim', + 'skin': '915533', + 'sleep': False, + 'sound': 'rosstavoTheme', + 'stickyHeader': True, + 'suppressModals': dict({ + 'hatchPet': False, + 'levelUp': False, + 'raisePet': False, + 'streak': False, + }), + 'tasks': dict({ + 'activeFilter': dict({ + 'daily': 'all', + 'habit': 'all', + 'reward': 'all', + 'todo': 'remaining', + }), + 'confirmScoreNotes': False, + 'groupByChallenge': False, + 'mirrorGroupTasks': list([ + ]), + }), + 'timezoneOffset': -60, + 'timezoneOffsetAtLastCron': -60, + 'toolbarCollapsed': False, + 'webhooks': dict({ + }), + }), + 'profile': dict({ + 'blurb': None, + 'imageUrl': None, + 'name': None, + }), + 'purchased': dict({ + 'ads': False, + 'background': dict({ + 'blue': True, + 'green': True, + 'purple': True, + 'red': True, + 'violet': True, + 'yellow': True, + }), + 'hair': dict({ + }), + 'mobileChat': None, + 'plan': dict({ + 'consecutive': dict({ + 'count': None, + 'gemCapExtra': None, + 'offset': None, + 'trinkets': None, + }), + 'dateUpdated': None, + 'extraMonths': None, + 'gemsBought': None, + 'mysteryItems': list([ + ]), + 'perkMonthCount': None, + 'quantity': None, + }), + 'shirt': dict({ + }), + 'skin': dict({ + }), + 'txnCount': 0, + }), + 'pushDevices': list([ + ]), + 'secret': None, + 'stats': dict({ + 'Class': 'warrior', + 'Int': 0, + 'Str': 0, + 'buffs': dict({ + 'Int': 0, + 'Str': 0, + 'con': 0, + 'per': 0, + 'seafoam': False, + 'shinySeed': False, + 'snowball': False, + 'spookySparkles': False, + 'stealth': 0, + 'streaks': False, + }), + 'con': 0, + 'exp': 41, + 'gp': 11.100978952781748, + 'hp': 25.40000000000002, + 'lvl': 2, + 'maxHealth': 50, + 'maxMP': 32, + 'mp': 32.0, + 'per': 0, + 'points': 2, + 'toNextLevel': 50, + 'training': dict({ + 'Int': 0, + 'Str': 0.0, + 'con': 0, + 'per': 0, + }), + }), + 'tags': list([ + dict({ + 'challenge': True, + 'group': None, + 'id': 'c1a35186-9895-4ac0-9cd7-49e7bb875695', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': '53d1deb8-ed2b-4f94-bbfc-955e9e92aa98', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': '29bf6a99-536f-446b-838f-a81d41e1ed4d', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': '1b1297e7-4fd8-460a-b148-e92d7bcfa9a5', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': '05e6cf40-48ea-415a-9b8b-e2ecad258ef6', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': 'fe53f179-59d8-4c28-9bf7-b9068ab552a4', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': 'c44e9e8c-4bff-42df-98d5-1a1a7b69eada', + 'name': 'tag', + }), + ]), + 'tasksOrder': dict({ + 'dailys': list([ + '6e53f1f5-a315-4edd-984d-8d762e4a08ef', + ]), + 'habits': list([ + '30923acd-3b4c-486d-9ef3-c8f57cf56049', + ]), + 'rewards': list([ + '2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9', + ]), + 'todos': list([ + 'e6e06dc6-c887-4b86-b175-b99cc2e20fdf', + ]), + }), + 'unpinnedItems': list([ + ]), + 'webhooks': list([ + ]), + }), + }), + }) +# --- diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 28dd7eb8c43..9050db1946d 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -7,9 +7,9 @@ 'capabilities': dict({ 'options': list([ 'warrior', - 'healer', - 'wizard', 'rogue', + 'wizard', + 'healer', ]), }), 'config_entry_id': , @@ -34,8 +34,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_class', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_class', 'unit_of_measurement': None, }) # --- @@ -43,12 +43,13 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItMTIgLTEyIDcyIDcyIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHBhdGggZmlsbD0iIzFGNkVBMiIgZD0iTTI0LjQ2MiAxLjE1MWMtMy42OTUgMy43MTQtNy4zMjEgMTQuNjItOS4yMzYgMjAuOTEzLS44NC0xLjA2Mi0xLjc5OS0yLjA3Ni0zLjE1OC0zLjMyNi00LjgwNiAzLjUxNC03LjM1NyA3LjI1LTEwLjY0NiAxMS44MTYgOC45MTYgNy45MjYgMTEuMjMzIDkuMjU2IDIyLjk5NSAxNi4wMDVsLjA0NS0uMDI1aC4wMDFsLjA0NS4wMjVjMTEuNzYtNi43NDkgMTQuMDc5LTguMDggMjIuOTk0LTE2LjAwNS0zLjI4Ny00LjU2Ni01Ljg0MS04LjMwMi0xMC42NDUtMTEuODE2LTEuMzYxIDEuMjUtMi4zMTcgMi4yNjQtMy4xNTggMy4zMjYtMS45MTUtNi4yOTQtNS41NDMtMTcuMi05LjIzNi0yMC45MTNoLS4wMDF6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzI3OEFCRiIgZD0iTTI0LjQ2NCAzMi4xMDhsMy41NjEtMS4yNjVjNi4wNzUtLjYwNSA4LjQyNS0yLjI4MSA5LjQyNi0zLjQ5OGw2LjA1MyAyLjcwNy4wMDIuMDEzYS40NS40NSAwIDAgMS0uMTA1LjI4NiA0Ljk3IDQuOTcgMCAwIDEtLjMyLjMzYy0zLjQwNSAzLjMxNS0xOC4yMzcgMTEuOTgxLTE4LjYxNyAxMi4yMDJWMzIuMTA4eiIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM1M0I0RTUiIGQ9Ik0zNi40MDYgMjMuMDQxYy4yMy0uMTg4LjI5Mi0uMzQuNjc0LS4zMzYgMS4xODMuMDY3IDUuMTY5IDUuMTc1IDYuMDY0IDYuNDYxLjE5NC4yNzguMzY2LjYxLjM2Ljg4NWwtNi4wNTMtMi43MDdjLjIzNi0uMzY3LjE5LS45Mi0uMzU1LTEuMjUyLS44MTgtLjUwMS0yLjUyOS45NDktMi45NTkuMjg0LS41OTktLjkyNyAyLjA0LTMuMTQ3IDIuMjctMy4zMzUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMkFBMENGIiBkPSJNMjMuNTkxIDcuNDg3Yy01LjM5IDE1LjQxOS04LjIyNyAyMy44MTctOC4yMjcgMjMuODE3bDkuMDk3IDQuNzZWNi44MzljLS4zNiAwLS43MTguMjE2LS44Ny42NDgiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMjc4QUJGIiBkPSJNMjUuMzMyIDcuNDg3Yy0uMTUyLS40MzItLjUxLS42NDgtLjg3LS42NDh2MjkuMjI1bDkuMDk3LTQuNzZzLTIuODM3LTguMzk4LTguMjI3LTIzLjgxNyIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM0REIyRDYiIGQ9Ik0yNC40NjIgMzIuMTA4TDIwLjkgMzAuODQzYy02LjA3NS0uNjA1LTguMzQyLTIuMzUyLTkuNDI1LTMuNDk4TDUuNDIgMzAuMDUybC0uMDAyLjAxM2EuNDUuNDUgMCAwIDAgLjEwNi4yODZjLjA5LjEwNC4xOTcuMjE1LjMxOC4zMyAzLjQwNiAzLjMxNSAxOC4yMzggMTEuOTgxIDE4LjYxOSAxMi4yMDJWMzIuMTA4eiIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM2QkM0RTkiIGQ9Ik0xMi41MTkgMjMuMDQxYy0uMjMtLjE4OC0uMjkyLS4zNC0uNjc0LS4zMzYtMS4xODMuMDY3LTUuMTY5IDUuMTc1LTYuMDYzIDYuNDYxLS4xOTQuMjc4LS4zNjcuNjEtLjM2MS44ODVsNi4wNTMtMi43MDdjLS4yMzYtLjM2Ny0uMTktLjkyLjM1Ni0xLjI1Mi44MTctLjUwMSAyLjUyOC45NDkgMi45NTguMjg0LjYtLjkyNy0yLjAzOS0zLjE0Ny0yLjI3LTMuMzM1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0E5REJGNiIgZD0iTTI0LjQ2MyAzNC45OTh2LTUuMTAxbC03LjIxLTQuMTMtMS40OCA0LjA0OXoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjODRDRkYyIiBkPSJNMjQuNDYzIDM0Ljk5OHYtNS4xMDFsNy4yMDctNC4xMyAxLjQ4MyA0LjA0OXoiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Class', 'options': list([ 'warrior', - 'healer', - 'wizard', 'rogue', + 'wizard', + 'healer', ]), }), 'context': , @@ -90,8 +91,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_constitution', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_constitution', 'unit_of_measurement': 'CON', }) # --- @@ -114,165 +115,6 @@ 'state': '102', }) # --- -# name: test_sensors[sensor.test_user_dailies-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_user_dailies', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dailies', - 'platform': 'habitica', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_dailys', - 'unit_of_measurement': 'tasks', - }) -# --- -# name: test_sensors[sensor.test_user_dailies-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1': dict({ - 'created_at': '2024-09-22T11:44:43.774Z', - 'every_x': 1, - 'frequency': 'weekly', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'is_due': True, - 'next_due': list([ - '2024-09-24T22:00:00.000Z', - '2024-09-27T22:00:00.000Z', - '2024-09-28T22:00:00.000Z', - '2024-10-01T22:00:00.000Z', - '2024-10-04T22:00:00.000Z', - '2024-10-08T22:00:00.000Z', - ]), - 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': 2, - 'repeat': dict({ - 'f': False, - 'm': False, - 's': True, - 'su': True, - 't': False, - 'th': False, - 'w': True, - }), - 'start_date': '2024-09-21T22:00:00.000Z', - 'tags': list([ - '51076966-2970-4b40-b6ba-d58c6a756dd7', - ]), - 'text': 'Fitnessstudio besuchen', - 'type': 'daily', - 'yester_daily': True, - }), - '564b9ac9-c53d-4638-9e7f-1cd96fe19baa': dict({ - 'completed': True, - 'created_at': '2024-07-07T17:51:53.268Z', - 'every_x': 1, - 'frequency': 'weekly', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'is_due': True, - 'next_due': list([ - 'Mon Sep 23 2024 00:00:00 GMT+0200', - 'Tue Sep 24 2024 00:00:00 GMT+0200', - 'Wed Sep 25 2024 00:00:00 GMT+0200', - 'Thu Sep 26 2024 00:00:00 GMT+0200', - 'Fri Sep 27 2024 00:00:00 GMT+0200', - 'Sat Sep 28 2024 00:00:00 GMT+0200', - ]), - 'notes': 'Klicke um Änderungen zu machen!', - 'priority': 1, - 'repeat': dict({ - 'f': True, - 'm': True, - 's': True, - 'su': True, - 't': True, - 'th': True, - 'w': True, - }), - 'start_date': '2024-07-06T22:00:00.000Z', - 'streak': 1, - 'text': 'Zahnseide benutzen', - 'type': 'daily', - 'value': -2.9663035443712333, - 'yester_daily': True, - }), - 'f2c85972-1a19-4426-bc6d-ce3337b9d99f': dict({ - 'created_at': '2024-07-07T17:51:53.266Z', - 'every_x': 1, - 'frequency': 'weekly', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'is_due': True, - 'next_due': list([ - '2024-09-22T22:00:00.000Z', - '2024-09-23T22:00:00.000Z', - '2024-09-24T22:00:00.000Z', - '2024-09-25T22:00:00.000Z', - '2024-09-26T22:00:00.000Z', - '2024-09-27T22:00:00.000Z', - ]), - 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': 1, - 'repeat': dict({ - 'f': True, - 'm': True, - 's': True, - 'su': True, - 't': True, - 'th': True, - 'w': True, - }), - 'start_date': '2024-07-06T22:00:00.000Z', - 'text': '5 Minuten ruhig durchatmen', - 'type': 'daily', - 'value': -1.919611992979862, - 'yester_daily': True, - }), - 'friendly_name': 'test-user Dailies', - 'unit_of_measurement': 'tasks', - }), - 'context': , - 'entity_id': 'sensor.test_user_dailies', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3', - }) -# --- # name: test_sensors[sensor.test_user_display_name-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -301,8 +143,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_display_name', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_display_name', 'unit_of_measurement': None, }) # --- @@ -319,6 +161,57 @@ 'state': 'test-user', }) # --- +# name: test_sensors[sensor.test_user_eggs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_eggs', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eggs', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_eggs_total', + 'unit_of_measurement': 'eggs', + }) +# --- +# name: test_sensors[sensor.test_user_eggs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Pandajunges': 2, + 'Tigerjunges': 0, + 'Wolfsjunges': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Egg_Egg.png', + 'friendly_name': 'test-user Eggs', + 'unit_of_measurement': 'eggs', + }), + 'context': , + 'entity_id': 'sensor.test_user_eggs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_sensors[sensor.test_user_experience-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -347,14 +240,15 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_experience', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_experience', 'unit_of_measurement': 'XP', }) # --- # name: test_sensors[sensor.test_user_experience-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkE2MjMiIGQ9Ik0xNiAxNmw4LTQtOC00LTQtOC00IDgtOCA0IDggNCA0IDh6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1LTIuNUwxMiAxMnpNMTIgMTkuNWwtMi41LTVMMTIgMTJ6TTE5LjUgMTJsLTUgMi41TDEyIDEyek0xMiA0LjVsMi41IDVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTkuNSAxMmwtNS0yLjVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTIgMTkuNWwyLjUtNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1IDIuNUwxMiAxMnpNMTIgNC41bC0yLjUgNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEwLjggMTMuMkw4LjUgMTJsMi4zLTEuMkwxMiA4LjVsMS4yIDIuMyAyLjMgMS4yLTIuMyAxLjItMS4yIDIuM3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Experience', 'unit_of_measurement': 'XP', }), @@ -397,8 +291,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_gems', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_gems', 'unit_of_measurement': 'gems', }) # --- @@ -414,7 +308,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '40', }) # --- # name: test_sensors[sensor.test_user_gold-entry] @@ -448,14 +342,15 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_gold', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_gold', 'unit_of_measurement': 'GP', }) # --- # name: test_sensors[sensor.test_user_gold-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEyIiBmaWxsPSIjRkZBNjIzIi8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTYuMyAxNy43Yy0zLjEtMy4xLTMuMS04LjIgMC0xMS4zIDMuMS0zLjEgOC4yLTMuMSAxMS4zIDAiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTE3LjcgNi4zYzMuMSAzLjEgMy4xIDguMiAwIDExLjMtMy4xIDMuMS04LjIgMy4xLTExLjMgMCIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0JGN0QxQSIgZD0iTTEyIDJDNi41IDIgMiA2LjUgMiAxMnM0LjUgMTAgMTAgMTAgMTAtNC41IDEwLTEwUzE3LjUgMiAxMiAyem0wIDE4Yy00LjQgMC04LTMuNi04LThzMy42LTggOC04IDggMy42IDggOC0zLjYgOC04IDh6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCRjdEMUEiIGQ9Ik0xMyA5djJoLTJWOUg5djZoMnYtMmgydjJoMlY5eiIgb3BhY2l0eT0iLjc1Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Gold', 'unit_of_measurement': 'GP', }), @@ -495,8 +390,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_habits', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_habits', 'unit_of_measurement': 'tasks', }) # --- @@ -504,60 +399,160 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ '1d147de6-5c02-4740-8e2f-71d3015a37f4': dict({ - 'created_at': '2024-07-07T17:51:53.266Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-07-07T17:51:53.266000+00:00', 'frequency': 'daily', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Eine kurze Pause machen', 'type': 'habit', 'up': True, }), 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4': dict({ - 'created_at': '2024-07-07T17:51:53.265Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-07-07T17:51:53.265000+00:00', 'down': True, 'frequency': 'daily', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', 'type': 'habit', }), 'e97659e0-2c42-4599-a7bb-00282adc410d': dict({ - 'created_at': '2024-07-07T17:51:53.264Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-07-07T17:51:53.264000+00:00', 'frequency': 'daily', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Füge eine Aufgabe zu Habitica hinzu', 'type': 'habit', 'up': True, }), 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a': dict({ - 'created_at': '2024-07-07T17:51:53.268Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-07-07T17:51:53.268000+00:00', 'down': True, 'frequency': 'daily', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Gesundes Essen/Junkfood', 'type': 'habit', 'up': True, @@ -573,6 +568,57 @@ 'state': '4', }) # --- +# name: test_sensors[sensor.test_user_hatching_potions-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_hatching_potions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hatching potions', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_hatching_potions_total', + 'unit_of_measurement': 'potions', + }) +# --- +# name: test_sensors[sensor.test_user_hatching_potions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Normales': 2, + 'Weißes': 0, + 'Wüstenfarbenes': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_RoyalPurple.png', + 'friendly_name': 'test-user Hatching potions', + 'unit_of_measurement': 'potions', + }), + 'context': , + 'entity_id': 'sensor.test_user_hatching_potions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_sensors[sensor.test_user_health-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -604,14 +650,15 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_health', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_health', 'unit_of_measurement': 'HP', }) # --- # name: test_sensors[sensor.test_user_health-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGNzRFNTIiIGQ9Ik0yIDQuNUw2LjE2NyAyIDEyIDUuMTY3IDE3LjgzMyAyIDIyIDQuNVYxMmwtNC4xNjcgNS44MzNMMTIgMjJsLTUuODMzLTQuMTY3TDIgMTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGNjE2NSIgZD0iTTcuMzMzIDE2LjY2N0wzLjY2NyAxMS41VjUuNDE3bDIuNS0xLjVMMTIgNy4wODNsNS44MzMtMy4xNjYgMi41IDEuNVYxMS41bC0zLjY2NiA1LjE2N0wxMiAxOS45MTd6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w0LjY2NyAyLjU4NEwxMiAxOS45MTd6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsLTQuNjY3IDIuNTg0TDEyIDE5LjkxN3oiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjMzMyAxNi42NjdMMy42NjcgMTEuNSAxMiAxNC4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQjUyNDI4IiBkPSJNMTYuNjY3IDE2LjY2N2wzLjY2Ni01LjE2N0wxMiAxNC4wODN6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsNS44MzMtMTAuMTY2IDIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNMNi4xNjcgMy45MTdsLTIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M0w2LjE2NyAzLjkxNyAxMiA3LjA4M3oiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w1LjgzMy0xMC4xNjZMMTIgNy4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNOS4xNjcgMTQuODMzbC0zLTQuMTY2VjYuODMzaC4wODNMMTIgOS45MTdsNS43NS0zLjA4NGguMDgzdjMuODM0bC0zIDQuMTY2TDEyIDE2LjkxN3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Health', 'unit_of_measurement': 'HP', }), @@ -620,7 +667,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[sensor.test_user_intelligence-entry] @@ -654,8 +701,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_intelligence', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_intelligence', 'unit_of_measurement': 'INT', }) # --- @@ -706,8 +753,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_level', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_level', 'unit_of_measurement': None, }) # --- @@ -755,14 +802,15 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_mana', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mana', 'unit_of_measurement': 'MP', }) # --- # name: test_sensors[sensor.test_user_mana-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiMyOTk1Q0QiIGQ9Ik0yMiAxNWwtMTAgOS0xMC05TDEyIDB6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzUwQjVFOSIgZD0iTTQuNiAxNC43bDcuNC0zdjkuNnoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMUY3MDlBIiBkPSJNMTIgMTEuN2w3LjQgMy03LjQgNi42eiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDExLjdWMy42bDcuNCAxMS4xeiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNiAxNC43TDEyIDMuNnY4LjF6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjIgMTQuM0wxMiA3LjJsNC44IDcuMS00LjggNC4zeiIgb3BhY2l0eT0iLjUiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Mana', 'unit_of_measurement': 'MP', }), @@ -802,8 +850,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_health_max', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_health_max', 'unit_of_measurement': 'HP', }) # --- @@ -849,14 +897,15 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_mana_max', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mana_max', 'unit_of_measurement': 'MP', }) # --- # name: test_sensors[sensor.test_user_max_mana-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiMyOTk1Q0QiIGQ9Ik0yMiAxNWwtMTAgOS0xMC05TDEyIDB6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzUwQjVFOSIgZD0iTTQuNiAxNC43bDcuNC0zdjkuNnoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMUY3MDlBIiBkPSJNMTIgMTEuN2w3LjQgMy03LjQgNi42eiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDExLjdWMy42bDcuNCAxMS4xeiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNiAxNC43TDEyIDMuNnY4LjF6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjIgMTQuM0wxMiA3LjJsNC44IDcuMS00LjggNC4zeiIgb3BhY2l0eT0iLjUiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Max. mana', 'unit_of_measurement': 'MP', }), @@ -899,8 +948,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_trinkets', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_trinkets', 'unit_of_measurement': '⧖', }) # --- @@ -947,14 +996,15 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_experience_max', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_experience_max', 'unit_of_measurement': 'XP', }) # --- # name: test_sensors[sensor.test_user_next_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkE2MjMiIGQ9Ik0xNiAxNmw4LTQtOC00LTQtOC00IDgtOCA0IDggNCA0IDh6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1LTIuNUwxMiAxMnpNMTIgMTkuNWwtMi41LTVMMTIgMTJ6TTE5LjUgMTJsLTUgMi41TDEyIDEyek0xMiA0LjVsMi41IDVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTkuNSAxMmwtNS0yLjVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTIgMTkuNWwyLjUtNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1IDIuNUwxMiAxMnpNMTIgNC41bC0yLjUgNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEwLjggMTMuMkw4LjUgMTJsMi4zLTEuMkwxMiA4LjVsMS4yIDIuMyAyLjMgMS4yLTIuMyAxLjItMS4yIDIuM3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Next level', 'unit_of_measurement': 'XP', }), @@ -997,8 +1047,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_perception', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_perception', 'unit_of_measurement': 'PER', }) # --- @@ -1021,6 +1071,109 @@ 'state': '75', }) # --- +# name: test_sensors[sensor.test_user_pet_food-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_pet_food', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pet food', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_food_total', + 'unit_of_measurement': 'foods', + }) +# --- +# name: test_sensors[sensor.test_user_pet_food-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Fleisch': 0, + 'Kartoffel': 2, + 'Milch': 1, + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNTgiIHZpZXdCb3g9IjAgMCA2MCA1OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CjxyZWN0IHg9IjM4IiB5PSI2IiB3aWR0aD0iMjIiIGhlaWdodD0iMjYiIGZpbGw9InVybCgjcGF0dGVybjBfMTMyN18yNTYpIj48L3JlY3Q+CjxyZWN0IHdpZHRoPSIyNiIgaGVpZ2h0PSIyNiIgZmlsbD0idXJsKCNwYXR0ZXJuMV8xMzI3XzI1NikiPjwvcmVjdD4KPHJlY3QgeD0iOCIgeT0iMzYiIHdpZHRoPSIzMCIgaGVpZ2h0PSIyMiIgZmlsbD0idXJsKCNwYXR0ZXJuMl8xMzI3XzI1NikiPjwvcmVjdD4KPGRlZnM+CjxwYXR0ZXJuIGlkPSJwYXR0ZXJuMF8xMzI3XzI1NiIgcGF0dGVybkNvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giIHdpZHRoPSIxIiBoZWlnaHQ9IjEiPgo8dXNlIHhsaW5rOmhyZWY9IiNpbWFnZTBfMTMyN18yNTYiIHRyYW5zZm9ybT0ic2NhbGUoMC4wNDU0NTQ1IDAuMDM4NDYxNSkiPjwvdXNlPgo8L3BhdHRlcm4+CjxwYXR0ZXJuIGlkPSJwYXR0ZXJuMV8xMzI3XzI1NiIgcGF0dGVybkNvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giIHdpZHRoPSIxIiBoZWlnaHQ9IjEiPgo8dXNlIHhsaW5rOmhyZWY9IiNpbWFnZTFfMTMyN18yNTYiIHRyYW5zZm9ybT0ic2NhbGUoMC4wMzg0NjE1KSI+PC91c2U+CjwvcGF0dGVybj4KPHBhdHRlcm4gaWQ9InBhdHRlcm4yXzEzMjdfMjU2IiBwYXR0ZXJuQ29udGVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgd2lkdGg9IjEiIGhlaWdodD0iMSI+Cjx1c2UgeGxpbms6aHJlZj0iI2ltYWdlMl8xMzI3XzI1NiIgdHJhbnNmb3JtPSJzY2FsZSgwLjAzMzMzMzMgMC4wNDU0NTQ1KSI+PC91c2U+CjwvcGF0dGVybj4KPGltYWdlIGlkPSJpbWFnZTBfMTMyN18yNTYiIHdpZHRoPSIyMiIgaGVpZ2h0PSIyNiIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFCWUFBQUFhQ0FZQUFBQ3pkcXhBQUFBQUFYTlNSMElBcnM0YzZRQUFBUWxKUkVGVVNBMjkwazFxd2xBVXhmRnNwK3NvWFlkem9lQWFXcUZiY1N4MTRMVFFTYnVBanJ1TFZnZFBudkFyY3NJalJsT0Z5OG45K3Q5alNOY04vQjd1N3N0cERJeWYzejZGMXVmek4yTXlRZnZmOTFMalo3czZSdlpqdlozbTR0Vmd3TjM2cTlUZ2tLclQ3K2VQVXNOZTA2b0JpNEJVblE2Q0UyaUJBcVhxVTV3LzV3b1dEVkwxVkgySzB3TWJBSGliZlpZYTZpMDFkenN3Nnk3bTUrVWZjTXhoUzNGd080WEp3SUNjNWVmRmFlcWc0NXVCMDVtODVWQ2R3ZDY3OVNxQVVnRmEyZ1FEV1RRb1Q4Mit2T2Q0Y3JBTEx0S3N6eDhYcFVhcmI3Nm5GcWdCK2NWZ29GVGdwK1ZMcVNIUHVkRTUwTCtCMTYrYlVzT2gwUTV6QVdoeXNFTU95Q2ZUc2VBRFNTQzNudTVpRmFjQUFBQUFTVVZPUks1Q1lJST0iPjwvaW1hZ2U+CjxpbWFnZSBpZD0iaW1hZ2UxXzEzMjdfMjU2IiB3aWR0aD0iMjYiIGhlaWdodD0iMjYiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBQm9BQUFBYUNBWUFBQUNwU2t6T0FBQUFBWE5TUjBJQXJzNGM2UUFBQVNsSlJFRlVTQTNGMURGT3cwQVFCVkRUUmhTcDZSSFVhVkxRY0FXT0VDa05SNkhpUHRSY0psMUtxQlp0cEVmeGpiV3J4QTZXUmw4em52bC8vcTdsWVlqbmJuTmIvZ3B0K2U3NWZWMXFxT3Ryb29GRWcxay9XeWdKNzNjM3BRWUIrY1VDL3k2VVRqaDYyMjVMamQwd25NS2kzWmhITlpzUVlvaFl2djk0TERYVU9UbSt2cFFhWHc5UHAxQ2ZkSVFRSXBSZkxJUVFPbnZFTU92Nk9XazZNd0NUY0hZaFJ3UlR3QUtKN3VUNzgxQnFjS2orZTFlY0VJQ3pDeUdtdkZtdFNnMmJFN1JwYnE3UCs4bTd1cG9RSjVBalo1d2IyeHltZzNROCtjZTR1cENOYzhPV0EzT3c2VWpqNGtMdWlDQnNDYWZqcHFQRmhhYSt2bDVoanZYN3VQQ09VSU1CNkFnaDRzejE0eGtKWkVFamRPYUlXbWd1ZVVlNVJyaVlVQ29UN01XYzc4NTdCZlFoL2dGQ2FOUWFobDJ1M2dBQUFBQkpSVTVFcmtKZ2dnPT0iPjwvaW1hZ2U+CjxpbWFnZSBpZD0iaW1hZ2UyXzEzMjdfMjU2IiB3aWR0aD0iMzAiIGhlaWdodD0iMjIiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBQjRBQUFBV0NBWUFBQURYWXl6UEFBQUFBWE5TUjBJQXJzNGM2UUFBQVJSSlJFRlVTQTNGMDhGdEFqRVVCRkNhZ0FZU0tkM1FBc1VnY1VrRHFTTzMxSkJqTHJuU0JsSms1TU9EMVdndDdSbzVJSDJOL2NlZStiUHNiamFEZjY4dmIyVmFnKzN1OGxQVHVyNHpLMWNwWko4eStoK0g3MUxMUHM4dDNoTklUQUY4dHpFQitQbHpLZFA2UGYrVld2akUwLzZyMUZvOVFBcE5UZXQ2dURIRDQvdTV6Qlhlb0ptME96SGhPZFBhd3o5c1RJQmdHbnJFeWV2dnR0dFNpMDVpdm95M3ZZTXBiQUFHeWVzL2JFeklmOFlJdGdZeCtDM0owb1dMdzR3WkpIcFVra2tzb1g0aW5sNHpxQU9KLzJic080TVNHa2d5aWZCUTMvbG1Vb1NERENGQi9IRGpOR1FNRFFDOWZIZ29XQk1kYkNYRlE0YXcyOWhFaEExZ2o0ZjZpZmpWU09ocHhnWllQWG52Qllhd1YyZnB2U3R4R0Z5dUlYV3NXUUFBQUFCSlJVNUVya0pnZ2c9PSI+PC9pbWFnZT4KPC9kZWZzPgo8L3N2Zz4=', + 'friendly_name': 'test-user Pet food', + 'unit_of_measurement': 'foods', + }), + 'context': , + 'entity_id': 'sensor.test_user_pet_food', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[sensor.test_user_quest_scrolls-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_quest_scrolls', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Quest scrolls', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_quest_scrolls', + 'unit_of_measurement': 'scrolls', + }) +# --- +# name: test_sensors[sensor.test_user_quest_scrolls-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Angriff des Banalen, Teil 1: Abwasch-Katastrophe!': 1, + 'Der Basi-List': 0, + 'Die goldene Ritterin, Teil 1: Ein ernstes Gespräch': 0, + 'Die ungezähmten Staubmäuse': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/inventory_quest_scroll_dustbunnies.png', + 'friendly_name': 'test-user Quest scrolls', + 'unit_of_measurement': 'scrolls', + }), + 'context': , + 'entity_id': 'sensor.test_user_quest_scrolls', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[sensor.test_user_rewards-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1049,8 +1202,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_rewards', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_rewards', 'unit_of_measurement': 'tasks', }) # --- @@ -1058,18 +1211,43 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b': dict({ - 'created_at': '2024-07-07T17:51:53.266Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-07-07T17:51:53.266000+00:00', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Belohne Dich selbst', 'type': 'reward', - 'value': 10, + 'value': 10.0, }), 'friendly_name': 'test-user Rewards', 'unit_of_measurement': 'tasks', @@ -1082,6 +1260,54 @@ 'state': '1', }) # --- +# name: test_sensors[sensor.test_user_saddles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_saddles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Saddles', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_saddle', + 'unit_of_measurement': 'saddles', + }) +# --- +# name: test_sensors[sensor.test_user_saddles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Food_Saddle.png', + 'friendly_name': 'test-user Saddles', + 'unit_of_measurement': 'saddles', + }), + 'context': , + 'entity_id': 'sensor.test_user_saddles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[sensor.test_user_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1113,8 +1339,8 @@ 'platform': 'habitica', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_strength', + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_strength', 'unit_of_measurement': 'STR', }) # --- @@ -1137,105 +1363,3 @@ 'state': '104', }) # --- -# name: test_sensors[sensor.test_user_to_do_s-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_user_to_do_s', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': "To-Do's", - 'platform': 'habitica', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_todos', - 'unit_of_measurement': 'tasks', - }) -# --- -# name: test_sensors[sensor.test_user_to_do_s-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - '1aa3137e-ef72-4d1f-91ee-41933602f438': dict({ - 'created_at': '2024-09-21T22:16:38.153Z', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': 1, - 'text': 'Garten pflegen', - 'type': 'todo', - }), - '2f6fcabc-f670-4ec3-ba65-817e8deea490': dict({ - 'created_at': '2024-09-21T22:17:19.513Z', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': 1, - 'text': 'Rechnungen bezahlen', - 'type': 'todo', - }), - '86ea2475-d1b5-4020-bdcc-c188c7996afa': dict({ - 'created_at': '2024-09-21T22:16:16.756Z', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': 1, - 'tags': list([ - '51076966-2970-4b40-b6ba-d58c6a756dd7', - ]), - 'text': 'Wochenendausflug planen', - 'type': 'todo', - }), - '88de7cd9-af2b-49ce-9afd-bf941d87336b': dict({ - 'created_at': '2024-09-21T22:17:57.816Z', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': 1, - 'text': 'Buch zu Ende lesen', - 'type': 'todo', - }), - 'friendly_name': "test-user To-Do's", - 'unit_of_measurement': 'tasks', - }), - 'context': , - 'entity_id': 'sensor.test_user_to_do_s', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4', - }) -# --- diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr new file mode 100644 index 00000000000..d0062212775 --- /dev/null +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -0,0 +1,6541 @@ +# serializer version: 1 +# name: test_get_tasks[all_tasks] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': True, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': '', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Gesundes Essen/Junkfood', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': False, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': None, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'isDue': None, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1.0, + }), + ]), + 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': '', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Eine kurze Pause machen', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': True, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'up': False, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'create_a_task', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': False, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': None, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'isDue': None, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1.0, + }), + ]), + 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'alias_zahnseide_benutzen', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + dict({ + 'completed': False, + 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'text': 'Checklist-item1', + }), + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.07645736684721605, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.921585289356988, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.9454824860630637, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9966001649571803, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -4.07641493832036, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9663035443712333, + }), + ]), + 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + ]), + 'notes': 'Klicke um Änderungen zu machen!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Zahnseide benutzen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -2.9663035443712333, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 2.023365658844519, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0738942424964806, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.10103816898038132, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.8963760215867302, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.919611992979862, + }), + ]), + 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Klicke um Deinen Terminplan festzulegen!', + 'priority': , + 'reminders': list([ + dict({ + 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'startDate': None, + 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + }), + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 0, + 'tags': list([ + ]), + 'text': '5 Minuten ruhig durchatmen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -1.919611992979862, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + dict({ + 'completed': True, + 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'text': 'Checklist-item1', + }), + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': True, + 'su': True, + 't': False, + 'th': False, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 0, + 'tags': list([ + UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + ]), + 'text': 'Fitnessstudio besuchen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('20409521-c096-447f-9a90-23e8da615710'), + UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + ]), + 'text': 'Buch zu Ende lesen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'pay_bills', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', + 'priority': , + 'reminders': list([ + dict({ + 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'startDate': None, + 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + }), + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Rechnungen bezahlen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Rasen mähen und die Pflanzen gießen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Garten pflegen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + ]), + 'text': 'Wochenendausflug planen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Belohne Dich selbst', + 'up': None, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 10.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Arbeite an einem kreativen Projekt', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 3, + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + ]), + 'text': 'Wocheneinkauf erledigen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 1.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + ]), + 'text': 'Wohnung aufräumen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 1.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + ]), + }) +# --- +# name: test_get_tasks[by alias] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': 'alias_zahnseide_benutzen', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + dict({ + 'completed': False, + 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'text': 'Checklist-item1', + }), + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.07645736684721605, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.921585289356988, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.9454824860630637, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9966001649571803, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -4.07641493832036, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9663035443712333, + }), + ]), + 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + ]), + 'notes': 'Klicke um Änderungen zu machen!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Zahnseide benutzen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -2.9663035443712333, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + ]), + }) +# --- +# name: test_get_tasks[by keyword] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': True, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'up': False, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'create_a_task', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': False, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': None, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'isDue': None, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1.0, + }), + ]), + 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + ]), + }) +# --- +# name: test_get_tasks[by tag] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + dict({ + 'completed': True, + 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'text': 'Checklist-item1', + }), + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': True, + 'su': True, + 't': False, + 'th': False, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 0, + 'tags': list([ + UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + ]), + 'text': 'Fitnessstudio besuchen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('20409521-c096-447f-9a90-23e8da615710'), + UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + ]), + 'text': 'Buch zu Ende lesen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + ]), + }) +# --- +# name: test_get_tasks[by task ID] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 2.023365658844519, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0738942424964806, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.10103816898038132, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.8963760215867302, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.919611992979862, + }), + ]), + 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Klicke um Deinen Terminplan festzulegen!', + 'priority': , + 'reminders': list([ + dict({ + 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'startDate': None, + 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + }), + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 0, + 'tags': list([ + ]), + 'text': '5 Minuten ruhig durchatmen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -1.919611992979862, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + ]), + }) +# --- +# name: test_get_tasks[by task name] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': False, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': None, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'isDue': None, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1.0, + }), + ]), + 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': '', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Eine kurze Pause machen', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'alias_zahnseide_benutzen', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + dict({ + 'completed': False, + 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'text': 'Checklist-item1', + }), + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.07645736684721605, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.921585289356988, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.9454824860630637, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9966001649571803, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -4.07641493832036, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9663035443712333, + }), + ]), + 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + ]), + 'notes': 'Klicke um Änderungen zu machen!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Zahnseide benutzen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -2.9663035443712333, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + ]), + }) +# --- +# name: test_get_tasks[easy tasks] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': True, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': '', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Gesundes Essen/Junkfood', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': False, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': None, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'isDue': None, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1.0, + }), + ]), + 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': '', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Eine kurze Pause machen', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': True, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'up': False, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'create_a_task', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': False, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': None, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'isDue': None, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1.0, + }), + ]), + 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'alias_zahnseide_benutzen', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + dict({ + 'completed': False, + 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'text': 'Checklist-item1', + }), + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.07645736684721605, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.921585289356988, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.9454824860630637, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9966001649571803, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -4.07641493832036, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9663035443712333, + }), + ]), + 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + ]), + 'notes': 'Klicke um Änderungen zu machen!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Zahnseide benutzen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -2.9663035443712333, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 2.023365658844519, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0738942424964806, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.10103816898038132, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.8963760215867302, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.919611992979862, + }), + ]), + 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Klicke um Deinen Terminplan festzulegen!', + 'priority': , + 'reminders': list([ + dict({ + 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'startDate': None, + 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + }), + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 0, + 'tags': list([ + ]), + 'text': '5 Minuten ruhig durchatmen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -1.919611992979862, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('20409521-c096-447f-9a90-23e8da615710'), + UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + ]), + 'text': 'Buch zu Ende lesen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'pay_bills', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', + 'priority': , + 'reminders': list([ + dict({ + 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'startDate': None, + 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + }), + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Rechnungen bezahlen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Rasen mähen und die Pflanzen gießen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Garten pflegen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + ]), + 'text': 'Wochenendausflug planen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Belohne Dich selbst', + 'up': None, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 10.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Arbeite an einem kreativen Projekt', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 3, + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + ]), + }) +# --- +# name: test_get_tasks[empty result] + dict({ + 'tasks': list([ + ]), + }) +# --- +# name: test_get_tasks[hard tasks] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + dict({ + 'completed': True, + 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'text': 'Checklist-item1', + }), + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': True, + 'su': True, + 't': False, + 'th': False, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 0, + 'tags': list([ + UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + ]), + 'text': 'Fitnessstudio besuchen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + ]), + 'text': 'Wohnung aufräumen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 1.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + ]), + }) +# --- +# name: test_get_tasks[medium tasks] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + ]), + 'text': 'Wocheneinkauf erledigen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 1.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + ]), + }) +# --- +# name: test_get_tasks[only dailies and habits] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': True, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': '', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Gesundes Essen/Junkfood', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': False, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': None, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'isDue': None, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1.0, + }), + ]), + 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': '', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Eine kurze Pause machen', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': True, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'up': False, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'create_a_task', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': False, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': None, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'isDue': None, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1.0, + }), + ]), + 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'alias_zahnseide_benutzen', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + dict({ + 'completed': False, + 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'text': 'Checklist-item1', + }), + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.07645736684721605, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.921585289356988, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.9454824860630637, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9966001649571803, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -4.07641493832036, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9663035443712333, + }), + ]), + 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + ]), + 'notes': 'Klicke um Änderungen zu machen!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Zahnseide benutzen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -2.9663035443712333, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 2.023365658844519, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0738942424964806, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.10103816898038132, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.8963760215867302, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.919611992979862, + }), + ]), + 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Klicke um Deinen Terminplan festzulegen!', + 'priority': , + 'reminders': list([ + dict({ + 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'startDate': None, + 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + }), + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 0, + 'tags': list([ + ]), + 'text': '5 Minuten ruhig durchatmen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -1.919611992979862, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + dict({ + 'completed': True, + 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'text': 'Checklist-item1', + }), + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': True, + 'su': True, + 't': False, + 'th': False, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 0, + 'tags': list([ + UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + ]), + 'text': 'Fitnessstudio besuchen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Arbeite an einem kreativen Projekt', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 3, + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + ]), + }) +# --- +# name: test_get_tasks[only dailies] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': 'alias_zahnseide_benutzen', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + dict({ + 'completed': False, + 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'text': 'Checklist-item1', + }), + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.07645736684721605, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.921585289356988, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.9454824860630637, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9966001649571803, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -4.07641493832036, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -2.9663035443712333, + }), + ]), + 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + ]), + 'notes': 'Klicke um Änderungen zu machen!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Zahnseide benutzen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -2.9663035443712333, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.02529999999999999, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9740518837628547, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.051222853419153, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0499115128458676, + }), + dict({ + 'completed': True, + 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 2.023365658844519, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0738942424964806, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.10103816898038132, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.8963760215867302, + }), + dict({ + 'completed': False, + 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.919611992979862, + }), + ]), + 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Klicke um Deinen Terminplan festzulegen!', + 'priority': , + 'reminders': list([ + dict({ + 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'startDate': None, + 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + }), + ]), + 'repeat': dict({ + 'f': True, + 'm': True, + 's': True, + 'su': True, + 't': True, + 'th': True, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 0, + 'tags': list([ + ]), + 'text': '5 Minuten ruhig durchatmen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -1.919611992979862, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + dict({ + 'completed': True, + 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'text': 'Checklist-item1', + }), + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'isDue': True, + 'nextDue': list([ + datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': True, + 'su': True, + 't': False, + 'th': False, + 'w': True, + }), + 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'streak': 0, + 'tags': list([ + UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + ]), + 'text': 'Fitnessstudio besuchen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Arbeite an einem kreativen Projekt', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 3, + ]), + 'yesterDaily': True, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'isDue': False, + 'nextDue': list([ + datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'up': None, + 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + ]), + }) +# --- +# name: test_get_tasks[only habits] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': True, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': '', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Gesundes Essen/Junkfood', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': False, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': None, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'isDue': None, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1.0, + }), + ]), + 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': '', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Eine kurze Pause machen', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': True, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'up': False, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'create_a_task', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': 0, + 'counterUp': 0, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': False, + 'everyX': None, + 'frequency': , + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + dict({ + 'completed': None, + 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'isDue': None, + 'scoredDown': 0, + 'scoredUp': 1, + 'value': 1.0, + }), + ]), + 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'up': True, + 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + ]), + }) +# --- +# name: test_get_tasks[only rewards] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Belohne Dich selbst', + 'up': None, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 10.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + ]), + }) +# --- +# name: test_get_tasks[only todos and rewards] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('20409521-c096-447f-9a90-23e8da615710'), + UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + ]), + 'text': 'Buch zu Ende lesen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'pay_bills', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', + 'priority': , + 'reminders': list([ + dict({ + 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'startDate': None, + 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + }), + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Rechnungen bezahlen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Rasen mähen und die Pflanzen gießen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Garten pflegen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + ]), + 'text': 'Wochenendausflug planen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Belohne Dich selbst', + 'up': None, + 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 10.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + ]), + 'text': 'Wocheneinkauf erledigen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 1.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + ]), + 'text': 'Wohnung aufräumen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 1.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + ]), + }) +# --- +# name: test_get_tasks[only todos] + dict({ + 'tasks': list([ + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('20409521-c096-447f-9a90-23e8da615710'), + UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + ]), + 'text': 'Buch zu Ende lesen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': 'pay_bills', + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', + 'priority': , + 'reminders': list([ + dict({ + 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'startDate': None, + 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + }), + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Rechnungen bezahlen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Rasen mähen und die Pflanzen gießen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Garten pflegen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), + 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + ]), + 'text': 'Wochenendausflug planen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + ]), + 'text': 'Wocheneinkauf erledigen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 1.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': , + 'alias': None, + 'attribute': , + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': True, + 'counterDown': None, + 'counterUp': None, + 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', + 'priority': , + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + ]), + 'text': 'Wohnung aufräumen', + 'up': None, + 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), + 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'value': 1.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + ]), + }) +# --- +# name: test_get_tasks[trivial tasks] + dict({ + 'tasks': list([ + ]), + }) +# --- diff --git a/tests/components/habitica/snapshots/test_switch.ambr b/tests/components/habitica/snapshots/test_switch.ambr index 3affbd11e2a..a865df3a4f4 100644 --- a/tests/components/habitica/snapshots/test_switch.ambr +++ b/tests/components/habitica/snapshots/test_switch.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_sleep', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_sleep', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr index 79eca9dbbb0..9cd6d9a540f 100644 --- a/tests/components/habitica/snapshots/test_todo.ambr +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -42,6 +42,19 @@ 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), + dict({ + 'description': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'due': '2024-12-14', + 'status': 'needs_action', + 'summary': 'Arbeite an einem kreativen Projekt', + 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', + }), + dict({ + 'description': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'status': 'needs_action', + 'summary': 'Lerne eine neue Programmiersprache', + 'uid': '7d92278b-9361-4854-83b6-0a66b57dce20', + }), ]), }), }) @@ -122,7 +135,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_dailys', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_dailys', 'unit_of_measurement': None, }) # --- @@ -137,7 +150,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '4', }) # --- # name: test_todos[todo.test_user_to_do_s-entry] @@ -169,7 +182,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_todos', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todos', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/habitica/test_binary_sensor.py b/tests/components/habitica/test_binary_sensor.py index 1710f8f217e..80acc92385f 100644 --- a/tests/components/habitica/test_binary_sensor.py +++ b/tests/components/habitica/test_binary_sensor.py @@ -1,19 +1,19 @@ """Tests for the Habitica binary sensor platform.""" from collections.abc import Generator -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from habiticalib import HabiticaUserResponse import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.habitica.const import ASSETS_URL, DEFAULT_URL, DOMAIN +from homeassistant.components.habitica.const import ASSETS_URL, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry, load_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -26,7 +26,7 @@ def binary_sensor_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_binary_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -54,23 +54,17 @@ async def test_binary_sensors( async def test_pending_quest_states( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, fixture: str, entity_state: str, entity_picture: str | None, ) -> None: """Test states of pending quest sensor.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture(f"{fixture}.json", DOMAIN), - ) - aioclient_mock.get(f"{DEFAULT_URL}/api/v3/tasks/user", json={"data": []}) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/content", - params={"language": "en"}, - json=load_json_object_fixture("content.json", DOMAIN), + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/habitica/test_button.py b/tests/components/habitica/test_button.py index 979cefef923..dc1a155b541 100644 --- a/tests/components/habitica/test_button.py +++ b/tests/components/habitica/test_button.py @@ -1,25 +1,31 @@ """Tests for Habitica button platform.""" from collections.abc import Generator -from http import HTTPStatus -import re -from unittest.mock import patch +from datetime import timedelta +from unittest.mock import AsyncMock, patch +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +from habiticalib import HabiticaUserResponse, Skill import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.habitica.const import DEFAULT_URL, DOMAIN +from homeassistant.components.habitica.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from .conftest import mock_called_with +from .conftest import ERROR_BAD_REQUEST, ERROR_NOT_AUTHORIZED, ERROR_TOO_MANY_REQUESTS -from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_fixture, + snapshot_platform, +) @pytest.fixture(autouse=True) @@ -44,29 +50,15 @@ def button_only() -> Generator[None]: async def test_buttons( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, fixture: str, ) -> None: """Test button entities.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture(f"{fixture}.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - params={"type": "completedTodos"}, - json=load_json_object_fixture("completed_todos.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - json=load_json_object_fixture("tasks.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/content", - params={"language": "en"}, - json=load_json_object_fixture("content.json", DOMAIN), + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -78,70 +70,87 @@ async def test_buttons( @pytest.mark.parametrize( - ("entity_id", "api_url", "fixture"), + ("entity_id", "call_func", "call_args", "fixture"), [ - ("button.test_user_allocate_all_stat_points", "user/allocate-now", "user"), - ("button.test_user_buy_a_health_potion", "user/buy-health-potion", "user"), - ("button.test_user_revive_from_death", "user/revive", "user"), - ("button.test_user_start_my_day", "cron", "user"), + ( + "button.test_user_allocate_all_stat_points", + "allocate_stat_points", + None, + "user", + ), + ("button.test_user_buy_a_health_potion", "buy_health_potion", None, "user"), + ("button.test_user_revive_from_death", "revive", None, "user"), + ("button.test_user_start_my_day", "run_cron", None, "user"), ( "button.test_user_chilling_frost", - "user/class/cast/frost", + "cast_skill", + Skill.CHILLING_FROST, "wizard_fixture", ), ( "button.test_user_earthquake", - "user/class/cast/earth", + "cast_skill", + Skill.EARTHQUAKE, "wizard_fixture", ), ( "button.test_user_ethereal_surge", - "user/class/cast/mpheal", + "cast_skill", + Skill.ETHEREAL_SURGE, "wizard_fixture", ), ( "button.test_user_stealth", - "user/class/cast/stealth", + "cast_skill", + Skill.STEALTH, "rogue_fixture", ), ( "button.test_user_tools_of_the_trade", - "user/class/cast/toolsOfTrade", + "cast_skill", + Skill.TOOLS_OF_THE_TRADE, "rogue_fixture", ), ( "button.test_user_defensive_stance", - "user/class/cast/defensiveStance", + "cast_skill", + Skill.DEFENSIVE_STANCE, "warrior_fixture", ), ( "button.test_user_intimidating_gaze", - "user/class/cast/intimidate", + "cast_skill", + Skill.INTIMIDATING_GAZE, "warrior_fixture", ), ( "button.test_user_valorous_presence", - "user/class/cast/valorousPresence", + "cast_skill", + Skill.VALOROUS_PRESENCE, "warrior_fixture", ), ( "button.test_user_healing_light", - "user/class/cast/heal", + "cast_skill", + Skill.HEALING_LIGHT, "healer_fixture", ), ( "button.test_user_protective_aura", - "user/class/cast/protectAura", + "cast_skill", + Skill.PROTECTIVE_AURA, "healer_fixture", ), ( "button.test_user_searing_brightness", - "user/class/cast/brightness", + "cast_skill", + Skill.SEARING_BRIGHTNESS, "healer_fixture", ), ( "button.test_user_blessing", - "user/class/cast/healAll", + "cast_skill", + Skill.BLESSING, "healer_fixture", ), ], @@ -149,58 +158,48 @@ async def test_buttons( async def test_button_press( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, entity_id: str, - api_url: str, + call_func: str, + call_args: Skill | None, fixture: str, ) -> None: """Test button press method.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture(f"{fixture}.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - params={"type": "completedTodos"}, - json=load_json_object_fixture("completed_todos.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - json=load_json_object_fixture("tasks.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/content", - params={"language": "en"}, - json=load_json_object_fixture("content.json", DOMAIN), + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - aioclient_mock.post(f"{DEFAULT_URL}/api/v3/{api_url}", json={"data": None}) - + mocked = getattr(habitica, call_func) + mocked.reset_mock() await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - - assert mock_called_with(aioclient_mock, "post", f"{DEFAULT_URL}/api/v3/{api_url}") + if call_args: + mocked.assert_awaited_once_with(call_args) + else: + mocked.assert_awaited_once() @pytest.mark.parametrize( - ("entity_id", "api_url"), + ("entity_id", "call_func"), [ - ("button.test_user_allocate_all_stat_points", "user/allocate-now"), - ("button.test_user_buy_a_health_potion", "user/buy-health-potion"), - ("button.test_user_revive_from_death", "user/revive"), - ("button.test_user_start_my_day", "cron"), - ("button.test_user_chilling_frost", "user/class/cast/frost"), - ("button.test_user_earthquake", "user/class/cast/earth"), - ("button.test_user_ethereal_surge", "user/class/cast/mpheal"), + ("button.test_user_allocate_all_stat_points", "allocate_stat_points"), + ("button.test_user_buy_a_health_potion", "buy_health_potion"), + ("button.test_user_revive_from_death", "revive"), + ("button.test_user_start_my_day", "run_cron"), + ("button.test_user_chilling_frost", "cast_skill"), + ("button.test_user_earthquake", "cast_skill"), + ("button.test_user_ethereal_surge", "cast_skill"), ], ids=[ "allocate-points", @@ -213,34 +212,39 @@ async def test_button_press( ], ) @pytest.mark.parametrize( - ("status_code", "msg", "exception"), + ("raise_exception", "msg", "expected_exception"), [ ( - HTTPStatus.TOO_MANY_REQUESTS, - "Rate limit exceeded, try again later", - ServiceValidationError, - ), - ( - HTTPStatus.BAD_REQUEST, - "Unable to connect to Habitica, try again later", + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", HomeAssistantError, ), ( - HTTPStatus.UNAUTHORIZED, + ERROR_BAD_REQUEST, + "Unable to connect to Habitica: reason", + HomeAssistantError, + ), + ( + ERROR_NOT_AUTHORIZED, "Unable to complete action, the required conditions are not met", ServiceValidationError, ), + ( + ClientError, + "Unable to connect to Habitica: ", + HomeAssistantError, + ), ], ) async def test_button_press_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, entity_id: str, - api_url: str, - status_code: HTTPStatus, + call_func: str, + raise_exception: Exception, msg: str, - exception: Exception, + expected_exception: Exception, ) -> None: """Test button press exceptions.""" @@ -250,13 +254,10 @@ async def test_button_press_exceptions( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/{api_url}", - status=status_code, - json={"data": None}, - ) + func = getattr(habitica, call_func) + func.side_effect = raise_exception - with pytest.raises(exception, match=msg): + with pytest.raises(expected_exception, match=msg): await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, @@ -264,8 +265,6 @@ async def test_button_press_exceptions( blocking=True, ) - assert mock_called_with(mock_habitica, "post", f"{DEFAULT_URL}/api/v3/{api_url}") - @pytest.mark.parametrize( ("fixture", "entity_ids"), @@ -315,21 +314,15 @@ async def test_button_press_exceptions( async def test_button_unavailable( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, fixture: str, entity_ids: list[str], ) -> None: """Test buttons are unavailable if conditions are not met.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture(f"{fixture}.json", DOMAIN), + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - json=load_json_object_fixture("tasks.json", DOMAIN), - ) - aioclient_mock.get(re.compile(r".*"), json={"data": []}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -340,3 +333,48 @@ async def test_button_unavailable( for entity_id in entity_ids: assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE + + +async def test_class_change( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test removing and adding skills after class change.""" + mage_skills = [ + "button.test_user_chilling_frost", + "button.test_user_earthquake", + "button.test_user_ethereal_surge", + ] + healer_skills = [ + "button.test_user_healing_light", + "button.test_user_protective_aura", + "button.test_user_searing_brightness", + "button.test_user_blessing", + ] + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture("wizard_fixture.json", DOMAIN) + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + for skill in mage_skills: + assert hass.states.get(skill) + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture("healer_fixture.json", DOMAIN) + ) + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for skill in mage_skills: + assert not hass.states.get(skill) + + for skill in healer_skills: + assert hass.states.get(skill) diff --git a/tests/components/habitica/test_calendar.py b/tests/components/habitica/test_calendar.py index a6cdb1a9306..24e640d0888 100644 --- a/tests/components/habitica/test_calendar.py +++ b/tests/components/habitica/test_calendar.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import patch +from freezegun.api import freeze_time import pytest from syrupy.assertion import SnapshotAssertion @@ -31,8 +32,8 @@ async def set_tz(hass: HomeAssistant) -> None: await hass.config.async_set_time_zone("Europe/Berlin") -@pytest.mark.usefixtures("mock_habitica") -@pytest.mark.freeze_time("2024-09-20T22:00:00.000Z") +@pytest.mark.usefixtures("habitica") +@freeze_time("2024-09-20T22:00:00.000Z") async def test_calendar_platform( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -59,14 +60,26 @@ async def test_calendar_platform( "calendar.test_user_to_do_reminders", ], ) -@pytest.mark.freeze_time("2024-09-20T22:00:00.000Z") -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.parametrize( + ("start_date", "end_date"), + [ + ("2024-08-29", "2024-10-08"), + ("2023-08-01", "2023-08-02"), + ], + ids=[ + "default date range", + "date range in the past", + ], +) +@pytest.mark.usefixtures("habitica") async def test_api_events( hass: HomeAssistant, snapshot: SnapshotAssertion, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, entity: str, + start_date: str, + end_date: str, ) -> None: """Test calendar event.""" @@ -76,7 +89,7 @@ async def test_api_events( client = await hass_client() response = await client.get( - f"/api/calendars/{entity}?start=2024-08-29&end=2024-10-08" + f"/api/calendars/{entity}?start={start_date}&end={end_date}" ) assert await response.json() == snapshot diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index 604877f0c47..07678b031bc 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -1,14 +1,22 @@ """Test the habitica config flow.""" -from unittest.mock import AsyncMock, MagicMock, patch +from typing import Any +from unittest.mock import AsyncMock -from aiohttp import ClientResponseError import pytest -from homeassistant import config_entries -from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN +from homeassistant.components.habitica.const import ( + CONF_API_USER, + DEFAULT_URL, + DOMAIN, + SECTION_DANGER_ZONE, + SECTION_REAUTH_API_KEY, + SECTION_REAUTH_LOGIN, +) +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, @@ -17,23 +25,51 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import ERROR_BAD_REQUEST, ERROR_NOT_AUTHORIZED + +from tests.common import MockConfigEntry + +TEST_API_USER = "a380546a-94be-4b8e-8a0b-23e0d5c03303" +TEST_API_KEY = "cd0e5985-17de-4b4f-849e-5d506c5e4382" + + MOCK_DATA_LOGIN_STEP = { CONF_USERNAME: "test-email@example.com", CONF_PASSWORD: "test-password", } MOCK_DATA_ADVANCED_STEP = { - CONF_API_USER: "test-api-user", - CONF_API_KEY: "test-api-key", + CONF_API_USER: TEST_API_USER, + CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, CONF_VERIFY_SSL: True, } +USER_INPUT_REAUTH_LOGIN = { + SECTION_REAUTH_LOGIN: { + CONF_USERNAME: "new-email", + CONF_PASSWORD: "new-password", + }, + SECTION_REAUTH_API_KEY: {}, +} +USER_INPUT_REAUTH_API_KEY = { + SECTION_REAUTH_LOGIN: {}, + SECTION_REAUTH_API_KEY: {CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382"}, +} +USER_INPUT_RECONFIGURE = { + CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382", + SECTION_DANGER_ZONE: { + CONF_URL: DEFAULT_URL, + CONF_VERIFY_SSL: True, + }, +} -async def test_form_login(hass: HomeAssistant) -> None: + +@pytest.mark.usefixtures("habitica") +async def test_form_login(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the login form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU @@ -47,55 +83,41 @@ async def test_form_login(hass: HomeAssistant) -> None: assert result["errors"] == {} assert result["step_id"] == "login" - mock_obj = MagicMock() - mock_obj.user.auth.local.login.post = AsyncMock() - mock_obj.user.auth.local.login.post.return_value = { - "id": "test-api-user", - "apiToken": "test-api-key", - "username": "test-username", - } - with ( - patch( - "homeassistant.components.habitica.config_flow.HabitipyAsync", - return_value=mock_obj, - ), - patch( - "homeassistant.components.habitica.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.habitica.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_DATA_LOGIN_STEP, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LOGIN_STEP, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" + assert result["title"] == "test-user" assert result["data"] == { - **MOCK_DATA_ADVANCED_STEP, - CONF_USERNAME: "test-username", + CONF_API_USER: TEST_API_USER, + CONF_API_KEY: TEST_API_KEY, + CONF_URL: DEFAULT_URL, + CONF_NAME: "test-user", + CONF_VERIFY_SSL: True, } - assert len(mock_setup.mock_calls) == 1 + assert result["result"].unique_id == TEST_API_USER + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( ("raise_error", "text_error"), [ - (ClientResponseError(MagicMock(), (), status=400), "cannot_connect"), - (ClientResponseError(MagicMock(), (), status=401), "invalid_auth"), + (ERROR_BAD_REQUEST, "cannot_connect"), + (ERROR_NOT_AUTHORIZED, "invalid_auth"), (IndexError(), "unknown"), ], ) -async def test_form_login_errors(hass: HomeAssistant, raise_error, text_error) -> None: +async def test_form_login_errors( + hass: HomeAssistant, habitica: AsyncMock, raise_error, text_error +) -> None: """Test we handle invalid credentials error.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU @@ -105,26 +127,69 @@ async def test_form_login_errors(hass: HomeAssistant, raise_error, text_error) - DOMAIN, context={"source": "login"} ) - mock_obj = MagicMock() - mock_obj.user.auth.local.login.post = AsyncMock(side_effect=raise_error) - with patch( - "homeassistant.components.habitica.config_flow.HabitipyAsync", - return_value=mock_obj, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_DATA_LOGIN_STEP, - ) + habitica.login.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LOGIN_STEP, + ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": text_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + # recover from errors + habitica.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LOGIN_STEP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-user" + assert result["data"] == { + CONF_API_USER: TEST_API_USER, + CONF_API_KEY: TEST_API_KEY, + CONF_URL: DEFAULT_URL, + CONF_NAME: "test-user", + CONF_VERIFY_SSL: True, + } + assert result["result"].unique_id == TEST_API_USER -async def test_form_advanced(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("habitica") +async def test_form__already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort form login when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "advanced"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("habitica") +async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU @@ -144,54 +209,41 @@ async def test_form_advanced(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_obj = MagicMock() - mock_obj.user.get = AsyncMock() - mock_obj.user.get.return_value = {"auth": {"local": {"username": "test-username"}}} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) + await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.habitica.config_flow.HabitipyAsync", - return_value=mock_obj, - ), - patch( - "homeassistant.components.habitica.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.habitica.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_DATA_ADVANCED_STEP, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - **MOCK_DATA_ADVANCED_STEP, - CONF_USERNAME: "test-username", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-user" + assert result["data"] == { + CONF_API_USER: TEST_API_USER, + CONF_API_KEY: TEST_API_KEY, + CONF_URL: DEFAULT_URL, + CONF_NAME: "test-user", + CONF_VERIFY_SSL: True, } - assert len(mock_setup.mock_calls) == 1 + assert result["result"].unique_id == TEST_API_USER + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( ("raise_error", "text_error"), [ - (ClientResponseError(MagicMock(), (), status=400), "cannot_connect"), - (ClientResponseError(MagicMock(), (), status=401), "invalid_auth"), + (ERROR_BAD_REQUEST, "cannot_connect"), + (ERROR_NOT_AUTHORIZED, "invalid_auth"), (IndexError(), "unknown"), ], ) async def test_form_advanced_errors( - hass: HomeAssistant, raise_error, text_error + hass: HomeAssistant, habitica: AsyncMock, raise_error, text_error ) -> None: """Test we handle invalid credentials error.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU @@ -201,17 +253,283 @@ async def test_form_advanced_errors( DOMAIN, context={"source": "advanced"} ) - mock_obj = MagicMock() - mock_obj.user.get = AsyncMock(side_effect=raise_error) + habitica.get_user.side_effect = raise_error - with patch( - "homeassistant.components.habitica.config_flow.HabitipyAsync", - return_value=mock_obj, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_DATA_ADVANCED_STEP, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": text_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + # recover from errors + habitica.get_user.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-user" + assert result["data"] == { + CONF_API_USER: TEST_API_USER, + CONF_API_KEY: TEST_API_KEY, + CONF_URL: DEFAULT_URL, + CONF_NAME: "test-user", + CONF_VERIFY_SSL: True, + } + assert result["result"].unique_id == TEST_API_USER + + +@pytest.mark.usefixtures("habitica") +async def test_form_advanced_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort user data set when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "advanced"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "user_input", + [ + (USER_INPUT_REAUTH_LOGIN), + (USER_INPUT_REAUTH_API_KEY), + ], + ids=["reauth with login details", "rauth with api key"], +) +@pytest.mark.usefixtures("habitica") +async def test_flow_reauth( + hass: HomeAssistant, config_entry: MockConfigEntry, user_input: dict[str, Any] +) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "user_input", "text_error"), + [ + ( + ERROR_BAD_REQUEST, + USER_INPUT_REAUTH_LOGIN, + "cannot_connect", + ), + ( + ERROR_NOT_AUTHORIZED, + USER_INPUT_REAUTH_LOGIN, + "invalid_auth", + ), + (IndexError(), USER_INPUT_REAUTH_LOGIN, "unknown"), + ( + ERROR_BAD_REQUEST, + USER_INPUT_REAUTH_API_KEY, + "cannot_connect", + ), + ( + ERROR_NOT_AUTHORIZED, + USER_INPUT_REAUTH_API_KEY, + "invalid_auth", + ), + (IndexError(), USER_INPUT_REAUTH_API_KEY, "unknown"), + ( + None, + {SECTION_REAUTH_LOGIN: {}, SECTION_REAUTH_API_KEY: {}}, + "invalid_credentials", + ), + ], + ids=[ + "login cannot_connect", + "login invalid_auth", + "login unknown", + "api_key cannot_connect", + "api_key invalid_auth", + "api_key unknown", + "invalid_credentials", + ], +) +async def test_flow_reauth_errors( + hass: HomeAssistant, + habitica: AsyncMock, + config_entry: MockConfigEntry, + raise_error: Exception, + user_input: dict[str, Any], + text_error: str, +) -> None: + """Test reauth flow with invalid credentials.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + habitica.get_user.side_effect = raise_error + habitica.login.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + habitica.get_user.side_effect = None + habitica.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT_REAUTH_API_KEY, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("habitica") +async def test_flow_reauth_unique_id_mismatch(hass: HomeAssistant) -> None: + """Test reauth flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="test-user", + data={ + CONF_URL: DEFAULT_URL, + CONF_API_USER: "371fcad5-0f9c-4211-931c-034a5d2a6213", + CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382", + }, + unique_id="371fcad5-0f9c-4211-931c-034a5d2a6213", + ) + + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_REAUTH_LOGIN, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("habitica") +async def test_flow_reconfigure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" + assert config_entry.data[CONF_URL] == DEFAULT_URL + assert config_entry.data[CONF_VERIFY_SSL] is True + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (ERROR_NOT_AUTHORIZED, "invalid_auth"), + (ERROR_BAD_REQUEST, "cannot_connect"), + (KeyError, "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + habitica: AsyncMock, + config_entry: MockConfigEntry, + raise_error: Exception, + text_error: str, +) -> None: + """Test reconfigure flow errors.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + habitica.get_user.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + habitica.get_user.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" + assert config_entry.data[CONF_URL] == DEFAULT_URL + assert config_entry.data[CONF_VERIFY_SSL] is True + + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/habitica/test_diagnostics.py b/tests/components/habitica/test_diagnostics.py new file mode 100644 index 00000000000..c6dbc37ff20 --- /dev/null +++ b/tests/components/habitica/test_diagnostics.py @@ -0,0 +1,27 @@ +"""Tests for Habitica diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("habitica") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index fd8a18b2d44..e953ec254d6 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -1,9 +1,10 @@ """Test the habitica module.""" import datetime -from http import HTTPStatus import logging +from unittest.mock import AsyncMock +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory import pytest @@ -11,25 +12,22 @@ from homeassistant.components.habitica.const import ( ATTR_ARGS, ATTR_DATA, ATTR_PATH, - DEFAULT_URL, DOMAIN, EVENT_API_CALL_SUCCESS, SERVICE_API_CALL, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_NAME from homeassistant.core import Event, HomeAssistant -from tests.common import ( - MockConfigEntry, - async_capture_events, - async_fire_time_changed, - load_json_object_fixture, +from .conftest import ( + ERROR_BAD_REQUEST, + ERROR_NOT_AUTHORIZED, + ERROR_NOT_FOUND, + ERROR_TOO_MANY_REQUESTS, ) -from tests.test_util.aiohttp import AiohttpClientMocker -TEST_API_CALL_ARGS = {"text": "Use API from Home Assistant", "type": "todo"} -TEST_USER_NAME = "test_user" +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed @pytest.fixture @@ -38,7 +36,7 @@ def capture_api_call_success(hass: HomeAssistant) -> list[Event]: return async_capture_events(hass, EVENT_API_CALL_SUCCESS) -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_entry_setup_unload( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: @@ -55,12 +53,11 @@ async def test_entry_setup_unload( assert config_entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_service_call( hass: HomeAssistant, config_entry: MockConfigEntry, capture_api_call_success: list[Event], - mock_habitica: AiohttpClientMocker, ) -> None: """Test integration setup, service call and unload.""" config_entry.add_to_hass(hass) @@ -71,16 +68,10 @@ async def test_service_call( assert len(capture_api_call_success) == 0 - mock_habitica.post( - "https://habitica.com/api/v3/tasks/user", - status=HTTPStatus.CREATED, - json={"data": TEST_API_CALL_ARGS}, - ) - TEST_SERVICE_DATA = { ATTR_NAME: "test-user", ATTR_PATH: ["tasks", "user", "post"], - ATTR_ARGS: TEST_API_CALL_ARGS, + ATTR_ARGS: {"text": "Use API from Home Assistant", "type": "todo"}, } await hass.services.async_call( DOMAIN, SERVICE_API_CALL, TEST_SERVICE_DATA, blocking=True @@ -94,21 +85,23 @@ async def test_service_call( @pytest.mark.parametrize( - ("status"), [HTTPStatus.NOT_FOUND, HTTPStatus.TOO_MANY_REQUESTS] + ("exception"), + [ERROR_BAD_REQUEST, ERROR_TOO_MANY_REQUESTS, ClientError], + ids=[ + "BadRequestError", + "TooManyRequestsError", + "ClientError", + ], ) async def test_config_entry_not_ready( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - status: HTTPStatus, + habitica: AsyncMock, + exception: Exception, ) -> None: """Test config entry not ready.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - status=status, - ) - + habitica.get_user.side_effect = exception config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -116,22 +109,40 @@ async def test_config_entry_not_ready( assert config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_config_entry_auth_failed( + hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock +) -> None: + """Test config entry auth failed setup error.""" + + habitica.get_user.side_effect = ERROR_NOT_AUTHORIZED + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id + + +@pytest.mark.parametrize("exception", [ERROR_NOT_FOUND, ClientError]) async def test_coordinator_update_failed( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, + exception: Exception, ) -> None: """Test coordinator update failed.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture("user.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - status=HTTPStatus.NOT_FOUND, - ) - + habitica.get_tasks.side_effect = exception config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -142,7 +153,7 @@ async def test_coordinator_update_failed( async def test_coordinator_rate_limited( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, ) -> None: @@ -154,11 +165,7 @@ async def test_coordinator_rate_limited( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.clear_requests() - mock_habitica.get( - f"{DEFAULT_URL}/api/v3/user", - status=HTTPStatus.TOO_MANY_REQUESTS, - ) + habitica.get_user.side_effect = ERROR_TOO_MANY_REQUESTS with caplog.at_level(logging.DEBUG): freezer.tick(datetime.timedelta(seconds=60)) diff --git a/tests/components/habitica/test_sensor.py b/tests/components/habitica/test_sensor.py index defe5a270ae..1c648e38720 100644 --- a/tests/components/habitica/test_sensor.py +++ b/tests/components/habitica/test_sensor.py @@ -7,7 +7,8 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.habitica.const import DOMAIN -from homeassistant.components.habitica.sensor import HabitipySensorEntity +from homeassistant.components.habitica.sensor import HabiticaSensorEntity +from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -26,7 +27,7 @@ def sensor_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_habitica", "entity_registry_enabled_by_default") +@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -35,6 +36,19 @@ async def test_sensors( ) -> None: """Test setup of the Habitica sensor platform.""" + for entity in ( + ("test_user_habits", "habits"), + ("test_user_rewards", "rewards"), + ("test_user_max_health", "health_max"), + ): + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{entity[1]}", + suggested_object_id=entity[0], + disabled_by=None, + ) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -44,14 +58,33 @@ async def test_sensors( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) -@pytest.mark.usefixtures("mock_habitica", "entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("entity_id", "key"), + [ + ("test_user_habits", HabiticaSensorEntity.HABITS), + ("test_user_rewards", HabiticaSensorEntity.REWARDS), + ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), + ], +) +@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") async def test_sensor_deprecation_issue( hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + entity_id: str, + key: HabiticaSensorEntity, ) -> None: - """Test task sensor deprecation issue.""" + """Test sensor deprecation issue.""" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", + suggested_object_id=entity_id, + disabled_by=None, + ) + assert entity_registry is not None with patch( "homeassistant.components.habitica.sensor.entity_used_in", return_value=True ): @@ -62,11 +95,57 @@ async def test_sensor_deprecation_issue( assert config_entry.state is ConfigEntryState.LOADED + assert entity_registry.async_get(f"sensor.{entity_id}") is not None assert issue_registry.async_get_issue( domain=DOMAIN, - issue_id=f"deprecated_task_entity_{HabitipySensorEntity.TODOS}", + issue_id=f"deprecated_entity_{key}", ) - assert issue_registry.async_get_issue( - domain=DOMAIN, - issue_id=f"deprecated_task_entity_{HabitipySensorEntity.DAILIES}", + + +@pytest.mark.parametrize( + ("entity_id", "key"), + [ + ("test_user_habits", HabiticaSensorEntity.HABITS), + ("test_user_rewards", HabiticaSensorEntity.REWARDS), + ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), + ], +) +@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") +async def test_sensor_deprecation_delete_disabled( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + entity_id: str, + key: HabiticaSensorEntity, +) -> None: + """Test sensor deletion .""" + + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", + suggested_object_id=entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + assert entity_registry is not None + with patch( + "homeassistant.components.habitica.sensor.entity_used_in", return_value=True + ): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{key}", + ) + is None ) + + assert entity_registry.async_get(f"sensor.{entity_id}") is None diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index cd363eba3b5..5fca1884bdf 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -1,25 +1,32 @@ """Test Habitica actions.""" from collections.abc import Generator -from http import HTTPStatus from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from uuid import UUID +from aiohttp import ClientError +from habiticalib import Direction, Skill import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.habitica.const import ( ATTR_CONFIG_ENTRY, ATTR_DIRECTION, ATTR_ITEM, + ATTR_KEYWORD, + ATTR_PRIORITY, ATTR_SKILL, + ATTR_TAG, ATTR_TARGET, ATTR_TASK, - DEFAULT_URL, + ATTR_TYPE, DOMAIN, SERVICE_ABORT_QUEST, SERVICE_ACCEPT_QUEST, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, SERVICE_SCORE_HABIT, @@ -31,13 +38,17 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from .conftest import load_json_object_fixture, mock_called_with +from .conftest import ( + ERROR_BAD_REQUEST, + ERROR_NOT_AUTHORIZED, + ERROR_NOT_FOUND, + ERROR_TOO_MANY_REQUESTS, +) from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker -REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica, try again later" -RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again later" +REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica: reason" +RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again in 5 seconds" @pytest.fixture(autouse=True) @@ -54,7 +65,7 @@ def services_only() -> Generator[None]: async def load_entry( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, services_only: Generator, ) -> None: """Load config entry.""" @@ -75,55 +86,70 @@ def uuid_mock() -> Generator[None]: @pytest.mark.parametrize( - ("service_data", "item", "target_id"), + ( + "service_data", + "call_args", + ), [ ( { ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_SKILL: "pickpocket", }, - "pickPocket", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.PICKPOCKET, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ( { ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_SKILL: "backstab", }, - "backStab", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.BACKSTAB, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ( { ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_SKILL: "fireball", }, - "fireball", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.BURST_OF_FLAMES, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ( { ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_SKILL: "smash", }, - "smash", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.BRUTAL_SMASH, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ( { ATTR_TASK: "Rechnungen bezahlen", ATTR_SKILL: "smash", }, - "smash", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.BRUTAL_SMASH, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ( { ATTR_TASK: "pay_bills", ATTR_SKILL: "smash", }, - "smash", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.BRUTAL_SMASH, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ], ids=[ @@ -138,18 +164,12 @@ def uuid_mock() -> Generator[None]: async def test_cast_skill( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service_data: dict[str, Any], - item: str, - target_id: str, + call_args: dict[str, Any], ) -> None: """Test Habitica cast skill action.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", - json={"success": True, "data": {}}, - ) - await hass.services.async_call( DOMAIN, SERVICE_CAST_SKILL, @@ -160,18 +180,13 @@ async def test_cast_skill( return_response=True, blocking=True, ) - - assert mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", - ) + habitica.cast_skill.assert_awaited_once_with(**call_args) @pytest.mark.parametrize( ( "service_data", - "http_status", + "raise_exception", "expected_exception", "expected_exception_msg", ), @@ -181,7 +196,7 @@ async def test_cast_skill( ATTR_TASK: "task-not-found", ATTR_SKILL: "smash", }, - HTTPStatus.OK, + None, ServiceValidationError, "Unable to complete action, could not find the task 'task-not-found'", ), @@ -190,8 +205,8 @@ async def test_cast_skill( ATTR_TASK: "Rechnungen bezahlen", ATTR_SKILL: "smash", }, - HTTPStatus.TOO_MANY_REQUESTS, - ServiceValidationError, + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, RATE_LIMIT_EXCEPTION_MSG, ), ( @@ -199,7 +214,7 @@ async def test_cast_skill( ATTR_TASK: "Rechnungen bezahlen", ATTR_SKILL: "smash", }, - HTTPStatus.NOT_FOUND, + ERROR_NOT_FOUND, ServiceValidationError, "Unable to cast skill, your character does not have the skill or spell smash", ), @@ -208,7 +223,7 @@ async def test_cast_skill( ATTR_TASK: "Rechnungen bezahlen", ATTR_SKILL: "smash", }, - HTTPStatus.UNAUTHORIZED, + ERROR_NOT_AUTHORIZED, ServiceValidationError, "Unable to cast skill, not enough mana. Your character has 50 MP, but the skill costs 10 MP", ), @@ -217,30 +232,33 @@ async def test_cast_skill( ATTR_TASK: "Rechnungen bezahlen", ATTR_SKILL: "smash", }, - HTTPStatus.BAD_REQUEST, + ERROR_BAD_REQUEST, HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), ], ) -@pytest.mark.usefixtures("mock_habitica") async def test_cast_skill_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service_data: dict[str, Any], - http_status: HTTPStatus, + raise_exception: Exception, expected_exception: Exception, expected_exception_msg: str, ) -> None: """Test Habitica cast skill action exceptions.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/user/class/cast/smash?targetId=2f6fcabc-f670-4ec3-ba65-817e8deea490", - json={"success": True, "data": {}}, - status=http_status, - ) - + habitica.cast_skill.side_effect = raise_exception with pytest.raises(expected_exception, match=expected_exception_msg): await hass.services.async_call( DOMAIN, @@ -254,11 +272,9 @@ async def test_cast_skill_exceptions( ) -@pytest.mark.usefixtures("mock_habitica") async def test_get_config_entry( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, ) -> None: """Test Habitica config entry exceptions.""" @@ -298,31 +314,24 @@ async def test_get_config_entry( @pytest.mark.parametrize( - ("service", "command"), + "service", [ - (SERVICE_ABORT_QUEST, "abort"), - (SERVICE_ACCEPT_QUEST, "accept"), - (SERVICE_CANCEL_QUEST, "cancel"), - (SERVICE_LEAVE_QUEST, "leave"), - (SERVICE_REJECT_QUEST, "reject"), - (SERVICE_START_QUEST, "force-start"), + SERVICE_ABORT_QUEST, + SERVICE_ACCEPT_QUEST, + SERVICE_CANCEL_QUEST, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, ], - ids=[], ) async def test_handle_quests( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service: str, - command: str, ) -> None: """Test Habitica actions for quest handling.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}", - json={"success": True, "data": {}}, - ) - await hass.services.async_call( DOMAIN, service, @@ -331,63 +340,70 @@ async def test_handle_quests( blocking=True, ) - assert mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}", - ) + getattr(habitica, service).assert_awaited_once() @pytest.mark.parametrize( ( - "http_status", + "raise_exception", "expected_exception", "expected_exception_msg", ), [ ( - HTTPStatus.TOO_MANY_REQUESTS, - ServiceValidationError, + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, RATE_LIMIT_EXCEPTION_MSG, ), ( - HTTPStatus.NOT_FOUND, + ERROR_NOT_FOUND, ServiceValidationError, "Unable to complete action, quest or group not found", ), ( - HTTPStatus.UNAUTHORIZED, + ERROR_NOT_AUTHORIZED, ServiceValidationError, "Action not allowed, only quest leader or group leader can perform this action", ), ( - HTTPStatus.BAD_REQUEST, + ERROR_BAD_REQUEST, HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +@pytest.mark.parametrize( + "service", + [ + SERVICE_ACCEPT_QUEST, + SERVICE_ABORT_QUEST, + SERVICE_CANCEL_QUEST, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, ], ) -@pytest.mark.usefixtures("mock_habitica") async def test_handle_quests_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, - http_status: HTTPStatus, + habitica: AsyncMock, + raise_exception: Exception, + service: str, expected_exception: Exception, expected_exception_msg: str, ) -> None: """Test Habitica handle quests action exceptions.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/groups/party/quests/accept", - json={"success": True, "data": {}}, - status=http_status, - ) - + getattr(habitica, service).side_effect = raise_exception with pytest.raises(expected_exception, match=expected_exception_msg): await hass.services.async_call( DOMAIN, - SERVICE_ACCEPT_QUEST, + service, service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id}, return_response=True, blocking=True, @@ -395,7 +411,7 @@ async def test_handle_quests_exceptions( @pytest.mark.parametrize( - ("service", "service_data", "task_id"), + ("service", "service_data", "call_args"), [ ( SERVICE_SCORE_HABIT, @@ -403,7 +419,10 @@ async def test_handle_quests_exceptions( ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_DIRECTION: "up", }, - "e97659e0-2c42-4599-a7bb-00282adc410d", + { + "task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"), + "direction": Direction.UP, + }, ), ( SERVICE_SCORE_HABIT, @@ -411,14 +430,20 @@ async def test_handle_quests_exceptions( ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_DIRECTION: "down", }, - "e97659e0-2c42-4599-a7bb-00282adc410d", + { + "task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"), + "direction": Direction.DOWN, + }, ), ( SERVICE_SCORE_REWARD, { ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", }, - "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + { + "task_id": UUID("5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), + "direction": Direction.UP, + }, ), ( SERVICE_SCORE_HABIT, @@ -426,7 +451,10 @@ async def test_handle_quests_exceptions( ATTR_TASK: "Füge eine Aufgabe zu Habitica hinzu", ATTR_DIRECTION: "up", }, - "e97659e0-2c42-4599-a7bb-00282adc410d", + { + "task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"), + "direction": Direction.UP, + }, ), ( SERVICE_SCORE_HABIT, @@ -434,7 +462,10 @@ async def test_handle_quests_exceptions( ATTR_TASK: "create_a_task", ATTR_DIRECTION: "up", }, - "e97659e0-2c42-4599-a7bb-00282adc410d", + { + "task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"), + "direction": Direction.UP, + }, ), ], ids=[ @@ -448,18 +479,13 @@ async def test_handle_quests_exceptions( async def test_score_task( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service: str, service_data: dict[str, Any], - task_id: str, + call_args: dict[str, Any], ) -> None: """Test Habitica score task action.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}", - json={"success": True, "data": {}}, - ) - await hass.services.async_call( DOMAIN, service, @@ -471,17 +497,13 @@ async def test_score_task( blocking=True, ) - assert mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}", - ) + habitica.update_score.assert_awaited_once_with(**call_args) @pytest.mark.parametrize( ( "service_data", - "http_status", + "raise_exception", "expected_exception", "expected_exception_msg", ), @@ -491,7 +513,7 @@ async def test_score_task( ATTR_TASK: "task does not exist", ATTR_DIRECTION: "up", }, - HTTPStatus.OK, + None, ServiceValidationError, "Unable to complete action, could not find the task 'task does not exist'", ), @@ -500,8 +522,8 @@ async def test_score_task( ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_DIRECTION: "up", }, - HTTPStatus.TOO_MANY_REQUESTS, - ServiceValidationError, + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, RATE_LIMIT_EXCEPTION_MSG, ), ( @@ -509,44 +531,42 @@ async def test_score_task( ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_DIRECTION: "up", }, - HTTPStatus.BAD_REQUEST, + ERROR_BAD_REQUEST, HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "up", + }, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), ( { ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", ATTR_DIRECTION: "up", }, - HTTPStatus.UNAUTHORIZED, + ERROR_NOT_AUTHORIZED, HomeAssistantError, - "Unable to buy reward, not enough gold. Your character has 137.63 GP, but the reward costs 10 GP", + "Unable to buy reward, not enough gold. Your character has 137.63 GP, but the reward costs 10.00 GP", ), ], ) -@pytest.mark.usefixtures("mock_habitica") async def test_score_task_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service_data: dict[str, Any], - http_status: HTTPStatus, + raise_exception: Exception, expected_exception: Exception, expected_exception_msg: str, ) -> None: """Test Habitica score task action exceptions.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/e97659e0-2c42-4599-a7bb-00282adc410d/score/up", - json={"success": True, "data": {}}, - status=http_status, - ) - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b/score/up", - json={"success": True, "data": {}}, - status=http_status, - ) - + habitica.update_score.side_effect = raise_exception with pytest.raises(expected_exception, match=expected_exception_msg): await hass.services.async_call( DOMAIN, @@ -561,100 +581,119 @@ async def test_score_task_exceptions( @pytest.mark.parametrize( - ("service_data", "item", "target_id"), + ("service_data", "call_args"), [ ( { ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_ITEM: "shiny_seed", }, - "shinySeed", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SHINY_SEED, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_ITEM: "seafoam", }, - "seafoam", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SEAFOAM, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_ITEM: "snowball", }, - "snowball", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SNOWBALL, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "test-user", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "test-username", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"), + }, ), ( { ATTR_TARGET: "test-partymember-username", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"), + }, ), ( { ATTR_TARGET: "test-partymember-displayname", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"), + }, ), ], - ids=[], + ids=[ + "use spooky sparkles/select self by id", + "use shiny seed", + "use seafoam", + "use snowball", + "select self by displayname", + "select self by username", + "select partymember by id", + "select partymember by username", + "select partymember by displayname", + ], ) async def test_transformation( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service_data: dict[str, Any], - item: str, - target_id: str, + call_args: dict[str, Any], ) -> None: - """Test Habitica user transformation item action.""" - mock_habitica.get( - f"{DEFAULT_URL}/api/v3/groups/party/members", - json=load_json_object_fixture("party_members.json", DOMAIN), - ) - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", - json={"success": True, "data": {}}, - ) + """Test Habitica use transformation item action.""" await hass.services.async_call( DOMAIN, @@ -667,18 +706,14 @@ async def test_transformation( blocking=True, ) - assert mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", - ) + habitica.cast_skill.assert_awaited_once_with(**call_args) @pytest.mark.parametrize( ( "service_data", - "http_status_members", - "http_status_cast", + "raise_exception_members", + "raise_exception_cast", "expected_exception", "expected_exception_msg", ), @@ -688,8 +723,8 @@ async def test_transformation( ATTR_TARGET: "user-not-found", ATTR_ITEM: "spooky_sparkles", }, - HTTPStatus.OK, - HTTPStatus.OK, + None, + None, ServiceValidationError, "Unable to find target 'user-not-found' in your party", ), @@ -698,18 +733,8 @@ async def test_transformation( ATTR_TARGET: "test-partymember-username", ATTR_ITEM: "spooky_sparkles", }, - HTTPStatus.TOO_MANY_REQUESTS, - HTTPStatus.OK, - ServiceValidationError, - RATE_LIMIT_EXCEPTION_MSG, - ), - ( - { - ATTR_TARGET: "test-partymember-username", - ATTR_ITEM: "spooky_sparkles", - }, - HTTPStatus.NOT_FOUND, - HTTPStatus.OK, + ERROR_NOT_FOUND, + None, ServiceValidationError, "Unable to find target, you are currently not in a party. You can only target yourself", ), @@ -718,19 +743,19 @@ async def test_transformation( ATTR_TARGET: "test-partymember-username", ATTR_ITEM: "spooky_sparkles", }, - HTTPStatus.BAD_REQUEST, - HTTPStatus.OK, + ERROR_BAD_REQUEST, + None, HomeAssistantError, - "Unable to connect to Habitica, try again later", + REQUEST_EXCEPTION_MSG, ), ( { ATTR_TARGET: "test-partymember-username", ATTR_ITEM: "spooky_sparkles", }, - HTTPStatus.OK, - HTTPStatus.TOO_MANY_REQUESTS, - ServiceValidationError, + None, + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, RATE_LIMIT_EXCEPTION_MSG, ), ( @@ -738,8 +763,8 @@ async def test_transformation( ATTR_TARGET: "test-partymember-username", ATTR_ITEM: "spooky_sparkles", }, - HTTPStatus.OK, - HTTPStatus.UNAUTHORIZED, + None, + ERROR_NOT_AUTHORIZED, ServiceValidationError, "Unable to use spooky_sparkles, you don't own this item", ), @@ -748,36 +773,47 @@ async def test_transformation( ATTR_TARGET: "test-partymember-username", ATTR_ITEM: "spooky_sparkles", }, - HTTPStatus.OK, - HTTPStatus.BAD_REQUEST, + None, + ERROR_BAD_REQUEST, HomeAssistantError, - "Unable to connect to Habitica, try again later", + REQUEST_EXCEPTION_MSG, + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + None, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + ClientError, + None, + HomeAssistantError, + "Unable to connect to Habitica: ", ), ], ) -@pytest.mark.usefixtures("mock_habitica") async def test_transformation_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service_data: dict[str, Any], - http_status_members: HTTPStatus, - http_status_cast: HTTPStatus, + raise_exception_members: Exception, + raise_exception_cast: Exception, expected_exception: Exception, expected_exception_msg: str, ) -> None: """Test Habitica transformation action exceptions.""" - mock_habitica.get( - f"{DEFAULT_URL}/api/v3/groups/party/members", - json=load_json_object_fixture("party_members.json", DOMAIN), - status=http_status_members, - ) - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/user/class/cast/spookySparkles?targetId=ffce870c-3ff3-4fa4-bad1-87612e52b8e7", - json={"success": True, "data": {}}, - status=http_status_cast, - ) + habitica.cast_skill.side_effect = raise_exception_cast + habitica.get_group_members.side_effect = raise_exception_members with pytest.raises(expected_exception, match=expected_exception_msg): await hass.services.async_call( DOMAIN, @@ -789,3 +825,67 @@ async def test_transformation_exceptions( return_response=True, blocking=True, ) + + +@pytest.mark.parametrize( + ("service_data"), + [ + {}, + {ATTR_TYPE: ["daily"]}, + {ATTR_TYPE: ["habit"]}, + {ATTR_TYPE: ["todo"]}, + {ATTR_TYPE: ["reward"]}, + {ATTR_TYPE: ["daily", "habit"]}, + {ATTR_TYPE: ["todo", "reward"]}, + {ATTR_PRIORITY: "trivial"}, + {ATTR_PRIORITY: "easy"}, + {ATTR_PRIORITY: "medium"}, + {ATTR_PRIORITY: "hard"}, + {ATTR_TASK: ["Zahnseide benutzen", "Eine kurze Pause machen"]}, + {ATTR_TASK: ["f2c85972-1a19-4426-bc6d-ce3337b9d99f"]}, + {ATTR_TASK: ["alias_zahnseide_benutzen"]}, + {ATTR_TAG: ["Training", "Gesundheit + Wohlbefinden"]}, + {ATTR_KEYWORD: "gewohnheit"}, + {ATTR_TAG: ["Home Assistant"]}, + ], + ids=[ + "all_tasks", + "only dailies", + "only habits", + "only todos", + "only rewards", + "only dailies and habits", + "only todos and rewards", + "trivial tasks", + "easy tasks", + "medium tasks", + "hard tasks", + "by task name", + "by task ID", + "by alias", + "by tag", + "by keyword", + "empty result", + ], +) +@pytest.mark.usefixtures("habitica") +async def test_get_tasks( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + service_data: dict[str, Any], +) -> None: + """Test Habitica get_tasks action.""" + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_TASKS, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + assert response == snapshot diff --git a/tests/components/habitica/test_switch.py b/tests/components/habitica/test_switch.py index 55ba7b19b22..1799788a48e 100644 --- a/tests/components/habitica/test_switch.py +++ b/tests/components/habitica/test_switch.py @@ -1,13 +1,12 @@ """Tests for the Habitica switch platform.""" from collections.abc import Generator -from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from aiohttp import ClientError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.habitica.const import DEFAULT_URL from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TOGGLE, @@ -17,13 +16,12 @@ from homeassistant.components.switch import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import mock_called_with +from .conftest import ERROR_BAD_REQUEST, ERROR_TOO_MANY_REQUESTS from tests.common import MockConfigEntry, snapshot_platform -from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture(autouse=True) @@ -36,7 +34,7 @@ def switch_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_switch( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -66,14 +64,10 @@ async def test_turn_on_off_toggle( hass: HomeAssistant, config_entry: MockConfigEntry, service_call: str, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test switch turn on/off, toggle method.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/user/sleep", - json={"success": True, "data": False}, - ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -87,7 +81,7 @@ async def test_turn_on_off_toggle( blocking=True, ) - assert mock_called_with(mock_habitica, "post", f"{DEFAULT_URL}/api/v3/user/sleep") + habitica.toggle_sleep.assert_awaited_once() @pytest.mark.parametrize( @@ -99,19 +93,20 @@ async def test_turn_on_off_toggle( ], ) @pytest.mark.parametrize( - ("status_code", "exception"), + ("raise_exception", "expected_exception"), [ - (HTTPStatus.TOO_MANY_REQUESTS, ServiceValidationError), - (HTTPStatus.BAD_REQUEST, HomeAssistantError), + (ERROR_TOO_MANY_REQUESTS, HomeAssistantError), + (ERROR_BAD_REQUEST, HomeAssistantError), + (ClientError, HomeAssistantError), ], ) async def test_turn_on_off_toggle_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, service_call: str, - mock_habitica: AiohttpClientMocker, - status_code: HTTPStatus, - exception: Exception, + habitica: AsyncMock, + raise_exception: Exception, + expected_exception: Exception, ) -> None: """Test switch turn on/off, toggle method.""" @@ -121,18 +116,12 @@ async def test_turn_on_off_toggle_exceptions( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/user/sleep", - status=status_code, - json={"success": True, "data": False}, - ) + habitica.toggle_sleep.side_effect = raise_exception - with pytest.raises(expected_exception=exception): + with pytest.raises(expected_exception=expected_exception): await hass.services.async_call( SWITCH_DOMAIN, service_call, {ATTR_ENTITY_ID: "switch.test_user_rest_in_the_inn"}, blocking=True, ) - - assert mock_called_with(mock_habitica, "post", f"{DEFAULT_URL}/api/v3/user/sleep") diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index 66f741eb39a..8f20b3e685a 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -1,15 +1,16 @@ """Tests for Habitica todo platform.""" from collections.abc import Generator -from http import HTTPStatus -import json -import re -from unittest.mock import patch +from datetime import date +from typing import Any +from unittest.mock import AsyncMock, patch +from uuid import UUID +from habiticalib import Direction, HabiticaTasksResponse, Task, TaskType import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.habitica.const import DEFAULT_URL, DOMAIN +from homeassistant.components.habitica.const import DOMAIN from homeassistant.components.todo import ( ATTR_DESCRIPTION, ATTR_DUE_DATE, @@ -22,18 +23,17 @@ from homeassistant.components.todo import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from .conftest import mock_called_with +from .conftest import ERROR_NOT_FOUND, ERROR_TOO_MANY_REQUESTS from tests.common import ( MockConfigEntry, async_get_persistent_notifications, - load_json_object_fixture, + load_fixture, snapshot_platform, ) -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -47,7 +47,7 @@ def todo_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_todos( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -72,7 +72,7 @@ async def test_todos( "todo.test_user_dailies", ], ) -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_todo_items( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -111,7 +111,7 @@ async def test_todo_items( async def test_complete_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, snapshot: SnapshotAssertion, entity_id: str, uid: str, @@ -124,10 +124,6 @@ async def test_complete_todo_item( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/up", - json=load_json_object_fixture("score_with_drop.json", DOMAIN), - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, @@ -136,9 +132,7 @@ async def test_complete_todo_item( blocking=True, ) - assert mock_called_with( - mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/up" - ) + habitica.update_score.assert_awaited_once_with(UUID(uid), Direction.UP) # Test notification for item drop notifications = async_get_persistent_notifications(hass) @@ -158,7 +152,7 @@ async def test_complete_todo_item( async def test_uncomplete_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, entity_id: str, uid: str, ) -> None: @@ -170,10 +164,6 @@ async def test_uncomplete_todo_item( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/down", - json={"data": {}, "success": True}, - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, @@ -182,9 +172,7 @@ async def test_uncomplete_todo_item( blocking=True, ) - assert mock_called_with( - mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/down" - ) + habitica.update_score.assert_called_once_with(UUID(uid), Direction.DOWN) @pytest.mark.parametrize( @@ -195,12 +183,30 @@ async def test_uncomplete_todo_item( ], ids=["completed", "needs_action"], ) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + r"Unable to update the score for your Habitica to-do `.+`, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_complete_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, uid: str, status: str, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when completing/uncompleting an item on the todo list.""" @@ -210,13 +216,10 @@ async def test_complete_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - re.compile(f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/.+"), - status=HTTPStatus.NOT_FOUND, - ) + habitica.update_score.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match=r"Unable to update the score for your Habitica to-do `.+`, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -228,30 +231,86 @@ async def test_complete_todo_item_exception( @pytest.mark.parametrize( - ("entity_id", "uid", "date"), + ("entity_id", "service_data", "call_args"), [ ( "todo.test_user_to_do_s", - "88de7cd9-af2b-49ce-9afd-bf941d87336b", - "2024-07-30", + { + ATTR_ITEM: "88de7cd9-af2b-49ce-9afd-bf941d87336b", + ATTR_RENAME: "test-summary", + ATTR_DESCRIPTION: "test-description", + ATTR_DUE_DATE: date(2024, 7, 30), + }, + ( + UUID("88de7cd9-af2b-49ce-9afd-bf941d87336b"), + Task( + notes="test-description", + text="test-summary", + date=date(2024, 7, 30), + ), + ), + ), + ( + "todo.test_user_to_do_s", + { + ATTR_ITEM: "88de7cd9-af2b-49ce-9afd-bf941d87336b", + ATTR_RENAME: "test-summary", + ATTR_DESCRIPTION: "test-description", + ATTR_DUE_DATE: None, + }, + ( + UUID("88de7cd9-af2b-49ce-9afd-bf941d87336b"), + Task( + notes="test-description", + text="test-summary", + date=None, + ), + ), + ), + ( + "todo.test_user_to_do_s", + { + ATTR_ITEM: "88de7cd9-af2b-49ce-9afd-bf941d87336b", + ATTR_RENAME: "test-summary", + ATTR_DESCRIPTION: None, + ATTR_DUE_DATE: date(2024, 7, 30), + }, + ( + UUID("88de7cd9-af2b-49ce-9afd-bf941d87336b"), + Task( + notes="", + text="test-summary", + date=date(2024, 7, 30), + ), + ), ), ( "todo.test_user_dailies", - "f2c85972-1a19-4426-bc6d-ce3337b9d99f", - None, + { + ATTR_ITEM: "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + ATTR_RENAME: "test-summary", + ATTR_DESCRIPTION: "test-description", + }, + ( + UUID("f2c85972-1a19-4426-bc6d-ce3337b9d99f"), + Task( + notes="test-description", + text="test-summary", + ), + ), ), ], - ids=["todo", "daily"], + ids=["todo", "todo remove date", "todo remove notes", "daily"], ) async def test_update_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, entity_id: str, - uid: str, - date: str, + service_data: dict[str, Any], + call_args: tuple[UUID, Task], ) -> None: - """Test update details of a item on the todo list.""" + """Test update details of an item on the todo list.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -259,38 +318,39 @@ async def test_update_todo_item( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.put( - f"{DEFAULT_URL}/api/v3/tasks/{uid}", - json={"data": {}, "success": True}, - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, - { - ATTR_ITEM: uid, - ATTR_RENAME: "test-summary", - ATTR_DESCRIPTION: "test-description", - ATTR_DUE_DATE: date, - }, + service_data, target={ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mock_call = mock_called_with( - mock_habitica, "PUT", f"{DEFAULT_URL}/api/v3/tasks/{uid}" - ) - assert mock_call - assert json.loads(mock_call[2]) == { - "date": date, - "notes": "test-description", - "text": "test-summary", - } + habitica.update_task.assert_awaited_once_with(*call_args) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to update the Habitica to-do `test-summary`, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_update_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when update item on the todo list.""" uid = "88de7cd9-af2b-49ce-9afd-bf941d87336b" @@ -300,14 +360,8 @@ async def test_update_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.put( - f"{DEFAULT_URL}/api/v3/tasks/{uid}", - status=HTTPStatus.NOT_FOUND, - ) - with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to update the Habitica to-do `test-summary`, please try again", - ): + habitica.update_task.side_effect = exception + with pytest.raises(expected_exception=expected_exception, match=exc_msg): await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, @@ -325,7 +379,7 @@ async def test_update_todo_item_exception( async def test_add_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test add a todo item to the todo list.""" @@ -335,11 +389,6 @@ async def test_add_todo_item( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/user", - json={"data": {}, "success": True}, - status=HTTPStatus.CREATED, - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.ADD_ITEM, @@ -352,24 +401,38 @@ async def test_add_todo_item( blocking=True, ) - mock_call = mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/tasks/user", + habitica.create_task.assert_awaited_once_with( + Task( + date=date(2024, 7, 30), + notes="test-description", + text="test-summary", + type=TaskType.TODO, + ) ) - assert mock_call - assert json.loads(mock_call[2]) == { - "date": "2024-07-30", - "notes": "test-description", - "text": "test-summary", - "type": "todo", - } +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to create new to-do `test-summary` for Habitica, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_add_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when adding a todo item to the todo list.""" @@ -379,13 +442,11 @@ async def test_add_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/user", - status=HTTPStatus.NOT_FOUND, - ) + habitica.create_task.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to create new to-do `test-summary` for Habitica, please try again", + expected_exception=expected_exception, + # match="Unable to create new to-do `test-summary` for Habitica, please try again", + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -403,7 +464,7 @@ async def test_add_todo_item_exception( async def test_delete_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test deleting a todo item from the todo list.""" @@ -414,10 +475,6 @@ async def test_delete_todo_item( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.delete( - f"{DEFAULT_URL}/api/v3/tasks/{uid}", - json={"data": {}, "success": True}, - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.REMOVE_ITEM, @@ -426,15 +483,31 @@ async def test_delete_todo_item( blocking=True, ) - assert mock_called_with( - mock_habitica, "delete", f"{DEFAULT_URL}/api/v3/tasks/{uid}" - ) + habitica.delete_task.assert_awaited_once_with(UUID(uid)) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to delete item from Habitica to-do list, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_delete_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when deleting a todo item from the todo list.""" @@ -445,13 +518,11 @@ async def test_delete_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.delete( - f"{DEFAULT_URL}/api/v3/tasks/{uid}", - status=HTTPStatus.NOT_FOUND, - ) + habitica.delete_task.side_effect = exception + with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to delete item from Habitica to-do list, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -465,7 +536,7 @@ async def test_delete_todo_item_exception( async def test_delete_completed_todo_items( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test deleting completed todo items from the todo list.""" config_entry.add_to_hass(hass) @@ -474,10 +545,6 @@ async def test_delete_completed_todo_items( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos", - json={"data": {}, "success": True}, - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.REMOVE_COMPLETED_ITEMS, @@ -486,15 +553,31 @@ async def test_delete_completed_todo_items( blocking=True, ) - assert mock_called_with( - mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos" - ) + habitica.delete_completed_todos.assert_awaited_once() +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to delete completed to-do items from Habitica to-do list, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_delete_completed_todo_items_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when deleting completed todo items from the todo list.""" config_entry.add_to_hass(hass) @@ -503,13 +586,10 @@ async def test_delete_completed_todo_items_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos", - status=HTTPStatus.NOT_FOUND, - ) + habitica.delete_completed_todos.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to delete completed to-do items from Habitica to-do list, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -539,7 +619,7 @@ async def test_delete_completed_todo_items_exception( async def test_move_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, hass_ws_client: WebSocketGenerator, entity_id: str, uid: str, @@ -553,12 +633,6 @@ async def test_move_todo_item( assert config_entry.state is ConfigEntryState.LOADED - for pos in (0, 1): - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/{pos}", - json={"data": {}, "success": True}, - ) - client = await hass_ws_client() # move to second position data = { @@ -572,6 +646,9 @@ async def test_move_todo_item( resp = await client.receive_json() assert resp.get("success") + habitica.reorder_task.assert_awaited_once_with(UUID(uid), 1) + habitica.reorder_task.reset_mock() + # move to top position data = { "id": id, @@ -583,19 +660,29 @@ async def test_move_todo_item( resp = await client.receive_json() assert resp.get("success") - for pos in (0, 1): - assert mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/{pos}", - ) + habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) +@pytest.mark.parametrize( + ("exception", "exc_msg"), + [ + ( + ERROR_NOT_FOUND, + "Unable to move the Habitica to-do to position 0, please try again", + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + ), + ], +) async def test_move_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, hass_ws_client: WebSocketGenerator, + exception: Exception, + exc_msg: str, ) -> None: """Test exception when moving todo item.""" @@ -606,11 +693,7 @@ async def test_move_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/0", - status=HTTPStatus.NOT_FOUND, - ) - + habitica.reorder_task.side_effect = exception client = await hass_ws_client() data = { @@ -620,8 +703,12 @@ async def test_move_todo_item_exception( "uid": uid, } await client.send_json_auto_id(data) + resp = await client.receive_json() - assert resp.get("success") is False + habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) + + assert resp["success"] is False + assert resp["error"]["message"] == exc_msg @pytest.mark.parametrize( @@ -635,6 +722,7 @@ async def test_move_todo_item_exception( ("duedate_fixture_6.json", "2024-10-21"), ("duedate_fixture_7.json", None), ("duedate_fixture_8.json", None), + ("duedate_fixture_9.json", None), ], ids=[ "default", @@ -645,37 +733,25 @@ async def test_move_todo_item_exception( "monthly starts on fixed day", "grey daily", "empty nextDue", + "grey daily no weekdays", ], ) @pytest.mark.usefixtures("set_tz") async def test_next_due_date( hass: HomeAssistant, fixture: str, - calculated_due_date: tuple | None, + calculated_due_date: str | None, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test next_due_date calculation.""" dailies_entity = "todo.test_user_dailies" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", json=load_json_object_fixture("user.json", DOMAIN) - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - params={"type": "completedTodos"}, - json={"data": []}, - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - json=load_json_object_fixture(fixture, DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/content", - params={"language": "en"}, - json=load_json_object_fixture("content.json", DOMAIN), - ) + habitica.get_tasks.side_effect = [ + HabiticaTasksResponse.from_json(load_fixture(fixture, DOMAIN)), + HabiticaTasksResponse.from_dict({"success": True, "data": []}), + ] config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py index d87bfd32326..2233ad194f5 100644 --- a/tests/components/harmony/test_config_flow.py +++ b/tests/components/harmony/test_config_flow.py @@ -5,12 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import aiohttp from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.harmony.config_flow import CannotConnect from homeassistant.components.harmony.const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry @@ -65,7 +65,7 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.12:8088/description", @@ -120,7 +120,7 @@ async def test_form_ssdp_fails_to_get_remote_id(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.12:8088/description", @@ -159,7 +159,7 @@ async def test_form_ssdp_aborts_before_checking_remoteid_if_host_known( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://2.2.2.2:8088/description", diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py new file mode 100644 index 00000000000..40ab253b7e6 --- /dev/null +++ b/tests/components/hassio/test_backup.py @@ -0,0 +1,1476 @@ +"""Test supervisor backup functionality.""" + +from collections.abc import ( + AsyncGenerator, + AsyncIterator, + Callable, + Coroutine, + Generator, +) +from dataclasses import replace +from datetime import datetime +from io import StringIO +import os +from typing import Any +from unittest.mock import ANY, AsyncMock, Mock, patch + +from aiohasupervisor.exceptions import ( + SupervisorBadRequestError, + SupervisorError, + SupervisorNotFoundError, +) +from aiohasupervisor.models import ( + backups as supervisor_backups, + mounts as supervisor_mounts, +) +from aiohasupervisor.models.mounts import MountsInfo +import pytest + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, + BackupAgent, + BackupAgentPlatformProtocol, + Folder, +) +from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .test_init import MOCK_ENVIRON + +from tests.common import mock_platform +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +TEST_BACKUP = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=[supervisor_backups.Folder.SHARE], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location=None, + locations={None}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP.compressed, + date=TEST_BACKUP.date, + extra=None, + folders=[supervisor_backups.Folder.SHARE], + homeassistant_exclude_database=False, + homeassistant="2024.12.0", + location=TEST_BACKUP.location, + locations=TEST_BACKUP.locations, + name=TEST_BACKUP.name, + protected=TEST_BACKUP.protected, + repositories=[], + size=TEST_BACKUP.size, + size_bytes=TEST_BACKUP.size_bytes, + slug=TEST_BACKUP.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP.type, +) + +TEST_BACKUP_2 = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=["share"], + homeassistant=False, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location=None, + locations={None}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP_2.compressed, + date=TEST_BACKUP_2.date, + extra=None, + folders=["share"], + homeassistant_exclude_database=False, + homeassistant=None, + location=TEST_BACKUP_2.location, + locations=TEST_BACKUP_2.locations, + name=TEST_BACKUP_2.name, + protected=TEST_BACKUP_2.protected, + repositories=[], + size=TEST_BACKUP_2.size, + size_bytes=TEST_BACKUP_2.size_bytes, + slug=TEST_BACKUP_2.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP_2.type, +) + +TEST_BACKUP_3 = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=["share"], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location="share", + locations={"share"}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP_3.compressed, + date=TEST_BACKUP_3.date, + extra=None, + folders=["share"], + homeassistant_exclude_database=False, + homeassistant=None, + location=TEST_BACKUP_3.location, + locations=TEST_BACKUP_3.locations, + name=TEST_BACKUP_3.name, + protected=TEST_BACKUP_3.protected, + repositories=[], + size=TEST_BACKUP_3.size, + size_bytes=TEST_BACKUP_3.size_bytes, + slug=TEST_BACKUP_3.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP_3.type, +) + + +TEST_BACKUP_4 = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=["share"], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location=None, + locations={None}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP.compressed, + date=TEST_BACKUP.date, + extra=None, + folders=["share"], + homeassistant_exclude_database=True, + homeassistant="2024.12.0", + location=TEST_BACKUP.location, + locations=TEST_BACKUP.locations, + name=TEST_BACKUP.name, + protected=TEST_BACKUP.protected, + repositories=[], + size=TEST_BACKUP.size, + size_bytes=TEST_BACKUP.size_bytes, + slug=TEST_BACKUP.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP.type, +) + + +@pytest.fixture(autouse=True) +def fixture_supervisor_environ() -> Generator[None]: + """Mock os environ for supervisor.""" + with patch.dict(os.environ, MOCK_ENVIRON): + yield + + +@pytest.fixture(autouse=True) +async def hassio_enabled( + hass: HomeAssistant, supervisor_client: AsyncMock +) -> AsyncGenerator[None]: + """Enable hassio.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=True), + patch("homeassistant.components.backup.backup.is_hassio", return_value=True), + ): + yield + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock +) -> None: + """Set up Backup integration.""" + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.async_block_till_done() + + +class BackupAgentTest(BackupAgent): + """Test backup agent.""" + + domain = "test" + + def __init__(self, name: str) -> None: + """Initialize the backup agent.""" + self.name = name + + async def async_download_backup( + self, backup_id: str, **kwargs: Any + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + return AsyncMock(spec_set=["__aiter__"]) + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + await open_stream() + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + return [] + + async def async_get_backup( + self, backup_id: str, **kwargs: Any + ) -> AgentBackup | None: + """Return a backup.""" + return None + + async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: + """Delete a backup file.""" + + +async def _setup_backup_platform( + hass: HomeAssistant, + *, + domain: str, + platform: BackupAgentPlatformProtocol, +) -> None: + """Set up a mock domain.""" + mock_platform(hass, f"{domain}.backup", platform) + assert await async_setup_component(hass, domain, {}) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("hassio_client") +@pytest.mark.parametrize( + ("mounts", "expected_agents"), + [ + (MountsInfo(default_backup_mount=None, mounts=[]), ["hassio.local"]), + ( + MountsInfo( + default_backup_mount=None, + mounts=[ + supervisor_mounts.CIFSMountResponse( + share="test", + name="test", + read_only=False, + state=supervisor_mounts.MountState.ACTIVE, + user_path="test", + usage=supervisor_mounts.MountUsage.BACKUP, + server="test", + type=supervisor_mounts.MountType.CIFS, + ) + ], + ), + ["hassio.local", "hassio.test"], + ), + ( + MountsInfo( + default_backup_mount=None, + mounts=[ + supervisor_mounts.CIFSMountResponse( + share="test", + name="test", + read_only=False, + state=supervisor_mounts.MountState.ACTIVE, + user_path="test", + usage=supervisor_mounts.MountUsage.MEDIA, + server="test", + type=supervisor_mounts.MountType.CIFS, + ) + ], + ), + ["hassio.local"], + ), + ], +) +async def test_agent_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + mounts: MountsInfo, + expected_agents: list[str], +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + supervisor_client.mounts.info.return_value = mounts + + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": agent_id} for agent_id in expected_agents], + } + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("backup", "backup_details", "expected_response"), + [ + ( + TEST_BACKUP, + TEST_BACKUP_DETAILS, + { + "addons": [ + {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} + ], + "agent_ids": ["hassio.local"], + "backup_id": "abc123", + "database_included": True, + "date": "1970-01-01T00:00:00+00:00", + "failed_agent_ids": [], + "folders": ["share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 1048576, + "with_automatic_settings": None, + }, + ), + ( + TEST_BACKUP_2, + TEST_BACKUP_DETAILS_2, + { + "addons": [ + {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} + ], + "agent_ids": ["hassio.local"], + "backup_id": "abc123", + "database_included": False, + "date": "1970-01-01T00:00:00+00:00", + "failed_agent_ids": [], + "folders": ["share"], + "homeassistant_included": False, + "homeassistant_version": None, + "name": "Test", + "protected": False, + "size": 1048576, + "with_automatic_settings": None, + }, + ), + ], +) +async def test_agent_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + backup: supervisor_backups.Backup, + backup_details: supervisor_backups.BackupComplete, + expected_response: dict[str, Any], +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + supervisor_client.backups.list.return_value = [backup, TEST_BACKUP_3] + supervisor_client.backups.backup_info.return_value = backup_details + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backups"] == [expected_response] + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_agent_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent download backup, when cloud user is logged in.""" + client = await hass_client() + backup_id = "abc123" + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.download_backup.return_value.__aiter__.return_value = ( + iter((b"backup data",)) + ) + + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=hassio.local") + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + supervisor_client.backups.download_backup.assert_called_once_with( + "abc123", options=supervisor_backups.DownloadBackupOptions(location=None) + ) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_agent_download_unavailable_backup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent download backup, when cloud user is logged in.""" + client = await hass_client() + backup_id = "abc123" + supervisor_client.backups.list.return_value = [TEST_BACKUP_3] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_3 + supervisor_client.backups.download_backup.return_value.__aiter__.return_value = ( + iter((b"backup data",)) + ) + + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=hassio.local") + assert resp.status == 404 + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_agent_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + backup_id = "test-backup" + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0, + ) + + supervisor_client.backups.reload.assert_not_called() + with ( + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.open"), + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("shutil.copy"), + ): + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=hassio.local", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + supervisor_client.backups.reload.assert_not_called() + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_agent_delete_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + supervisor_client.backups.remove_backup.assert_called_once_with( + backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + ) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("remove_side_effect", "expected_response"), + [ + ( + SupervisorBadRequestError("blah"), + { + "success": False, + "error": {"code": "unknown_error", "message": "Unknown error"}, + }, + ), + ( + SupervisorBadRequestError("Backup does not exist"), + { + "success": True, + "result": {"agent_errors": {}}, + }, + ), + ( + SupervisorNotFoundError(), + { + "success": True, + "result": {"agent_errors": {}}, + }, + ), + ], +) +async def test_agent_delete_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + remove_side_effect: Exception, + expected_response: dict[str, Any], +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + supervisor_client.backups.remove_backup.side_effect = remove_side_effect + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response == {"id": 1, "type": "result"} | expected_response + supervisor_client.backups.remove_backup.assert_called_once_with( + backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + ) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("event_data", "mount_info_calls"), + [ + ( + { + "event": "job", + "data": {"name": "mount_manager_create_mount", "done": True}, + }, + 1, + ), + ( + { + "event": "job", + "data": {"name": "mount_manager_create_mount", "done": False}, + }, + 0, + ), + ( + { + "event": "job", + "data": {"name": "mount_manager_remove_mount", "done": True}, + }, + 1, + ), + ( + { + "event": "job", + "data": {"name": "mount_manager_remove_mount", "done": False}, + }, + 0, + ), + ({"event": "job", "data": {"name": "other_job", "done": True}}, 0), + ( + { + "event": "other_event", + "data": {"name": "mount_manager_remove_mount", "done": True}, + }, + 0, + ), + ], +) +async def test_agents_notify_on_mount_added_removed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + event_data: dict[str, Any], + mount_info_calls: int, +) -> None: + """Test the listener is called when mounts are added or removed.""" + client = await hass_ws_client(hass) + assert supervisor_client.mounts.info.call_count == 1 + assert supervisor_client.mounts.info.call_args[0] == () + supervisor_client.mounts.info.reset_mock() + + await client.send_json_auto_id({"type": "supervisor/event", "data": event_data}) + response = await client.receive_json() + assert response["success"] + await hass.async_block_till_done() + assert supervisor_client.mounts.info.call_count == mount_info_calls + + +DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( + addons=None, + background=True, + compressed=True, + extra={ + "instance_id": ANY, + "with_automatic_settings": False, + }, + folders={"ssl"}, + homeassistant_exclude_database=False, + homeassistant=True, + location=[None], + name="Test", + password=None, +) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("extra_generate_options", "expected_supervisor_options"), + [ + ( + {}, + DEFAULT_BACKUP_OPTIONS, + ), + ( + {"include_addons": ["addon_1", "addon_2"]}, + replace(DEFAULT_BACKUP_OPTIONS, addons={"addon_1", "addon_2"}), + ), + ( + {"include_all_addons": True}, + replace(DEFAULT_BACKUP_OPTIONS, addons="ALL"), + ), + ( + {"include_database": False}, + replace(DEFAULT_BACKUP_OPTIONS, homeassistant_exclude_database=True), + ), + ( + {"include_folders": ["media", "share"]}, + replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share", "ssl"}), + ), + ( + { + "include_folders": ["media"], + "include_database": False, + "include_homeassistant": False, + }, + replace( + DEFAULT_BACKUP_OPTIONS, + folders={"media"}, + homeassistant=False, + homeassistant_exclude_database=True, + ), + ), + ], +) +async def test_reader_writer_create( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + extra_generate_options: dict[str, Any], + expected_supervisor_options: supervisor_backups.PartialBackupOptions, +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + | extra_generate_options + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + supervisor_client.backups.partial_backup.assert_called_once_with( + expected_supervisor_options + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("side_effect", "error_code", "error_message"), + [ + ( + SupervisorError("Boom!"), + "home_assistant_error", + "Error creating backup: Boom!", + ), + (Exception("Boom!"), "unknown_error", "Unknown error"), + ], +) +async def test_reader_writer_create_partial_backup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + side_effect: Exception, + error_code: str, + error_message: str, +) -> None: + """Test client partial backup error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.side_effect = side_effect + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == error_code + assert response["error"]["message"] == error_message + + assert supervisor_client.backups.partial_backup.call_count == 1 + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_create_missing_reference_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test missing reference error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + assert supervisor_client.backups.partial_backup.call_count == 1 + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + await hass.async_block_till_done() + + assert supervisor_client.backups.backup_info.call_count == 0 + assert supervisor_client.backups.download_backup.call_count == 0 + assert supervisor_client.backups.remove_backup.call_count == 0 + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) +@pytest.mark.parametrize( + ("method", "download_call_count", "remove_call_count"), + [("download_backup", 1, 1), ("remove_backup", 1, 1)], +) +async def test_reader_writer_create_download_remove_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + exception: Exception, + method: str, + download_call_count: int, + remove_call_count: int, +) -> None: + """Test download and remove error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + method_mock = getattr(supervisor_client.backups, method) + method_mock.side_effect = exception + + remote_agent = BackupAgentTest("remote") + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + assert supervisor_client.backups.partial_backup.call_count == 1 + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + await hass.async_block_till_done() + + assert supervisor_client.backups.backup_info.call_count == 1 + assert supervisor_client.backups.download_backup.call_count == download_call_count + assert supervisor_client.backups.remove_backup.call_count == remove_call_count + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) +async def test_reader_writer_create_info_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + exception: Exception, +) -> None: + """Test backup info error when generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.side_effect = exception + + remote_agent = BackupAgentTest("remote") + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + assert supervisor_client.backups.partial_backup.call_count == 1 + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + await hass.async_block_till_done() + + assert supervisor_client.backups.backup_info.call_count == 1 + assert supervisor_client.backups.download_backup.call_count == 0 + assert supervisor_client.backups.remove_backup.call_count == 0 + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_create_remote_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup which will be uploaded to a remote agent.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + + remote_agent = BackupAgentTest("remote") + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["test.remote"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + supervisor_client.backups.partial_backup.assert_called_once_with( + replace(DEFAULT_BACKUP_OPTIONS, location=LOCATION_CLOUD_BACKUP), + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_called_once_with("test_slug") + supervisor_client.backups.remove_backup.assert_called_once_with( + "test_slug", + options=supervisor_backups.RemoveBackupOptions({LOCATION_CLOUD_BACKUP}), + ) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("extra_generate_options", "expected_error"), + [ + ( + {"include_homeassistant": False}, + { + "code": "home_assistant_error", + "message": "Cannot create a backup with database but without Home Assistant", + }, + ), + ( + {"include_homeassistant": False, "include_database": False}, + { + "code": "unknown_error", + "message": "Unknown error", + }, + ), + ], +) +async def test_reader_writer_create_wrong_parameters( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + extra_generate_options: dict[str, Any], + expected_error: dict[str, str], +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + | extra_generate_options + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "stage": None, + "state": "failed", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "idle", + } + + response = await client.receive_json() + assert not response["success"] + assert response["error"] == expected_error + + supervisor_client.backups.partial_backup.assert_not_called() + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_agent_receive_remote_backup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test receiving a backup which will be uploaded to a remote agent.""" + client = await hass_client() + backup_id = "test-backup" + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.upload_backup.return_value = "test_slug" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0.0, + ) + + remote_agent = BackupAgentTest("remote") + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + supervisor_client.backups.reload.assert_not_called() + with ( + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.open"), + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("shutil.copy"), + ): + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + + supervisor_client.backups.download_backup.assert_called_once_with("test_slug") + supervisor_client.backups.remove_backup.assert_called_once_with( + "test_slug", + options=supervisor_backups.RemoveBackupOptions({LOCATION_CLOUD_BACKUP}), + ) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_restore( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = "abc123" + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "idle", + } + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=None, + password=None, + ), + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "stage": None, + "state": "completed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + +@pytest.mark.parametrize( + ("supervisor_error_string", "expected_error_code"), + [ + ( + "Invalid password for backup", + "password_incorrect", + ), + ( + "Backup was made on supervisor version 2025.12.0, can't restore on 2024.12.0. Must update supervisor first.", + "home_assistant_error", + ), + ], +) +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_restore_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + supervisor_error_string: str, + expected_error_code: str, +) -> None: + """Test restoring a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.side_effect = SupervisorBadRequestError( + supervisor_error_string + ) + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=None, + password=None, + ), + ) + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "stage": None, + "state": "failed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert response["error"]["code"] == expected_error_code + + +@pytest.mark.parametrize( + ("backup", "backup_details", "parameters", "expected_error"), + [ + ( + TEST_BACKUP, + TEST_BACKUP_DETAILS, + {"restore_database": False}, + "Restore database must match backup", + ), + ( + TEST_BACKUP, + TEST_BACKUP_DETAILS, + {"restore_homeassistant": False}, + "Cannot restore database without Home Assistant", + ), + ( + TEST_BACKUP_4, + TEST_BACKUP_DETAILS_4, + {"restore_homeassistant": True, "restore_database": True}, + "Restore database must match backup", + ), + ], +) +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_restore_wrong_parameters( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + backup: supervisor_backups.Backup, + backup_details: supervisor_backups.BackupComplete, + parameters: dict[str, Any], + expected_error: str, +) -> None: + """Test trigger restore.""" + client = await hass_ws_client(hass) + supervisor_client.backups.list.return_value = [backup] + supervisor_client.backups.backup_info.return_value = backup_details + + default_parameters = { + "type": "backup/restore", + "agent_id": "hassio.local", + "backup_id": "abc123", + } + + await client.send_json_auto_id(default_parameters | parameters) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "home_assistant_error", + "message": expected_error, + } diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 56f0dcb706c..e6375171dab 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -341,20 +341,6 @@ async def test_api_set_yellow_settings( assert aioclient_mock.call_count == 1 -@pytest.mark.usefixtures("hassio_stubs") -async def test_api_reboot_host( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test setup with API ping.""" - aioclient_mock.post( - "http://127.0.0.1/host/reboot", - json={"result": "ok", "data": {}}, - ) - - assert await handler.async_reboot_host(hass) == {} - assert aioclient_mock.call_count == 1 - - @pytest.mark.usefixtures("hassio_stubs") async def test_send_command_invalid_command(hass: HomeAssistant) -> None: """Test send command fails when command is invalid.""" diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index a12f4c610ad..122467c6b02 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -2,92 +2,127 @@ from __future__ import annotations -from collections.abc import Sequence -from unittest.mock import Mock, patch +from collections.abc import AsyncIterator +from unittest.mock import AsyncMock, Mock, patch from pyheos import ( - Dispatcher, + CONTROLS_ALL, Heos, HeosGroup, + HeosOptions, HeosPlayer, - HeosSource, - InputSource, + LineOutLevelType, + MediaItem, + MediaType, + NetworkType, + PlayerUpdateResult, + PlayState, + RepeatType, const, ) import pytest +import pytest_asyncio -from homeassistant.components import ssdp from homeassistant.components.heos import DOMAIN -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @pytest.fixture(name="config_entry") -def config_entry_fixture(): +def config_entry_fixture() -> MockConfigEntry: """Create a mock HEOS config entry.""" return MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, title="Controller (127.0.0.1)" + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1"}, + title="HEOS System (via 127.0.0.1)", + unique_id=DOMAIN, ) -@pytest.fixture(name="controller") -def controller_fixture( - players, favorites, input_sources, playlists, change_data, dispatcher, group -): +@pytest.fixture(name="config_entry_options") +def config_entry_options_fixture() -> MockConfigEntry: + """Create a mock HEOS config entry with options.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1"}, + title="HEOS System (via 127.0.0.1)", + options={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + unique_id=DOMAIN, + ) + + +@pytest_asyncio.fixture(name="controller", autouse=True) +async def controller_fixture( + players: dict[int, HeosPlayer], + favorites: dict[int, MediaItem], + input_sources: list[MediaItem], + playlists: list[MediaItem], + change_data: PlayerUpdateResult, + group: dict[int, HeosGroup], +) -> AsyncIterator[Heos]: """Create a mock Heos controller fixture.""" - mock_heos = Mock(Heos) + mock_heos = Heos(HeosOptions(host="127.0.0.1")) for player in players.values(): player.heos = mock_heos - mock_heos.dispatcher = dispatcher - mock_heos.get_players.return_value = players - mock_heos.players = players - mock_heos.get_favorites.return_value = favorites - mock_heos.get_input_sources.return_value = input_sources - mock_heos.get_playlists.return_value = playlists - mock_heos.load_players.return_value = change_data - mock_heos.is_signed_in = True - mock_heos.signed_in_username = "user@user.com" - mock_heos.connection_state = const.STATE_CONNECTED - mock_heos.get_groups.return_value = group - mock_heos.create_group.return_value = None - mock = Mock(return_value=mock_heos) - + mock_heos.connect = AsyncMock() + mock_heos.disconnect = AsyncMock() + mock_heos.sign_in = AsyncMock() + mock_heos.sign_out = AsyncMock() + mock_heos.get_players = AsyncMock(return_value=players) + mock_heos._players = players + mock_heos.get_favorites = AsyncMock(return_value=favorites) + mock_heos.get_input_sources = AsyncMock(return_value=input_sources) + mock_heos.get_playlists = AsyncMock(return_value=playlists) + mock_heos.load_players = AsyncMock(return_value=change_data) + mock_heos._signed_in_username = "user@user.com" + mock_heos.get_groups = AsyncMock(return_value=group) + mock_heos._groups = group + mock_heos.set_group = AsyncMock(return_value=None) + new_mock = Mock(return_value=mock_heos) + mock_heos.new_mock = new_mock with ( - patch("homeassistant.components.heos.Heos", new=mock), - patch("homeassistant.components.heos.config_flow.Heos", new=mock), + patch("homeassistant.components.heos.coordinator.Heos", new=new_mock), + patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), ): yield mock_heos -@pytest.fixture(name="config") -def config_fixture(): - """Create hass config fixture.""" - return {DOMAIN: {CONF_HOST: "127.0.0.1"}} - - @pytest.fixture(name="players") -def player_fixture(quick_selects): +def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: """Create two mock HeosPlayers.""" players = {} for i in (1, 2): - player = Mock(HeosPlayer) - player.player_id = i - if i > 1: - player.name = f"Test Player {i}" - else: - player.name = "Test Player" - player.model = "Test Model" - player.version = "1.0.0" - player.is_muted = False - player.available = True - player.state = const.PLAY_STATE_STOP - player.ip_address = f"127.0.0.{i}" - player.network = "wired" - player.shuffle = False - player.repeat = const.REPEAT_OFF - player.volume = 25 - player.now_playing_media.supported_controls = const.CONTROLS_ALL + player = HeosPlayer( + player_id=i, + group_id=999, + name="Test Player" if i == 1 else f"Test Player {i}", + model="HEOS Drive HS2" if i == 1 else "Speaker", + serial="123456", + version="1.0.0", + line_out=LineOutLevelType.VARIABLE, + is_muted=False, + available=True, + state=PlayState.STOP, + ip_address=f"127.0.0.{i}", + network=NetworkType.WIRED, + shuffle=False, + repeat=RepeatType.OFF, + volume=25, + heos=None, + ) + player.now_playing_media = Mock() + player.now_playing_media.supported_controls = CONTROLS_ALL player.now_playing_media.album_id = 1 player.now_playing_media.queue_id = 1 player.now_playing_media.source_id = 1 @@ -100,66 +135,124 @@ def player_fixture(quick_selects): player.now_playing_media.current_position = None player.now_playing_media.image_url = "http://" player.now_playing_media.song = "Song" - player.get_quick_selects.return_value = quick_selects + player.add_to_queue = AsyncMock() + player.clear_queue = AsyncMock() + player.get_quick_selects = AsyncMock(return_value=quick_selects) + player.mute = AsyncMock() + player.pause = AsyncMock() + player.play = AsyncMock() + player.play_media = AsyncMock() + player.play_next = AsyncMock() + player.play_previous = AsyncMock() + player.play_preset_station = AsyncMock() + player.play_quick_select = AsyncMock() + player.play_url = AsyncMock() + player.set_mute = AsyncMock() + player.set_play_mode = AsyncMock() + player.set_quick_select = AsyncMock() + player.set_volume = AsyncMock() + player.stop = AsyncMock() + player.unmute = AsyncMock() players[player.player_id] = player return players @pytest.fixture(name="group") -def group_fixture(players): +def group_fixture() -> dict[int, HeosGroup]: """Create a HEOS group consisting of two players.""" - group = Mock(HeosGroup) - group.leader = players[1] - group.members = [players[2]] - group.group_id = 999 + group = HeosGroup( + name="Group", group_id=999, lead_player_id=1, member_player_ids=[2] + ) return {group.group_id: group} @pytest.fixture(name="favorites") -def favorites_fixture() -> dict[int, HeosSource]: +def favorites_fixture() -> dict[int, MediaItem]: """Create favorites fixture.""" - station = Mock(HeosSource) - station.type = const.TYPE_STATION - station.name = "Today's Hits Radio" - station.media_id = "123456789" - radio = Mock(HeosSource) - radio.type = const.TYPE_STATION - radio.name = "Classical MPR (Classical Music)" - radio.media_id = "s1234" + station = MediaItem( + source_id=const.MUSIC_SOURCE_PANDORA, + name="Today's Hits Radio", + media_id="123456789", + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ) + radio = MediaItem( + source_id=const.MUSIC_SOURCE_TUNEIN, + name="Classical MPR (Classical Music)", + media_id="s1234", + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ) return {1: station, 2: radio} @pytest.fixture(name="input_sources") -def input_sources_fixture() -> Sequence[InputSource]: +def input_sources_fixture() -> list[MediaItem]: """Create a set of input sources for testing.""" - source = Mock(InputSource) - source.player_id = 1 - source.input_name = const.INPUT_AUX_IN_1 - source.name = "HEOS Drive - Line In 1" - return [source] - - -@pytest.fixture(name="dispatcher") -def dispatcher_fixture() -> Dispatcher: - """Create a dispatcher for testing.""" - return Dispatcher() + return [ + MediaItem( + source_id=const.MUSIC_SOURCE_AUX_INPUT, + name="HEOS Drive - Line In 1", + media_id=const.INPUT_AUX_IN_1, + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ), + MediaItem( + source_id=const.MUSIC_SOURCE_AUX_INPUT, + name="Speaker - Line In 1", + media_id=const.INPUT_AUX_IN_1, + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ), + ] @pytest.fixture(name="discovery_data") -def discovery_data_fixture() -> dict: +def discovery_data_fixture() -> SsdpServiceInfo: """Return mock discovery data for testing.""" - return ssdp.SsdpServiceInfo( + return SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1:60006/upnp/desc/aios_device/aios_device.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Office", - ssdp.ATTR_UPNP_MANUFACTURER: "Denon", - ssdp.ATTR_UPNP_MODEL_NAME: "HEOS Drive", - ssdp.ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", - ssdp.ATTR_UPNP_SERIAL: None, - ssdp.ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", + ATTR_UPNP_FRIENDLY_NAME: "Office", + ATTR_UPNP_MANUFACTURER: "Denon", + ATTR_UPNP_MODEL_NAME: "HEOS Drive", + ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", + ATTR_UPNP_SERIAL: None, + ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", + }, + ) + + +@pytest.fixture(name="discovery_data_bedroom") +def discovery_data_fixture_bedroom() -> SsdpServiceInfo: + """Return mock discovery data for testing.""" + return SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.2:60006/upnp/desc/aios_device/aios_device.xml", + upnp={ + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", + ATTR_UPNP_FRIENDLY_NAME: "Bedroom", + ATTR_UPNP_MANUFACTURER: "Denon", + ATTR_UPNP_MODEL_NAME: "HEOS Drive", + ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", + ATTR_UPNP_SERIAL: None, + ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", }, ) @@ -178,21 +271,27 @@ def quick_selects_fixture() -> dict[int, str]: @pytest.fixture(name="playlists") -def playlists_fixture() -> Sequence[HeosSource]: +def playlists_fixture() -> list[MediaItem]: """Create favorites fixture.""" - playlist = Mock(HeosSource) - playlist.type = const.TYPE_PLAYLIST - playlist.name = "Awesome Music" + playlist = MediaItem( + source_id=const.MUSIC_SOURCE_PLAYLISTS, + name="Awesome Music", + type=MediaType.PLAYLIST, + playable=True, + browsable=True, + image_url="", + heos=None, + ) return [playlist] @pytest.fixture(name="change_data") -def change_data_fixture() -> dict: +def change_data_fixture() -> PlayerUpdateResult: """Create player change data for testing.""" - return {const.DATA_MAPPED_IDS: {}, const.DATA_NEW: []} + return PlayerUpdateResult() @pytest.fixture(name="change_data_mapped_ids") -def change_data_mapped_ids_fixture() -> dict: +def change_data_mapped_ids_fixture() -> PlayerUpdateResult: """Create player change data for testing.""" - return {const.DATA_MAPPED_IDS: {101: 1}, const.DATA_NEW: []} + return PlayerUpdateResult(updated_player_ids={1: 101}) diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..7bfdac232cb --- /dev/null +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -0,0 +1,36 @@ +# serializer version: 1 +# name: test_state_attributes + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'http://', + 'friendly_name': 'Test Player', + 'group_members': list([ + 'media_player.test_player', + 'media_player.test_player_2', + ]), + 'is_volume_muted': False, + 'media_album_id': 1, + 'media_album_name': 'Album', + 'media_artist': 'Artist', + 'media_content_id': '1', + 'media_content_type': , + 'media_queue_id': 1, + 'media_source_id': 1, + 'media_station': 'Station Name', + 'media_title': 'Song', + 'media_type': 'Station', + 'repeat': , + 'shuffle': False, + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'supported_features': , + 'volume_level': 0.25, + }), + 'entity_id': 'media_player.test_player', + 'state': 'idle', + }) +# --- diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 7b737d7bb4b..39ede354496 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,159 +1,475 @@ """Tests for the Heos config flow module.""" -from unittest.mock import patch -from urllib.parse import urlparse +from pyheos import CommandAuthenticationError, CommandFailedError, Heos, HeosError +import pytest -from pyheos import HeosError - -from homeassistant.components import heos, ssdp -from homeassistant.components.heos.config_flow import HeosFlowHandler -from homeassistant.components.heos.const import DATA_DISCOVERED_HOSTS, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER -from homeassistant.const import CONF_HOST +from homeassistant.components.heos.const import DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo + +from tests.common import MockConfigEntry -async def test_flow_aborts_already_setup(hass: HomeAssistant, config_entry) -> None: +async def test_flow_aborts_already_setup( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test flow aborts when entry already setup.""" config_entry.add_to_hass(hass) - flow = HeosFlowHandler() - flow.hass = hass - result = await flow.async_step_user() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" async def test_no_host_shows_form(hass: HomeAssistant) -> None: """Test form is shown when host not provided.""" - flow = HeosFlowHandler() - flow.hass = hass - result = await flow.async_step_user() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} -async def test_cannot_connect_shows_error_form(hass: HomeAssistant, controller) -> None: +async def test_cannot_connect_shows_error_form( + hass: HomeAssistant, controller: Heos +) -> None: """Test form is shown with error when cannot connect.""" controller.connect.side_effect = HeosError() result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "127.0.0.1"} + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "127.0.0.1"} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"][CONF_HOST] == "cannot_connect" assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - controller.connect.reset_mock() - controller.disconnect.reset_mock() -async def test_create_entry_when_host_valid(hass: HomeAssistant, controller) -> None: +async def test_create_entry_when_host_valid( + hass: HomeAssistant, controller: Heos +) -> None: """Test result type is create entry when host is valid.""" data = {CONF_HOST: "127.0.0.1"} - with patch("homeassistant.components.heos.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_USER}, data=data - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"].unique_id == DOMAIN - assert result["title"] == "Controller (127.0.0.1)" - assert result["data"] == data - assert controller.connect.call_count == 1 - assert controller.disconnect.call_count == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=data + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DOMAIN + assert result["title"] == "HEOS System (via 127.0.0.1)" + assert result["data"] == data + assert controller.connect.call_count == 2 # Also called in async_setup_entry + assert controller.disconnect.call_count == 1 async def test_create_entry_when_friendly_name_valid( - hass: HomeAssistant, controller + hass: HomeAssistant, controller: Heos ) -> None: """Test result type is create entry when friendly name is valid.""" - hass.data[DATA_DISCOVERED_HOSTS] = {"Office (127.0.0.1)": "127.0.0.1"} + hass.data[DOMAIN] = {"Office (127.0.0.1)": "127.0.0.1"} data = {CONF_HOST: "Office (127.0.0.1)"} - with patch("homeassistant.components.heos.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_USER}, data=data - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"].unique_id == DOMAIN - assert result["title"] == "Controller (127.0.0.1)" - assert result["data"] == {CONF_HOST: "127.0.0.1"} - assert controller.connect.call_count == 1 - assert controller.disconnect.call_count == 1 - assert DATA_DISCOVERED_HOSTS not in hass.data + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=data + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DOMAIN + assert result["title"] == "HEOS System (via 127.0.0.1)" + assert result["data"] == {CONF_HOST: "127.0.0.1"} + assert controller.connect.call_count == 2 # Also called in async_setup_entry + assert controller.disconnect.call_count == 1 + assert DOMAIN not in hass.data async def test_discovery_shows_create_form( - hass: HomeAssistant, controller, discovery_data: ssdp.SsdpServiceInfo + hass: HomeAssistant, + discovery_data: SsdpServiceInfo, + discovery_data_bedroom: SsdpServiceInfo, ) -> None: - """Test discovery shows form to confirm setup and subsequent abort.""" + """Test discovery shows form to confirm setup.""" - await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + # Single discovered host shows form for user to finish setup. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data ) - await hass.async_block_till_done() - flows_in_progress = hass.config_entries.flow.async_progress() - assert flows_in_progress[0]["context"]["unique_id"] == DOMAIN - assert len(flows_in_progress) == 1 - assert hass.data[DATA_DISCOVERED_HOSTS] == {"Office (127.0.0.1)": "127.0.0.1"} + assert hass.data[DOMAIN] == {"Office (127.0.0.1)": "127.0.0.1"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - port = urlparse(discovery_data.ssdp_location).port - discovery_data.ssdp_location = f"http://127.0.0.2:{port}/" - discovery_data.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Bedroom" - - await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + # Subsequent discovered hosts append to discovered hosts and abort. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom ) - await hass.async_block_till_done() - flows_in_progress = hass.config_entries.flow.async_progress() - assert flows_in_progress[0]["context"]["unique_id"] == DOMAIN - assert len(flows_in_progress) == 1 - assert hass.data[DATA_DISCOVERED_HOSTS] == { + assert hass.data[DOMAIN] == { "Office (127.0.0.1)": "127.0.0.1", "Bedroom (127.0.0.2)": "127.0.0.2", } + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" async def test_discovery_flow_aborts_already_setup( - hass: HomeAssistant, controller, discovery_data: ssdp.SsdpServiceInfo, config_entry + hass: HomeAssistant, discovery_data: SsdpServiceInfo, config_entry: MockConfigEntry ) -> None: """Test discovery flow aborts when entry already setup.""" config_entry.add_to_hass(hass) - flow = HeosFlowHandler() - flow.hass = hass - result = await flow.async_step_ssdp(discovery_data) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" -async def test_discovery_sets_the_unique_id( - hass: HomeAssistant, controller, discovery_data: ssdp.SsdpServiceInfo +async def test_reconfigure_validates_and_updates_config( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: - """Test discovery sets the unique id.""" + """Test reconfigure validates host and successfully updates.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" - port = urlparse(discovery_data.ssdp_location).port - discovery_data.ssdp_location = f"http://127.0.0.2:{port}/" - discovery_data.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Bedroom" - - await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + # Test reconfigure initially shows form with current host value. + host = next( + key.default() for key in result["data_schema"].schema if key == CONF_HOST ) - await hass.async_block_till_done() - flows_in_progress = hass.config_entries.flow.async_progress() - assert flows_in_progress[0]["context"]["unique_id"] == DOMAIN - assert len(flows_in_progress) == 1 - assert hass.data[DATA_DISCOVERED_HOSTS] == {"Bedroom (127.0.0.2)": "127.0.0.2"} + assert host == "127.0.0.1" + assert result["errors"] == {} + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + + # Test reconfigure successfully updates. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.2"}, + ) + assert controller.connect.call_count == 2 # Also called when entry reloaded + assert controller.disconnect.call_count == 1 + assert config_entry.data == {CONF_HOST: "127.0.0.2"} + assert config_entry.unique_id == DOMAIN + assert result["reason"] == "reconfigure_successful" + assert result["type"] is FlowResultType.ABORT -async def test_import_sets_the_unique_id(hass: HomeAssistant, controller) -> None: - """Test import sets the unique id.""" +async def test_reconfigure_cannot_connect_recovers( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test reconfigure cannot connect and recovers.""" + controller.connect.side_effect = HeosError() + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" - with patch("homeassistant.components.heos.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - heos.DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "127.0.0.2"}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.2"}, + ) + + assert controller.connect.call_count == 1 + assert controller.disconnect.call_count == 1 + host = next( + key.default() for key in result["data_schema"].schema if key == CONF_HOST + ) + assert host == "127.0.0.2" + assert result["errors"][CONF_HOST] == "cannot_connect" + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + + # Test reconfigure recovers and successfully updates. + controller.connect.side_effect = None + controller.connect.reset_mock() + controller.disconnect.reset_mock() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.2"}, + ) + assert controller.connect.call_count == 2 # Also called when entry reloaded + assert controller.disconnect.call_count == 1 + assert config_entry.data == {CONF_HOST: "127.0.0.2"} + assert config_entry.unique_id == DOMAIN + assert result["reason"] == "reconfigure_successful" + assert result["type"] is FlowResultType.ABORT + + +@pytest.mark.parametrize( + ("error", "expected_error_key"), + [ + ( + CommandAuthenticationError("sign_in", "Invalid credentials", 6), + "invalid_auth", + ), + (CommandFailedError("sign_in", "System error", 12), "unknown"), + (HeosError(), "unknown"), + ], +) +async def test_options_flow_signs_in( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + error: HeosError, + expected_error_key: str, +) -> None: + """Test options flow signs-in with entered credentials.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Start the options flow. Entry has not current options. + assert CONF_USERNAME not in config_entry.options + assert CONF_PASSWORD not in config_entry.options + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["step_id"] == "init" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Invalid credentials, system error, or unexpected error. + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + controller.sign_in.side_effect = error + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 1 + assert controller.sign_out.call_count == 0 + assert result["step_id"] == "init" + assert result["errors"] == {"base": expected_error_key} + assert result["type"] is FlowResultType.FORM + + # Valid credentials signs-in and creates entry + controller.sign_in.reset_mock() + controller.sign_in.side_effect = None + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 1 + assert controller.sign_out.call_count == 0 + assert result["data"] == user_input assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"].unique_id == DOMAIN + + +async def test_options_flow_signs_out( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test options flow signs-out when credentials cleared.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Start the options flow. Entry has not current options. + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["step_id"] == "init" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Fail to sign-out, show error + user_input = {} + controller.sign_out.side_effect = HeosError() + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 1 + assert result["step_id"] == "init" + assert result["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.FORM + + # Clear credentials + controller.sign_out.reset_mock() + controller.sign_out.side_effect = None + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 1 + assert result["data"] == user_input + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("user_input", "expected_errors"), + [ + ({CONF_USERNAME: "user"}, {CONF_PASSWORD: "password_missing"}), + ({CONF_PASSWORD: "pass"}, {CONF_USERNAME: "username_missing"}), + ], +) +async def test_options_flow_missing_one_param_recovers( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + user_input: dict[str, str], + expected_errors: dict[str, str], +) -> None: + """Test options flow signs-in after recovering from only username or password being entered.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Start the options flow. Entry has not current options. + assert CONF_USERNAME not in config_entry.options + assert CONF_PASSWORD not in config_entry.options + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["step_id"] == "init" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Enter only username or password + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert result["step_id"] == "init" + assert result["errors"] == expected_errors + assert result["type"] is FlowResultType.FORM + + # Enter valid credentials + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 1 + assert controller.sign_out.call_count == 0 + assert result["data"] == user_input + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("error", "expected_error_key"), + [ + ( + CommandAuthenticationError("sign_in", "Invalid credentials", 6), + "invalid_auth", + ), + (CommandFailedError("sign_in", "System error", 12), "unknown"), + (HeosError(), "unknown"), + ], +) +async def test_reauth_signs_in_aborts( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + error: HeosError, + expected_error_key: str, +) -> None: + """Test reauth flow signs-in with entered credentials and aborts.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + result = await config_entry.start_reauth_flow(hass) + assert config_entry.state is ConfigEntryState.LOADED + + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Invalid credentials, system error, or unexpected error. + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + controller.sign_in.side_effect = error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 1 + assert controller.sign_out.call_count == 0 + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": expected_error_key} + assert result["type"] is FlowResultType.FORM + + # Valid credentials signs-in, updates options, and aborts + controller.sign_in.reset_mock() + controller.sign_in.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 1 + assert controller.sign_out.call_count == 0 + assert config_entry.options[CONF_USERNAME] == user_input[CONF_USERNAME] + assert config_entry.options[CONF_PASSWORD] == user_input[CONF_PASSWORD] + assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + + +async def test_reauth_signs_out( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test reauth flow signs-out when credentials cleared and aborts.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + result = await config_entry.start_reauth_flow(hass) + assert config_entry.state is ConfigEntryState.LOADED + + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Fail to sign-out, show error + user_input = {} + controller.sign_out.side_effect = HeosError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 1 + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.FORM + + # Cleared credentials signs-out, updates options, and aborts + controller.sign_out.reset_mock() + controller.sign_out.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 1 + assert CONF_USERNAME not in config_entry.options + assert CONF_PASSWORD not in config_entry.options + assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + + +@pytest.mark.parametrize( + ("user_input", "expected_errors"), + [ + ({CONF_USERNAME: "user"}, {CONF_PASSWORD: "password_missing"}), + ({CONF_PASSWORD: "pass"}, {CONF_USERNAME: "username_missing"}), + ], +) +async def test_reauth_flow_missing_one_param_recovers( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + user_input: dict[str, str], + expected_errors: dict[str, str], +) -> None: + """Test reauth flow signs-in after recovering from only username or password being entered.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Start the options flow. Entry has not current options. + result = await config_entry.start_reauth_flow(hass) + assert config_entry.state is ConfigEntryState.LOADED + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Enter only username or password + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == expected_errors + assert result["type"] is FlowResultType.FORM + + # Enter valid credentials + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 1 + assert controller.sign_out.call_count == 0 + assert config_entry.options[CONF_USERNAME] == user_input[CONF_USERNAME] + assert config_entry.options[CONF_PASSWORD] == user_input[CONF_PASSWORD] + assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 9341c8fbace..4c5eee67e2c 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -1,199 +1,194 @@ """Tests for the init module.""" -import asyncio -from unittest.mock import Mock, patch +from typing import cast -from pyheos import CommandFailedError, HeosError, const +from pyheos import Heos, HeosError, HeosOptions, SignalHeosEvent, SignalType import pytest -from homeassistant.components.heos import ( - ControllerManager, - async_setup_entry, - async_unload_entry, -) -from homeassistant.components.heos.const import ( - DATA_CONTROLLER_MANAGER, - DATA_SOURCE_MANAGER, - DOMAIN, -) -from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN -from homeassistant.const import CONF_HOST +from homeassistant.components.heos.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.setup import async_setup_component +from homeassistant.helpers import device_registry as dr - -async def test_async_setup_creates_entry(hass: HomeAssistant, config) -> None: - """Test component setup creates entry from config.""" - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - entry = entries[0] - assert entry.title == "Controller (127.0.0.1)" - assert entry.data == {CONF_HOST: "127.0.0.1"} - assert entry.unique_id == DOMAIN - - -async def test_async_setup_updates_entry( - hass: HomeAssistant, config_entry, config, controller -) -> None: - """Test component setup updates entry from config.""" - config[DOMAIN][CONF_HOST] = "127.0.0.2" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - entry = entries[0] - assert entry.title == "Controller (127.0.0.2)" - assert entry.data == {CONF_HOST: "127.0.0.2"} - assert entry.unique_id == DOMAIN - - -async def test_async_setup_returns_true( - hass: HomeAssistant, config_entry, config -) -> None: - """Test component setup from config.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0] == config_entry - - -async def test_async_setup_no_config_returns_true( - hass: HomeAssistant, config_entry -) -> None: - """Test component setup from entry only.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0] == config_entry +from tests.common import MockConfigEntry async def test_async_setup_entry_loads_platforms( - hass: HomeAssistant, config_entry, controller, input_sources, favorites + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, ) -> None: """Test load connects to heos, retrieves players, and loads platforms.""" config_entry.add_to_hass(hass) - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await async_setup_entry(hass, config_entry) - # Assert platforms loaded - await hass.async_block_till_done() - assert forward_mock.call_count == 1 - assert controller.connect.call_count == 1 - assert controller.get_players.call_count == 1 - assert controller.get_favorites.call_count == 1 - assert controller.get_input_sources.call_count == 1 - controller.disconnect.assert_not_called() - assert hass.data[DOMAIN][DATA_CONTROLLER_MANAGER].controller == controller - assert hass.data[DOMAIN][MEDIA_PLAYER_DOMAIN] == controller.players - assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].favorites == favorites - assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].inputs == input_sources + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + assert hass.states.get("media_player.test_player") is not None + assert controller.connect.call_count == 1 + assert controller.get_players.call_count == 1 + assert controller.get_favorites.call_count == 1 + assert controller.get_input_sources.call_count == 1 + controller.disconnect.assert_not_called() + + +async def test_async_setup_entry_with_options_loads_platforms( + hass: HomeAssistant, config_entry_options: MockConfigEntry, controller: Heos +) -> None: + """Test load connects to heos with options, retrieves players, and loads platforms.""" + config_entry_options.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry_options.entry_id) + + # Assert options passed and methods called + assert config_entry_options.state is ConfigEntryState.LOADED + options = cast(HeosOptions, controller.new_mock.call_args[0][0]) + assert options.host == config_entry_options.data[CONF_HOST] + assert options.credentials.username == config_entry_options.options[CONF_USERNAME] + assert options.credentials.password == config_entry_options.options[CONF_PASSWORD] + assert controller.connect.call_count == 1 + assert controller.get_players.call_count == 1 + assert controller.get_favorites.call_count == 1 + assert controller.get_input_sources.call_count == 1 + controller.disconnect.assert_not_called() + + +async def test_async_setup_entry_auth_failure_starts_reauth( + hass: HomeAssistant, + config_entry_options: MockConfigEntry, + controller: Heos, +) -> None: + """Test load with auth failure starts reauth, loads platforms.""" + config_entry_options.add_to_hass(hass) + + # Simulates what happens when the controller can't sign-in during connection + async def connect_send_auth_failure() -> None: + controller._signed_in_username = None + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID + ) + + controller.connect.side_effect = connect_send_auth_failure + + assert await hass.config_entries.async_setup(config_entry_options.entry_id) + + # Assert entry loaded and reauth flow started + assert controller.connect.call_count == 1 + assert controller.get_favorites.call_count == 0 + controller.disconnect.assert_not_called() + assert config_entry_options.state is ConfigEntryState.LOADED + assert any( + config_entry_options.async_get_active_flows(hass, sources=[SOURCE_REAUTH]) + ) async def test_async_setup_entry_not_signed_in_loads_platforms( hass: HomeAssistant, - config_entry, - controller, - input_sources, + config_entry: MockConfigEntry, + controller: Heos, caplog: pytest.LogCaptureFixture, ) -> None: """Test setup does not retrieve favorites when not logged in.""" config_entry.add_to_hass(hass) - controller.is_signed_in = False - controller.signed_in_username = None - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await async_setup_entry(hass, config_entry) - # Assert platforms loaded - await hass.async_block_till_done() - assert forward_mock.call_count == 1 - assert controller.connect.call_count == 1 - assert controller.get_players.call_count == 1 - assert controller.get_favorites.call_count == 0 - assert controller.get_input_sources.call_count == 1 - controller.disconnect.assert_not_called() - assert hass.data[DOMAIN][DATA_CONTROLLER_MANAGER].controller == controller - assert hass.data[DOMAIN][MEDIA_PLAYER_DOMAIN] == controller.players - assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].favorites == {} - assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].inputs == input_sources + controller._signed_in_username = None + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert controller.connect.call_count == 1 + assert controller.get_players.call_count == 1 + assert controller.get_favorites.call_count == 0 + assert controller.get_input_sources.call_count == 1 + controller.disconnect.assert_not_called() assert ( - "127.0.0.1 is not logged in to a HEOS account and will be unable to retrieve " - "HEOS favorites: Use the 'heos.sign_in' service to sign-in to a HEOS account" + "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services" in caplog.text ) async def test_async_setup_entry_connect_failure( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Connection failure raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) controller.connect.side_effect = HeosError() - with pytest.raises(ConfigEntryNotReady): - await async_setup_entry(hass, config_entry) + assert not await hass.config_entries.async_setup(config_entry.entry_id) assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - controller.connect.reset_mock() - controller.disconnect.reset_mock() + assert config_entry.state is ConfigEntryState.SETUP_RETRY async def test_async_setup_entry_player_failure( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: - """Failure to retrieve players/sources raises ConfigEntryNotReady.""" + """Failure to retrieve players raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) controller.get_players.side_effect = HeosError() - with pytest.raises(ConfigEntryNotReady): - await async_setup_entry(hass, config_entry) + assert not await hass.config_entries.async_setup(config_entry.entry_id) assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - controller.connect.reset_mock() - controller.disconnect.reset_mock() + assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass: HomeAssistant, config_entry, controller) -> None: - """Test entries are unloaded correctly.""" - controller_manager = Mock(ControllerManager) - hass.data[DOMAIN] = {DATA_CONTROLLER_MANAGER: controller_manager} - with patch.object( - hass.config_entries, "async_forward_entry_unload", return_value=True - ) as unload: - assert await async_unload_entry(hass, config_entry) - await hass.async_block_till_done() - assert controller_manager.disconnect.call_count == 1 - assert unload.call_count == 1 - assert DOMAIN not in hass.data - - -async def test_update_sources_retry( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, +async def test_async_setup_entry_favorites_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: - """Test update sources retries on failures to max attempts.""" + """Failure to retrieve favorites loads.""" config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - controller.get_favorites.reset_mock() - controller.get_input_sources.reset_mock() - source_manager = hass.data[DOMAIN][DATA_SOURCE_MANAGER] - source_manager.retry_delay = 0 - source_manager.max_retry_attempts = 1 - controller.get_favorites.side_effect = CommandFailedError("Test", "test", 0) - controller.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} + controller.get_favorites.side_effect = HeosError() + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_async_setup_entry_inputs_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Failure to retrieve inputs loads.""" + config_entry.add_to_hass(hass) + controller.get_input_sources.side_effect = HeosError() + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_unload_entry( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test entries are unloaded correctly.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert controller.disconnect.call_count == 1 + + +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test device information populates correctly.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + device = device_registry.async_get_device({(DOMAIN, "1")}) + assert device.manufacturer == "HEOS" + assert device.model == "Drive HS2" + assert device.name == "Test Player" + assert device.serial_number == "123456" + assert device.sw_version == "1.0.0" + device = device_registry.async_get_device({(DOMAIN, "2")}) + assert device.manufacturer == "HEOS" + assert device.model == "Speaker" + + +async def test_device_id_migration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test that legacy non-string device identifiers are migrated to strings.""" + config_entry.add_to_hass(hass) + # Create a device with a legacy identifier + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, 1)} ) - # Wait until it's finished - while "Unable to update sources" not in caplog.text: - await asyncio.sleep(0.1) - assert controller.get_favorites.call_count == 2 + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("Other", 1)} + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert device_registry.async_get_device({("Other", 1)}) is not None + assert device_registry.async_get_device({(DOMAIN, 1)}) is None + assert device_registry.async_get_device({(DOMAIN, "1")}) is not None diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 089fa1cceea..2d9f69d764d 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -1,32 +1,41 @@ """Tests for the Heos Media Player platform.""" -import asyncio +from datetime import timedelta +import re from typing import Any -from pyheos import CommandFailedError, const -from pyheos.error import HeosError -import pytest - -from homeassistant.components.heos import media_player -from homeassistant.components.heos.const import ( - DATA_SOURCE_MANAGER, - DOMAIN, - SIGNAL_HEOS_UPDATED, +from freezegun.api import FrozenDateTimeFactory +from pyheos import ( + AddCriteriaType, + CommandFailedError, + Heos, + HeosError, + MediaItem, + MediaType as HeosMediaType, + PlayerUpdateResult, + PlayState, + RepeatType, + SignalHeosEvent, + SignalType, + const, ) +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.heos.const import DOMAIN from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_ALBUM_NAME, - ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_DURATION, ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_REPEAT, ATTR_MEDIA_SHUFFLE, - ATTR_MEDIA_TITLE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MEDIA_PLAYER_DOMAIN, @@ -35,18 +44,17 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, - MediaPlayerEntityFeature, MediaType, + RepeatMode, ) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, + SERVICE_REPEAT_SET, SERVICE_SHUFFLE_SET, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, @@ -55,81 +63,51 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry - - -async def setup_platform( - hass: HomeAssistant, config_entry: MockConfigEntry, config: dict[str, Any] -) -> None: - """Set up the media player platform for testing.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() +from tests.common import MockConfigEntry, async_fire_time_changed async def test_state_attributes( - hass: HomeAssistant, config_entry, config, controller + hass: HomeAssistant, config_entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Tests the state attributes.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) state = hass.states.get("media_player.test_player") - assert state.state == STATE_IDLE - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.25 - assert not state.attributes[ATTR_MEDIA_VOLUME_MUTED] - assert state.attributes[ATTR_MEDIA_CONTENT_ID] == "1" - assert state.attributes[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ATTR_MEDIA_DURATION not in state.attributes - assert ATTR_MEDIA_POSITION not in state.attributes - assert state.attributes[ATTR_MEDIA_TITLE] == "Song" - assert state.attributes[ATTR_MEDIA_ARTIST] == "Artist" - assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Album" - assert not state.attributes[ATTR_MEDIA_SHUFFLE] - assert state.attributes["media_album_id"] == 1 - assert state.attributes["media_queue_id"] == 1 - assert state.attributes["media_source_id"] == 1 - assert state.attributes["media_station"] == "Station Name" - assert state.attributes["media_type"] == "Station" - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Player" - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.NEXT_TRACK - | MediaPlayerEntityFeature.PREVIOUS_TRACK - | media_player.BASE_SUPPORTED_FEATURES - ) - assert ATTR_INPUT_SOURCE not in state.attributes - assert ( - state.attributes[ATTR_INPUT_SOURCE_LIST] - == hass.data[DOMAIN][DATA_SOURCE_MANAGER].source_list + assert state == snapshot( + exclude=props( + "entity_picture_local", + "context", + "last_changed", + "last_reported", + "last_updated", + ) ) async def test_updates_from_signals( - hass: HomeAssistant, config_entry, config, controller, favorites + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Tests dispatched signals update player.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test player does not update for other players - player.state = const.PLAY_STATE_PLAY - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED + player.state = PlayState.PLAY + await player.heos.dispatcher.wait_send( + SignalType.PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE # Test player_update standard events - player.state = const.PLAY_STATE_PLAY - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + player.state = PlayState.PLAY + await player.heos.dispatcher.wait_send( + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() @@ -139,8 +117,8 @@ async def test_updates_from_signals( # Test player_update progress events player.now_playing_media.duration = 360000 player.now_playing_media.current_position = 1000 - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, + await player.heos.dispatcher.wait_send( + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_NOW_PLAYING_PROGRESS, ) @@ -153,102 +131,122 @@ async def test_updates_from_signals( async def test_updates_from_connection_event( hass: HomeAssistant, - config_entry, - config, - controller, + config_entry: MockConfigEntry, + controller: Heos, caplog: pytest.LogCaptureFixture, ) -> None: """Tests player updates from connection event after connection failure.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() - - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) # Connected player.available = True - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) - await event.wait() + await player.heos.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 # Disconnected - event.clear() - player.reset_mock() controller.load_players.reset_mock() player.available = False - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) - await event.wait() + await player.heos.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED + ) + await hass.async_block_till_done() state = hass.states.get("media_player.test_player") assert state.state == STATE_UNAVAILABLE assert controller.load_players.call_count == 0 # Connected handles refresh failure - event.clear() - player.reset_mock() controller.load_players.reset_mock() controller.load_players.side_effect = CommandFailedError(None, "Failure", 1) player.available = True - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) - await event.wait() + await player.heos.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 assert "Unable to refresh players" in caplog.text +async def test_updates_from_connection_event_new_player_ids( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + controller: Heos, + change_data_mapped_ids: PlayerUpdateResult, +) -> None: + """Test player ids changed after reconnection updates ids.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Assert current IDs + assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") + + # Send event which will result in updated IDs. + controller.load_players.return_value = change_data_mapped_ids + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() + + # Assert updated IDs and previous don't exist + assert not device_registry.async_get_device(identifiers={(DOMAIN, "1")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "101")}) + assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "101") + + async def test_updates_from_sources_updated( - hass: HomeAssistant, config_entry, config, controller, input_sources + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in sources list.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) - - input_sources.clear() - player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} + controller.get_input_sources.return_value = [] + await player.heos.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) - await event.wait() - source_list = hass.data[DOMAIN][DATA_SOURCE_MANAGER].source_list - assert len(source_list) == 2 + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("media_player.test_player") - assert state.attributes[ATTR_INPUT_SOURCE_LIST] == source_list + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ + "Today's Hits Radio", + "Classical MPR (Classical Music)", + ] async def test_updates_from_players_changed( hass: HomeAssistant, - config_entry, - config, - controller, - change_data, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + change_data: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() - - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) assert hass.states.get("media_player.test_player").state == STATE_IDLE - player.state = const.PLAY_STATE_PLAY - player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data + player.state = PlayState.PLAY + await player.heos.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data ) - await event.wait() await hass.async_block_till_done() assert hass.states.get("media_player.test_player").state == STATE_PLAYING @@ -257,40 +255,33 @@ async def test_updates_from_players_changed_new_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, - config_entry, - config, - controller, - change_data_mapped_ids, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + change_data_mapped_ids: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() # Assert device registry matches current id - assert device_registry.async_get_device(identifiers={(DOMAIN, 1)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) # Assert entity registry matches current id assert ( entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") == "media_player.test_player" ) - # Trigger update - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) - player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, + await player.heos.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data_mapped_ids, ) - await event.wait() + await hass.async_block_till_done() # Assert device registry identifiers were updated assert len(device_registry.devices) == 2 - assert device_registry.async_get_device(identifiers={(DOMAIN, 101)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "101")}) # Assert entity registry unique id was updated assert len(entity_registry.entities) == 2 assert ( @@ -300,251 +291,445 @@ async def test_updates_from_players_changed_new_ids( async def test_updates_from_user_changed( - hass: HomeAssistant, config_entry, config, controller + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in user.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) - - controller.is_signed_in = False - controller.signed_in_username = None - player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None + controller._signed_in_username = None + await player.heos.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None ) - await event.wait() - source_list = hass.data[DOMAIN][DATA_SOURCE_MANAGER].source_list - assert len(source_list) == 1 + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("media_player.test_player") - assert state.attributes[ATTR_INPUT_SOURCE_LIST] == source_list + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ + "HEOS Drive - Line In 1", + "Speaker - Line In 1", + ] + + +async def test_updates_from_groups_changed( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test player updates from changes to groups.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Assert current state + assert hass.states.get("media_player.test_player").attributes[ + ATTR_GROUP_MEMBERS + ] == ["media_player.test_player", "media_player.test_player_2"] + assert hass.states.get("media_player.test_player_2").attributes[ + ATTR_GROUP_MEMBERS + ] == ["media_player.test_player", "media_player.test_player_2"] + + # Clear group information + controller._groups = {} + for player in controller.players.values(): + player.group_id = None + await controller.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_GROUPS_CHANGED, None + ) + await hass.async_block_till_done() + + # Assert groups changed + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_GROUP_MEMBERS] + is None + ) + assert ( + hass.states.get("media_player.test_player_2").attributes[ATTR_GROUP_MEMBERS] + is None + ) async def test_clear_playlist( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the clear playlist service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_CLEAR_PLAYLIST, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.clear_queue.call_count == 1 + + +async def test_clear_playlist_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test error raised when clear playlist fails.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.clear_queue.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, match=re.escape("Unable to clear playlist: Failure (1)") + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.clear_queue.call_count == 1 - player.clear_queue.reset_mock() - player.clear_queue.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to clear playlist: Failure (1)" in caplog.text + assert player.clear_queue.call_count == 1 async def test_pause( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the pause service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.pause.call_count == 1 + + +async def test_pause_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the pause service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.pause.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, match=re.escape("Unable to pause: Failure (1)") + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.pause.call_count == 1 - player.pause.reset_mock() - player.pause.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to pause: Failure (1)" in caplog.text + assert player.pause.call_count == 1 async def test_play( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the play service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.play.call_count == 1 + + +async def test_play_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the play service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.play.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, match=re.escape("Unable to play: Failure (1)") + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play.call_count == 1 - player.play.reset_mock() - player.play.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to play: Failure (1)" in caplog.text + assert player.play.call_count == 1 async def test_previous_track( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the previous track service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.play_previous.call_count == 1 + + +async def test_previous_track_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the previous track service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.play_previous.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to move to previous track: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_previous.call_count == 1 - player.play_previous.reset_mock() - player.play_previous.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to move to previous track: Failure (1)" in caplog.text + assert player.play_previous.call_count == 1 async def test_next_track( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the next track service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.play_next.call_count == 1 + + +async def test_next_track_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the next track service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.play_next.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to move to next track: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_next.call_count == 1 - player.play_next.reset_mock() - player.play_next.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to move to next track: Failure (1)" in caplog.text + assert player.play_next.call_count == 1 async def test_stop( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the stop service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert player.stop.call_count == 1 + + +async def test_stop_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the stop service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.stop.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to stop: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.stop.call_count == 1 - player.stop.reset_mock() - player.stop.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to stop: Failure (1)" in caplog.text + assert player.stop.call_count == 1 async def test_volume_mute( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the volume mute service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + assert player.set_mute.call_count == 1 + + +async def test_volume_mute_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the volume mute service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.set_mute.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set mute: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) - assert player.set_mute.call_count == 1 - player.set_mute.reset_mock() - player.set_mute.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to set mute: Failure (1)" in caplog.text + assert player.set_mute.call_count == 1 async def test_shuffle_set( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the shuffle set service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SHUFFLE_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, + blocking=True, + ) + player.set_play_mode.assert_called_once_with(player.repeat, True) + + +async def test_shuffle_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the shuffle set service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set shuffle: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_SHUFFLE_SET, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, blocking=True, ) - player.set_play_mode.assert_called_once_with(player.repeat, True) - player.set_play_mode.reset_mock() - player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to set shuffle: Failure (1)" in caplog.text + player.set_play_mode.assert_called_once_with(player.repeat, True) + + +async def test_repeat_set( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the repeat set service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_REPEAT: RepeatMode.ONE}, + blocking=True, + ) + player.set_play_mode.assert_called_once_with(RepeatType.ON_ONE, player.shuffle) + + +async def test_repeat_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the repeat set service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set repeat: Failure (1)"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_REPEAT: RepeatMode.ALL, + }, + blocking=True, + ) + player.set_play_mode.assert_called_once_with(RepeatType.ON_ALL, player.shuffle) async def test_volume_set( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the volume set service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + player.set_volume.assert_called_once_with(100) + + +async def test_volume_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the volume set service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.set_volume.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set volume level: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True, ) - player.set_volume.assert_called_once_with(100) - player.set_volume.reset_mock() - player.set_volume.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to set volume level: Failure (1)" in caplog.text + player.set_volume.assert_called_once_with(100) async def test_select_favorite( - hass: HomeAssistant, config_entry, config, controller, favorites + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + favorites: dict[int, MediaItem], ) -> None: """Tests selecting a music service favorite and state.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test set music service preset favorite = favorites[1] @@ -554,11 +739,12 @@ async def test_select_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_favorite.assert_called_once_with(1) + player.play_preset_station.assert_called_once_with(1) # Test state is matched by station name + player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = favorite.name - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + await player.heos.dispatcher.wait_send( + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") @@ -566,10 +752,14 @@ async def test_select_favorite( async def test_select_radio_favorite( - hass: HomeAssistant, config_entry, config, controller, favorites + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + favorites: dict[int, MediaItem], ) -> None: """Tests selecting a radio favorite and state.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test set radio preset favorite = favorites[2] @@ -579,12 +769,13 @@ async def test_select_radio_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_favorite.assert_called_once_with(2) + player.play_preset_station.assert_called_once_with(2) # Test state is matched by album id + player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = "Classical" player.now_playing_media.album_id = favorite.media_id - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + await player.heos.dispatcher.wait_send( + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") @@ -593,405 +784,437 @@ async def test_select_radio_favorite( async def test_select_radio_favorite_command_error( hass: HomeAssistant, - config_entry, - config, - controller, - favorites, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + favorites: dict[int, MediaItem], ) -> None: - """Tests command error logged when playing favorite.""" - await setup_platform(hass, config_entry, config) + """Tests command error raises when playing favorite.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test set radio preset favorite = favorites[2] - player.play_favorite.side_effect = CommandFailedError(None, "Failure", 1) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, - blocking=True, - ) - player.play_favorite.assert_called_once_with(2) - assert "Unable to select source: Failure (1)" in caplog.text + player.play_preset_station.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to select source: Failure (1)"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_INPUT_SOURCE: favorite.name, + }, + blocking=True, + ) + player.play_preset_station.assert_called_once_with(2) +@pytest.mark.parametrize( + ("source_name", "station"), + [ + ("HEOS Drive - Line In 1", "Line In 1"), + ("Speaker - Line In 1", "Speaker - Line In 1"), + ], +) async def test_select_input_source( - hass: HomeAssistant, config_entry, config, controller, input_sources + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + input_sources: list[MediaItem], + source_name: str, + station: str, ) -> None: """Tests selecting input source and state.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # Test proper service called - input_source = input_sources[0] + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE, { ATTR_ENTITY_ID: "media_player.test_player", - ATTR_INPUT_SOURCE: input_source.name, + ATTR_INPUT_SOURCE: source_name, }, blocking=True, ) - player.play_input_source.assert_called_once_with(input_source) - # Test state is matched by media id + input_sources = next( + input_sources + for input_sources in input_sources + if input_sources.name == source_name + ) + player.play_media.assert_called_once_with(input_sources) + # Update the now_playing_media to reflect play_media player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT + player.now_playing_media.station = station player.now_playing_media.media_id = const.INPUT_AUX_IN_1 - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + await player.heos.dispatcher.wait_send( + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") - assert state.attributes[ATTR_INPUT_SOURCE] == input_source.name + assert state.attributes[ATTR_INPUT_SOURCE] == source_name -async def test_select_input_unknown( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, +async def test_select_input_unknown_raises( + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: - """Tests selecting an unknown input.""" - await setup_platform(hass, config_entry, config) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: "Unknown"}, - blocking=True, - ) - assert "Unknown source: Unknown" in caplog.text + """Tests selecting an unknown input raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + with pytest.raises( + ServiceValidationError, + match=re.escape("Unknown source: Unknown"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: "Unknown"}, + blocking=True, + ) async def test_select_input_command_error( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - input_sources, + config_entry: MockConfigEntry, + controller: Heos, + input_sources: list[MediaItem], ) -> None: """Tests selecting an unknown input.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] input_source = input_sources[0] - player.play_input_source.side_effect = CommandFailedError(None, "Failure", 1) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_SELECT_SOURCE, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_INPUT_SOURCE: input_source.name, - }, - blocking=True, - ) - player.play_input_source.assert_called_once_with(input_source) - assert "Unable to select source: Failure (1)" in caplog.text + player.play_media.side_effect = CommandFailedError(None, "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to select source: Failure (1)"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_INPUT_SOURCE: input_source.name, + }, + blocking=True, + ) + player.play_media.assert_called_once_with(input_source) async def test_unload_config_entry( - hass: HomeAssistant, config_entry, config, controller + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test the player is set unavailable when the config entry is unloaded.""" - await setup_platform(hass, config_entry, config) - await hass.config_entries.async_unload(config_entry.entry_id) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE -async def test_play_media_url( +@pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) +async def test_play_media( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + media_type: MediaType, ) -> None: """Test the play media service with type url.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] url = "http://news/podcast.mp3" - # First pass completes successfully, second pass raises command error - for _ in range(2): - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.URL, - ATTR_MEDIA_CONTENT_ID: url, - }, - blocking=True, - ) - player.play_url.assert_called_once_with(url) - player.play_url.reset_mock() - player.play_url.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to play media: Failure (1)" in caplog.text + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: media_type, + ATTR_MEDIA_CONTENT_ID: url, + }, + blocking=True, + ) + player.play_url.assert_called_once_with(url) -async def test_play_media_music( +@pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) +async def test_play_media_error( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + media_type: MediaType, ) -> None: - """Test the play media service with type music.""" - await setup_platform(hass, config_entry, config) + """Test the play media service with type url error raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] + player.play_url.side_effect = CommandFailedError(None, "Failure", 1) url = "http://news/podcast.mp3" - # First pass completes successfully, second pass raises command error - for _ in range(2): + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: url, }, blocking=True, ) - player.play_url.assert_called_once_with(url) - player.play_url.reset_mock() - player.play_url.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to play media: Failure (1)" in caplog.text + player.play_url.assert_called_once_with(url) +@pytest.mark.parametrize( + ("content_id", "expected_index"), [("1", 1), ("Quick Select 2", 2)] +) async def test_play_media_quick_select( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - quick_selects, + config_entry: MockConfigEntry, + controller: Heos, + content_id: str, + expected_index: int, ) -> None: """Test the play media service with type quick_select.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - quick_select = list(quick_selects.items())[0] - index = quick_select[0] - name = quick_select[1] - # Play by index await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_CONTENT_TYPE: "quick_select", - ATTR_MEDIA_CONTENT_ID: str(index), - }, - blocking=True, - ) - player.play_quick_select.assert_called_once_with(index) - # Play by name - player.play_quick_select.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "quick_select", - ATTR_MEDIA_CONTENT_ID: name, - }, - blocking=True, - ) - player.play_quick_select.assert_called_once_with(index) - # Invalid name - player.play_quick_select.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "quick_select", - ATTR_MEDIA_CONTENT_ID: "Invalid", + ATTR_MEDIA_CONTENT_ID: content_id, }, blocking=True, ) + player.play_quick_select.assert_called_once_with(expected_index) + + +async def test_play_media_quick_select_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the play media service with invalid quick_select raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid quick select 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "quick_select", + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) assert player.play_quick_select.call_count == 0 - assert "Unable to play media: Invalid quick select 'Invalid'" in caplog.text +@pytest.mark.parametrize( + ("enqueue", "criteria"), + [ + (None, AddCriteriaType.REPLACE_AND_PLAY), + (True, AddCriteriaType.ADD_TO_END), + ("next", AddCriteriaType.PLAY_NEXT), + ], +) async def test_play_media_playlist( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - playlists, + config_entry: MockConfigEntry, + controller: Heos, + playlists: list[MediaItem], + enqueue: Any, + criteria: AddCriteriaType, ) -> None: """Test the play media service with type playlist.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] playlist = playlists[0] - # Play without enqueuing + service_data = { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, + ATTR_MEDIA_CONTENT_ID: playlist.name, + } + if enqueue is not None: + service_data[ATTR_MEDIA_ENQUEUE] = enqueue await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, - ATTR_MEDIA_CONTENT_ID: playlist.name, - }, - blocking=True, - ) - player.add_to_queue.assert_called_once_with( - playlist, const.ADD_QUEUE_REPLACE_AND_PLAY - ) - # Play with enqueuing - player.add_to_queue.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, - ATTR_MEDIA_CONTENT_ID: playlist.name, - ATTR_MEDIA_ENQUEUE: True, - }, - blocking=True, - ) - player.add_to_queue.assert_called_once_with(playlist, const.ADD_QUEUE_ADD_TO_END) - # Invalid name - player.add_to_queue.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, - ATTR_MEDIA_CONTENT_ID: "Invalid", - }, + service_data, blocking=True, ) + player.add_to_queue.assert_called_once_with(playlist, criteria) + + +async def test_play_media_playlist_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the play media service with an invalid playlist name.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid playlist 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) assert player.add_to_queue.call_count == 0 - assert "Unable to play media: Invalid playlist 'Invalid'" in caplog.text +@pytest.mark.parametrize( + ("content_id", "expected_index"), [("1", 1), ("Classical MPR (Classical Music)", 2)] +) async def test_play_media_favorite( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - favorites, + config_entry: MockConfigEntry, + controller: Heos, + content_id: str, + expected_index: int, ) -> None: """Test the play media service with type favorite.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - quick_select = list(favorites.items())[0] - index = quick_select[0] - name = quick_select[1].name - # Play by index await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_CONTENT_TYPE: "favorite", - ATTR_MEDIA_CONTENT_ID: str(index), + ATTR_MEDIA_CONTENT_ID: content_id, }, blocking=True, ) - player.play_favorite.assert_called_once_with(index) - # Play by name - player.play_favorite.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "favorite", - ATTR_MEDIA_CONTENT_ID: name, - }, - blocking=True, - ) - player.play_favorite.assert_called_once_with(index) - # Invalid name - player.play_favorite.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "favorite", - ATTR_MEDIA_CONTENT_ID: "Invalid", - }, - blocking=True, - ) - assert player.play_favorite.call_count == 0 - assert "Unable to play media: Invalid favorite 'Invalid'" in caplog.text + player.play_preset_station.assert_called_once_with(expected_index) + + +async def test_play_media_favorite_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test the play media service with an invalid favorite raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid favorite 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "favorite", + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) + assert player.play_preset_station.call_count == 0 async def test_play_media_invalid_type( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test the play media service with an invalid type.""" - await setup_platform(hass, config_entry, config) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "Other", - ATTR_MEDIA_CONTENT_ID: "", - }, - blocking=True, - ) - assert "Unable to play media: Unsupported media type 'Other'" in caplog.text + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Unsupported media type 'Other'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "Other", + ATTR_MEDIA_CONTENT_ID: "", + }, + blocking=True, + ) +@pytest.mark.parametrize( + ("members", "expected"), + [ + (["media_player.test_player_2"], [1, 2]), + (["media_player.test_player_2", "media_player.test_player"], [1, 2]), + (["media_player.test_player"], [1]), + ], +) async def test_media_player_join_group( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + members: list[str], + expected: tuple[int, list[int]], ) -> None: """Test grouping of media players through the join service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_JOIN, { ATTR_ENTITY_ID: "media_player.test_player", - ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + ATTR_GROUP_MEMBERS: members, }, blocking=True, ) - controller.create_group.assert_called_once_with( - 1, - [ - 2, - ], - ) - assert "Failed to group media_player.test_player with" not in caplog.text + controller.set_group.assert_called_once_with(expected) - controller.create_group.side_effect = HeosError("error") - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_JOIN, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], - }, - blocking=True, - ) - assert "Failed to group media_player.test_player with" in caplog.text + +async def test_media_player_join_group_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test grouping of media players through the join service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.set_group.side_effect = HeosError("error") + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to join players: error"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + }, + blocking=True, + ) async def test_media_player_group_members( hass: HomeAssistant, - config_entry, - config, - controller, + config_entry: MockConfigEntry, + controller: Heos, caplog: pytest.LogCaptureFixture, ) -> None: """Test group_members attribute.""" - await setup_platform(hass, config_entry, config) - await hass.async_block_till_done() + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player_entity = hass.states.get("media_player.test_player") assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [ "media_player.test_player", @@ -1003,55 +1226,119 @@ async def test_media_player_group_members( async def test_media_player_group_members_error( hass: HomeAssistant, - config_entry, - config, - controller, + config_entry: MockConfigEntry, + controller: Heos, caplog: pytest.LogCaptureFixture, ) -> None: """Test error in HEOS API.""" controller.get_groups.side_effect = HeosError("error") - await setup_platform(hass, config_entry, config) - await hass.async_block_till_done() - assert "Unable to get HEOS group info" in caplog.text + controller._groups = {} + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert "Unable to retrieve groups" in caplog.text player_entity = hass.states.get("media_player.test_player") - assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [] + assert player_entity.attributes[ATTR_GROUP_MEMBERS] is None +@pytest.mark.parametrize( + ("entity_id", "expected_args"), + [("media_player.test_player", [1]), ("media_player.test_player_2", [1])], +) async def test_media_player_unjoin_group( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: Heos, + entity_id: str, + expected_args: list[int], ) -> None: - """Test ungrouping of media players through the join service.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, - player.player_id, - const.EVENT_PLAYER_STATE_CHANGED, - ) - await hass.async_block_till_done() + """Test ungrouping of media players through the unjoin service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_UNJOIN, { - ATTR_ENTITY_ID: "media_player.test_player", + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) - controller.create_group.assert_called_once_with(1, []) - assert "Failed to ungroup media_player.test_player" not in caplog.text + controller.set_group.assert_called_once_with(expected_args) - controller.create_group.side_effect = HeosError("error") - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_UNJOIN, - { - ATTR_ENTITY_ID: "media_player.test_player", - }, - blocking=True, + +async def test_media_player_unjoin_group_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: + """Test ungrouping of media players through the unjoin service error raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.set_group.side_effect = HeosError("error") + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to unjoin player: error"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + }, + blocking=True, + ) + + +async def test_media_player_group_fails_when_entity_removed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + entity_registry: er.EntityRegistry, +) -> None: + """Test grouping fails when entity removed.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + # Remove one of the players + entity_registry.async_remove("media_player.test_player_2") + + # Attempt to group + with pytest.raises(ServiceValidationError, match="was not found"): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + }, + blocking=True, + ) + controller.set_group.assert_not_called() + + +async def test_media_player_group_fails_wrong_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + entity_registry: er.EntityRegistry, +) -> None: + """Test grouping fails when trying to join from the wrong integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + # Create an entity in another integration + entry = entity_registry.async_get_or_create( + "media_player", "Other", "test_player_2" ) - assert "Failed to ungroup media_player.test_player" in caplog.text + + # Attempt to group + with pytest.raises( + ServiceValidationError, match="is not a HEOS media player entity" + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: [entry.entity_id], + }, + blocking=True, + ) + controller.set_group.assert_not_called() diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index d8b8b5038b0..8ca365497c6 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -1,6 +1,6 @@ """Tests for the services module.""" -from pyheos import CommandFailedError, HeosError, const +from pyheos import CommandAuthenticationError, Heos, HeosError import pytest from homeassistant.components.heos.const import ( @@ -11,21 +11,17 @@ from homeassistant.components.heos.const import ( SERVICE_SIGN_OUT, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry -async def setup_component(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: - """Set up the component for testing.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - -async def test_sign_in(hass: HomeAssistant, config_entry, controller) -> None: +async def test_sign_in( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: """Test the sign-in service.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( DOMAIN, @@ -37,30 +33,19 @@ async def test_sign_in(hass: HomeAssistant, config_entry, controller) -> None: controller.sign_in.assert_called_once_with("test@test.com", "password") -async def test_sign_in_not_connected( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture -) -> None: - """Test sign-in service logs error when not connected.""" - await setup_component(hass, config_entry) - controller.connection_state = const.STATE_RECONNECTING - - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) - - assert controller.sign_in.call_count == 0 - assert "Unable to sign in because HEOS is not connected" in caplog.text - - async def test_sign_in_failed( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + caplog: pytest.LogCaptureFixture, ) -> None: """Test sign-in service logs error when not connected.""" - await setup_component(hass, config_entry) - controller.sign_in.side_effect = CommandFailedError("", "Invalid credentials", 6) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + controller.sign_in.side_effect = CommandAuthenticationError( + "", "Invalid credentials", 6 + ) await hass.services.async_call( DOMAIN, @@ -74,10 +59,15 @@ async def test_sign_in_failed( async def test_sign_in_unknown_error( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + caplog: pytest.LogCaptureFixture, ) -> None: """Test sign-in service logs error for failure.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.sign_in.side_effect = HeosError() await hass.services.async_call( @@ -91,33 +81,56 @@ async def test_sign_in_unknown_error( assert "Unable to sign in" in caplog.text -async def test_sign_out(hass: HomeAssistant, config_entry, controller) -> None: +async def test_sign_in_not_loaded_raises( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test the sign-in service when entry not loaded raises exception.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) + + with pytest.raises(HomeAssistantError, match="The HEOS integration is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True, + ) + + +async def test_sign_out( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos +) -> None: """Test the sign-out service.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) assert controller.sign_out.call_count == 1 -async def test_sign_out_not_connected( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture +async def test_sign_out_not_loaded_raises( + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: - """Test the sign-out service.""" - await setup_component(hass, config_entry) - controller.connection_state = const.STATE_RECONNECTING + """Test the sign-out service when entry not loaded raises exception.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) - - assert controller.sign_out.call_count == 0 - assert "Unable to sign out because HEOS is not connected" in caplog.text + with pytest.raises(HomeAssistantError, match="The HEOS integration is not loaded"): + await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) async def test_sign_out_unknown_error( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: Heos, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the sign-out service.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) controller.sign_out.side_effect = HeosError() await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) diff --git a/tests/components/history/conftest.py b/tests/components/history/conftest.py index dd10fccccdc..8269d3319cb 100644 --- a/tests/components/history/conftest.py +++ b/tests/components/history/conftest.py @@ -8,12 +8,12 @@ from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 694c5c20707..3039612d1a0 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -459,7 +459,11 @@ async def test_async_on_entire_period( def _fake_states(*args, **kwargs): return { "binary_sensor.test_on_id": [ - ha.State("binary_sensor.test_on_id", "on", last_changed=start_time), + ha.State( + "binary_sensor.test_on_id", + "on", + last_changed=(start_time - timedelta(seconds=10)), + ), ha.State("binary_sensor.test_on_id", "on", last_changed=t0), ha.State("binary_sensor.test_on_id", "on", last_changed=t1), ha.State("binary_sensor.test_on_id", "on", last_changed=t2), @@ -1461,6 +1465,105 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None assert hass.states.get("sensor.sensor4").state == "50.0" +async def test_state_change_during_window_rollover( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test when the tracked sensor and the start/end window change during the same update.""" + await hass.config.async_set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=23, minute=0, second=0, microsecond=0) + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "on", + last_changed=start_time - timedelta(hours=11), + last_updated=start_time - timedelta(hours=11), + ), + ] + } + + # The test begins at 23:00, and queries from the database that the sensor has been on since 12:00. + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ), + freeze_time(start_time), + ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor1", + "state": "on", + "start": "{{ today_at() }}", + "end": "{{ now() }}", + "type": "time", + } + ] + }, + ) + await hass.async_block_till_done() + + await async_update_entity(hass, "sensor.sensor1") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "11.0" + + # Advance 59 minutes, to record the last minute update just before midnight, just like a real system would do. + t2 = start_time + timedelta(minutes=59, microseconds=300) + with freeze_time(t2): + async_fire_time_changed(hass, t2) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "11.98" + + # One minute has passed and the time has now rolled over into a new day, resetting the recorder window. The sensor will then query the database for updates, + # and will see that the sensor is ON starting from midnight. + t3 = t2 + timedelta(minutes=1) + + def _fake_states_t3(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "on", + last_changed=t3.replace(hour=0, minute=0, second=0, microsecond=0), + last_updated=t3.replace(hour=0, minute=0, second=0, microsecond=0), + ), + ] + } + + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states_t3, + ), + freeze_time(t3), + ): + # The sensor turns off around this time, before the sensor does its normal polled update. + hass.states.async_set("binary_sensor.state", "off") + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get("sensor.sensor1").state == "0.0" + + # More time passes, and the history stats does a polled update again. It should be 0 since the sensor has been off since midnight. + t4 = t3 + timedelta(minutes=10) + with freeze_time(t4): + async_fire_time_changed(hass, t4) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "0.0" + + @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii"]) async def test_end_time_with_microseconds_zeroed( time_zone: str, diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py index e5dba49dcc1..8749954c364 100644 --- a/tests/components/hive/test_config_flow.py +++ b/tests/components/hive/test_config_flow.py @@ -25,52 +25,6 @@ MFA_RESEND_CODE = "0000" MFA_INVALID_CODE = "HIVE" -async def test_import_flow(hass: HomeAssistant) -> None: - """Check import flow.""" - - with ( - patch( - "homeassistant.components.hive.config_flow.Auth.login", - return_value={ - "ChallengeName": "SUCCESS", - "AuthenticationResult": { - "RefreshToken": "mock-refresh-token", - "AccessToken": "mock-access-token", - }, - }, - ), - patch( - "homeassistant.components.hive.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.hive.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == USERNAME - assert result["data"] == { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - "tokens": { - "AuthenticationResult": { - "AccessToken": "mock-access-token", - "RefreshToken": "mock-refresh-token", - }, - "ChallengeName": "SUCCESS", - }, - } - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_user_flow(hass: HomeAssistant) -> None: """Test the user flow.""" result = await hass.config_entries.flow.async_init( @@ -91,9 +45,6 @@ async def test_user_flow(hass: HomeAssistant) -> None: }, }, ), - patch( - "homeassistant.components.hive.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.hive.async_setup_entry", return_value=True, @@ -119,7 +70,6 @@ async def test_user_flow(hass: HomeAssistant) -> None: }, } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -185,9 +135,6 @@ async def test_user_flow_2fa(hass: HomeAssistant) -> None: "mock-device-password", ], ), - patch( - "homeassistant.components.hive.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.hive.async_setup_entry", return_value=True, @@ -220,7 +167,6 @@ async def test_user_flow_2fa(hass: HomeAssistant) -> None: ], } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -462,9 +408,6 @@ async def test_user_flow_2fa_send_new_code(hass: HomeAssistant) -> None: "mock-device-password", ], ), - patch( - "homeassistant.components.hive.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.hive.async_setup_entry", return_value=True, @@ -493,7 +436,6 @@ async def test_user_flow_2fa_send_new_code(hass: HomeAssistant) -> None: "mock-device-password", ], } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py index 466dbaffd8b..f561c4a4b9f 100644 --- a/tests/components/holiday/test_config_flow.py +++ b/tests/components/holiday/test_config_flow.py @@ -1,19 +1,25 @@ """Test the Holiday config flow.""" +from datetime import datetime from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory +from holidays import UNOFFICIAL import pytest from homeassistant import config_entries -from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN -from homeassistant.const import CONF_COUNTRY +from homeassistant.components.holiday.const import ( + CONF_CATEGORIES, + CONF_PROVINCE, + DOMAIN, +) +from homeassistant.const import CONF_COUNTRY, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" @@ -49,6 +55,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_setup_entry") async def test_form_no_subdivision(hass: HomeAssistant) -> None: """Test we get the forms correctly without subdivision.""" result = await hass.config_entries.flow.async_init( @@ -71,6 +78,7 @@ async def test_form_no_subdivision(hass: HomeAssistant) -> None: } +@pytest.mark.usefixtures("mock_setup_entry") async def test_form_translated_title(hass: HomeAssistant) -> None: """Test the title gets translated.""" hass.config.language = "de" @@ -90,6 +98,7 @@ async def test_form_translated_title(hass: HomeAssistant) -> None: assert result2["title"] == "Schweden" +@pytest.mark.usefixtures("mock_setup_entry") async def test_single_combination_country_province(hass: HomeAssistant) -> None: """Test that configuring more than one instance is rejected.""" data_de = { @@ -129,6 +138,7 @@ async def test_single_combination_country_province(hass: HomeAssistant) -> None: assert result_de_step2["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_setup_entry") async def test_form_babel_unresolved_language(hass: HomeAssistant) -> None: """Test the config flow if using not babel supported language.""" hass.config.language = "en-XX" @@ -175,6 +185,7 @@ async def test_form_babel_unresolved_language(hass: HomeAssistant) -> None: } +@pytest.mark.usefixtures("mock_setup_entry") async def test_form_babel_replace_dash_with_underscore(hass: HomeAssistant) -> None: """Test the config flow if using language with dash.""" hass.config.language = "en-GB" @@ -221,7 +232,8 @@ async def test_form_babel_replace_dash_with_underscore(hass: HomeAssistant) -> N } -async def test_reconfigure(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure(hass: HomeAssistant) -> None: """Test reconfigure flow.""" entry = MockConfigEntry( domain=DOMAIN, @@ -248,9 +260,38 @@ async def test_reconfigure(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> assert entry.data == {"country": "DE", "province": "NW"} -async def test_reconfigure_incorrect_language( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_with_categories(hass: HomeAssistant) -> None: + """Test reconfigure flow with categories.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Unites States, TX", + data={"country": "US", "province": "TX"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROVINCE: "AL", + CONF_CATEGORIES: [UNOFFICIAL], + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + entry = hass.config_entries.async_get_entry(entry.entry_id) + assert entry.title == "United States, AL" + assert entry.data == {CONF_COUNTRY: "US", CONF_PROVINCE: "AL"} + assert entry.options == {CONF_CATEGORIES: ["unofficial"]} + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_incorrect_language(hass: HomeAssistant) -> None: """Test reconfigure flow default to English.""" hass.config.language = "en-XX" @@ -279,9 +320,8 @@ async def test_reconfigure_incorrect_language( assert entry.data == {"country": "DE", "province": "NW"} -async def test_reconfigure_entry_exists( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_entry_exists(hass: HomeAssistant) -> None: """Test reconfigure flow stops if other entry already exist.""" entry = MockConfigEntry( domain=DOMAIN, @@ -312,3 +352,92 @@ async def test_reconfigure_entry_exists( entry = hass.config_entries.async_get_entry(entry.entry_id) assert entry.title == "Germany, BW" assert entry.data == {"country": "DE", "province": "BW"} + + +async def test_form_with_options( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the flow with configuring options.""" + await hass.config.async_set_time_zone("America/Chicago") + zone = await dt_util.async_get_time_zone("America/Chicago") + # Oct 31st is a Friday. Unofficial holiday as Halloween + freezer.move_to(datetime(2024, 10, 31, 12, 0, 0, tzinfo=zone)) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "US", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROVINCE: "TX", + CONF_CATEGORIES: [UNOFFICIAL], + }, + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "United States, TX" + assert result["data"] == { + CONF_COUNTRY: "US", + CONF_PROVINCE: "TX", + } + assert result["options"] == { + CONF_CATEGORIES: ["unofficial"], + } + + state = hass.states.get("calendar.united_states_tx") + assert state + assert state.state == STATE_ON + + entries = hass.config_entries.async_entries(DOMAIN) + entry = entries[0] + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_CATEGORIES: []}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_CATEGORIES: [], + } + + state = hass.states.get("calendar.united_states_tx") + assert state + assert state.state == STATE_OFF + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_options_abort_no_categories(hass: HomeAssistant) -> None: + """Test the options flow abort if no categories to select.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "SE"}, + title="Sweden", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_categories" diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 4e790074700..2ac8c851e1b 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -182,10 +182,16 @@ def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock: mock.get_programs_active.side_effect = HomeConnectError mock.get_programs_available.side_effect = HomeConnectError mock.start_program.side_effect = HomeConnectError + mock.select_program.side_effect = HomeConnectError + mock.pause_program.side_effect = HomeConnectError mock.stop_program.side_effect = HomeConnectError + mock.set_options_active_program.side_effect = HomeConnectError + mock.set_options_selected_program.side_effect = HomeConnectError mock.get_status.side_effect = HomeConnectError mock.get_settings.side_effect = HomeConnectError mock.set_setting.side_effect = HomeConnectError + mock.set_setting.side_effect = HomeConnectError + mock.execute_command.side_effect = HomeConnectError return mock diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index 99f10fe2847..f3131eac52f 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -2,6 +2,7 @@ # name: test_async_get_config_entry_diagnostics dict({ 'BOSCH-000000000-000000000000': dict({ + 'connected': True, 'programs': list([ ]), 'status': dict({ @@ -23,6 +24,7 @@ }), }), 'BOSCH-HCS000000-D00000000001': dict({ + 'connected': True, 'programs': list([ 'LaundryCare.WasherDryer.Program.Mix', 'LaundryCare.Washer.Option.Temperature', @@ -46,6 +48,7 @@ }), }), 'BOSCH-HCS000000-D00000000002': dict({ + 'connected': True, 'programs': list([ ]), 'status': dict({ @@ -67,6 +70,7 @@ }), }), 'BOSCH-HCS000000-D00000000003': dict({ + 'connected': True, 'programs': list([ ]), 'status': dict({ @@ -88,6 +92,7 @@ }), }), 'BOSCH-HCS000000-D00000000004': dict({ + 'connected': True, 'programs': list([ ]), 'status': dict({ @@ -144,6 +149,7 @@ }), }), 'BOSCH-HCS000000-D00000000005': dict({ + 'connected': True, 'programs': list([ ]), 'status': dict({ @@ -165,6 +171,7 @@ }), }), 'BOSCH-HCS000000-D00000000006': dict({ + 'connected': True, 'programs': list([ ]), 'status': dict({ @@ -186,6 +193,7 @@ }), }), 'BOSCH-HCS01OVN1-43E0065FE245': dict({ + 'connected': True, 'programs': list([ 'Cooking.Oven.Program.HeatingMode.HotAir', 'Cooking.Oven.Program.HeatingMode.TopBottomHeating', @@ -217,6 +225,7 @@ }), }), 'BOSCH-HCS04DYR1-831694AE3C5A': dict({ + 'connected': True, 'programs': list([ 'LaundryCare.Dryer.Program.Cotton', 'LaundryCare.Dryer.Program.Synthetic', @@ -241,6 +250,7 @@ }), }), 'BOSCH-HCS06COM1-D70390681C2C': dict({ + 'connected': True, 'programs': list([ 'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso', 'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato', @@ -268,6 +278,7 @@ }), }), 'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({ + 'connected': True, 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -319,6 +330,7 @@ }), }), 'SIEMENS-HCS03WCH1-7BC6383CF794': dict({ + 'connected': True, 'programs': list([ 'LaundryCare.Washer.Program.Cotton', 'LaundryCare.Washer.Program.EasyCare', @@ -356,6 +368,7 @@ }), }), 'SIEMENS-HCS05FRF1-304F4F9E541D': dict({ + 'connected': True, 'programs': list([ ]), 'status': dict({ @@ -415,6 +428,7 @@ # --- # name: test_async_get_device_diagnostics dict({ + 'connected': True, 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index d0bc5e77735..f2db6e2b67a 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -1,8 +1,9 @@ """Test diagnostics for Home Connect.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock +from homeconnect.api import HomeConnectError import pytest from syrupy import SnapshotAssertion @@ -63,14 +64,13 @@ async def test_async_get_device_diagnostics( @pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_exceptions( +async def test_async_device_diagnostics_not_found( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[], Awaitable[bool]], setup_credentials: None, get_appliances: MagicMock, device_registry: dr.DeviceRegistry, - snapshot: SnapshotAssertion, ) -> None: """Test device config entry diagnostics.""" get_appliances.side_effect = get_all_appliances @@ -85,3 +85,45 @@ async def test_async_device_diagnostics_exceptions( with pytest.raises(ValueError): await async_get_device_diagnostics(hass, config_entry, device) + + +@pytest.mark.parametrize( + ("api_error", "expected_connection_status"), + [ + (HomeConnectError(), "unknown"), + ( + HomeConnectError( + { + "key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed", + } + ), + "offline", + ), + ], +) +@pytest.mark.usefixtures("bypass_throttle") +async def test_async_device_diagnostics_api_error( + api_error: HomeConnectError, + expected_connection_status: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, + appliance: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device config entry diagnostics.""" + appliance.get_programs_available.side_effect = api_error + get_appliances.return_value = [appliance] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.haId)}, + ) + + diagnostics = await async_get_device_diagnostics(hass, config_entry, device) + assert diagnostics["programs"] is None diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 849e93e33d2..69601efb42d 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -10,7 +10,10 @@ from requests import HTTPError import requests_mock from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.home_connect import SCAN_INTERVAL +from homeassistant.components.home_connect import ( + SCAN_INTERVAL, + bsh_key_to_translation_key, +) from homeassistant.components.home_connect.const import ( BSH_CHILD_LOCK_STATE, BSH_OPERATION_STATE, @@ -26,7 +29,9 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from script.hassfest.translations import RE_TRANSLATION_KEY from .conftest import ( CLIENT_ID, @@ -286,8 +291,40 @@ async def test_services( ) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) @pytest.mark.usefixtures("bypass_throttle") async def test_services_exception( + service_call: list[dict[str, Any]], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, + problematic_appliance: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a HomeAssistantError when there is an API error.""" + get_appliances.return_value = [problematic_appliance] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, problematic_appliance.haId)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(HomeAssistantError): + await hass.services.async_call(**service_call) + + +@pytest.mark.usefixtures("bypass_throttle") +async def test_services_appliance_not_found( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[], Awaitable[bool]], @@ -295,7 +332,7 @@ async def test_services_exception( get_appliances: MagicMock, appliance: Mock, ) -> None: - """Raise a ValueError when device id does not match.""" + """Raise a ServiceValidationError when device id does not match.""" get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup() @@ -305,7 +342,7 @@ async def test_services_exception( service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" - with pytest.raises(AssertionError): + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): await hass.services.async_call(**service_call) @@ -372,3 +409,10 @@ async def test_entity_migration( domain, DOMAIN, f"{appliance.haId}-{expected_unique_id_suffix}" ) assert config_entry_v1_1.minor_version == 2 + + +async def test_bsh_key_transformations() -> None: + """Test that the key transformations are compatible valid translations keys and can be reversed.""" + program = "Dishcare.Dishwasher.Program.Eco50" + translation_key = bsh_key_to_translation_key(program) + assert RE_TRANSLATION_KEY.match(translation_key) diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 7a9747929c9..471ddf0ec54 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -27,7 +27,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from .conftest import get_all_appliances @@ -341,7 +341,7 @@ async def test_switch_exception_handling( problematic_appliance.status.update(status) service_data["entity_id"] = entity_id - with pytest.raises(ServiceValidationError, match=exception_match): + with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( LIGHT_DOMAIN, service, service_data, blocking=True ) diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index f70e307cb41..bce19161cf8 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -24,7 +24,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from .conftest import get_all_appliances @@ -162,7 +162,7 @@ async def test_number_entity_error( getattr(problematic_appliance, mock_attr)() with pytest.raises( - ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*" + HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" ): await hass.services.async_call( NUMBER_DOMAIN, diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py new file mode 100644 index 00000000000..af975979196 --- /dev/null +++ b/tests/components/home_connect/test_select.py @@ -0,0 +1,200 @@ +"""Tests for home_connect select entities.""" + +from collections.abc import Awaitable, Callable, Generator +from unittest.mock import MagicMock, Mock + +from homeconnect.api import HomeConnectError +import pytest + +from homeassistant.components.home_connect.const import ( + BSH_ACTIVE_PROGRAM, + BSH_SELECTED_PROGRAM, +) +from homeassistant.components.select import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import get_all_appliances + +from tests.common import MockConfigEntry, load_json_object_fixture + +SETTINGS_STATUS = { + setting.pop("key"): setting + for setting in load_json_object_fixture("home_connect/settings.json") + .get("Washer") + .get("data") + .get("settings") +} + +PROGRAM = "Dishcare.Dishwasher.Program.Eco50" + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SELECT] + + +async def test_select( + bypass_throttle: Generator[None], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: Mock, +) -> None: + """Test select entity.""" + get_appliances.side_effect = get_all_appliances + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_filter_unknown_programs( + bypass_throttle: Generator[None], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: Mock, + appliance: Mock, + entity_registry: er.EntityRegistry, +) -> None: + """Test select that programs that are not part of the official Home Connect API specification are filtered out. + + We use two programs to ensure that programs are iterated over a copy of the list, + and it does not raise problems when removing an element from the original list. + """ + appliance.status.update(SETTINGS_STATUS) + appliance.get_programs_available.return_value = [ + PROGRAM, + "NonOfficialProgram", + "AntotherNonOfficialProgram", + ] + get_appliances.return_value = [appliance] + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + entity = entity_registry.async_get("select.washer_selected_program") + assert entity + assert entity.capabilities.get(ATTR_OPTIONS) == [ + "dishcare_dishwasher_program_eco_50" + ] + + +@pytest.mark.parametrize( + ("entity_id", "status", "program_to_set"), + [ + ( + "select.washer_selected_program", + {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "dishcare_dishwasher_program_eco_50", + ), + ( + "select.washer_active_program", + {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "dishcare_dishwasher_program_eco_50", + ), + ], +) +async def test_select_functionality( + entity_id: str, + status: dict, + program_to_set: str, + bypass_throttle: Generator[None], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + appliance: Mock, + get_appliances: MagicMock, +) -> None: + """Test select functionality.""" + appliance.status.update(SETTINGS_STATUS) + appliance.get_programs_available.return_value = [PROGRAM] + get_appliances.return_value = [appliance] + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + appliance.status.update(status) + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, + blocking=True, + ) + assert hass.states.is_state(entity_id, program_to_set) + + +@pytest.mark.parametrize( + ( + "entity_id", + "status", + "program_to_set", + "mock_attr", + "exception_match", + ), + [ + ( + "select.washer_selected_program", + {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "dishcare_dishwasher_program_eco_50", + "select_program", + r"Error.*select.*program.*", + ), + ( + "select.washer_active_program", + {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "dishcare_dishwasher_program_eco_50", + "start_program", + r"Error.*start.*program.*", + ), + ], +) +async def test_select_exception_handling( + entity_id: str, + status: dict, + program_to_set: str, + mock_attr: str, + exception_match: str, + bypass_throttle: Generator[None], + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, + problematic_appliance: Mock, + get_appliances: MagicMock, +) -> None: + """Test exception handling.""" + problematic_appliance.get_programs_available.side_effect = None + problematic_appliance.get_programs_available.return_value = [PROGRAM] + get_appliances.return_value = [problematic_appliance] + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + # Assert that an exception is called. + with pytest.raises(HomeConnectError): + getattr(problematic_appliance, mock_attr)() + + problematic_appliance.status.update(status) + with pytest.raises(HomeAssistantError, match=exception_match): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {"entity_id": entity_id, "option": program_to_set}, + blocking=True, + ) + assert getattr(problematic_appliance, mock_attr).call_count == 2 diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index e4f45fbcdf9..9d54feeaa54 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -6,18 +6,21 @@ from unittest.mock import MagicMock, Mock from homeconnect.api import HomeConnectAppliance, HomeConnectError import pytest +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( ATTR_ALLOWED_VALUES, ATTR_CONSTRAINTS, BSH_ACTIVE_PROGRAM, BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, BSH_POWER_STATE, + DOMAIN, REFRIGERATION_SUPERMODEFREEZER, ) +from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -29,7 +32,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component from .conftest import get_all_appliances @@ -219,7 +224,7 @@ async def test_switch_exception_handling( with pytest.raises(HomeConnectError): getattr(problematic_appliance, mock_attr)() - with pytest.raises(ServiceValidationError, match=exception_match): + with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True ) @@ -344,7 +349,7 @@ async def test_ent_desc_switch_exception_handling( getattr(problematic_appliance, mock_attr)() problematic_appliance.status.update(status) - with pytest.raises(ServiceValidationError, match=exception_match): + with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) @@ -370,32 +375,6 @@ async def test_ent_desc_switch_exception_handling( STATE_OFF, "Dishwasher", ), - ( - "switch.dishwasher_power", - { - BSH_POWER_STATE: {"value": ""}, - BSH_OPERATION_STATE: { - "value": "BSH.Common.EnumType.OperationState.Run" - }, - }, - [BSH_POWER_ON], - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_power", - { - BSH_POWER_STATE: {"value": ""}, - BSH_OPERATION_STATE: { - "value": "BSH.Common.EnumType.OperationState.Inactive" - }, - }, - [BSH_POWER_ON], - SERVICE_TURN_ON, - STATE_OFF, - "Dishwasher", - ), ( "switch.dishwasher_power", {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, @@ -502,7 +481,76 @@ async def test_power_switch_service_validation_errors( appliance.status.update({BSH_POWER_STATE: {"value": BSH_POWER_ON}}) - with pytest.raises(ServiceValidationError, match=exception_match): + with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("bypass_throttle") +async def test_create_issue( + hass: HomeAssistant, + appliance: Mock, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + entity_id = "switch.washer_program_mix" + appliance.status.update(SETTINGS_STATUS) + appliance.get_programs_available.return_value = [PROGRAM] + get_appliances.return_value = [appliance] + issue_id = f"deprecated_program_switch_{entity_id}" + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "action": "switch.turn_on", + "entity_id": entity_id, + }, + ], + } + } + }, + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 25ce39786a5..1401e07b05a 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -12,7 +12,7 @@ from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from .conftest import get_all_appliances @@ -136,7 +136,7 @@ async def test_time_entity_error( getattr(problematic_appliance, mock_attr)() with pytest.raises( - ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*" + HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" ): await hass.services.async_call( TIME_DOMAIN, diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 33d78cd6c9f..0aed3dc929e 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -38,6 +38,7 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, + MockEntityPlatform, MockUser, async_capture_events, async_mock_service, @@ -90,6 +91,8 @@ async def test_reload_core_conf(hass: HomeAssistant) -> None: ent = entity.Entity() ent.entity_id = "test.entity" ent.hass = hass + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) ent.async_write_ha_state() state = hass.states.get("test.entity") @@ -184,6 +187,7 @@ async def test_turn_on_skips_domains_without_service( # because by mocking out the call service method, we mock out all # So we mimic how the service registry calls services service_call = ha.ServiceCall( + hass, "homeassistant", "turn_on", {"entity_id": ["light.test", "sensor.bla", "binary_sensor.blub", "light.bla"]}, diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 85882274fec..fe4fb53962a 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -1770,8 +1770,7 @@ async def test_if_fires_on_entities_change_overlap_for_template( "entity_id": ["test.entity_1", "test.entity_2"], "above": above, "below": below, - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", @@ -1938,8 +1937,7 @@ async def test_variables_priority( "entity_id": ["test.entity_1", "test.entity_2"], "above": above, "below": below, - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 83157a158a6..c3117bbb660 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -1423,8 +1423,7 @@ async def test_if_fires_on_entities_change_overlap_for_template( "platform": "state", "entity_id": ["test.entity_1", "test.entity_2"], "to": "world", - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", @@ -1727,8 +1726,7 @@ async def test_variables_priority( "platform": "state", "entity_id": ["test.entity_1", "test.entity_2"], "to": "world", - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 8900998a7b8..40f62baa5e7 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -156,6 +156,86 @@ async def test_if_fires_using_at_input_datetime( ) +@pytest.mark.parametrize(("hour"), [0, 5, 23]) +@pytest.mark.parametrize( + ("has_date", "has_time"), [(True, True), (False, True), (True, False)] +) +@pytest.mark.parametrize( + ("offset", "delta"), + [ + ("00:00:10", timedelta(seconds=10)), + ("-00:00:10", timedelta(seconds=-10)), + ({"minutes": 5}, timedelta(minutes=5)), + ("01:00:10", timedelta(hours=1, seconds=10)), + ], +) +async def test_if_fires_using_at_input_datetime_with_offset( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], + has_date: bool, + has_time: bool, + offset: str, + delta: timedelta, + hour: int, +) -> None: + """Test for firing at input_datetime.""" + await async_setup_component( + hass, + "input_datetime", + {"input_datetime": {"trigger": {"has_date": has_date, "has_time": has_time}}}, + ) + now = dt_util.now() + + start_dt = now.replace( + hour=hour if has_time else 0, minute=0, second=0, microsecond=0 + ) + timedelta(2) + trigger_dt = start_dt + delta + + await hass.services.async_call( + "input_datetime", + "set_datetime", + { + ATTR_ENTITY_ID: "input_datetime.trigger", + "datetime": str(start_dt.replace(tzinfo=None)), + }, + blocking=True, + ) + await hass.async_block_till_done() + + time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) + + some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": {"entity_id": "input_datetime.trigger", "offset": offset}, + }, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(service_calls) == 2 + assert ( + service_calls[1].data["some"] + == f"time-{trigger_dt.day}-{trigger_dt.hour}-input_datetime.trigger" + ) + + @pytest.mark.parametrize( ("conf_at", "trigger_deltas"), [ @@ -654,10 +734,6 @@ def test_schema_valid(conf) -> None: {"platform": "time", "at": "binary_sensor.bla"}, {"platform": "time", "at": 745}, {"platform": "time", "at": "25:00"}, - { - "platform": "time", - "at": {"entity_id": "input_datetime.bla", "offset": "0:10"}, - }, {"platform": "time", "at": {"entity_id": "13:00:00", "offset": "0:10"}}, ], ) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 8b0995a67f3..145087073af 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -7,7 +7,6 @@ from typing import Any from unittest.mock import AsyncMock, Mock, call, patch import pytest -from universal_silabs_flasher.const import ApplicationType from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( @@ -17,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import BaseFirmwareOptionsFlow, ) from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, get_otbr_addon_manager, get_zigbee_flasher_addon_manager, ) diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 5a6f765c44c..f5375fb51dd 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -3,13 +3,13 @@ from unittest.mock import AsyncMock import pytest -from universal_silabs_flasher.const import ApplicationType from homeassistant.components.hassio import AddonError, AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, ) +from homeassistant.components.homeassistant_hardware.util import ApplicationType from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 4a30a39686f..3f019a0409c 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -2,13 +2,14 @@ from unittest.mock import AsyncMock, patch -from universal_silabs_flasher.const import ApplicationType - from homeassistant.components.hassio import AddonError, AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, FirmwareGuess, + FlasherApplicationType, get_zha_device_path, guess_firmware_type, + probe_silabs_firmware_type, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -156,3 +157,27 @@ async def test_guess_firmware_type(hass: HomeAssistant) -> None: assert (await guess_firmware_type(hass, path)) == FirmwareGuess( is_running=True, firmware_type=ApplicationType.CPC, source="multiprotocol" ) + + +async def test_probe_silabs_firmware_type() -> None: + """Test probing Silabs firmware type.""" + + with patch( + "homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type", + side_effect=RuntimeError, + ): + assert (await probe_silabs_firmware_type("/dev/ttyUSB0")) is None + + with patch( + "homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type", + side_effect=lambda self: setattr(self, "app_type", FlasherApplicationType.EZSP), + autospec=True, + ) as mock_probe_app_type: + # The application type constant is converted back and forth transparently + result = await probe_silabs_firmware_type( + "/dev/ttyUSB0", probe_methods=[ApplicationType.EZSP] + ) + assert result is ApplicationType.EZSP + + flasher = mock_probe_app_type.mock_calls[0].args[0] + assert flasher._probe_methods == [FlasherApplicationType.EZSP] diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 055b6347267..904fcac321c 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -4,7 +4,6 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components import usb from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_ZIGBEE, @@ -17,10 +16,11 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry -USB_DATA_SKY = usb.UsbServiceInfo( +USB_DATA_SKY = UsbServiceInfo( device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", vid="10C4", pid="EA60", @@ -29,7 +29,7 @@ USB_DATA_SKY = usb.UsbServiceInfo( description="SkyConnect v1.0", ) -USB_DATA_ZBT1 = usb.UsbServiceInfo( +USB_DATA_ZBT1 = UsbServiceInfo( device="/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", vid="10C4", pid="EA60", @@ -47,7 +47,7 @@ USB_DATA_ZBT1 = usb.UsbServiceInfo( ], ) async def test_config_flow( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test the config flow for SkyConnect.""" result = await hass.config_entries.flow.async_init( @@ -102,7 +102,7 @@ async def test_config_flow( ], ) async def test_options_flow( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test the options flow for SkyConnect.""" config_entry = MockConfigEntry( @@ -168,7 +168,7 @@ async def test_options_flow( ], ) async def test_options_flow_multipan_uninstall( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test options flow for when multi-PAN firmware is installed.""" config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index e1c13771fdc..15eeb205537 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -2,9 +2,10 @@ from unittest.mock import patch -from universal_silabs_flasher.const import ApplicationType - -from homeassistant.components.homeassistant_hardware.util import FirmwareGuess +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareGuess, +) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/homeassistant_sky_connect/test_util.py b/tests/components/homeassistant_sky_connect/test_util.py index 1d1d70c1b4c..2801b3d00bb 100644 --- a/tests/components/homeassistant_sky_connect/test_util.py +++ b/tests/components/homeassistant_sky_connect/test_util.py @@ -8,7 +8,7 @@ from homeassistant.components.homeassistant_sky_connect.util import ( get_hardware_variant, get_usb_service_info, ) -from homeassistant.components.usb import UsbServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index ab6f158b211..1067be7b56e 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Home Assistant Yellow config flow.""" from collections.abc import Generator -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -36,6 +36,16 @@ def config_flow_handler(hass: HomeAssistant) -> Generator[None]: yield +@pytest.fixture(autouse=True) +def mock_get_supervisor_client(supervisor_client: AsyncMock) -> Generator[None]: + """Mock get_supervisor_client method.""" + with patch( + "homeassistant.components.homeassistant_yellow.config_flow.get_supervisor_client", + return_value=supervisor_client, + ): + yield + + @pytest.fixture(name="get_yellow_settings") def mock_get_yellow_settings(): """Mock getting yellow settings.""" @@ -56,12 +66,9 @@ def mock_set_yellow_settings(): @pytest.fixture(name="reboot_host") -def mock_reboot_host(): +def mock_reboot_host(supervisor_client: AsyncMock) -> AsyncMock: """Mock rebooting host.""" - with patch( - "homeassistant.components.homeassistant_yellow.config_flow.async_reboot_host", - ) as reboot_host: - yield reboot_host + return supervisor_client.host.reboot async def test_config_flow(hass: HomeAssistant) -> None: @@ -130,11 +137,11 @@ async def test_config_flow_single_entry(hass: HomeAssistant) -> None: ) async def test_option_flow_led_settings( hass: HomeAssistant, - get_yellow_settings, - set_yellow_settings, - reboot_host, - reboot_menu_choice, - reboot_calls, + get_yellow_settings: AsyncMock, + set_yellow_settings: AsyncMock, + reboot_host: AsyncMock, + reboot_menu_choice: str, + reboot_calls: int, ) -> None: """Test updating LED settings.""" mock_integration(hass, MockModule("hassio")) @@ -176,7 +183,7 @@ async def test_option_flow_led_settings( {"next_step_id": reboot_menu_choice}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(reboot_host.mock_calls) == reboot_calls + assert reboot_host.call_count == reboot_calls async def test_option_flow_led_settings_unchanged( diff --git a/tests/components/homee/__init__.py b/tests/components/homee/__init__.py new file mode 100644 index 00000000000..03095aca7df --- /dev/null +++ b/tests/components/homee/__init__.py @@ -0,0 +1 @@ +"""Tests for the homee component.""" diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py new file mode 100644 index 00000000000..a777f6b59a9 --- /dev/null +++ b/tests/components/homee/conftest.py @@ -0,0 +1,68 @@ +"""Fixtures for Homee integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.homee.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +HOMEE_ID = "00055511EECC" +HOMEE_IP = "192.168.1.11" +HOMEE_NAME = "TestHomee" +TESTUSER = "testuser" +TESTPASS = "testpass" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=f"{HOMEE_NAME} ({HOMEE_IP})", + domain=DOMAIN, + data={ + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + unique_id=HOMEE_ID, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.homee.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_homee() -> Generator[AsyncMock]: + """Return a mock Homee instance.""" + with ( + patch( + "homeassistant.components.homee.config_flow.Homee", autospec=True + ) as mocked_homee, + patch( + "homeassistant.components.homee.Homee", + autospec=True, + ), + ): + homee = mocked_homee.return_value + + homee.host = HOMEE_IP + homee.user = TESTUSER + homee.password = TESTPASS + homee.settings = MagicMock() + homee.settings.uid = HOMEE_ID + homee.settings.homee_name = HOMEE_NAME + homee.reconnect_interval = 10 + + homee.get_access_token.return_value = "test_token" + + yield homee diff --git a/tests/components/homee/test_config_flow.py b/tests/components/homee/test_config_flow.py new file mode 100644 index 00000000000..4dfe8226d16 --- /dev/null +++ b/tests/components/homee/test_config_flow.py @@ -0,0 +1,132 @@ +"""Test the Homee config flow.""" + +from unittest.mock import AsyncMock + +from pyHomee import HomeeAuthFailedException, HomeeConnectionFailedException +import pytest + +from homeassistant.components.homee.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import HOMEE_ID, HOMEE_IP, HOMEE_NAME, TESTPASS, TESTUSER + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_homee", "mock_config_entry", "mock_setup_entry") +async def test_config_flow( + hass: HomeAssistant, +) -> None: + """Test the complete config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + "host": HOMEE_IP, + "username": TESTUSER, + "password": TESTPASS, + } + assert result["title"] == f"{HOMEE_NAME} ({HOMEE_IP})" + assert result["result"].unique_id == HOMEE_ID + + +@pytest.mark.parametrize( + ("side_eff", "error"), + [ + ( + HomeeConnectionFailedException("connection timed out"), + {"base": "cannot_connect"}, + ), + ( + HomeeAuthFailedException("wrong username or password"), + {"base": "invalid_auth"}, + ), + ( + Exception, + {"base": "unknown"}, + ), + ], +) +async def test_config_flow_errors( + hass: HomeAssistant, + mock_homee: AsyncMock, + side_eff: Exception, + error: dict[str, str], +) -> None: + """Test the config flow fails as expected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + flow_id = result["flow_id"] + + mock_homee.get_access_token.side_effect = side_eff + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={ + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == error + + mock_homee.get_access_token.side_effect = None + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={ + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_homee") +async def test_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow aborts when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index c37cac84b8a..00cf42bb916 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -121,7 +121,7 @@ async def test_home_accessory(hass: HomeAssistant, hk_driver) -> None: serv = acc3.services[0] # SERV_ACCESSORY_INFO assert ( serv.get_characteristic(CHAR_NAME).value - == "Home Accessory that exceeds the maximum maximum maximum maximum " + == "Home Accessory that exceeds the maximum maximum maximum maximum" ) assert ( serv.get_characteristic(CHAR_MANUFACTURER).value @@ -154,7 +154,7 @@ async def test_home_accessory(hass: HomeAssistant, hk_driver) -> None: serv = acc4.services[0] # SERV_ACCESSORY_INFO assert ( serv.get_characteristic(CHAR_NAME).value - == "Home Accessory that exceeds the maximum maximum maximum maximum " + == "Home Accessory that exceeds the maximum maximum maximum maximum" ) assert ( serv.get_characteristic(CHAR_MANUFACTURER).value diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index d6d0c7118db..c1b4ecfed70 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -1406,7 +1406,6 @@ async def test_options_flow_exclude_mode_skips_category_entities( "switch", "sonos", "config", - device_id="1234", entity_category=EntityCategory.CONFIG, ) hass.states.async_set(sonos_config_switch.entity_id, "off") @@ -1415,7 +1414,6 @@ async def test_options_flow_exclude_mode_skips_category_entities( "switch", "sonos", "notconfig", - device_id="1234", entity_category=None, ) hass.states.async_set(sonos_notconfig_switch.entity_id, "off") @@ -1510,7 +1508,6 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( "switch", "sonos", "config", - device_id="1234", hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(sonos_hidden_switch.entity_id, "off") @@ -1594,7 +1591,6 @@ async def test_options_flow_include_mode_allows_hidden_entities( "switch", "sonos", "config", - device_id="1234", hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(sonos_hidden_switch.entity_id, "off") diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 4000c61e422..0829c96ce1d 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -615,7 +615,6 @@ async def test_homekit_entity_glob_filter_with_config_entities( "select", "any", "any", - device_id="1234", entity_category=EntityCategory.CONFIG, ) hass.states.async_set(select_config_entity.entity_id, "off") @@ -624,7 +623,6 @@ async def test_homekit_entity_glob_filter_with_config_entities( "switch", "any", "any", - device_id="1234", entity_category=EntityCategory.CONFIG, ) hass.states.async_set(switch_config_entity.entity_id, "off") @@ -669,7 +667,6 @@ async def test_homekit_entity_glob_filter_with_hidden_entities( "select", "any", "any", - device_id="1234", hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(select_config_entity.entity_id, "off") @@ -678,7 +675,6 @@ async def test_homekit_entity_glob_filter_with_hidden_entities( "switch", "any", "any", - device_id="1234", hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(switch_config_entity.entity_id, "off") @@ -1867,7 +1863,11 @@ async def test_homekit_ignored_missing_devices( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test HomeKit handles a device in the entity registry but missing from the device registry.""" + """Test HomeKit handles a device in the entity registry but missing from the device registry. + + If the entity registry is updated to remove entities linked to non-existent devices, + or set the link to None, this test can be removed. + """ entry = await async_init_integration(hass) homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) @@ -1885,47 +1885,37 @@ async def test_homekit_ignored_missing_devices( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_registry.async_get_or_create( + binary_sensor_entity = entity_registry.async_get_or_create( "binary_sensor", "powerwall", "battery_charging", device_id=device_entry.id, original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ) - entity_registry.async_get_or_create( + sensor_entity = entity_registry.async_get_or_create( "sensor", "powerwall", "battery", device_id=device_entry.id, original_device_class=SensorDeviceClass.BATTERY, ) - light = entity_registry.async_get_or_create( + light_entity = light = entity_registry.async_get_or_create( "light", "powerwall", "demo", device_id=device_entry.id ) # Delete the device to make sure we fallback # to using the platform - device_registry.async_remove_device(device_entry.id) - # Wait for the entities to be removed - await asyncio.sleep(0) - await asyncio.sleep(0) - # Restore the registry - entity_registry.async_get_or_create( - "binary_sensor", - "powerwall", - "battery_charging", - device_id=device_entry.id, - original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - ) - entity_registry.async_get_or_create( - "sensor", - "powerwall", - "battery", - device_id=device_entry.id, - original_device_class=SensorDeviceClass.BATTERY, - ) - light = entity_registry.async_get_or_create( - "light", "powerwall", "demo", device_id=device_entry.id - ) + with patch( + "homeassistant.helpers.entity_registry.async_entries_for_device", + return_value=[], + ): + device_registry.async_remove_device(device_entry.id) + # Wait for the device registry event handlers to execute + await asyncio.sleep(0) + await asyncio.sleep(0) + # Check the entities were not removed + assert binary_sensor_entity.entity_id in entity_registry.entities + assert sensor_entity.entity_id in entity_registry.entities + assert light_entity.entity_id in entity_registry.entities hass.states.async_set(light.entity_id, STATE_ON) hass.states.async_set("light.two", STATE_ON) diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index a45e4988c36..c1870cecd9c 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1,6 +1,7 @@ """Test different accessory types: Lights.""" from datetime import timedelta +import sys from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest @@ -20,8 +21,8 @@ from homeassistant.components.light import ( ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -540,6 +541,422 @@ async def test_light_color_temperature_and_rgb_color( assert acc.char_saturation.value == 100 +async def test_light_invalid_hs_color( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light that starts out with an invalid hs color.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: 260, + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + + assert hasattr(acc, "char_color_temp") + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 4464}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 224 + assert acc.char_hue.value == 27 + assert acc.char_saturation.value == 27 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 2840}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 352 + assert acc.char_hue.value == 28 + assert acc.char_saturation.value == 61 + + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] + + # Set from HomeKit + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 20, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 250, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 50, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 50, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 + + assert len(events) == 1 + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 1, brightness at 20{PERCENTAGE}, color temperature at 250" + ) + + # Only set Hue + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 30, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[1] + assert call_turn_on[1].data[ATTR_HS_COLOR] == (30, 50) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 50)" + + # Only set Saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 20, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[2] + assert call_turn_on[2].data[ATTR_HS_COLOR] == (30, 20) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 20)" + + # Generate a conflict by setting hue and then color temp + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 80, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 320, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[3] + assert call_turn_on[3].data[ATTR_COLOR_TEMP_KELVIN] == 3125 + assert events[-1].data[ATTR_VALUE] == "color temperature at 320" + + # Generate a conflict by setting color temp then saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 404, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 35, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[4] + assert call_turn_on[4].data[ATTR_HS_COLOR] == (80, 35) + assert events[-1].data[ATTR_VALUE] == "set color at (80, 35)" + + # Set from HASS + hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (100, 100)}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 404 + assert acc.char_hue.value == 100 + assert acc.char_saturation.value == 100 + + +async def test_light_invalid_values( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light with a variety of invalid values.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 0 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 500 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + + +async def test_light_out_of_range_color_temp(hass: HomeAssistant, hk_driver) -> None: + """Test light with an out of range color temp.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + +async def test_reversed_color_temp_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test light with a reversed color temp min max.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 3000, + ATTR_MIN_COLOR_TEMP_KELVIN: 4000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + @pytest.mark.parametrize( "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] ) @@ -1391,8 +1808,8 @@ async def test_light_min_max_mireds(hass: HomeAssistant, hk_driver) -> None: { ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_BRIGHTNESS: 255, - ATTR_MAX_MIREDS: 500.5, - ATTR_MIN_MIREDS: 153.5, + ATTR_MIN_COLOR_TEMP_KELVIN: 1999, + ATTR_MAX_COLOR_TEMP_KELVIN: 6499, }, ) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index 2961fe52170..7691e341dcc 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -1,17 +1,34 @@ """Test different accessory types: Locks.""" +from unittest.mock import MagicMock + import pytest -from homeassistant.components.homekit.const import ATTR_VALUE +from homeassistant.components import lock +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.event import EventDeviceClass +from homeassistant.components.homekit.accessories import HomeBridge +from homeassistant.components.homekit.const import ( + ATTR_VALUE, + CHAR_PROGRAMMABLE_SWITCH_EVENT, + CONF_LINKED_DOORBELL_SENSOR, + SERV_DOORBELL, + SERV_STATELESS_PROGRAMMABLE_SWITCH, +) from homeassistant.components.homekit.type_locks import Lock from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.const import ( ATTR_CODE, + ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import async_mock_service @@ -135,3 +152,285 @@ async def test_no_code( assert acc.char_target_state.value == 1 assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None + + +async def test_lock_with_linked_doorbell_sensor(hass: HomeAssistant, hk_driver) -> None: + """Test a lock with a linked doorbell sensor can update.""" + code = "1234" + await async_setup_component(hass, lock.DOMAIN, {lock.DOMAIN: {"platform": "demo"}}) + await hass.async_block_till_done() + doorbell_entity_id = "binary_sensor.doorbell" + + hass.states.async_set( + doorbell_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY}, + ) + await hass.async_block_till_done() + entity_id = "lock.demo_lock" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Lock( + hass, + hk_driver, + "Lock", + entity_id, + 2, + { + ATTR_CODE: code, + CONF_LINKED_DOORBELL_SENSOR: doorbell_entity_id, + }, + ) + bridge = HomeBridge("hass", hk_driver, "Test Bridge") + bridge.add_accessory(acc) + + acc.run() + + assert acc.aid == 2 + assert acc.category == 6 # DoorLock + + service = acc.get_service(SERV_DOORBELL) + assert service + char = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) + assert char + + assert char.value is None + + service2 = acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) + assert service2 + char2 = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) + assert char2 + broker = MagicMock() + char2.broker = broker + assert char2.value is None + + hass.states.async_set( + doorbell_entity_id, + STATE_OFF, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY}, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 0 + + char.set_value(True) + char2.set_value(True) + broker.reset_mock() + + hass.states.async_set( + doorbell_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY}, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + hass.states.async_set( + doorbell_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY}, + force_update=True, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 0 + broker.reset_mock() + + hass.states.async_set( + doorbell_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY, "other": "attr"}, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 0 + broker.reset_mock() + + # Ensure we do not throw when the linked + # doorbell sensor is removed + hass.states.async_remove(doorbell_entity_id) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + + +async def test_lock_with_linked_doorbell_event(hass: HomeAssistant, hk_driver) -> None: + """Test a lock with a linked doorbell event can update.""" + await async_setup_component(hass, lock.DOMAIN, {lock.DOMAIN: {"platform": "demo"}}) + await hass.async_block_till_done() + doorbell_entity_id = "event.doorbell" + code = "1234" + + hass.states.async_set( + doorbell_entity_id, + dt_util.utcnow().isoformat(), + {ATTR_DEVICE_CLASS: EventDeviceClass.DOORBELL}, + ) + await hass.async_block_till_done() + entity_id = "lock.demo_lock" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Lock( + hass, + hk_driver, + "Lock", + entity_id, + 2, + { + ATTR_CODE: code, + CONF_LINKED_DOORBELL_SENSOR: doorbell_entity_id, + }, + ) + bridge = HomeBridge("hass", hk_driver, "Test Bridge") + bridge.add_accessory(acc) + + acc.run() + + assert acc.aid == 2 + assert acc.category == 6 # DoorLock + + service = acc.get_service(SERV_DOORBELL) + assert service + char = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) + assert char + + assert char.value is None + + service2 = acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) + assert service2 + char2 = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) + assert char2 + broker = MagicMock() + char2.broker = broker + assert char2.value is None + + hass.states.async_set( + doorbell_entity_id, + STATE_UNKNOWN, + {ATTR_DEVICE_CLASS: EventDeviceClass.DOORBELL}, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 0 + + char.set_value(True) + char2.set_value(True) + broker.reset_mock() + + original_time = dt_util.utcnow().isoformat() + hass.states.async_set( + doorbell_entity_id, + original_time, + {ATTR_DEVICE_CLASS: EventDeviceClass.DOORBELL}, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + hass.states.async_set( + doorbell_entity_id, + original_time, + {ATTR_DEVICE_CLASS: EventDeviceClass.DOORBELL}, + force_update=True, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 0 + broker.reset_mock() + + hass.states.async_set( + doorbell_entity_id, + original_time, + {ATTR_DEVICE_CLASS: EventDeviceClass.DOORBELL, "other": "attr"}, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 0 + broker.reset_mock() + + # Ensure we do not throw when the linked + # doorbell sensor is removed + hass.states.async_remove(doorbell_entity_id) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + + await hass.async_block_till_done() + hass.states.async_set( + doorbell_entity_id, + STATE_UNAVAILABLE, + {ATTR_DEVICE_CLASS: EventDeviceClass.DOORBELL}, + ) + await hass.async_block_till_done() + # Ensure re-adding does not fire an event + assert not broker.mock_calls + broker.reset_mock() + + # going from unavailable to a state should not fire an event + hass.states.async_set( + doorbell_entity_id, + dt_util.utcnow().isoformat(), + {ATTR_DEVICE_CLASS: EventDeviceClass.DOORBELL}, + ) + await hass.async_block_till_done() + assert not broker.mock_calls + + # But a second update does + hass.states.async_set( + doorbell_entity_id, + dt_util.utcnow().isoformat(), + {ATTR_DEVICE_CLASS: EventDeviceClass.DOORBELL}, + ) + await hass.async_block_till_done() + assert broker.mock_calls + + +async def test_lock_with_a_missing_linked_doorbell_sensor( + hass: HomeAssistant, hk_driver +) -> None: + """Test a lock with a configured linked doorbell sensor that is missing.""" + await async_setup_component(hass, lock.DOMAIN, {lock.DOMAIN: {"platform": "demo"}}) + await hass.async_block_till_done() + code = "1234" + doorbell_entity_id = "binary_sensor.doorbell" + entity_id = "lock.demo_lock" + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Lock( + hass, + hk_driver, + "Lock", + entity_id, + 2, + { + ATTR_CODE: code, + CONF_LINKED_DOORBELL_SENSOR: doorbell_entity_id, + }, + ) + bridge = HomeBridge("hass", hk_driver, "Test Bridge") + bridge.add_accessory(acc) + + acc.run() + + assert acc.aid == 2 + assert acc.category == 6 # DoorLock + + assert not acc.get_service(SERV_DOORBELL) + assert not acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 9b708f18b8a..0d19763e4c7 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -26,8 +26,7 @@ from homeassistant.components.vacuum import ( SERVICE_START, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_CLEANING, - STATE_DOCKED, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.const import ( @@ -295,7 +294,7 @@ async def test_vacuum_set_state_with_returnhome_and_start_support( hass.states.async_set( entity_id, - STATE_CLEANING, + VacuumActivity.CLEANING, { ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START @@ -306,7 +305,7 @@ async def test_vacuum_set_state_with_returnhome_and_start_support( hass.states.async_set( entity_id, - STATE_DOCKED, + VacuumActivity.DOCKED, { ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 8454610566b..fc4cfa78ca4 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -26,6 +26,7 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_STEP, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, + DEFAULT_MIN_TEMP, DOMAIN as DOMAIN_CLIMATE, FAN_AUTO, FAN_HIGH, @@ -921,8 +922,8 @@ async def test_thermostat_fahrenheit( await hass.async_block_till_done() assert call_set_temperature[0] assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.5 - assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 68 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.4 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 68.18 assert len(events) == 1 assert events[-1].data[ATTR_VALUE] == "CoolingThresholdTemperature to 23°C" @@ -942,8 +943,8 @@ async def test_thermostat_fahrenheit( await hass.async_block_till_done() assert call_set_temperature[1] assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.5 - assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.5 + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.4 + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.6 assert len(events) == 2 assert events[-1].data[ATTR_VALUE] == "HeatingThresholdTemperature to 22°C" @@ -962,7 +963,7 @@ async def test_thermostat_fahrenheit( await hass.async_block_till_done() assert call_set_temperature[2] assert call_set_temperature[2].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.0 + assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.2 assert len(events) == 3 assert events[-1].data[ATTR_VALUE] == "TargetTemperature to 24.0°C" @@ -2009,8 +2010,8 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], - ATTR_MAX_TEMP: 50, - ATTR_MIN_TEMP: 100, + ATTR_MAX_TEMP: 100, + ATTR_MIN_TEMP: 50, } hass.states.async_set( entity_id, @@ -2024,14 +2025,14 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No acc.run() await hass.async_block_till_done() - assert acc.char_cooling_thresh_temp.value == 100 - assert acc.char_heating_thresh_temp.value == 100 + assert acc.char_cooling_thresh_temp.value == 50 + assert acc.char_heating_thresh_temp.value == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_target_heat_cool.value == 3 @@ -2048,7 +2049,7 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No }, ) await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 100.0 + assert acc.char_heating_thresh_temp.value == 50.0 assert acc.char_cooling_thresh_temp.value == 100.0 assert acc.char_current_heat_cool.value == 1 assert acc.char_target_heat_cool.value == 3 @@ -2633,3 +2634,44 @@ async def test_thermostat_handles_unknown_state(hass: HomeAssistant, hk_driver) assert call_set_hvac_mode assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_thermostat_reversed_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test reversed min/max temperatures.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + ATTR_MAX_TEMP: DEFAULT_MAX_TEMP, + ATTR_MIN_TEMP: DEFAULT_MIN_TEMP, + } + # support_auto = True + hass.states.async_set( + entity_id, + HVACMode.OFF, + base_attrs, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 7f7e3ee0ce0..853db54b992 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -159,8 +159,20 @@ def test_validate_entity_config() -> None: assert vec({"lock.demo": {}}) == { "lock.demo": {ATTR_CODE: None, CONF_LOW_BATTERY_THRESHOLD: 20} } - assert vec({"lock.demo": {ATTR_CODE: "1234"}}) == { - "lock.demo": {ATTR_CODE: "1234", CONF_LOW_BATTERY_THRESHOLD: 20} + + assert vec( + { + "lock.demo": { + ATTR_CODE: "1234", + CONF_LINKED_DOORBELL_SENSOR: "event.doorbell", + } + } + ) == { + "lock.demo": { + ATTR_CODE: "1234", + CONF_LOW_BATTERY_THRESHOLD: 20, + CONF_LINKED_DOORBELL_SENSOR: "event.doorbell", + } } assert vec({"media_player.demo": {}}) == { @@ -256,6 +268,7 @@ def test_cleanup_name_for_homekit() -> None: """Ensure name sanitize works as expected.""" assert cleanup_name_for_homekit("abc") == "abc" + assert cleanup_name_for_homekit("abc ") == "abc" assert cleanup_name_for_homekit("a b c") == "a b c" assert cleanup_name_for_homekit("ab_c") == "ab c" assert ( @@ -267,14 +280,16 @@ def test_cleanup_name_for_homekit() -> None: def test_temperature_to_homekit() -> None: """Test temperature conversion from HA to HomeKit.""" - assert temperature_to_homekit(20.46, UnitOfTemperature.CELSIUS) == 20.5 - assert temperature_to_homekit(92.1, UnitOfTemperature.FAHRENHEIT) == 33.4 + assert temperature_to_homekit(20.46, UnitOfTemperature.CELSIUS) == 20.46 + assert temperature_to_homekit(92.1, UnitOfTemperature.FAHRENHEIT) == pytest.approx( + 33.388888888888886 + ) def test_temperature_to_states() -> None: """Test temperature conversion from HomeKit to HA.""" assert temperature_to_states(20, UnitOfTemperature.CELSIUS) == 20.0 - assert temperature_to_states(20.2, UnitOfTemperature.FAHRENHEIT) == 68.5 + assert temperature_to_states(20.2, UnitOfTemperature.FAHRENHEIT) == 68.36 def test_density_to_air_quality() -> None: diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index b96da507adf..2bd5e7faf75 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -1474,7 +1474,7 @@ 'state': dict({ 'attributes': dict({ 'changed_by': None, - 'code_arm_required': True, + 'code_arm_required': False, 'code_format': None, 'friendly_name': 'Aqara-Hub-E1-00A0 Security System', 'supported_features': , @@ -1848,7 +1848,7 @@ 'state': dict({ 'attributes': dict({ 'changed_by': None, - 'code_arm_required': True, + 'code_arm_required': False, 'code_format': None, 'friendly_name': 'Aqara Hub-1563 Security System', 'supported_features': , diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py index 1e9f023fc46..3ab9dc82e41 100644 --- a/tests/components/homekit_controller/test_alarm_control_panel.py +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -6,6 +6,7 @@ from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from homeassistant.components.alarm_control_panel import ATTR_CODE_ARM_REQUIRED from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -106,6 +107,7 @@ async def test_switch_read_alarm_state( state = await helper.poll_and_get_state() assert state.state == "armed_home" assert state.attributes["battery_level"] == 50 + assert state.attributes[ATTR_CODE_ARM_REQUIRED] is False await helper.async_update( ServicesTypes.SECURITY_SYSTEM, diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 4fb0a80cd26..424f93f7142 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -15,7 +15,6 @@ from bleak.exc import BleakError import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from homeassistant.components.homekit_controller.storage import async_get_entity_storage @@ -23,6 +22,10 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -176,9 +179,9 @@ def get_flow_context( def get_device_discovery_info( device, upper_case_props=False, missing_csharp=False, paired=False -) -> zeroconf.ZeroconfServiceInfo: +) -> ZeroconfServiceInfo: """Turn a aiohomekit format zeroconf entry into a homeassistant one.""" - result = zeroconf.ZeroconfServiceInfo( + result = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname=device.description.name, @@ -187,7 +190,7 @@ def get_device_discovery_info( properties={ "md": device.description.model, "pv": "1.0", - zeroconf.ATTR_PROPERTIES_ID: device.description.id, + ATTR_PROPERTIES_ID: device.description.id, "c#": device.description.config_num, "s#": device.description.state_num, "ff": "0", @@ -330,7 +333,7 @@ async def test_id_missing(hass: HomeAssistant, controller) -> None: discovery_info = get_device_discovery_info(device) # Remove id from device - del discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] + del discovery_info.properties[ATTR_PROPERTIES_ID] # Device is discovered result = await hass.config_entries.flow.async_init( @@ -346,7 +349,7 @@ async def test_discovery_ignored_model(hass: HomeAssistant, controller) -> None: """Already paired.""" device = setup_mock_accessory(controller) discovery_info = get_device_discovery_info(device) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" discovery_info.properties["md"] = "HHKBridge1,1" # Device is discovered @@ -375,7 +378,7 @@ async def test_discovery_ignored_hk_bridge( connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}, ) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -403,7 +406,7 @@ async def test_discovery_does_not_ignore_non_homekit( connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}, ) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -582,7 +585,7 @@ async def test_discovery_already_configured_update_csharp( # Set device as already paired discovery_info.properties["sf"] = 0x00 discovery_info.properties["c#"] = 99999 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -967,7 +970,7 @@ async def test_discovery_dismiss_existing_flow_on_paired( # Set device as already not paired discovery_info.properties["sf"] = 0x01 discovery_info.properties["c#"] = 99999 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -1201,7 +1204,7 @@ async def test_discovery_updates_ip_when_config_entry_set_up( # Set device as already paired discovery_info.properties["sf"] = 0x00 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -1239,7 +1242,7 @@ async def test_discovery_updates_ip_config_entry_not_set_up( # Set device as already paired discovery_info.properties["sf"] = 0x00 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" # Device is discovered result = await hass.config_entries.flow.async_init( diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index 7a3d3f06b09..ff57cd168c9 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8177,6 +8177,125 @@ "serializedGlobalTradeItemNumber": "3014F7110000000000ESIIE3", "type": "ENERGY_SENSORS_INTERFACE", "updateState": "UP_TO_DATE" + }, + "3014F7110000000000DSDPCB": { + "availableFirmwareVersion": "1.0.6", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.6", + "firmwareVersionInteger": 65542, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F7110000000000DSDPCB", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "dutyCycle": false, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000042"], + "index": 0, + "label": "", + "lockJammed": null, + "lowBat": false, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -58, + "rssiPeerValue": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceOperationMode": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false + }, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "actionParameter": "NOT_CUSTOMISABLE", + "binaryBehaviorType": "NORMALLY_CLOSE", + "channelRole": "DOOR_BELL_INPUT", + "corrosionPreventionActive": false, + "deviceId": "3014F7110000000000DSDPCB", + "doorBellSensorEventTimestamp": 1673006015756, + "eventDelay": 0, + "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000056"], + "index": 1, + "label": "", + "multiModeInputMode": "KEY_BEHAVIOR", + "supportedOptionalFeatures": { + "IFeatureAccessAuthorizationSensorChannel": false, + "IFeatureGarageGroupSensorChannel": true, + "IFeatureLightGroupSensorChannel": false, + "IFeatureShadingGroupSensorChannel": false, + "IOptionalFeatureDoorBellSensorEventTimestamp": true, + "IOptionalFeatureEventDelay": false, + "IOptionalFeatureLongPressSupported": false, + "IOptionalFeatureWindowState": false + }, + "windowState": "CLOSED" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000DSDPCB", + "label": "dsdpcb_klingel", + "lastStatusUpdate": 1673006015756, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 410, + "modelType": "HmIP-DSD-PCB", + "oem": "eQ-3", + "permanentlyReachable": false, + "serializedGlobalTradeItemNumber": "3014F7110000000000DSDPCB", + "type": "DOOR_BELL_CONTACT_INTERFACE", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 5b4993f7314..5ec37d8d8f5 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -28,7 +28,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 308 + assert len(mock_hap.hmip_device_by_entity_id) == 310 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_event.py b/tests/components/homematicip_cloud/test_event.py new file mode 100644 index 00000000000..de615b35808 --- /dev/null +++ b/tests/components/homematicip_cloud/test_event.py @@ -0,0 +1,37 @@ +"""Tests for the HomematicIP Cloud event.""" + +from homematicip.base.channel_event import ChannelEvent + +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .helper import HomeFactory, get_and_check_entity_basics + + +async def test_door_bell_event( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, +) -> None: + """Test of door bell event of HmIP-DSD-PCB.""" + entity_id = "event.dsdpcb_klingel_doorbell" + entity_name = "dsdpcb_klingel doorbell" + device_model = "HmIP-DSD-PCB" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["dsdpcb_klingel"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + ch = hmip_device.functionalChannels[1] + channel_event = ChannelEvent( + channelEventType="DOOR_BELL_SENSOR_EVENT", channelIndex=1, deviceId=ch.device.id + ) + + assert ha_state.state == STATE_UNKNOWN + + ch.fire_channel_event(channel_event) + + ha_state = hass.states.get(entity_id) + assert ha_state.state != STATE_UNKNOWN diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index dfd92577a04..b540ebac91a 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -3,8 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.errors import NotFoundError -from homewizard_energy.v1.models import Data, Device, State, System +from homewizard_energy.models import CombinedModels, Device, Measurement, State, System import pytest from homeassistant.components.homewizard.const import DOMAIN @@ -27,7 +26,7 @@ def mock_homewizardenergy( """Return a mock bridge.""" with ( patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergyV1", + "homeassistant.components.homewizard.HomeWizardEnergyV1", autospec=True, ) as homewizard, patch( @@ -37,26 +36,31 @@ def mock_homewizardenergy( ): client = homewizard.return_value - client.device.return_value = Device.from_dict( - load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) - ) - client.data.return_value = Data.from_dict( - load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) + client.combined.return_value = CombinedModels( + device=Device.from_dict( + load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) + ), + measurement=Measurement.from_dict( + load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) + ), + state=( + State.from_dict( + load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists() + else None + ), + system=( + System.from_dict( + load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists() + else None + ), ) - if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists(): - client.state.return_value = State.from_dict( - load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) - ) - else: - client.state.side_effect = NotFoundError - - if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists(): - client.system.return_value = System.from_dict( - load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) - ) - else: - client.system.side_effect = NotFoundError + # device() call is used during configuration flow + client.device.return_value = client.combined.return_value.device yield client diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index cb5e7ef1f43..b8cf98d9211 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -2,82 +2,83 @@ # name: test_diagnostics[HWE-KWH1] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 74.052, - 'active_current_a': 0.273, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 1-phase', + 'product_name': 'kWh meter', + 'product_type': 'HWE-KWH1', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.611, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': -1058.296, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': -58.612, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 228.472, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 74.052, + 'average_power_15m_w': None, + 'current_a': 0.273, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 255.551, + 'energy_export_t1_kwh': 255.551, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 2.705, + 'energy_import_t1_kwh': 2.705, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.0, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 255.551, - 'total_energy_export_t1_kwh': 255.551, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 2.705, - 'total_energy_import_t1_kwh': 2.705, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': 0.611, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -1058.296, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': -1058.296, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': -58.612, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 228.472, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'HWE-KWH1', - 'name': 'Wi-Fi kWh Meter 1-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'kWh meter', - 'product_type': 'HWE-KWH1', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -91,82 +92,83 @@ # name: test_diagnostics[HWE-KWH3] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': 0, - 'active_apparent_power_l2_va': 3548.879, - 'active_apparent_power_l3_va': 3563.414, - 'active_apparent_power_va': 7112.293, - 'active_current_a': 30.999, - 'active_current_l1_a': 0, - 'active_current_l2_a': 15.521, - 'active_current_l3_a': 15.477, - 'active_frequency_hz': 49.926, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 3-phase', + 'product_name': 'KWh meter 3-phase', + 'product_type': 'HWE-KWH3', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': 1, - 'active_power_factor_l2': 0.999, - 'active_power_factor_l3': 0.997, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': 158.102, - 'active_power_l3_w': 0.0, - 'active_power_w': -900.194, - 'active_reactive_power_l1_var': 0, - 'active_reactive_power_l2_var': -166.675, - 'active_reactive_power_l3_var': -262.35, - 'active_reactive_power_var': -429.025, - 'active_tariff': None, - 'active_voltage_l1_v': 230.751, - 'active_voltage_l2_v': 228.391, - 'active_voltage_l3_v': 229.612, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': 0.0, + 'apparent_power_l2_va': 3548.879, + 'apparent_power_l3_va': 3563.414, + 'apparent_power_va': 7112.293, + 'average_power_15m_w': None, + 'current_a': 30.999, + 'current_l1_a': 0.0, + 'current_l2_a': 15.521, + 'current_l3_a': 15.477, + 'cycles': None, + 'energy_export_kwh': 0.523, + 'energy_export_t1_kwh': 0.523, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 0.101, + 'energy_import_t1_kwh': 0.101, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 49.926, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0.523, - 'total_energy_export_t1_kwh': 0.523, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 0.101, - 'total_energy_import_t1_kwh': 0.101, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': 1.0, + 'power_factor_l2': 0.999, + 'power_factor_l3': 0.997, + 'power_l1_w': -1058.296, + 'power_l2_w': 158.102, + 'power_l3_w': 0.0, + 'power_w': -900.194, + 'protocol_version': None, + 'reactive_power_l1_var': 0.0, + 'reactive_power_l2_var': -166.675, + 'reactive_power_l3_var': -262.35, + 'reactive_power_var': -429.025, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': 230.751, + 'voltage_l2_v': 228.391, + 'voltage_l3_v': 229.612, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'HWE-KWH3', - 'name': 'Wi-Fi kWh Meter 3-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'KWh meter 3-phase', - 'product_type': 'HWE-KWH3', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -180,133 +182,119 @@ # name: test_diagnostics[HWE-P1] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': -4, - 'active_current_l2_a': 2, - 'active_current_l3_a': 0, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '4.19', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi P1 Meter', + 'product_name': 'P1 meter', + 'product_type': 'HWE-P1', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': 12.345, - 'active_power_average_w': 123.0, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -123, - 'active_power_l2_w': 456, - 'active_power_l3_w': 123.456, - 'active_power_w': -123, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': 2, - 'active_voltage_l1_v': 230.111, - 'active_voltage_l2_v': 230.222, - 'active_voltage_l3_v': 230.333, - 'active_voltage_v': None, 'any_power_fail_count': 4, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': 123.0, + 'current_a': None, + 'current_l1_a': -4.0, + 'current_l2_a': 2.0, + 'current_l3_a': 0.0, + 'cycles': None, + 'energy_export_kwh': 13086.777, + 'energy_export_t1_kwh': 4321.333, + 'energy_export_t2_kwh': 8765.444, + 'energy_export_t3_kwh': 8765.444, + 'energy_export_t4_kwh': 8765.444, + 'energy_import_kwh': 13779.338, + 'energy_import_t1_kwh': 10830.511, + 'energy_import_t2_kwh': 2948.827, + 'energy_import_t3_kwh': 2948.827, + 'energy_import_t4_kwh': 2948.827, 'external_devices': dict({ 'gas_meter_G001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'gas_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 111.111, }), 'heat_meter_H001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'heat_meter', 'unique_id': '**REDACTED**', 'unit': 'GJ', 'value': 444.444, }), 'inlet_heat_meter_IH001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'inlet_heat_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 555.555, }), 'warm_water_meter_WW001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'warm_water_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 333.333, }), 'water_meter_W001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'water_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 222.222, }), }), - 'gas_timestamp': '2021-03-14T11:22:33', - 'gas_unique_id': '**REDACTED**', + 'frequency_hz': 50.0, 'long_power_fail_count': 5, 'meter_model': 'ISKRA 2M550T-101', 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', 'monthly_power_peak_w': 1111.0, - 'smr_version': 50, - 'total_energy_export_kwh': 13086.777, - 'total_energy_export_t1_kwh': 4321.333, - 'total_energy_export_t2_kwh': 8765.444, - 'total_energy_export_t3_kwh': 8765.444, - 'total_energy_export_t4_kwh': 8765.444, - 'total_energy_import_kwh': 13779.338, - 'total_energy_import_t1_kwh': 10830.511, - 'total_energy_import_t2_kwh': 2948.827, - 'total_energy_import_t3_kwh': 2948.827, - 'total_energy_import_t4_kwh': 2948.827, - 'total_gas_m3': 1122.333, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -123.0, + 'power_l2_w': 456.0, + 'power_l3_w': 123.456, + 'power_w': -123.0, + 'protocol_version': 50, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': 2, + 'timestamp': None, 'total_liter_m3': 1234.567, - 'unique_meter_id': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'voltage_l1_v': 230.111, + 'voltage_l2_v': 230.222, + 'voltage_l3_v': 230.333, 'voltage_sag_l1_count': 1, 'voltage_sag_l2_count': 2, 'voltage_sag_l3_count': 3, 'voltage_swell_l1_count': 4, 'voltage_swell_l2_count': 5, 'voltage_swell_l3_count': 6, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 100, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '4.19', - 'product': dict({ - 'description': 'The HomeWizard P1 Meter gives you detailed insight in your electricity-, gas consumption and solar surplus.', - 'model': 'HWE-P1', - 'name': 'Wi-Fi P1 Meter', - 'url': 'https://www.homewizard.com/p1-meter/', - }), - 'product_name': 'P1 meter', - 'product_type': 'HWE-P1', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -320,86 +308,87 @@ # name: test_diagnostics[HWE-SKT-11] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': None, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.03', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Energy Socket', + 'product_name': 'Energy Socket', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': 1457.277, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': 1457.277, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': None, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 0.0, + 'energy_export_t1_kwh': 0.0, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 63.651, + 'energy_import_t1_kwh': 63.651, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': None, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0, - 'total_energy_export_t1_kwh': 0, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 63.651, - 'total_energy_import_t1_kwh': 63.651, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': 1457.277, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 1457.277, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 94, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.03', - 'product': dict({ - 'description': 'Measure and switch every device.', - 'model': 'HWE-SKT', - 'name': 'Wi-Fi Energy Socket', - 'url': 'https://www.homewizard.com/energy-socket/', - }), - 'product_name': 'Energy Socket', - 'product_type': 'HWE-SKT', - 'serial': '**REDACTED**', - }), 'state': dict({ 'brightness': 255, 'power_on': True, 'switch_lock': False, }), 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': 100.0, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -413,86 +402,87 @@ # name: test_diagnostics[HWE-SKT-21] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 666.768, - 'active_current_a': 2.346, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50.005, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '4.07', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Energy Socket', + 'product_name': 'Energy Socket', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.81688, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': 543.312, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': 543.312, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': 123.456, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 231.539, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 666.768, + 'average_power_15m_w': None, + 'current_a': 2.346, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 85.951, + 'energy_export_t1_kwh': 85.951, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 30.511, + 'energy_import_t1_kwh': 30.511, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.005, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 85.951, - 'total_energy_export_t1_kwh': 85.951, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 30.511, - 'total_energy_import_t1_kwh': 30.511, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': 0.81688, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': 543.312, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 543.312, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': 123.456, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 231.539, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 100, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '4.07', - 'product': dict({ - 'description': 'Measure and switch every device.', - 'model': 'HWE-SKT', - 'name': 'Wi-Fi Energy Socket', - 'url': 'https://www.homewizard.com/energy-socket/', - }), - 'product_name': 'Energy Socket', - 'product_type': 'HWE-SKT', - 'serial': '**REDACTED**', - }), 'state': dict({ 'brightness': 255, 'power_on': True, 'switch_lock': False, }), 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': 100.0, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -506,82 +496,83 @@ # name: test_diagnostics[HWE-WTR] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': None, - 'active_liter_lpm': 0, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': None, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': None, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': None, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '2.03', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Watermeter', + 'product_name': 'Watermeter', + 'product_type': 'HWE-WTR', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ + 'active_liter_lpm': 0.0, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': None, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': None, + 'energy_export_t1_kwh': None, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': None, + 'energy_import_t1_kwh': None, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': None, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': None, - 'total_energy_export_t1_kwh': None, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': None, - 'total_energy_import_t1_kwh': None, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': None, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': None, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': 17.014, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 84, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '2.03', - 'product': dict({ - 'description': 'Real-time water consumption insights', - 'model': 'HWE-WTR', - 'name': 'Wi-Fi Watermeter', - 'url': 'https://www.homewizard.com/watermeter/', - }), - 'product_name': 'Watermeter', - 'product_type': 'HWE-WTR', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -595,82 +586,83 @@ # name: test_diagnostics[SDM230] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 74.052, - 'active_current_a': 0.273, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 1-phase', + 'product_name': 'kWh meter', + 'product_type': 'SDM230-wifi', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.611, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': -1058.296, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': -58.612, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 228.472, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 74.052, + 'average_power_15m_w': None, + 'current_a': 0.273, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 255.551, + 'energy_export_t1_kwh': 255.551, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 2.705, + 'energy_import_t1_kwh': 2.705, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.0, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 255.551, - 'total_energy_export_t1_kwh': 255.551, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 2.705, - 'total_energy_import_t1_kwh': 2.705, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': 0.611, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -1058.296, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': -1058.296, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': -58.612, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 228.472, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'SDM230-wifi', - 'name': 'Wi-Fi kWh Meter 1-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'kWh meter', - 'product_type': 'SDM230-wifi', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ @@ -684,82 +676,83 @@ # name: test_diagnostics[SDM630] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': 0, - 'active_apparent_power_l2_va': 3548.879, - 'active_apparent_power_l3_va': 3563.414, - 'active_apparent_power_va': 7112.293, - 'active_current_a': 30.999, - 'active_current_l1_a': 0, - 'active_current_l2_a': 15.521, - 'active_current_l3_a': 15.477, - 'active_frequency_hz': 49.926, + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 3-phase', + 'product_name': 'KWh meter 3-phase', + 'product_type': 'SDM630-wifi', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': 1, - 'active_power_factor_l2': 0.999, - 'active_power_factor_l3': 0.997, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': 158.102, - 'active_power_l3_w': 0.0, - 'active_power_w': -900.194, - 'active_reactive_power_l1_var': 0, - 'active_reactive_power_l2_var': -166.675, - 'active_reactive_power_l3_var': -262.35, - 'active_reactive_power_var': -429.025, - 'active_tariff': None, - 'active_voltage_l1_v': 230.751, - 'active_voltage_l2_v': 228.391, - 'active_voltage_l3_v': 229.612, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': 0.0, + 'apparent_power_l2_va': 3548.879, + 'apparent_power_l3_va': 3563.414, + 'apparent_power_va': 7112.293, + 'average_power_15m_w': None, + 'current_a': 30.999, + 'current_l1_a': 0.0, + 'current_l2_a': 15.521, + 'current_l3_a': 15.477, + 'cycles': None, + 'energy_export_kwh': 0.523, + 'energy_export_t1_kwh': 0.523, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 0.101, + 'energy_import_t1_kwh': 0.101, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 49.926, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0.523, - 'total_energy_export_t1_kwh': 0.523, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 0.101, - 'total_energy_import_t1_kwh': 0.101, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': 1.0, + 'power_factor_l2': 0.999, + 'power_factor_l3': 0.997, + 'power_l1_w': -1058.296, + 'power_l2_w': 158.102, + 'power_l3_w': 0.0, + 'power_w': -900.194, + 'protocol_version': None, + 'reactive_power_l1_var': 0.0, + 'reactive_power_l2_var': -166.675, + 'reactive_power_l3_var': -262.35, + 'reactive_power_var': -429.025, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': 230.751, + 'voltage_l2_v': 228.391, + 'voltage_l3_v': 229.612, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'SDM630-wifi', - 'name': 'Wi-Fi kWh Meter 3-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'KWh meter 3-phase', - 'product_type': 'SDM630-wifi', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', }), }), 'entry': dict({ diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 49f23cf8e2f..b14028cd97c 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -14,7 +14,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100.0', + 'state': '100', }) # --- # name: test_number_entities[HWE-SKT-11].1 @@ -106,7 +106,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100.0', + 'state': '100', }) # --- # name: test_number_entities[HWE-SKT-21].1 diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index a91c87722d1..31a949ca7bd 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -431,7 +431,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_power:device-registry] @@ -1124,7 +1124,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_apparent_power_phase_2:device-registry] @@ -1472,7 +1472,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_current_phase_2:device-registry] @@ -2084,7 +2084,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_power_factor_phase_2:device-registry] @@ -2702,7 +2702,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_reactive_power_phase_2:device-registry] @@ -3476,7 +3476,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-4', + 'state': '-4.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:device-registry] @@ -3563,7 +3563,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '2.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:device-registry] @@ -3650,7 +3650,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] @@ -4689,7 +4689,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:device-registry] @@ -4945,7 +4945,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] @@ -5117,7 +5117,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:device-registry] @@ -5207,7 +5207,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '456', + 'state': '456.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:device-registry] @@ -6468,7 +6468,7 @@ 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_water_usage:state] @@ -6476,7 +6476,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Water usage', 'state_class': , - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.device_water_usage', @@ -7236,7 +7236,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-4', + 'state': '-4.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_2:device-registry] @@ -7323,7 +7323,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '2.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_3:device-registry] @@ -7410,7 +7410,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_dsmr_version:device-registry] @@ -8449,7 +8449,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_long_power_failures_detected:device-registry] @@ -8705,7 +8705,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_failures_detected:device-registry] @@ -8877,7 +8877,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_2:device-registry] @@ -8967,7 +8967,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '456', + 'state': '456.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_3:device-registry] @@ -10228,7 +10228,7 @@ 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_water_usage:state] @@ -10236,7 +10236,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Water usage', 'state_class': , - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.device_water_usage', @@ -10909,7 +10909,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_1:device-registry] @@ -10996,7 +10996,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_2:device-registry] @@ -11083,7 +11083,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_3:device-registry] @@ -11170,7 +11170,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export:device-registry] @@ -12127,7 +12127,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:device-registry] @@ -13562,7 +13562,7 @@ 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_water_usage:state] @@ -13570,7 +13570,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Water usage', 'state_class': , - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.device_water_usage', @@ -13664,7 +13664,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-SKT-11-entity_ids2][sensor.device_energy_import:device-registry] @@ -15301,7 +15301,7 @@ 'supported_features': 0, 'translation_key': 'active_liter_lpm', 'unique_id': 'HWE-P1_5c2fafabcdef_active_liter_lpm', - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }) # --- # name: test_sensors[HWE-WTR-entity_ids4][sensor.device_water_usage:state] @@ -15309,14 +15309,14 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Water usage', 'state_class': , - 'unit_of_measurement': 'l/min', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.device_water_usage', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-WTR-entity_ids4][sensor.device_wi_fi_ssid:device-registry] @@ -15919,7 +15919,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[SDM230-entity_ids5][sensor.device_power:device-registry] @@ -16612,7 +16612,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_apparent_power_phase_2:device-registry] @@ -16960,7 +16960,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_current_phase_2:device-registry] @@ -17572,7 +17572,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_power_factor_phase_2:device-registry] @@ -18190,7 +18190,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_reactive_power_phase_2:device-registry] diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index c2ef87970f3..8f6af16068d 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -12,7 +12,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -45,7 +45,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -81,7 +81,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -94,7 +94,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -127,7 +127,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -163,7 +163,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on] +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -177,7 +177,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on].1 +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -210,7 +210,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on].2 +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -246,7 +246,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -259,7 +259,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -292,7 +292,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -328,7 +328,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock] +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', @@ -341,7 +341,7 @@ 'state': 'off', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock].1 +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -374,7 +374,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock].2 +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -410,7 +410,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on] +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -424,7 +424,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on].1 +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -457,7 +457,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on].2 +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -493,7 +493,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -506,7 +506,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -539,7 +539,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -575,7 +575,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock] +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', @@ -588,7 +588,7 @@ 'state': 'off', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock].1 +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -621,7 +621,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock].2 +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -657,7 +657,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -670,7 +670,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -703,7 +703,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -739,7 +739,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -752,7 +752,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -785,7 +785,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -821,7 +821,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -834,7 +834,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -867,7 +867,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 84bdb0ba921..b2ae7bd45e0 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -8,11 +8,12 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -54,7 +55,7 @@ async def test_discovery_flow_works( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -100,7 +101,7 @@ async def test_discovery_flow_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -137,7 +138,7 @@ async def test_discovery_flow_during_onboarding_disabled_api( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -181,7 +182,7 @@ async def test_discovery_disabled_api( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -216,7 +217,7 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -242,7 +243,7 @@ async def test_discovery_invalid_api(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -263,6 +264,80 @@ async def test_discovery_invalid_api(hass: HomeAssistant) -> None: assert result["reason"] == "unsupported_api_version" +async def test_dhcp_discovery_updates_entry( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery updates config entries.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.0.0.127", + hostname="HW-p1meter-aabbcc", + macaddress="5c2fafabcdef", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception"), + [(DisabledError), (RequestError)], +) +async def test_dhcp_discovery_updates_entry_fails( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test DHCP discovery updates config entries, but fails to connect.""" + mock_homewizardenergy.device.side_effect = exception + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.0.0.127", + hostname="HW-p1meter-aabbcc", + macaddress="5c2fafabcdef", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "unknown" + + +async def test_dhcp_discovery_ignores_unknown( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, +) -> None: + """Test DHCP discovery is only used for updates. + + Anything else will just abort the flow. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="HW-p1meter-aabbcc", + macaddress="5c2fafabcdef", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "unknown" + + async def test_discovery_flow_updates_new_ip( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -276,7 +351,7 @@ async def test_discovery_flow_updates_new_ip( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.0.0.127"), ip_addresses=[ip_address("1.0.0.127")], port=80, @@ -406,3 +481,131 @@ async def test_reauth_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "api_not_enabled"} + + +async def test_reconfigure( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # original entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "127.0.0.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.0.0.127", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" + + +async def test_reconfigure_nochange( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration without changing values.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # original entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "127.0.0.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "127.0.0.1" + + +async def test_reconfigure_wrongdevice( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entering ip of other device and prevent changing it based on serial.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # simulate different serial number, as if user entered wrong IP + mock_homewizardenergy.device.return_value.serial = "not_5c2fafabcdef" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.0.0.127", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + + # entry should still be original entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "127.0.0.1" + + +@pytest.mark.parametrize( + ("exception", "reason"), + [(DisabledError, "api_not_enabled"), (RequestError, "network_error")], +) +async def test_reconfigure_cannot_connect( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test reconfiguration fails when not able to connect.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + mock_homewizardenergy.device.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.0.0.127", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + assert result["data_schema"]({}) == {CONF_IP_ADDRESS: "127.0.0.1"} + + # attempt with valid IP should work + mock_homewizardenergy.device.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.0.0.127", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index a01f075ee61..ed4bad8b2e8 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -25,7 +25,7 @@ async def test_load_unload( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED - assert len(mock_homewizardenergy.device.mock_calls) == 1 + assert len(mock_homewizardenergy.combined.mock_calls) == 1 await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -39,7 +39,7 @@ async def test_load_failed_host_unavailable( mock_homewizardenergy: MagicMock, ) -> None: """Test setup handles unreachable host.""" - mock_homewizardenergy.device.side_effect = TimeoutError() + mock_homewizardenergy.combined.side_effect = TimeoutError() mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -53,7 +53,7 @@ async def test_load_detect_api_disabled( mock_homewizardenergy: MagicMock, ) -> None: """Test setup detects disabled API.""" - mock_homewizardenergy.device.side_effect = DisabledError() + mock_homewizardenergy.combined.side_effect = DisabledError() mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -115,7 +115,7 @@ async def test_disablederror_reloads_integration( assert len(flows) == 0 # Simulate DisabledError and wait for next update - mock_homewizardenergy.device.side_effect = DisabledError() + mock_homewizardenergy.combined.side_effect = DisabledError() freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index ddadf09bb6e..b668043608c 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.models import CombinedModels, Measurement, State, System import pytest from syrupy.assertion import SnapshotAssertion @@ -42,9 +43,11 @@ async def test_number_entities( assert snapshot == device_entry # Test unknown handling - assert state.state == "100.0" + assert state.state == "100" - mock_homewizardenergy.state.return_value.brightness = None + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, measurement=Measurement(), system=System(), state=State() + ) async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() @@ -53,7 +56,7 @@ async def test_number_entities( assert state.state == STATE_UNKNOWN # Test service methods - assert len(mock_homewizardenergy.state_set.mock_calls) == 0 + assert len(mock_homewizardenergy.state.mock_calls) == 0 await hass.services.async_call( number.DOMAIN, SERVICE_SET_VALUE, @@ -64,10 +67,10 @@ async def test_number_entities( blocking=True, ) - assert len(mock_homewizardenergy.state_set.mock_calls) == 1 - mock_homewizardenergy.state_set.assert_called_with(brightness=129) + assert len(mock_homewizardenergy.system.mock_calls) == 1 + mock_homewizardenergy.system.assert_called_with(status_led_brightness_pct=50) - mock_homewizardenergy.state_set.side_effect = RequestError + mock_homewizardenergy.system.side_effect = RequestError with pytest.raises( HomeAssistantError, match=r"^An error occurred while communicating with HomeWizard device$", @@ -82,7 +85,7 @@ async def test_number_entities( blocking=True, ) - mock_homewizardenergy.state_set.side_effect = DisabledError + mock_homewizardenergy.system.side_effect = DisabledError with pytest.raises( HomeAssistantError, match=r"^The local API is disabled$", diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 60077c2cdf9..128a3de2ebf 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock from homewizard_energy.errors import RequestError -from homewizard_energy.v1.models import Data import pytest from syrupy.assertion import SnapshotAssertion @@ -456,7 +455,7 @@ async def test_sensors_unreachable( assert (state := hass.states.get("sensor.device_energy_import_tariff_1")) assert state.state == "10830.511" - mock_homewizardenergy.data.side_effect = exception + mock_homewizardenergy.combined.side_effect = exception async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() @@ -464,15 +463,17 @@ async def test_sensors_unreachable( assert state.state == STATE_UNAVAILABLE +@pytest.mark.parametrize("exception", [RequestError]) async def test_external_sensors_unreachable( hass: HomeAssistant, mock_homewizardenergy: MagicMock, + exception: Exception, ) -> None: """Test external device sensor handles API unreachable.""" assert (state := hass.states.get("sensor.gas_meter_gas")) assert state.state == "111.111" - mock_homewizardenergy.data.return_value = Data.from_dict({}) + mock_homewizardenergy.combined.side_effect = exception async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index d9f1ac26b4f..ccf99ee27fa 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -86,17 +86,17 @@ async def test_entities_not_created_for_device( @pytest.mark.parametrize( ("device_fixture", "entity_id", "method", "parameter"), [ - ("HWE-SKT-11", "switch.device", "state_set", "power_on"), - ("HWE-SKT-11", "switch.device_switch_lock", "state_set", "switch_lock"), - ("HWE-SKT-11", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-SKT-21", "switch.device", "state_set", "power_on"), - ("HWE-SKT-21", "switch.device_switch_lock", "state_set", "switch_lock"), - ("HWE-SKT-21", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-WTR", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("SDM230", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("SDM630", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-KWH1", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-KWH3", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("HWE-SKT-11", "switch.device", "state", "power_on"), + ("HWE-SKT-11", "switch.device_switch_lock", "state", "switch_lock"), + ("HWE-SKT-11", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-SKT-21", "switch.device", "state", "power_on"), + ("HWE-SKT-21", "switch.device_switch_lock", "state", "switch_lock"), + ("HWE-SKT-21", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-WTR", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("SDM230", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("SDM630", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-KWH1", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-KWH3", "switch.device_cloud_connection", "system", "cloud_enabled"), ], ) async def test_switch_entities( @@ -200,9 +200,9 @@ async def test_switch_entities( @pytest.mark.parametrize( ("entity_id", "method"), [ - ("switch.device", "state"), - ("switch.device_switch_lock", "state"), - ("switch.device_cloud_connection", "system"), + ("switch.device", "combined"), + ("switch.device_switch_lock", "combined"), + ("switch.device_cloud_connection", "combined"), ], ) async def test_switch_unreachable( diff --git a/tests/components/honeywell/__init__.py b/tests/components/honeywell/__init__.py index 98fcaa551bf..94022667e0e 100644 --- a/tests/components/honeywell/__init__.py +++ b/tests/components/honeywell/__init__.py @@ -1,4 +1,4 @@ -"""Tests for honeywell component.""" +"""Tests for Honeywell component.""" from unittest.mock import MagicMock diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index e48664db9ae..dd3341aa75c 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -127,7 +127,16 @@ def device(): mock_device.refresh = AsyncMock() mock_device.heat_away_temp = HEATAWAY mock_device.cool_away_temp = COOLAWAY - + mock_device.has_humidifier = False + mock_device.has_dehumidifier = False + mock_device.humidifier_upper_limit = 60 + mock_device.humidifier_lower_limit = 10 + mock_device.humidifier_setpoint = 20 + mock_device.dehumidifier_mode = 1 + mock_device.dehumidifier_upper_limit = 55 + mock_device.dehumidifier_lower_limit = 15 + mock_device.dehumidifier_setpoint = 30 + mock_device.dehumidifier_mode = 1 mock_device.raw_dr_data = {"CoolSetpLimit": None, "HeatSetpLimit": None} return mock_device @@ -149,6 +158,8 @@ def device_with_outdoor_sensor(): mock_device.temperature_unit = "C" mock_device.outdoor_temperature = OUTDOORTEMP mock_device.outdoor_humidity = OUTDOORHUMIDITY + mock_device.has_humidifier = False + mock_device.has_dehumidifier = False mock_device.raw_ui_data = { "SwitchOffAllowed": True, "SwitchAutoAllowed": True, @@ -188,6 +199,16 @@ def another_device(): mock_device.mac_address = "macaddress1" mock_device.outdoor_temperature = None mock_device.outdoor_humidity = None + mock_device.has_humidifier = False + mock_device.has_dehumidifier = False + mock_device.humidifier_upper_limit = 60 + mock_device.humidifier_lower_limit = 10 + mock_device.humidifier_setpoint = 20 + mock_device.dehumidifier_mode = 1 + mock_device.dehumidifier_upper_limit = 55 + mock_device.dehumidifier_lower_limit = 15 + mock_device.dehumidifier_setpoint = 30 + mock_device.dehumidifier_mode = 1 mock_device.raw_ui_data = { "SwitchOffAllowed": True, "SwitchAutoAllowed": True, diff --git a/tests/components/honeywell/snapshots/test_climate.ambr b/tests/components/honeywell/snapshots/test_climate.ambr index f26064b335a..1e9958acb3f 100644 --- a/tests/components/honeywell/snapshots/test_climate.ambr +++ b/tests/components/honeywell/snapshots/test_climate.ambr @@ -12,6 +12,7 @@ ]), 'friendly_name': 'device1', 'humidity': None, + 'hvac_action': , 'hvac_modes': list([ , , diff --git a/tests/components/honeywell/snapshots/test_humidity.ambr b/tests/components/honeywell/snapshots/test_humidity.ambr new file mode 100644 index 00000000000..369167b8c1e --- /dev/null +++ b/tests/components/honeywell/snapshots/test_humidity.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_static_attributes[dehumidifier] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 50, + 'device_class': 'dehumidifier', + 'friendly_name': 'device1 Dehumidifier', + 'humidity': 30, + 'max_humidity': 55, + 'min_humidity': 15, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.device1_dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_static_attributes[humidifier] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 50, + 'device_class': 'humidifier', + 'friendly_name': 'device1 Humidifier', + 'humidity': 20, + 'max_humidity': 60, + 'min_humidity': 10, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.device1_humidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 73c5ff33dbc..7411a40e74a 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -1,4 +1,4 @@ -"""Test the Whirlpool Sixth Sense climate domain.""" +"""Test the Honeywell climate domain.""" import datetime from unittest.mock import MagicMock @@ -1200,6 +1200,7 @@ async def test_unique_id( entity_registry: er.EntityRegistry, ) -> None: """Test unique id convert to string.""" + config_entry.add_to_hass(hass) entity_registry.async_get_or_create( Platform.CLIMATE, DOMAIN, diff --git a/tests/components/honeywell/test_humidity.py b/tests/components/honeywell/test_humidity.py new file mode 100644 index 00000000000..2e1f8cec6aa --- /dev/null +++ b/tests/components/honeywell/test_humidity.py @@ -0,0 +1,110 @@ +"""Test the Honeywell humidity domain.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + DOMAIN as HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + + +async def test_humidifier_service_calls( + hass: HomeAssistant, device: MagicMock, config_entry: MagicMock +) -> None: + """Test the setup of the climate entities when there are no additional options available.""" + device.has_humidifier = True + await init_integration(hass, config_entry) + entity_id = f"humidifier.{device.name}_humidifier" + assert hass.states.get(f"humidifier.{device.name}_dehumidifier") is None + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.set_humidifier_auto.assert_called_once() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.set_humidifier_off.assert_called_once() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: entity_id, ATTR_HUMIDITY: 40}, + blocking=True, + ) + device.set_humidifier_setpoint.assert_called_once_with(40) + + +async def test_dehumidifier_service_calls( + hass: HomeAssistant, device: MagicMock, config_entry: MagicMock +) -> None: + """Test the setup of the climate entities when there are no additional options available.""" + device.has_dehumidifier = True + await init_integration(hass, config_entry) + entity_id = f"humidifier.{device.name}_dehumidifier" + assert hass.states.get(f"humidifier.{device.name}_humidifier") is None + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.set_dehumidifier_auto.assert_called_once() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device.set_dehumidifier_off.assert_called_once() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: entity_id, ATTR_HUMIDITY: 40}, + blocking=True, + ) + device.set_dehumidifier_setpoint.assert_called_once_with(40) + + +async def test_static_attributes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device: MagicMock, + config_entry: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test static humidifier attributes.""" + device.has_dehumidifier = True + device.has_humidifier = True + await init_integration(hass, config_entry) + + entity_id_dehumidifier = f"humidifier.{device.name}_dehumidifier" + entity_id_humidifier = f"humidifier.{device.name}_humidifier" + entry = entity_registry.async_get(entity_id_dehumidifier) + assert entry + + state = hass.states.get(entity_id_dehumidifier) + + assert state == snapshot(name="dehumidifier") + + state = hass.states.get(entity_id_humidifier) + + assert state == snapshot(name="humidifier") diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 052c0031469..e31e630807e 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -192,16 +192,16 @@ async def test_cannot_access_with_trusted_ip( for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) for remote_addr in TRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) async def test_auth_active_access_with_access_token_in_header( @@ -256,16 +256,16 @@ async def test_auth_active_access_with_trusted_ip( for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) for remote_addr in TRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) async def test_auth_legacy_support_api_password_cannot_access( diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index a9a147eb17e..f75b0e7f2b0 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -13,7 +13,6 @@ import requests_mock from requests_mock import ANY from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.huawei_lte.const import CONF_UNAUTHENTICATED_MODE, DOMAIN from homeassistant.const import ( CONF_NAME, @@ -25,6 +24,18 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -267,8 +278,8 @@ async def test_success(hass: HomeAssistant, login_requests_mock, scheme: str) -> "text": "Mock device", }, { - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", - ssdp.ATTR_UPNP_SERIAL: "00000000", + ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + ATTR_UPNP_SERIAL: "00000000", }, { "type": FlowResultType.FORM, @@ -283,8 +294,8 @@ async def test_success(hass: HomeAssistant, login_requests_mock, scheme: str) -> "text": "100002", }, { - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", - # No ssdp.ATTR_UPNP_SERIAL + ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + # No ATTR_UPNP_SERIAL }, { "type": FlowResultType.FORM, @@ -322,18 +333,18 @@ async def test_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context=context, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="upnp:rootdevice", ssdp_location=f"{url}:60957/rootDesc.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", - ssdp.ATTR_UPNP_MANUFACTURER: "Huawei", - ssdp.ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", - ssdp.ATTR_UPNP_MODEL_NAME: "Huawei router", - ssdp.ATTR_UPNP_MODEL_NUMBER: "12345678", - ssdp.ATTR_UPNP_PRESENTATION_URL: url, - ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + ATTR_UPNP_MANUFACTURER: "Huawei", + ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", + ATTR_UPNP_MODEL_NAME: "Huawei router", + ATTR_UPNP_MODEL_NUMBER: "12345678", + ATTR_UPNP_PRESENTATION_URL: url, + ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", **upnp_data, }, ), diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 692bd1405cf..e4bdda422d1 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -9,12 +9,15 @@ import pytest import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.hue import config_flow, const from homeassistant.components.hue.errors import CannotConnect from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker, ClientError @@ -424,13 +427,13 @@ async def test_bridge_homekit( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -474,13 +477,13 @@ async def test_bridge_homekit_already_configured( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -578,7 +581,7 @@ async def test_bridge_zeroconf( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.217"), ip_addresses=[ip_address("192.168.1.217")], port=443, @@ -614,7 +617,7 @@ async def test_bridge_zeroconf_already_exists( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.217"), ip_addresses=[ip_address("192.168.1.217")], port=443, @@ -639,7 +642,7 @@ async def test_bridge_zeroconf_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::eeb5:faff:fe84:b17d"), ip_addresses=[ip_address("fd00::eeb5:faff:fe84:b17d")], port=443, @@ -687,7 +690,7 @@ async def test_bridge_connection_failed( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=443, @@ -708,13 +711,13 @@ async def test_bridge_connection_failed( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index c742124e4f0..a9fc1e5c70b 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -11,7 +11,7 @@ from homeassistant.components.light import ColorMode from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import color +from homeassistant.util import color as color_util from .conftest import create_config_entry @@ -167,10 +167,10 @@ LIGHT_RAW = { }, "swversion": "66009461", } -LIGHT_GAMUT = color.GamutType( - color.XYPoint(0.704, 0.296), - color.XYPoint(0.2151, 0.7106), - color.XYPoint(0.138, 0.08), +LIGHT_GAMUT = color_util.GamutType( + color_util.XYPoint(0.704, 0.296), + color_util.XYPoint(0.2151, 0.7106), + color_util.XYPoint(0.138, 0.08), ) LIGHT_GAMUT_TYPE = "A" @@ -770,7 +770,7 @@ def test_hs_color() -> None: rooms={}, ) - assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) + assert light.hs_color == color_util.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) async def test_group_features( diff --git a/tests/components/hue/test_migration.py b/tests/components/hue/test_migration.py index 388e2f68f99..7b00630f573 100644 --- a/tests/components/hue/test_migration.py +++ b/tests/components/hue/test_migration.py @@ -166,6 +166,7 @@ async def test_group_entity_migration_with_v1_id( ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + config_entry.add_to_hass(hass) # create (deviceless) entity with V1 schema in registry # using the legacy style group id as unique id @@ -201,6 +202,7 @@ async def test_group_entity_migration_with_v2_group_id( ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + config_entry.add_to_hass(hass) # create (deviceless) entity with V1 schema in registry # using the V2 group id as unique id diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py index 2725f942576..ce54863736b 100644 --- a/tests/components/humidifier/test_init.py +++ b/tests/components/humidifier/test_init.py @@ -1,15 +1,11 @@ """The tests for the humidifier component.""" -from enum import Enum -from types import ModuleType from unittest.mock import MagicMock import pytest -from homeassistant.components import humidifier from homeassistant.components.humidifier import ( ATTR_HUMIDITY, - ATTR_MODE, DOMAIN as HUMIDIFIER_DOMAIN, MODE_ECO, MODE_NORMAL, @@ -20,13 +16,7 @@ from homeassistant.components.humidifier import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from tests.common import ( - MockConfigEntry, - MockEntity, - help_test_all, - import_and_test_deprecated_constant_enum, - setup_test_component_platform, -) +from tests.common import MockConfigEntry, MockEntity, setup_test_component_platform class MockHumidifierEntity(MockEntity, HumidifierEntity): @@ -60,61 +50,6 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: assert humidifier.turn_off.called -def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, str]]: - return [(enum_field, constant_prefix) for enum_field in enum] - - -@pytest.mark.parametrize( - "module", - [humidifier, humidifier.const], -) -def test_all(module: ModuleType) -> None: - """Test module.__all__ is correctly set.""" - help_test_all(module) - - -@pytest.mark.parametrize( - ("enum", "constant_prefix"), - _create_tuples(humidifier.HumidifierEntityFeature, "SUPPORT_") - + _create_tuples(humidifier.HumidifierDeviceClass, "DEVICE_CLASS_"), -) -@pytest.mark.parametrize(("module"), [humidifier, humidifier.const]) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: Enum, - constant_prefix: str, - module: ModuleType, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, module, enum, constant_prefix, "2025.1" - ) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockHumidifierEntity(HumidifierEntity): - _attr_mode = "mode1" - - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockHumidifierEntity() - assert entity.supported_features_compat is HumidifierEntityFeature(1) - assert "MockHumidifierEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "HumidifierEntityFeature.MODES" in caplog.text - caplog.clear() - assert entity.supported_features_compat is HumidifierEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text - - assert entity.state_attributes[ATTR_MODE] == "mode1" - - async def test_humidity_validation( hass: HomeAssistant, register_test_integration: MockConfigEntry, diff --git a/tests/components/hunterdouglas_powerview/const.py b/tests/components/hunterdouglas_powerview/const.py index 65b03fd5ec2..2c122ae10f2 100644 --- a/tests/components/hunterdouglas_powerview/const.py +++ b/tests/components/hunterdouglas_powerview/const.py @@ -3,32 +3,36 @@ from ipaddress import IPv4Address from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) MOCK_MAC = "AA::BB::CC::DD::EE::FF" MOCK_SERIAL = "A1B2C3D4E5G6H7" -HOMEKIT_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( +HOMEKIT_DISCOVERY_GEN2 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", name="Powerview Generation 2._hap._tcp.local.", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC}, type="mock_type", ) -HOMEKIT_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( +HOMEKIT_DISCOVERY_GEN3 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", name="Powerview Generation 3._hap._tcp.local.", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC}, type="mock_type", ) -ZEROCONF_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY_GEN2 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", @@ -38,7 +42,7 @@ ZEROCONF_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -ZEROCONF_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY_GEN3 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", @@ -48,19 +52,19 @@ ZEROCONF_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -DHCP_DISCOVERY_GEN2 = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN2 = DhcpServiceInfo( hostname="Powerview Generation 2", ip="1.2.3.4", macaddress="aabbccddeeff", ) -DHCP_DISCOVERY_GEN2_NO_NAME = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN2_NO_NAME = DhcpServiceInfo( hostname="", ip="1.2.3.4", macaddress="aabbccddeeff", ) -DHCP_DISCOVERY_GEN3 = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN3 = DhcpServiceInfo( hostname="Powerview Generation 3", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index 42589bb10e0..cf159c23bae 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -5,12 +5,13 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DHCP_DATA, DISCOVERY_DATA, HOMEKIT_DATA, MOCK_SERIAL @@ -65,7 +66,7 @@ async def test_form_homekit_and_dhcp_cannot_connect( hass: HomeAssistant, mock_setup_entry: MagicMock, source: str, - discovery_info: dhcp.DhcpServiceInfo, + discovery_info: DhcpServiceInfo, api_version: int, ) -> None: """Test we get the form with homekit and dhcp source.""" @@ -112,7 +113,7 @@ async def test_form_homekit_and_dhcp( hass: HomeAssistant, mock_setup_entry: MagicMock, source: str, - discovery_info: dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo, + discovery_info: DhcpServiceInfo | ZeroconfServiceInfo, api_version: int, ) -> None: """Test we get the form with homekit and dhcp source.""" @@ -166,10 +167,10 @@ async def test_discovered_by_homekit_and_dhcp( hass: HomeAssistant, mock_setup_entry: MagicMock, homekit_source: str, - homekit_discovery: zeroconf.ZeroconfServiceInfo, + homekit_discovery: ZeroconfServiceInfo, api_version: int, dhcp_source: str, - dhcp_discovery: dhcp.DhcpServiceInfo, + dhcp_discovery: DhcpServiceInfo, dhcp_api_version: int, ) -> None: """Test we get the form with homekit and abort for dhcp source when we get both.""" @@ -368,6 +369,7 @@ async def test_migrate_entry( version=1, minor_version=1, ) + entry.add_to_hass(hass) # Add entries with int unique_id entity_registry.async_get_or_create( @@ -387,7 +389,6 @@ async def test_migrate_entry( assert entry.version == 1 assert entry.minor_version == 1 - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 0202cec05b9..49994e4f3ae 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -58,6 +58,15 @@ def mock_values(mower_time_zone) -> dict[str, MowerAttributes]: ) +@pytest.fixture(name="values_one_mower") +def mock_values_one_mower(mower_time_zone) -> dict[str, MowerAttributes]: + """Fixture to set correct scope for the token.""" + return mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower1.json", DOMAIN), + mower_time_zone, + ) + + @pytest.fixture def mock_config_entry(jwt: str, expires_at: int, scope: str) -> MockConfigEntry: """Return the default mocked config entry.""" @@ -119,3 +128,26 @@ def mock_automower_client(values) -> Generator[AsyncMock]: return_value=mock, ): yield mock + + +@pytest.fixture +def mock_automower_client_one_mower(values) -> Generator[AsyncMock]: + """Mock a Husqvarna Automower client.""" + + async def listen() -> None: + """Mock listen.""" + listen_block = asyncio.Event() + await listen_block.wait() + pytest.fail("Listen was not cancelled!") + + mock = AsyncMock(spec=AutomowerSession) + mock.auth = AsyncMock(side_effect=ClientWebSocketResponse) + mock.commands = AsyncMock(spec_set=_MowerCommands) + mock.get_status.return_value = values + mock.start_listening = AsyncMock(side_effect=listen) + + with patch( + "homeassistant.components.husqvarna_automower.AutomowerSession", + return_value=mock, + ): + yield mock diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index a0bb8302fcc..a4dc986c2f9 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -71,9 +71,7 @@ 'activity': 'parked_in_cs', 'error_code': 0, 'error_datetime': None, - 'error_datetime_naive': None, 'error_key': None, - 'error_timestamp': 0, 'inactive_reason': 'none', 'is_error_confirmable': False, 'mode': 'main_area', @@ -82,9 +80,7 @@ 'work_area_name': 'Front lawn', }), 'planner': dict({ - 'next_start': 1685991600000, 'next_start_datetime': '2023-06-05T19:00:00+02:00', - 'next_start_datetime_naive': '2023-06-05T19:00:00', 'override': dict({ 'action': 'not_active', }), @@ -141,7 +137,6 @@ 'cutting_height': 50, 'enabled': False, 'last_time_completed': '2024-08-12T05:07:49+02:00', - 'last_time_completed_naive': '2024-08-12T05:07:49', 'name': 'my_lawn', 'progress': 20, }), @@ -149,7 +144,6 @@ 'cutting_height': 50, 'enabled': True, 'last_time_completed': '2024-08-12T07:54:29+02:00', - 'last_time_completed_naive': '2024-08-12T07:54:29', 'name': 'Front lawn', 'progress': 40, }), @@ -157,7 +151,6 @@ 'cutting_height': 25, 'enabled': True, 'last_time_completed': None, - 'last_time_completed_naive': None, 'name': 'Back lawn', 'progress': None, }), diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index 25fa64b531f..5bef810150d 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -3,7 +3,7 @@ import datetime from unittest.mock import AsyncMock, patch -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest @@ -69,7 +69,7 @@ async def test_button_states_and_commands( await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2023-06-05T00:16:00+00:00" - getattr(mock_automower_client.commands, "error_confirm").side_effect = ApiException( + getattr(mock_automower_client.commands, "error_confirm").side_effect = ApiError( "Test error" ) with pytest.raises( @@ -111,7 +111,7 @@ async def test_sync_clock( await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2024-02-29T11:00:00+00:00" - mock_automower_client.commands.set_datetime.side_effect = ApiException("Test error") + mock_automower_client.commands.set_datetime.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index ae688571d2c..ec1fb7391b4 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -7,10 +7,10 @@ import time from unittest.mock import AsyncMock, patch from aioautomower.exceptions import ( - ApiException, - AuthException, + ApiError, + AuthError, + HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, - TimeoutException, ) from aioautomower.model import MowerAttributes, WorkArea from freezegun.api import FrozenDateTimeFactory @@ -111,8 +111,8 @@ async def test_expired_token_refresh_failure( @pytest.mark.parametrize( ("exception", "entry_state"), [ - (ApiException, ConfigEntryState.SETUP_RETRY), - (AuthException, ConfigEntryState.SETUP_ERROR), + (ApiError, ConfigEntryState.SETUP_RETRY), + (AuthError, ConfigEntryState.SETUP_ERROR), ], ) async def test_update_failed( @@ -142,7 +142,7 @@ async def test_update_failed( ), ( ["start_listening"], - TimeoutException, + HusqvarnaTimeoutError, "Failed to listen to websocket.", ), ], @@ -227,32 +227,79 @@ async def test_coordinator_automatic_registry_cleanup( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, values: dict[str, MowerAttributes], + freezer: FrozenDateTimeFactory, ) -> None: """Test automatic registry cleanup.""" await setup_integration(hass, mock_config_entry) entry = hass.config_entries.async_entries(DOMAIN)[0] await hass.async_block_till_done() + # Count current entitties and devices current_entites = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) current_devices = len( dr.async_entries_for_config_entry(device_registry, entry.entry_id) ) - - values.pop(TEST_MOWER_ID) + # Remove mower 2 and check if it worked + mower2 = values.pop("1234") mock_automower_client.get_status.return_value = values - await hass.config_entries.async_reload(mock_config_entry.entry_id) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) - == current_entites - 37 + == current_entites - 12 ) assert ( len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == current_devices - 1 ) + # Add mower 2 and check if it worked + values["1234"] = mower2 + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) + == current_entites + ) + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices + ) + + # Remove mower 1 and check if it worked + mower1 = values.pop(TEST_MOWER_ID) + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices - 1 + ) + # Add mower 1 and check if it worked + values[TEST_MOWER_ID] = mower1 + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices + ) + assert ( + len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) + == current_entites + ) async def test_add_and_remove_work_area( diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index 3aca509e865..044989e5cf0 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerActivities, MowerAttributes, MowerStates from freezegun.api import FrozenDateTimeFactory import pytest @@ -82,7 +82,7 @@ async def test_lawn_mower_commands( getattr( mock_automower_client.commands, aioautomower_command - ).side_effect = ApiException("Test error") + ).side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -142,7 +142,7 @@ async def test_lawn_mower_service_commands( getattr( mock_automower_client.commands, aioautomower_command - ).side_effect = ApiException("Test error") + ).side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -196,7 +196,7 @@ async def test_lawn_mower_override_work_area_command( getattr( mock_automower_client.commands, aioautomower_command - ).side_effect = ApiException("Test error") + ).side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index e1f232e7b5c..55bf5dda7eb 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest @@ -40,7 +40,7 @@ async def test_number_commands( mocked_method = mock_automower_client.commands.set_cutting_height mocked_method.assert_called_once_with(TEST_MOWER_ID, 3) - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -84,7 +84,7 @@ async def test_number_workarea_commands( assert state.state is not None assert state.state == "75" - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_select.py b/tests/components/husqvarna_automower/test_select.py index 18d1b0ed21f..01e7607735b 100644 --- a/tests/components/husqvarna_automower/test_select.py +++ b/tests/components/husqvarna_automower/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import HeadlightModes, MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest @@ -77,7 +77,7 @@ async def test_select_commands( mocked_method.assert_called_once_with(TEST_MOWER_ID, service.upper()) assert len(mocked_method.mock_calls) == 1 - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index 06fcc30e40c..08ed5251344 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -111,6 +111,7 @@ async def test_work_area_sensor( assert state.state == "my_lawn" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("sensor_to_test"), [ @@ -167,6 +168,7 @@ async def test_error_sensor( assert state.state == expected_state +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_snapshot( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/husqvarna_automower/test_switch.py b/tests/components/husqvarna_automower/test_switch.py index 100fd9fe3a4..48903a9630b 100644 --- a/tests/components/husqvarna_automower/test_switch.py +++ b/tests/components/husqvarna_automower/test_switch.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch import zoneinfo -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes, MowerModes, Zone from aioautomower.utils import mower_list_to_dictionary_dataclass from freezegun.api import FrozenDateTimeFactory @@ -92,7 +92,7 @@ async def test_switch_commands( mocked_method = getattr(mock_automower_client.commands, aioautomower_command) mocked_method.assert_called_once_with(TEST_MOWER_ID) - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -144,12 +144,12 @@ async def test_stay_out_zone_switch_commands( freezer.tick(timedelta(seconds=EXECUTION_TIME_DELAY)) async_fire_time_changed(hass) await hass.async_block_till_done() - mocked_method.assert_called_once_with(TEST_MOWER_ID, TEST_ZONE_ID, boolean) + mocked_method.assert_called_once_with(TEST_MOWER_ID, TEST_ZONE_ID, switch=boolean) state = hass.states.get(entity_id) assert state is not None assert state.state == excepted_state - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -207,7 +207,7 @@ async def test_work_area_switch_commands( assert state is not None assert state.state == excepted_state - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index a938322414b..2de7fb1da9a 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -56,7 +56,6 @@ def mock_legacy_pydrawise( @pytest.fixture def mock_pydrawise( - mock_auth: AsyncMock, user: User, controller: Controller, zones: list[Zone], diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index e85b1b9b249..cf723d885e1 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -9,7 +9,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.hydrawise.const import DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -21,6 +21,7 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_auth: AsyncMock, mock_pydrawise: AsyncMock, user: User, ) -> None: @@ -32,25 +33,26 @@ async def test_form( assert result["step_id"] == "user" assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}, ) mock_pydrawise.get_user.return_value = user await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Hydrawise" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "asdf@asdf.com" + assert result["data"] == { CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__", } assert len(mock_setup_entry.mock_calls) == 1 - mock_pydrawise.get_user.assert_called_once_with(fetch_zones=False) + mock_auth.token.assert_awaited_once_with() + mock_pydrawise.get_user.assert_awaited_once_with(fetch_zones=False) async def test_form_api_error( - hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User + hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock, user: User ) -> None: """Test we handle API errors.""" mock_pydrawise.get_user.side_effect = ClientError("XXX") @@ -67,12 +69,33 @@ async def test_form_api_error( mock_pydrawise.get_user.reset_mock(side_effect=True) mock_pydrawise.get_user.return_value = user - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_form_connect_timeout( - hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User +async def test_form_auth_connect_timeout( + hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock +) -> None: + """Test we handle connection timeout errors.""" + mock_auth.token.side_effect = TimeoutError + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], data + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "timeout_connect"} + + mock_auth.token.reset_mock(side_effect=True) + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_client_connect_timeout( + hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock, user: User ) -> None: """Test we handle API errors.""" mock_pydrawise.get_user.side_effect = TimeoutError @@ -89,15 +112,15 @@ async def test_form_connect_timeout( mock_pydrawise.get_user.reset_mock(side_effect=True) mock_pydrawise.get_user.return_value = user - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_form_not_authorized_error( - hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User + hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock ) -> None: """Test we handle API errors.""" - mock_pydrawise.get_user.side_effect = NotAuthorizedError + mock_auth.token.side_effect = NotAuthorizedError init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -109,15 +132,15 @@ async def test_form_not_authorized_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} - mock_pydrawise.get_user.reset_mock(side_effect=True) - mock_pydrawise.get_user.return_value = user - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) - assert result2["type"] is FlowResultType.CREATE_ENTRY + mock_auth.token.reset_mock(side_effect=True) + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_reauth( hass: HomeAssistant, user: User, + mock_auth: AsyncMock, mock_pydrawise: AsyncMock, ) -> None: """Test that re-authorization works.""" @@ -125,7 +148,8 @@ async def test_reauth( title="Hydrawise", domain=DOMAIN, data={ - CONF_API_KEY: "__api_key__", + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "bad-password", }, unique_id="hydrawise-12345", ) @@ -137,14 +161,49 @@ async def test_reauth( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 [result] = flows - assert result["step_id"] == "user" + assert result["step_id"] == "reauth_confirm" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}, - ) mock_pydrawise.get_user.return_value = user + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "__password__"} + ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_fails( + hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock, user: User +) -> None: + """Test that the reauth flow handles API errors.""" + mock_config_entry = MockConfigEntry( + title="Hydrawise", + domain=DOMAIN, + data={ + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "bad-password", + }, + unique_id="hydrawise-12345", + ) + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + + mock_auth.token.side_effect = NotAuthorizedError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "__password__"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + mock_auth.token.reset_mock(side_effect=True) + mock_pydrawise.get_user.return_value = user + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "__password__"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index 4109fe0f653..ac7e6c25b0d 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -10,7 +10,6 @@ from unittest.mock import AsyncMock, Mock, patch from hyperion import const -from homeassistant.components import ssdp from homeassistant.components.hyperion.const import ( CONF_AUTH_ID, CONF_CREATE_TOKEN, @@ -30,6 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from . import ( TEST_AUTH_REQUIRED_RESP, @@ -67,7 +67,7 @@ TEST_REQUEST_TOKEN_FAIL = { "error": "Token request timeout or denied", } -TEST_SSDP_SERVICE_INFO = ssdp.SsdpServiceInfo( +TEST_SSDP_SERVICE_INFO = SsdpServiceInfo( ssdp_st="upnp:rootdevice", ssdp_location=f"http://{TEST_HOST}:{TEST_PORT_UI}/description.xml", ssdp_usn=f"uuid:{TEST_SYSINFO_ID}", diff --git a/tests/components/idasen_desk/conftest.py b/tests/components/idasen_desk/conftest.py index 24ef8311445..20cc95e8ae4 100644 --- a/tests/components/idasen_desk/conftest.py +++ b/tests/components/idasen_desk/conftest.py @@ -19,9 +19,14 @@ def mock_bluetooth(enable_bluetooth: None) -> Generator[None]: @pytest.fixture(autouse=False) def mock_desk_api(): """Set up idasen desk API fixture.""" - with mock.patch( - "homeassistant.components.idasen_desk.coordinator.Desk" - ) as desk_patched: + with ( + mock.patch( + "homeassistant.components.idasen_desk.coordinator.Desk" + ) as desk_patched, + mock.patch( + "homeassistant.components.idasen_desk.config_flow.Desk", new=desk_patched + ), + ): mock_desk = MagicMock() def mock_init( @@ -33,17 +38,20 @@ def mock_desk_api(): desk_patched.side_effect = mock_init - async def mock_connect(ble_device): + async def mock_connect(ble_device, retry: bool = True): mock_desk.is_connected = True - mock_desk.trigger_update_callback(None) + if mock_desk.trigger_update_callback: + mock_desk.trigger_update_callback(None) async def mock_disconnect(): mock_desk.is_connected = False - mock_desk.trigger_update_callback(None) + if mock_desk.trigger_update_callback: + mock_desk.trigger_update_callback(None) async def mock_move_to(height: float): mock_desk.height_percent = height - mock_desk.trigger_update_callback(height) + if mock_desk.trigger_update_callback: + mock_desk.trigger_update_callback(height) async def mock_move_up(): await mock_move_to(100) diff --git a/tests/components/idasen_desk/test_buttons.py b/tests/components/idasen_desk/test_button.py similarity index 100% rename from tests/components/idasen_desk/test_buttons.py rename to tests/components/idasen_desk/test_button.py diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py index c27cdea58aa..baeed6be1ab 100644 --- a/tests/components/idasen_desk/test_config_flow.py +++ b/tests/components/idasen_desk/test_config_flow.py @@ -1,6 +1,6 @@ """Test the IKEA Idasen Desk config flow.""" -from unittest.mock import ANY, patch +from unittest.mock import ANY, MagicMock, patch from bleak.exc import BleakError from idasen_ha.errors import AuthFailedError @@ -17,7 +17,7 @@ from . import IDASEN_DISCOVERY_INFO, NOT_IDASEN_DISCOVERY_INFO from tests.common import MockConfigEntry -async def test_user_step_success(hass: HomeAssistant) -> None: +async def test_user_step_success(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: """Test user step success path.""" with patch( "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", @@ -30,14 +30,9 @@ async def test_user_step_success(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), - patch("homeassistant.components.idasen_desk.config_flow.Desk.disconnect"), - patch( - "homeassistant.components.idasen_desk.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): + with patch( + "homeassistant.components.idasen_desk.async_setup_entry", return_value=True + ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -89,9 +84,20 @@ async def test_user_step_no_new_devices_found(hass: HomeAssistant) -> None: assert result["reason"] == "no_devices_found" -@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (TimeoutError, "cannot_connect"), + (BleakError, "cannot_connect"), + (AuthFailedError, "auth_failed"), + (RuntimeError, "unknown"), + ], +) async def test_user_step_cannot_connect( - hass: HomeAssistant, exception: Exception + hass: HomeAssistant, + mock_desk_api: MagicMock, + exception: Exception, + expected_error: str, ) -> None: """Test user step with a cannot connect error.""" with patch( @@ -105,90 +111,26 @@ async def test_user_step_cannot_connect( assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.idasen_desk.config_flow.Desk.connect", - side_effect=exception, - ), - patch("homeassistant.components.idasen_desk.config_flow.Desk.disconnect"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, - }, - ) - await hass.async_block_till_done() + default_connect_side_effect = mock_desk_api.connect.side_effect + mock_desk_api.connect.side_effect = exception + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": expected_error} - with ( - patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), - patch("homeassistant.components.idasen_desk.config_flow.Desk.disconnect"), - patch( - "homeassistant.components.idasen_desk.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, - }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == IDASEN_DISCOVERY_INFO.name - assert result3["data"] == { - CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, - } - assert result3["result"].unique_id == IDASEN_DISCOVERY_INFO.address - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_user_step_auth_failed(hass: HomeAssistant) -> None: - """Test user step with an auth failed error.""" + mock_desk_api.connect.side_effect = default_connect_side_effect with patch( - "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", - return_value=[IDASEN_DISCOVERY_INFO], - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - with ( - patch( - "homeassistant.components.idasen_desk.config_flow.Desk.connect", - side_effect=AuthFailedError, - ), - patch("homeassistant.components.idasen_desk.config_flow.Desk.disconnect"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "auth_failed"} - - with ( - patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), - patch("homeassistant.components.idasen_desk.config_flow.Desk.disconnect"), - patch( - "homeassistant.components.idasen_desk.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { @@ -206,70 +148,9 @@ async def test_user_step_auth_failed(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_step_unknown_exception(hass: HomeAssistant) -> None: - """Test user step with an unknown exception.""" - with patch( - "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", - return_value=[NOT_IDASEN_DISCOVERY_INFO, IDASEN_DISCOVERY_INFO], - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - with ( - patch( - "homeassistant.components.idasen_desk.config_flow.Desk.connect", - side_effect=RuntimeError, - ), - patch( - "homeassistant.components.idasen_desk.config_flow.Desk.disconnect", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "unknown"} - - with ( - patch( - "homeassistant.components.idasen_desk.config_flow.Desk.connect", - ), - patch( - "homeassistant.components.idasen_desk.config_flow.Desk.disconnect", - ), - patch( - "homeassistant.components.idasen_desk.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, - }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == IDASEN_DISCOVERY_INFO.name - assert result3["data"] == { - CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, - } - assert result3["result"].unique_id == IDASEN_DISCOVERY_INFO.address - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_bluetooth_step_success(hass: HomeAssistant) -> None: +async def test_bluetooth_step_success( + hass: HomeAssistant, mock_desk_api: MagicMock +) -> None: """Test bluetooth step success path.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -280,16 +161,10 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.idasen_desk.config_flow.Desk.connect" - ) as desk_connect, - patch("homeassistant.components.idasen_desk.config_flow.Desk.disconnect"), - patch( - "homeassistant.components.idasen_desk.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): + with patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -305,4 +180,4 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: } assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address assert len(mock_setup_entry.mock_calls) == 1 - desk_connect.assert_called_with(ANY, retry=False) + mock_desk_api.connect.assert_called_with(ANY, retry=False) diff --git a/tests/components/idasen_desk/test_sensors.py b/tests/components/idasen_desk/test_sensor.py similarity index 100% rename from tests/components/idasen_desk/test_sensors.py rename to tests/components/idasen_desk/test_sensor.py diff --git a/tests/components/igloohome/__init__.py b/tests/components/igloohome/__init__.py new file mode 100644 index 00000000000..835414b2825 --- /dev/null +++ b/tests/components/igloohome/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the igloohome integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the igloohome integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/igloohome/conftest.py b/tests/components/igloohome/conftest.py new file mode 100644 index 00000000000..d630f5af7cb --- /dev/null +++ b/tests/components/igloohome/conftest.py @@ -0,0 +1,72 @@ +"""Common fixtures for the igloohome tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from igloohome_api import GetDeviceInfoResponse, GetDevicesResponse +import pytest + +from homeassistant.components.igloohome.const import DOMAIN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +GET_DEVICE_INFO_RESPONSE_LOCK = GetDeviceInfoResponse( + id="123456", + type="Lock", + deviceId="OE1X123cbb11", + deviceName="Front Door", + pairedAt="2024-11-09T11:19:25+00:00", + homeId=[], + linkedDevices=[], + batteryLevel=100, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.igloohome.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +async def mock_auth() -> Generator[AsyncMock]: + """Set up the mock usages of the igloohome_api.Auth class. Defaults to always successfully operate.""" + with patch( + "homeassistant.components.igloohome.config_flow.IgloohomeAuth.async_get_access_token", + return_value="mock_access_token", + ) as mock_auth: + yield mock_auth + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Client Credentials", + domain=DOMAIN, + version=1, + data={CONF_CLIENT_ID: "client-id", CONF_CLIENT_SECRET: "client-secret"}, + ) + + +@pytest.fixture(autouse=True) +def mock_api() -> Generator[AsyncMock]: + """Set up the Api module. Defaults to always returning a single lock.""" + with ( + patch( + "homeassistant.components.igloohome.IgloohomeApi", + autospec=True, + ) as api_mock, + ): + api = api_mock.return_value + api.get_devices.return_value = GetDevicesResponse( + nextCursor="", + payload=[GET_DEVICE_INFO_RESPONSE_LOCK], + ) + api.get_device_info.return_value = GET_DEVICE_INFO_RESPONSE_LOCK + yield api diff --git a/tests/components/igloohome/snapshots/test_sensor.ambr b/tests/components/igloohome/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..f65baa484a0 --- /dev/null +++ b/tests/components/igloohome/snapshots/test_sensor.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_sensors[sensor.front_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'igloohome', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'battery_OE1X123cbb11', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.front_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Front Door Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.front_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/igloohome/test_config_flow.py b/tests/components/igloohome/test_config_flow.py new file mode 100644 index 00000000000..1f7656d88d6 --- /dev/null +++ b/tests/components/igloohome/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test the igloohome config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock + +from aiohttp import ClientError +from igloohome_api import AuthException +import pytest + +from homeassistant import config_entries +from homeassistant.components.igloohome.const import DOMAIN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +FORM_USER_INPUT = { + CONF_CLIENT_ID: "client-id", + CONF_CLIENT_SECRET: "client-secret", +} + + +async def test_form_valid_input( + hass: HomeAssistant, + mock_setup_entry: Generator[AsyncMock], + mock_auth: Generator[AsyncMock], +) -> None: + """Test that the form correct reacts to valid input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FORM_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Client Credentials" + assert result["data"] == FORM_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "result_error"), + [(AuthException(), "invalid_auth"), (ClientError(), "cannot_connect")], +) +async def test_form_invalid_input( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_auth: Generator[AsyncMock], + exception: Exception, + result_error: str, +) -> None: + """Tests where we handle errors in the config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_auth.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FORM_USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": result_error} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + mock_auth.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FORM_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Client Credentials" + assert result["data"] == FORM_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_abort_on_matching_entry( + hass: HomeAssistant, + mock_config_entry: Generator[AsyncMock], + mock_auth: Generator[AsyncMock], +) -> None: + """Tests where we handle errors in the config flow.""" + # Create first config flow. + await setup_integration(hass, mock_config_entry) + + # Attempt another config flow with the same client credentials + # and ensure that FlowResultType.ABORT is returned. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FORM_USER_INPUT, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/igloohome/test_sensor.py b/tests/components/igloohome/test_sensor.py new file mode 100644 index 00000000000..bfc60574450 --- /dev/null +++ b/tests/components/igloohome/test_sensor.py @@ -0,0 +1,26 @@ +"""Test sensors for igloohome integration.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the igloohome sensors.""" + with patch("homeassistant.components.igloohome.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/image_upload/test_media_source.py b/tests/components/image_upload/test_media_source.py new file mode 100644 index 00000000000..d66e099bdc9 --- /dev/null +++ b/tests/components/image_upload/test_media_source.py @@ -0,0 +1,90 @@ +"""Test image_upload media source.""" + +import tempfile +from unittest.mock import patch + +from aiohttp import ClientSession +import pytest + +from homeassistant.components import media_source +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import TEST_IMAGE + +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +async def setup_media_source(hass: HomeAssistant) -> None: + """Set up media source.""" + assert await async_setup_component(hass, "media_source", {}) + + +async def __upload_test_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> str: + with ( + tempfile.TemporaryDirectory() as tempdir, + patch.object(hass.config, "path", return_value=tempdir), + ): + assert await async_setup_component(hass, "image_upload", {}) + client: ClientSession = await hass_client() + + file = await hass.async_add_executor_job(TEST_IMAGE.open, "rb") + res = await client.post("/api/image/upload", data={"file": file}) + hass.async_add_executor_job(file.close) + + assert res.status == 200 + item = await res.json() + assert item["content_type"] == "image/png" + assert item["filesize"] == 38847 + return item["id"] + + +async def test_browsing( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test browsing image media source.""" + image_id = await __upload_test_image(hass, hass_client) + + item = await media_source.async_browse_media(hass, "media-source://image_upload") + + assert item is not None + assert item.title == "Image Upload" + assert len(item.children) == 1 + assert item.children[0].media_content_type == "image/png" + assert item.children[0].identifier == image_id + assert item.children[0].thumbnail == f"/api/image/serve/{image_id}/256x256" + + with pytest.raises( + media_source.BrowseError, + match="Unknown item", + ): + await media_source.async_browse_media( + hass, "media-source://image_upload/invalid_path" + ) + + +async def test_resolving( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test resolving.""" + image_id = await __upload_test_image(hass, hass_client) + item = await media_source.async_resolve_media( + hass, f"media-source://image_upload/{image_id}", None + ) + assert item is not None + assert item.url == f"/api/image/serve/{image_id}/original" + assert item.mime_type == "image/png" + + invalid_id = "aabbccddeeff" + with pytest.raises( + media_source.Unresolvable, + match=f"Could not resolve media item: {invalid_id}", + ): + await media_source.async_resolve_media( + hass, f"media-source://image_upload/{invalid_id}", None + ) diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 037960c9e5d..8f6761bd795 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -141,6 +141,8 @@ TEST_CONTENT_MULTIPART_BASE64_INVALID = ( ) EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) +EMPTY_SEARCH_RESPONSE_ALT = ("OK", [b"Search completed (0.0001 + 0.000 secs)."]) + BAD_RESPONSE = ("BAD", [b"", b"Unexpected error"]) TEST_SEARCH_RESPONSE = ("OK", [b"1", b"Search completed (0.0001 + 0.000 secs)."]) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 7bdfc44571a..b86855bd78f 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -20,6 +20,7 @@ from homeassistant.util.dt import utcnow from .const import ( BAD_RESPONSE, EMPTY_SEARCH_RESPONSE, + EMPTY_SEARCH_RESPONSE_ALT, TEST_BADLY_ENCODED_CONTENT, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, @@ -170,11 +171,8 @@ async def test_receiving_message_successfully( assert data["subject"] == "Test subject" assert data["uid"] == "1" assert "Test body" in data["text"] - assert ( - valid_date - and isinstance(data["date"], datetime) - or not valid_date - and data["date"] is None + assert (valid_date and isinstance(data["date"], datetime)) or ( + not valid_date and data["date"] is None ) @@ -517,6 +515,11 @@ async def test_fetch_number_of_messages( assert state.state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "empty_search_reponse", + [EMPTY_SEARCH_RESPONSE, EMPTY_SEARCH_RESPONSE_ALT], + ids=["regular_empty_search_response", "alt_empty_search_response"], +) @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize( ("imap_fetch", "valid_date"), @@ -525,7 +528,10 @@ async def test_fetch_number_of_messages( ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_reset_last_message( - hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + valid_date: bool, + empty_search_reponse: tuple[str, list[bytes]], ) -> None: """Test receiving a message successfully.""" event = asyncio.Event() # needed for pushed coordinator to make a new loop @@ -572,15 +578,12 @@ async def test_reset_last_message( assert data["subject"] == "Test subject" assert data["text"] assert data["initial"] - assert ( - valid_date - and isinstance(data["date"], datetime) - or not valid_date - and data["date"] is None + assert (valid_date and isinstance(data["date"], datetime)) or ( + not valid_date and data["date"] is None ) # Simulate an update where no messages are found (needed for pushed coordinator) - mock_imap_protocol.search.return_value = Response(*EMPTY_SEARCH_RESPONSE) + mock_imap_protocol.search.return_value = Response(*empty_search_reponse) # Make sure we have an update async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index 6f23ed3ee80..a10b9b54532 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -16,11 +16,11 @@ HYDROLOGICAL_DATA = HydrologicalData( river="River Name", station_id="123", water_level=SensorData(name="Water Level", value=526.0), - flood_alarm_level=SensorData(name="Flood Alarm Level", value=630.0), - flood_warning_level=SensorData(name="Flood Warning Level", value=590.0), + flood_alarm_level=SensorData(name="Flood Alarm Level", value=None), + flood_warning_level=SensorData(name="Flood Warning Level", value=None), water_temperature=SensorData(name="Water Temperature", value=10.8), - flood_alarm=False, - flood_warning=False, + flood_alarm=None, + flood_warning=None, water_level_measurement_date=datetime(2024, 4, 27, 10, 0, tzinfo=UTC), water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 494980ba4ce..a98f60a2b3e 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -20,17 +20,17 @@ 'version': 1, }), 'hydrological_data': dict({ - 'flood_alarm': False, + 'flood_alarm': None, 'flood_alarm_level': dict({ 'name': 'Flood Alarm Level', 'unit': None, - 'value': 630.0, + 'value': None, }), - 'flood_warning': False, + 'flood_warning': None, 'flood_warning_level': dict({ 'name': 'Flood Warning Level', 'unit': None, - 'value': 590.0, + 'value': None, }), 'river': 'River Name', 'station': 'Station Name', diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 6c69b890842..c7779f5d850 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -1,108 +1,4 @@ # serializer version: 1 -# name: test_sensor[sensor.river_name_station_name_flood_alarm_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.river_name_station_name_flood_alarm_level', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood alarm level', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_alarm_level', - 'unique_id': '123_flood_alarm_level', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_alarm_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'distance', - 'friendly_name': 'River Name (Station Name) Flood alarm level', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.river_name_station_name_flood_alarm_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '630.0', - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_warning_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.river_name_station_name_flood_warning_level', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood warning level', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_warning_level', - 'unique_id': '123_flood_warning_level', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_warning_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'distance', - 'friendly_name': 'River Name (Station Name) Flood warning level', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.river_name_station_name_flood_warning_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '590.0', - }) -# --- # name: test_sensor[sensor.river_name_station_name_water_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/imgw_pib/test_init.py b/tests/components/imgw_pib/test_init.py index e1b7cda7c88..e352c643676 100644 --- a/tests/components/imgw_pib/test_init.py +++ b/tests/components/imgw_pib/test_init.py @@ -4,9 +4,11 @@ from unittest.mock import AsyncMock, patch from imgw_pib import ApiError +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM from homeassistant.components.imgw_pib.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import init_integration @@ -43,3 +45,27 @@ async def test_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + + +async def test_remove_binary_sensor_entity( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing a binary_sensor entity.""" + entity_id = "binary_sensor.river_name_station_name_flood_alarm" + mock_config_entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + BINARY_SENSOR_PLATFORM, + DOMAIN, + "123_flood_alarm", + suggested_object_id=entity_id.rsplit(".", maxsplit=1)[-1], + config_entry=mock_config_entry, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None diff --git a/tests/components/imgw_pib/test_sensor.py b/tests/components/imgw_pib/test_sensor.py index 276c021fad5..a1920f38006 100644 --- a/tests/components/imgw_pib/test_sensor.py +++ b/tests/components/imgw_pib/test_sensor.py @@ -7,7 +7,8 @@ from imgw_pib import ApiError import pytest from syrupy import SnapshotAssertion -from homeassistant.components.imgw_pib.const import UPDATE_INTERVAL +from homeassistant.components.imgw_pib.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -65,3 +66,27 @@ async def test_availability( assert state assert state.state != STATE_UNAVAILABLE assert state.state == "526.0" + + +async def test_remove_entity( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing entity.""" + entity_id = "sensor.river_name_station_name_flood_alarm_level" + mock_config_entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + SENSOR_PLATFORM, + DOMAIN, + "123_flood_alarm_level", + suggested_object_id=entity_id.rsplit(".", maxsplit=1)[-1], + config_entry=mock_config_entry, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 2df4be2ba7d..4536c64349c 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -664,6 +664,6 @@ async def test_provision_fails_invalid_data( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "invalid_improv_data" assert ( - "Aborting improv flow, device AA:BB:CC:DD:EE:F0 sent invalid improv data: '000000000000'" + "Received invalid improv via BLE data '000000000000' from device with bluetooth address 'AA:BB:CC:DD:EE:F0'" in caplog.text ) diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index f17547a1445..aacfa886f52 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -7,8 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from incomfortclient import DisplayCode import pytest -from homeassistant.components.incomfort import DOMAIN -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.incomfort.const import DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -19,8 +18,13 @@ MOCK_CONFIG = { "password": "verysecret", } +MOCK_CONFIG_DHCP = { + "username": "admin", + "password": "verysecret", +} + MOCK_HEATER_STATUS = { - "display_code": DisplayCode(126), + "display_code": DisplayCode.STANDBY, "display_text": "standby", "fault_code": None, "is_burning": False, @@ -36,6 +40,23 @@ MOCK_HEATER_STATUS = { "rfstatus_cntr": 0, } +MOCK_HEATER_STATUS_HEATING = { + "display_code": DisplayCode.OPENTHERM, + "display_text": "opentherm", + "fault_code": None, + "is_burning": True, + "is_failed": False, + "is_pumping": True, + "is_tapping": False, + "heater_temp": 35.34, + "tap_temp": 30.21, + "pressure": 1.86, + "serial_no": "c0ffeec0ffee", + "nodenr": 249, + "rf_message_rssi": 30, + "rfstatus_cntr": 0, +} + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -53,12 +74,22 @@ def mock_entry_data() -> dict[str, Any]: return MOCK_CONFIG +@pytest.fixture +def mock_entry_options() -> dict[str, Any] | None: + """Mock config entry options for fixture.""" + return None + + @pytest.fixture def mock_config_entry( - hass: HomeAssistant, mock_entry_data: dict[str, Any] -) -> ConfigEntry: + hass: HomeAssistant, + mock_entry_data: dict[str, Any], + mock_entry_options: dict[str, Any], +) -> MockConfigEntry: """Mock a config entry setup for incomfort integration.""" - entry = MockConfigEntry(domain=DOMAIN, data=mock_entry_data) + entry = MockConfigEntry( + domain=DOMAIN, data=mock_entry_data, options=mock_entry_options + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/incomfort/snapshots/test_binary_sensor.ambr b/tests/components/incomfort/snapshots/test_binary_sensor.ambr index 2f2319b6a44..fe0d8edd0f0 100644 --- a/tests/components/incomfort/snapshots/test_binary_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_binary_sensor.ambr @@ -10,7 +10,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -57,7 +57,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -105,7 +105,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -152,7 +152,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -199,7 +199,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -246,7 +246,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -294,7 +294,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -341,7 +341,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -388,7 +388,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -435,7 +435,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -483,7 +483,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -530,7 +530,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -577,7 +577,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -624,7 +624,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -672,7 +672,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -719,7 +719,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -766,7 +766,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -813,7 +813,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -861,7 +861,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -908,7 +908,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index 17adcbb3bab..e0e8b9562dd 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-entry] +# name: test_setup_platform[legacy-override][climate.thermostat_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -16,7 +16,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': None, + 'entity_category': , 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -38,7 +38,73 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-state] +# name: test_setup_platform[legacy-override][climate.thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.4, + 'friendly_name': 'Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 19.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + 'supported_features': , + 'temperature': 18.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': , + 'entity_id': 'climate.thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 21.4, @@ -65,7 +131,7 @@ 'state': 'heat', }) # --- -# name: test_setup_platform[new_thermostat][climate.thermostat_1-entry] +# name: test_setup_platform[modern-override][climate.thermostat_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -82,7 +148,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': None, + 'entity_category': , 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -104,7 +170,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_platform[new_thermostat][climate.thermostat_1-state] +# name: test_setup_platform[modern-override][climate.thermostat_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 21.4, @@ -116,7 +182,73 @@ 'max_temp': 30.0, 'min_temp': 5.0, 'status': dict({ - 'override': 18.0, + 'override': 19.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + 'supported_features': , + 'temperature': 19.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[modern-zero_override][climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': , + 'entity_id': 'climate.thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[modern-zero_override][climate.thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.4, + 'friendly_name': 'Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 0.0, 'room_temp': 21.42, 'setpoint': 18.0, }), diff --git a/tests/components/incomfort/snapshots/test_diagnostics.ambr b/tests/components/incomfort/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e7c99f37acd --- /dev/null +++ b/tests/components/incomfort/snapshots/test_diagnostics.ambr @@ -0,0 +1,35 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config': dict({ + 'host': '192.168.1.12', + 'password': '**REDACTED**', + 'username': 'admin', + }), + 'gateway': dict({ + 'heater_0': dict({ + 'display_code': 126, + 'display_text': 'standby', + 'fault_code': None, + 'heater_temp': 35.34, + 'is_burning': False, + 'is_failed': False, + 'is_pumping': False, + 'is_tapping': False, + 'nodenr': 249, + 'pressure': 1.86, + 'rf_message_rssi': 30, + 'rfstatus_cntr': 0, + 'rooms': dict({ + '0': dict({ + 'override': 18.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + }), + 'serial_no': 'c0ffeec0ffee', + 'tap_temp': 30.21, + }), + }), + }) +# --- diff --git a/tests/components/incomfort/snapshots/test_sensor.ambr b/tests/components/incomfort/snapshots/test_sensor.ambr index 8c9ea60f455..a69a64d964e 100644 --- a/tests/components/incomfort/snapshots/test_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_sensor.ambr @@ -12,7 +12,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_pressure', 'has_entity_name': True, 'hidden_by': None, @@ -63,7 +63,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_tap_temperature', 'has_entity_name': True, 'hidden_by': None, @@ -115,7 +115,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_temperature', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/snapshots/test_water_heater.ambr b/tests/components/incomfort/snapshots/test_water_heater.ambr index 06b0d0c1e52..d2cd955a9fc 100644 --- a/tests/components/incomfort/snapshots/test_water_heater.ambr +++ b/tests/components/incomfort/snapshots/test_water_heater.ambr @@ -13,7 +13,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'water_heater', - 'entity_category': None, + 'entity_category': , 'entity_id': 'water_heater.boiler', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/test_binary_sensor.py b/tests/components/incomfort/test_binary_sensor.py index c724cf4b7b2..e90cc3ac391 100644 --- a/tests/components/incomfort/test_binary_sensor.py +++ b/tests/components/incomfort/test_binary_sensor.py @@ -17,6 +17,7 @@ from tests.common import snapshot_platform @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.BINARY_SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_platform( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -45,6 +46,7 @@ async def test_setup_platform( ids=["is_failed", "is_pumping", "is_burning", "is_tapping"], ) @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.BINARY_SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_binary_sensors_alt( hass: HomeAssistant, mock_incomfort: MagicMock, diff --git a/tests/components/incomfort/test_climate.py b/tests/components/incomfort/test_climate.py index ae4c1cf31f7..dbcf14e3bd7 100644 --- a/tests/components/incomfort/test_climate.py +++ b/tests/components/incomfort/test_climate.py @@ -1,15 +1,19 @@ """Climate sensor tests for Intergas InComfort integration.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from syrupy import SnapshotAssertion +from homeassistant.components import climate +from homeassistant.components.incomfort.coordinator import InComfortData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import MOCK_HEATER_STATUS, MOCK_HEATER_STATUS_HEATING + from tests.common import snapshot_platform @@ -17,10 +21,15 @@ from tests.common import snapshot_platform @pytest.mark.parametrize( "mock_room_status", [ - {"room_temp": 21.42, "setpoint": 18.0, "override": 18.0}, + {"room_temp": 21.42, "setpoint": 18.0, "override": 19.0}, {"room_temp": 21.42, "setpoint": 18.0, "override": 0.0}, ], - ids=["new_thermostat", "legacy_thermostat"], + ids=["override", "zero_override"], +) +@pytest.mark.parametrize( + "mock_entry_options", + [None, {"legacy_setpoint_status": True}], + ids=["modern", "legacy"], ) async def test_setup_platform( hass: HomeAssistant, @@ -31,8 +40,54 @@ async def test_setup_platform( ) -> None: """Test the incomfort entities are set up correctly. - Legacy thermostats report 0.0 as override if no override is set, - but new thermostat sync the override with the actual setpoint instead. + Thermostats report 0.0 as override if no override is set + or when the setpoint has been changed manually, + Some older thermostats do not reset the override setpoint has been changed manually. """ await hass.config_entries.async_setup(mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("mock_heater_status", "hvac_action"), + [ + (MOCK_HEATER_STATUS.copy(), climate.HVACAction.IDLE), + (MOCK_HEATER_STATUS_HEATING.copy(), climate.HVACAction.HEATING), + ], + ids=["idle", "heating"], +) +async def test_hvac_state( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: ConfigEntry, + hvac_action: climate.HVACAction, +) -> None: + """Test the HVAC state of the thermostat.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("climate.thermostat_1") + assert state is not None + assert state.attributes["hvac_action"] is hvac_action + + +async def test_target_temp( + hass: HomeAssistant, mock_incomfort: MagicMock, mock_config_entry: ConfigEntry +) -> None: + """Test changing the target temperature.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("climate.thermostat_1") + assert state is not None + + incomfort_data: InComfortData = mock_config_entry.runtime_data.incomfort_data + + with patch.object( + incomfort_data.heaters[0].rooms[0], "set_override", AsyncMock() + ) as mock_set_override: + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_TEMPERATURE, + service_data={ + ATTR_ENTITY_ID: "climate.thermostat_1", + ATTR_TEMPERATURE: 19.0, + }, + ) + mock_set_override.assert_called_once_with(19.0) diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index 7a942dab817..e3579182b3d 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -1,21 +1,36 @@ """Tests for the Intergas InComfort config flow.""" -from unittest.mock import AsyncMock, MagicMock +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError -from incomfortclient import IncomfortError, InvalidHeaterList +from incomfortclient import InvalidGateway, InvalidHeaterList import pytest -from homeassistant.components.incomfort import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.components.incomfort.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .conftest import MOCK_CONFIG +from .conftest import MOCK_CONFIG, MOCK_CONFIG_DHCP from tests.common import MockConfigEntry +DHCP_SERVICE_INFO = DhcpServiceInfo( + hostname="rfgateway", + ip="192.168.1.12", + macaddress="0004A3DEADFF", +) + +DHCP_SERVICE_INFO_ALT = DhcpServiceInfo( + hostname="rfgateway", + ip="192.168.1.99", + macaddress="0004A3DEADFF", +) + async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock @@ -38,51 +53,9 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 -async def test_import( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock +async def test_entry_already_configured( + hass: HomeAssistant, mock_incomfort: MagicMock ) -> None: - """Test we van import from YAML.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway" - assert result["data"] == MOCK_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("exc", "abort_reason"), - [ - (IncomfortError(ClientResponseError(None, None, status=401)), "auth_error"), - (IncomfortError(ClientResponseError(None, None, status=404)), "not_found"), - (IncomfortError(ClientResponseError(None, None, status=500)), "unknown"), - (IncomfortError, "unknown"), - (InvalidHeaterList, "no_heaters"), - (ValueError, "unknown"), - (TimeoutError, "timeout_error"), - ], -) -async def test_import_fails( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_incomfort: MagicMock, - exc: Exception, - abort_reason: str, -) -> None: - """Test YAML import fails.""" - mock_incomfort().heaters.side_effect = exc - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == abort_reason - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_entry_already_configured(hass: HomeAssistant) -> None: """Test aborting if the entry is already configured.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) entry.add_to_hass(hass) @@ -108,24 +81,22 @@ async def test_entry_already_configured(hass: HomeAssistant) -> None: ("exc", "error", "base"), [ ( - IncomfortError(ClientResponseError(None, None, status=401)), + InvalidGateway, "auth_error", - CONF_PASSWORD, - ), - ( - IncomfortError(ClientResponseError(None, None, status=404)), - "not_found", "base", ), ( - IncomfortError(ClientResponseError(None, None, status=500)), + InvalidHeaterList, + "no_heaters", + "base", + ), + ( + ClientResponseError(None, None, status=500), "unknown", "base", ), - (IncomfortError, "unknown", "base"), - (ValueError, "unknown", "base"), (TimeoutError, "timeout_error", "base"), - (InvalidHeaterList, "no_heaters", "base"), + (ValueError, "unknown", "base"), ], ) async def test_form_validation( @@ -157,3 +128,276 @@ async def test_form_validation( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert "errors" not in result + + +async def test_dhcp_flow_simple( + hass: HomeAssistant, + mock_incomfort: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test dhcp flow for older gateway without authentication needed. + + Assert on the creation of the gateway device, climate and boiler devices. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {"host": "192.168.1.12"} + + config_entry: ConfigEntry = result["result"] + entry_id = config_entry.entry_id + + await hass.async_block_till_done(wait_background_tasks=True) + + # Check the gateway device is discovered + gateway_device = device_registry.async_get_device(identifiers={(DOMAIN, entry_id)}) + assert gateway_device is not None + assert gateway_device.name == "RFGateway" + assert gateway_device.manufacturer == "Intergas" + assert gateway_device.connections == {("mac", "00:04:a3:de:ad:ff")} + + devices = device_registry.devices.get_devices_for_config_entry_id(entry_id) + assert len(devices) == 3 + boiler_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee")} + ) + assert boiler_device.via_device_id == gateway_device.id + assert boiler_device is not None + climate_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee_1")} + ) + assert climate_device is not None + assert climate_device.via_device_id == gateway_device.id + + # Check the host is dynamically updated + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO_ALT + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == DHCP_SERVICE_INFO_ALT.ip + + +async def test_dhcp_flow_migrates_existing_entry_without_unique_id( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test dhcp flow migrates an existing entry without unique_id.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Check the gateway device is discovered after a reload + # And has updated connections + gateway_device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert gateway_device is not None + assert gateway_device.name == "RFGateway" + assert gateway_device.manufacturer == "Intergas" + assert gateway_device.connections == {("mac", "00:04:a3:de:ad:ff")} + + devices = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(devices) == 3 + boiler_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee")} + ) + assert boiler_device.via_device_id == gateway_device.id + assert boiler_device is not None + climate_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee_1")} + ) + assert climate_device is not None + assert climate_device.via_device_id == gateway_device.id + + +async def test_dhcp_flow_wih_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock +) -> None: + """Test dhcp flow for with authentication.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_confirm" + + # Try again, but now with the correct host, but still with an auth error + with patch.object( + mock_incomfort(), + "heaters", + side_effect=InvalidGateway, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.12"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_auth" + assert result["errors"] == {"base": "auth_error"} + + # Submit the form with added credentials + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONFIG_DHCP + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-authentication flow succeeds.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_flow_failure( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-authentication flow fails.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch.object( + mock_incomfort(), + "heaters", + side_effect=InvalidGateway, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "incorrect-password"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "auth_error"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-configure flow succeeds.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_failure( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-configure flow fails.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch.object( + mock_incomfort(), + "heaters", + side_effect=InvalidGateway, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "wrong-password"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "auth_error"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("user_input", "legacy_setpoint_status"), + [ + ({}, False), + ({"legacy_setpoint_status": False}, False), + ({"legacy_setpoint_status": True}, True), + ], +) +async def test_options_flow( + hass: HomeAssistant, + mock_incomfort: MagicMock, + user_input: dict[str, Any], + legacy_setpoint_status: bool, +) -> None: + """Test options flow.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + with patch("homeassistant.components.incomfort.async_setup_entry") as restart_mock: + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert restart_mock.call_count == 1 + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == {"legacy_setpoint_status": legacy_setpoint_status} + assert entry.options.get("legacy_setpoint_status", False) is legacy_setpoint_status diff --git a/tests/components/incomfort/test_diagnostics.py b/tests/components/incomfort/test_diagnostics.py new file mode 100644 index 00000000000..02493681705 --- /dev/null +++ b/tests/components/incomfort/test_diagnostics.py @@ -0,0 +1,24 @@ +"""Test diagnostics for the Intergas InComfort integration.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, SnapshotAssertion +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the incomfort integration diagnostics.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + snapshot.assert_match( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + ) diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index 0390a47a616..a9b3a8e4e3a 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -1,11 +1,11 @@ """Tests for Intergas InComfort integration.""" from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from aiohttp import ClientResponseError +from aiohttp import ClientResponseError, RequestInfo from freezegun.api import FrozenDateTimeFactory -from incomfortclient import IncomfortError +from incomfortclient import InvalidGateway, InvalidHeaterList import pytest from homeassistant.components.incomfort.coordinator import UPDATE_INTERVAL @@ -17,6 +17,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_platforms( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -28,6 +29,7 @@ async def test_setup_platforms( assert mock_config_entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_coordinator_updates( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -59,12 +61,31 @@ async def test_coordinator_updates( assert state.state == "1.84" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( "exc", [ - IncomfortError(ClientResponseError(None, None, status=401)), - IncomfortError(ClientResponseError(None, None, status=500)), - IncomfortError(ValueError("some_error")), + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=401, + ), + InvalidHeaterList, + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=500, + ), TimeoutError, ], ) @@ -91,3 +112,60 @@ async def test_coordinator_update_fails( state = hass.states.get("sensor.boiler_pressure") assert state is not None assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("exc", "config_entry_state"), + [ + ( + InvalidGateway, + ConfigEntryState.SETUP_ERROR, + ), + (InvalidHeaterList, ConfigEntryState.SETUP_RETRY), + ( + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=404, + ), + ConfigEntryState.SETUP_ERROR, + ), + ( + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=500, + ), + ConfigEntryState.SETUP_RETRY, + ), + (TimeoutError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_entry_setup_fails( + hass: HomeAssistant, + mock_incomfort: MagicMock, + freezer: FrozenDateTimeFactory, + mock_config_entry: ConfigEntry, + exc: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test the incomfort coordinator entry setup fails.""" + with patch( + "homeassistant.components.incomfort.async_connect_gateway", + AsyncMock(side_effect=exc), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("sensor.boiler_pressure") + assert state is None + assert mock_config_entry.state is config_entry_state diff --git a/tests/components/incomfort/test_sensor.py b/tests/components/incomfort/test_sensor.py index d01fd9b403e..df0db39a56c 100644 --- a/tests/components/incomfort/test_sensor.py +++ b/tests/components/incomfort/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -12,6 +13,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.SENSOR]) async def test_setup_platform( hass: HomeAssistant, diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index 411f084d39a..7d491f0cdcd 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -217,6 +217,34 @@ async def test_set_datetime_3(hass: HomeAssistant) -> None: assert state.attributes["timestamp"] == dt_obj.timestamp() +async def test_set_datetime_4(hass: HomeAssistant) -> None: + """Test set_datetime method using timestamp 0.""" + await async_setup_component( + hass, DOMAIN, {DOMAIN: {"test_datetime": {"has_time": True, "has_date": True}}} + ) + + entity_id = "input_datetime.test_datetime" + + dt_obj = datetime.datetime( + 1969, 12, 31, 16, 00, 00, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) + + await async_set_timestamp(hass, entity_id, 0) + + state = hass.states.get(entity_id) + assert state.state == dt_obj.strftime(FORMAT_DATETIME) + assert state.attributes["has_time"] + assert state.attributes["has_date"] + + assert state.attributes["year"] == 1969 + assert state.attributes["month"] == 12 + assert state.attributes["day"] == 31 + assert state.attributes["hour"] == 16 + assert state.attributes["minute"] == 00 + assert state.attributes["second"] == 0 + assert state.attributes["timestamp"] == 0 + + async def test_set_datetime_time(hass: HomeAssistant) -> None: """Test set_datetime method with only time.""" await async_setup_component( diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 31d38a603f1..33e71be6dc2 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -8,7 +8,6 @@ import pytest from voluptuous_serialize import convert from homeassistant import config_entries -from homeassistant.components import dhcp, usb from homeassistant.components.insteon.config_flow import ( STEP_HUB_V1, STEP_HUB_V2, @@ -20,6 +19,8 @@ from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import ( MOCK_DEVICE, @@ -209,7 +210,7 @@ async def test_form_select_hub_v2(hass: HomeAssistant) -> None: async def test_form_discovery_dhcp(hass: HomeAssistant) -> None: """Test the discovery of the Hub via DHCP.""" - discovery_info = dhcp.DhcpServiceInfo("1.2.3.4", "", "aabbccddeeff") + discovery_info = DhcpServiceInfo("1.2.3.4", "", "aabbccddeeff") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=discovery_info ) @@ -270,7 +271,7 @@ async def test_failed_connection_hub(hass: HomeAssistant) -> None: async def test_discovery_via_usb(hass: HomeAssistant) -> None: """Test usb flow.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyINSTEON", pid="AAAA", vid="AAAA", @@ -302,7 +303,7 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE: "/dev/ttyUSB1"}} ).add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyINSTEON", pid="AAAA", vid="AAAA", diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 974c8bb8691..07390cd9571 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -843,6 +843,39 @@ async def test_on_valid_source_expect_update_on_time( assert float(state.state) < 1.8 +async def test_on_0_source_expect_0_and_update_when_source_gets_positive( + hass: HomeAssistant, +) -> None: + """Test whether time based integration updates the integral on a valid zero source.""" + start_time = dt_util.utcnow() + + with freeze_time(start_time) as freezer: + await _setup_integral_sensor(hass, max_sub_interval=DEFAULT_MAX_SUB_INTERVAL) + await _update_source_sensor(hass, 0) + await hass.async_block_till_done() + + # wait one minute and one second + freezer.tick(61) + async_fire_time_changed(hass, dt_util.now()) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + + assert condition.async_numeric_state(hass, state) is True + assert float(state.state) == 0 # integral is 0 after integration of 0 + + # wait one second and update state + freezer.tick(1) + async_fire_time_changed(hass, dt_util.now()) + await _update_source_sensor(hass, 100) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + + # approx 100*1/3600 (right method after 1 second since last integration) + assert 0.027 < float(state.state) < 0.029 + + async def test_on_unvailable_source_expect_no_update_on_time( hass: HomeAssistant, ) -> None: diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index f1465c4dcd4..96d0fa17e63 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import AsyncMock from intellifire4py.exceptions import LoginError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.intellifire.const import CONF_SERIAL, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -166,7 +166,7 @@ async def test_dhcp_discovery_intellifire_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="zentrios-Test", @@ -196,7 +196,7 @@ async def test_dhcp_discovery_non_intellifire_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="zentrios-Evil", diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 20c0f9d8d44..0db9682d0ad 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -466,3 +466,14 @@ async def test_intents_with_no_responses(hass: HomeAssistant) -> None: for intent_name in (intent.INTENT_NEVERMIND, intent.INTENT_RESPOND): response = await intent.async_handle(hass, "test", intent_name, {}) assert not response.speech + + +async def test_intents_respond_intent(hass: HomeAssistant) -> None: + """Test HassRespond intent with a response slot value.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + response = await intent.async_handle( + hass, "test", intent.INTENT_RESPOND, {"response": {"value": "Hello World"}} + ) + assert response.speech["plain"]["speech"] == "Hello World" diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py index d194d532513..1789e981e2d 100644 --- a/tests/components/intent/test_timers.py +++ b/tests/components/intent/test_timers.py @@ -1587,3 +1587,182 @@ async def test_async_device_supports_timers(hass: HomeAssistant) -> None: # After handler registration assert async_device_supports_timers(hass, device_id) + + +async def test_cancel_all_timers(hass: HomeAssistant, init_components) -> None: + """Test cancelling all timers.""" + device_id = "test_device" + + started_event = asyncio.Event() + num_started = 0 + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal num_started + + if event_type == TimerEventType.STARTED: + num_started += 1 + if num_started == 3: + started_event.set() + + async_register_timer_handler(hass, device_id, handle_timer) + + # Start timers + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "pizza"}, "minutes": {"value": 10}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "tv"}, "minutes": {"value": 10}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result2 = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "media"}, "minutes": {"value": 15}}, + device_id=device_id, + ) + assert result2.response_type == intent.IntentResponseType.ACTION_DONE + + # Wait for all timers to start + async with asyncio.timeout(1): + await started_event.wait() + + # Cancel all timers + result = await intent.async_handle( + hass, "test", intent.INTENT_CANCEL_ALL_TIMERS, {}, device_id=device_id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + assert result.speech_slots.get("canceled", 0) == 3 + + # No timers should be running for test_device + result = await intent.async_handle( + hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=device_id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 0 + + +async def test_cancel_all_timers_area( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test cancelling all timers in an area.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + area_kitchen = area_registry.async_create("kitchen") + device_kitchen = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "kitchen-device")}, + ) + device_registry.async_update_device(device_kitchen.id, area_id=area_kitchen.id) + + area_living_room = area_registry.async_create("living room") + device_living_room = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "living_room-device")}, + ) + device_registry.async_update_device( + device_living_room.id, area_id=area_living_room.id + ) + + started_event = asyncio.Event() + num_timers = 3 + num_started = 0 + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal num_started + + if event_type == TimerEventType.STARTED: + num_started += 1 + if num_started == num_timers: + started_event.set() + + async_register_timer_handler(hass, device_kitchen.id, handle_timer) + async_register_timer_handler(hass, device_living_room.id, handle_timer) + + # Start timers in different areas + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "pizza"}, "minutes": {"value": 10}}, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "tv"}, "minutes": {"value": 10}}, + device_id=device_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "media"}, "minutes": {"value": 15}}, + device_id=device_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Wait for all timers to start + async with asyncio.timeout(1): + await started_event.wait() + + # Cancel all timers in kitchen + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_ALL_TIMERS, + {"area": {"value": "kitchen"}}, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + assert result.speech_slots.get("canceled", 0) == 1 + assert result.speech_slots.get("area") == "kitchen" + + # No timers should be running in kitchen + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"area": {"value": "kitchen"}}, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 0 + + # timers should be running in living room + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"area": {"value": "living room"}}, + device_id=device_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 2 diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index 26c575f0407..39084b9298b 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch from homeassistant import config as hass_config from homeassistant.components.intent_script import DOMAIN -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import ATTR_FRIENDLY_NAME, SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -235,17 +235,31 @@ async def test_intent_script_targets( floor_1 = floor_registry.async_create("first floor") kitchen = area_registry.async_get_or_create("kitchen") area_registry.async_update(kitchen.id, floor_id=floor_1.floor_id) + bathroom = area_registry.async_get_or_create("bathroom") entity_registry.async_get_or_create( - "light", "demo", "1234", suggested_object_id="kitchen" + "light", "demo", "kitchen", suggested_object_id="kitchen" ) entity_registry.async_update_entity("light.kitchen", area_id=kitchen.id) - hass.states.async_set("light.kitchen", "off") + hass.states.async_set( + "light.kitchen", "off", attributes={ATTR_FRIENDLY_NAME: "overhead light"} + ) + entity_registry.async_get_or_create( + "light", "demo", "bathroom", suggested_object_id="bathroom" + ) + entity_registry.async_update_entity("light.bathroom", area_id=bathroom.id) + hass.states.async_set( + "light.bathroom", "off", attributes={ATTR_FRIENDLY_NAME: "overhead light"} + ) response = await intent.async_handle( hass, "test", "Targets", - {"name": {"value": "kitchen"}, "domain": {"value": "light"}}, + { + "name": {"value": "overhead light"}, + "domain": {"value": "light"}, + "preferred_area_id": {"value": "kitchen"}, + }, ) assert len(calls) == 1 assert calls[0].data["targets"] == {"entities": ["light.kitchen"]} diff --git a/tests/components/iotty/conftest.py b/tests/components/iotty/conftest.py index 1935a069cca..51a23bf18c7 100644 --- a/tests/components/iotty/conftest.py +++ b/tests/components/iotty/conftest.py @@ -6,10 +6,12 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientSession from iottycloud.device import Device from iottycloud.lightswitch import LightSwitch +from iottycloud.outlet import Outlet from iottycloud.shutter import Shutter from iottycloud.verbs import ( LS_DEVICE_TYPE_UID, OPEN_PERCENTAGE, + OU_DEVICE_TYPE_UID, RESULT, SH_DEVICE_TYPE_UID, STATUS, @@ -73,6 +75,22 @@ test_sh_one_added = [ sh_2, ] +ou_0 = Outlet("TestOU", "TEST_SERIAL_OU_0", OU_DEVICE_TYPE_UID, "[TEST] Outlet 0") + +ou_1 = Outlet("TestOU1", "TEST_SERIAL_OU_1", OU_DEVICE_TYPE_UID, "[TEST] Outlet 1") + +ou_2 = Outlet("TestOU2", "TEST_SERIAL_OU_2", OU_DEVICE_TYPE_UID, "[TEST] Outlet 2") + +test_ou = [ou_0, ou_1] + +test_ou_one_removed = [ou_0] + +test_ou_one_added = [ + ou_0, + ou_1, + ou_2, +] + @pytest.fixture async def local_oauth_impl(hass: HomeAssistant): @@ -175,6 +193,16 @@ def mock_get_devices_twolightswitches() -> Generator[AsyncMock]: yield mock_fn +@pytest.fixture +def mock_get_devices_two_outlets() -> Generator[AsyncMock]: + """Mock for get_devices, returning two outlets.""" + + with patch( + "iottycloud.cloudapi.CloudApi.get_devices", return_value=test_ou + ) as mock_fn: + yield mock_fn + + @pytest.fixture def mock_get_devices_twoshutters() -> Generator[AsyncMock]: """Mock for get_devices, returning two shutters.""" diff --git a/tests/components/iotty/snapshots/test_switch.ambr b/tests/components/iotty/snapshots/test_switch.ambr index 8ec22ed162a..c6e8764cf37 100644 --- a/tests/components/iotty/snapshots/test_switch.ambr +++ b/tests/components/iotty/snapshots/test_switch.ambr @@ -120,6 +120,19 @@ 'switch.test_light_switch_2_test_serial_2', ]) # --- +# name: test_outlet_insertion_ok + list([ + 'switch.test_outlet_0_test_serial_ou_0', + 'switch.test_outlet_1_test_serial_ou_1', + ]) +# --- +# name: test_outlet_insertion_ok.1 + list([ + 'switch.test_outlet_0_test_serial_ou_0', + 'switch.test_outlet_1_test_serial_ou_1', + 'switch.test_outlet_2_test_serial_ou_2', + ]) +# --- # name: test_setup_entry_ok_nodevices list([ ]) diff --git a/tests/components/iotty/test_switch.py b/tests/components/iotty/test_switch.py index 235a897c305..069fa665cac 100644 --- a/tests/components/iotty/test_switch.py +++ b/tests/components/iotty/test_switch.py @@ -20,12 +20,52 @@ from homeassistant.helpers import ( entity_registry as er, ) -from .conftest import test_ls_one_added, test_ls_one_removed +from .conftest import test_ls_one_added, test_ls_one_removed, test_ou_one_added from tests.common import MockConfigEntry, async_fire_time_changed -async def test_turn_on_ok( +async def check_command_ok( + entity_id: str, + initial_status: str, + final_status: str, + command: str, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + local_oauth_impl: ClientSession, + mock_get_status, + mock_command_fn, +) -> None: + """Issue a command.""" + + mock_config_entry.add_to_hass(hass) + + config_entry_oauth2_flow.async_register_implementation( + hass, DOMAIN, local_oauth_impl + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert (state := hass.states.get(entity_id)) + assert state.state == initial_status + + mock_get_status.return_value = {RESULT: {STATUS: final_status}} + + await hass.services.async_call( + SWITCH_DOMAIN, + command, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + mock_command_fn.assert_called_once() + + assert (state := hass.states.get(entity_id)) + assert state.state == final_status + + +async def test_turn_on_light_ok( hass: HomeAssistant, mock_config_entry: MockConfigEntry, local_oauth_impl: ClientSession, @@ -37,34 +77,45 @@ async def test_turn_on_ok( entity_id = "switch.test_light_switch_0_test_serial_0" - mock_config_entry.add_to_hass(hass) - - config_entry_oauth2_flow.async_register_implementation( - hass, DOMAIN, local_oauth_impl + await check_command_ok( + entity_id=entity_id, + initial_status=STATUS_OFF, + final_status=STATUS_ON, + command=SERVICE_TURN_ON, + hass=hass, + mock_config_entry=mock_config_entry, + local_oauth_impl=local_oauth_impl, + mock_get_status=mock_get_status_filled_off, + mock_command_fn=mock_command_fn, ) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert (state := hass.states.get(entity_id)) - assert state.state == STATUS_OFF +async def test_turn_on_outlet_ok( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + local_oauth_impl: ClientSession, + mock_get_devices_two_outlets, + mock_get_status_filled_off, + mock_command_fn, +) -> None: + """Issue a turnon command.""" - mock_get_status_filled_off.return_value = {RESULT: {STATUS: STATUS_ON}} + entity_id = "switch.test_outlet_0_test_serial_ou_0" - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, + await check_command_ok( + entity_id=entity_id, + initial_status=STATUS_OFF, + final_status=STATUS_ON, + command=SERVICE_TURN_ON, + hass=hass, + mock_config_entry=mock_config_entry, + local_oauth_impl=local_oauth_impl, + mock_get_status=mock_get_status_filled_off, + mock_command_fn=mock_command_fn, ) - await hass.async_block_till_done() - mock_command_fn.assert_called_once() - assert (state := hass.states.get(entity_id)) - assert state.state == STATUS_ON - - -async def test_turn_off_ok( +async def test_turn_off_light_ok( hass: HomeAssistant, mock_config_entry: MockConfigEntry, local_oauth_impl: ClientSession, @@ -76,32 +127,43 @@ async def test_turn_off_ok( entity_id = "switch.test_light_switch_0_test_serial_0" - mock_config_entry.add_to_hass(hass) - - config_entry_oauth2_flow.async_register_implementation( - hass, DOMAIN, local_oauth_impl + await check_command_ok( + entity_id=entity_id, + initial_status=STATUS_ON, + final_status=STATUS_OFF, + command=SERVICE_TURN_OFF, + hass=hass, + mock_config_entry=mock_config_entry, + local_oauth_impl=local_oauth_impl, + mock_get_status=mock_get_status_filled, + mock_command_fn=mock_command_fn, ) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert (state := hass.states.get(entity_id)) - assert state.state == STATUS_ON +async def test_turn_off_outlet_ok( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + local_oauth_impl: ClientSession, + mock_get_devices_two_outlets, + mock_get_status_filled, + mock_command_fn, +) -> None: + """Issue a turnoff command.""" - mock_get_status_filled.return_value = {RESULT: {STATUS: STATUS_OFF}} + entity_id = "switch.test_outlet_0_test_serial_ou_0" - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, + await check_command_ok( + entity_id=entity_id, + initial_status=STATUS_ON, + final_status=STATUS_OFF, + command=SERVICE_TURN_OFF, + hass=hass, + mock_config_entry=mock_config_entry, + local_oauth_impl=local_oauth_impl, + mock_get_status=mock_get_status_filled, + mock_command_fn=mock_command_fn, ) - await hass.async_block_till_done() - mock_command_fn.assert_called_once() - - assert (state := hass.states.get(entity_id)) - assert state.state == STATUS_OFF - async def test_setup_entry_ok_nodevices( hass: HomeAssistant, @@ -229,6 +291,40 @@ async def test_devices_insertion_ok( assert hass.states.async_entity_ids() == snapshot +async def test_outlet_insertion_ok( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + local_oauth_impl: ClientSession, + mock_get_devices_two_outlets, + mock_get_status_filled, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test iotty switch insertion.""" + + mock_config_entry.add_to_hass(hass) + + config_entry_oauth2_flow.async_register_implementation( + hass, DOMAIN, local_oauth_impl + ) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Should have two devices + assert hass.states.async_entity_ids_count() == 2 + assert hass.states.async_entity_ids() == snapshot + + mock_get_devices_two_outlets.return_value = test_ou_one_added + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should have three devices + assert hass.states.async_entity_ids_count() == 3 + assert hass.states.async_entity_ids() == snapshot + + async def test_api_not_ok_entities_stay_the_same_as_before( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py index ab5998c922f..031ff3c31d4 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -6,6 +6,7 @@ from pyipma.forecast import Forecast, Forecast_Location, Weather_Type from pyipma.observation import Observation from pyipma.rcm import RCM from pyipma.uv import UV +from pyipma.warnings import Warning from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME @@ -20,6 +21,20 @@ ENTRY_CONFIG = { class MockLocation: """Mock Location from pyipma.""" + async def warnings(self, api): + """Mock Warnings.""" + return [ + Warning( + text="Na costa Sul, ondas de sueste com 2 a 2,5 metros, em especial " + "no barlavento.", + awarenessTypeName="Agitação Marítima", + idAreaAviso="FAR", + startTime=datetime(2024, 12, 26, 12, 24), + awarenessLevelID="yellow", + endTime=datetime(2024, 12, 28, 6, 0), + ) + ] + async def fire_risk(self, api): """Mock Fire Risk.""" return RCM("some place", 3, (0, 0)) diff --git a/tests/components/ipma/test_sensor.py b/tests/components/ipma/test_sensor.py index adff8206add..455a85002d3 100644 --- a/tests/components/ipma/test_sensor.py +++ b/tests/components/ipma/test_sensor.py @@ -35,3 +35,19 @@ async def test_ipma_uv_index_create_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.hometown_uv_index") assert state.state == "6" + + +async def test_ipma_warning_create_sensors(hass: HomeAssistant) -> None: + """Test creation of warning sensors.""" + + with patch("pyipma.location.Location.get", return_value=MockLocation()): + entry = MockConfigEntry(domain="ipma", data=ENTRY_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hometown_weather_alert") + + assert state.state == "yellow" + + assert state.attributes["awarenessTypeName"] == "Agitação Marítima" diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index ca374bd7e5e..89b54e22bbb 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -2,9 +2,9 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.ipp.const import CONF_BASE_PATH from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo ATTR_HOSTNAME = "hostname" ATTR_PROPERTIES = "properties" @@ -30,7 +30,7 @@ MOCK_USER_INPUT = { CONF_BASE_PATH: BASE_PATH, } -MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_IPP_SERVICE_INFO = ZeroconfServiceInfo( type=IPP_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPP_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), @@ -40,7 +40,7 @@ MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( properties={"rp": ZEROCONF_RP}, ) -MOCK_ZEROCONF_IPPS_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_IPPS_SERVICE_INFO = ZeroconfServiceInfo( type=IPPS_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPPS_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index a7c3592ae73..f14043c096e 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -5,7 +5,22 @@ from unittest.mock import AsyncMock, MagicMock, patch from bleak.backends.device import BLEDevice from habluetooth import BluetoothServiceInfoBleak -from pynecil import DeviceInfoResponse, LiveDataResponse, OperatingMode, PowerSource +from pynecil import ( + AnimationSpeed, + AutostartMode, + BatteryType, + DeviceInfoResponse, + LatestRelease, + LiveDataResponse, + LockingMode, + LogoDuration, + OperatingMode, + PowerSource, + ScreenOrientationMode, + ScrollSpeed, + SettingsDataResponse, + TempUnit, +) import pytest from homeassistant.components.iron_os import DOMAIN @@ -108,24 +123,20 @@ def mock_ble_device() -> Generator[MagicMock]: @pytest.fixture(autouse=True) -def mock_githubapi() -> Generator[AsyncMock]: - """Mock aiogithubapi.""" +def mock_ironosupdate() -> Generator[AsyncMock]: + """Mock IronOSUpdate.""" with patch( - "homeassistant.components.iron_os.GitHubAPI", + "homeassistant.components.iron_os.IronOSUpdate", autospec=True, ) as mock_client: client = mock_client.return_value - client.repos.releases.latest = AsyncMock() - - client.repos.releases.latest.return_value.data.html_url = ( - "https://github.com/Ralim/IronOS/releases/tag/v2.22" + client.latest_release.return_value = LatestRelease( + html_url="https://github.com/Ralim/IronOS/releases/tag/v2.22", + name="V2.22 | TS101 & S60 Added | PinecilV2 improved", + tag_name="v2.22", + body="**RELEASE_NOTES**", ) - client.repos.releases.latest.return_value.data.name = ( - "V2.22 | TS101 & S60 Added | PinecilV2 improved" - ) - client.repos.releases.latest.return_value.data.tag_name = "v2.22" - client.repos.releases.latest.return_value.data.body = "**RELEASE_NOTES**" yield client @@ -145,6 +156,42 @@ def mock_pynecil() -> Generator[AsyncMock]: device_sn="0000c0ffeec0ffee", name=DEFAULT_NAME, ) + client.get_settings.return_value = SettingsDataResponse( + sleep_temp=150, + sleep_timeout=5, + min_dc_voltage_cells=BatteryType.BATTERY_3S, + min_volltage_per_cell=3.3, + qc_ideal_voltage=9.0, + accel_sensitivity=7, + shutdown_time=10, + keep_awake_pulse_power=0.5, + keep_awake_pulse_delay=4, + keep_awake_pulse_duration=1, + voltage_div=600, + boost_temp=420, + calibration_offset=900, + power_limit=12.0, + temp_increment_long=10, + temp_increment_short=1, + hall_sensitivity=7, + pd_negotiation_timeout=2.0, + display_brightness=3, + orientation_mode=ScreenOrientationMode.RIGHT_HANDED, + animation_speed=AnimationSpeed.MEDIUM, + autostart_mode=AutostartMode.IDLE, + temp_unit=TempUnit.CELSIUS, + desc_scroll_speed=ScrollSpeed.FAST, + logo_duration=LogoDuration.LOOP, + locking_mode=LockingMode.FULL_LOCKING, + animation_loop=True, + cooling_temp_blink=True, + idle_screen_details=True, + solder_screen_details=True, + invert_buttons=True, + display_invert=True, + calibrate_cjc=True, + usb_pd_mode=True, + ) client.get_live_data.return_value = LiveDataResponse( live_temp=298, setpoint_temp=300, diff --git a/tests/components/iron_os/snapshots/test_binary_sensor.ambr b/tests/components/iron_os/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..17b49c1d687 --- /dev/null +++ b/tests/components/iron_os/snapshots/test_binary_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.pinecil_soldering_tip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.pinecil_soldering_tip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Soldering tip', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_tip_connected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.pinecil_soldering_tip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Pinecil Soldering tip', + }), + 'context': , + 'entity_id': 'binary_sensor.pinecil_soldering_tip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/iron_os/snapshots/test_button.ambr b/tests/components/iron_os/snapshots/test_button.ambr new file mode 100644 index 00000000000..64a71f5e424 --- /dev/null +++ b/tests/components/iron_os/snapshots/test_button.ambr @@ -0,0 +1,93 @@ +# serializer version: 1 +# name: test_button_platform[button.pinecil_restore_default_settings-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.pinecil_restore_default_settings', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Restore default settings', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_settings_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_platform[button.pinecil_restore_default_settings-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Restore default settings', + }), + 'context': , + 'entity_id': 'button.pinecil_restore_default_settings', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_platform[button.pinecil_save_settings-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.pinecil_save_settings', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Save settings', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_settings_save', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_platform[button.pinecil_save_settings-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Save settings', + }), + 'context': , + 'entity_id': 'button.pinecil_save_settings', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/iron_os/snapshots/test_diagnostics.ambr b/tests/components/iron_os/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f8db1262254 --- /dev/null +++ b/tests/components/iron_os/snapshots/test_diagnostics.ambr @@ -0,0 +1,18 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry_data': dict({ + 'address': 'c0:ff:ee:c0:ff:ee', + }), + 'device_info': dict({ + '__type': "", + 'repr': "DeviceInfoResponse(build='v2.22', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=False)", + }), + 'live_data': dict({ + '__type': "", + 'repr': 'LiveDataResponse(live_temp=298, setpoint_temp=300, dc_voltage=20.6, handle_temp=36.3, pwm_level=41, power_src=, tip_resistance=6.2, uptime=1671, movement_time=10000, max_tip_temp_ability=460, tip_voltage=2212, hall_sensor=0, operating_mode=, estimated_power=24.8)', + }), + 'settings_data': dict({ + }), + }) +# --- diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index 2f5ee62e37e..fc4fe96d746 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -1,4 +1,732 @@ # serializer version: 1 +# name: test_state[number.pinecil_boost_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 450, + 'min': 0, + 'mode': , + 'step': 10, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_boost_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Boost temperature', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_boost_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_boost_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pinecil Boost temperature', + 'max': 450, + 'min': 0, + 'mode': , + 'step': 10, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_boost_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '420', + }) +# --- +# name: test_state[number.pinecil_calibration_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2500, + 'min': 100, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_calibration_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Calibration offset', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_calibration_offset', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_calibration_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Calibration offset', + 'max': 2500, + 'min': 100, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_calibration_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '900', + }) +# --- +# name: test_state[number.pinecil_display_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_display_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display brightness', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_display_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[number.pinecil_display_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Display brightness', + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.pinecil_display_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_state[number.pinecil_hall_effect_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_hall_effect_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hall effect sensitivity', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_hall_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[number.pinecil_hall_effect_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Hall effect sensitivity', + 'max': 9, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.pinecil_hall_effect_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_state[number.pinecil_keep_awake_pulse_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 22.5, + 'min': 2.5, + 'mode': , + 'step': 2.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_keep_awake_pulse_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Keep-awake pulse delay', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_keep_awake_pulse_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_keep_awake_pulse_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Keep-awake pulse delay', + 'max': 22.5, + 'min': 2.5, + 'mode': , + 'step': 2.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_keep_awake_pulse_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_state[number.pinecil_keep_awake_pulse_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2250, + 'min': 250, + 'mode': , + 'step': 250, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_keep_awake_pulse_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Keep-awake pulse duration', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_keep_awake_pulse_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_keep_awake_pulse_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Keep-awake pulse duration', + 'max': 2250, + 'min': 250, + 'mode': , + 'step': 250, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_keep_awake_pulse_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '250', + }) +# --- +# name: test_state[number.pinecil_keep_awake_pulse_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.9, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_keep_awake_pulse_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Keep-awake pulse intensity', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_keep_awake_pulse_power', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_keep_awake_pulse_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Keep-awake pulse intensity', + 'max': 9.9, + 'min': 0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_keep_awake_pulse_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_state[number.pinecil_long_press_temperature_step-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 90, + 'min': 5, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_long_press_temperature_step', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Long-press temperature step', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_temp_increment_long', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_long_press_temperature_step-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Long-press temperature step', + 'max': 90, + 'min': 5, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_long_press_temperature_step', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_state[number.pinecil_min_voltage_per_cell-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3.8, + 'min': 2.4, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_min_voltage_per_cell', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Min. voltage per cell', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_min_voltage_per_cell', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_min_voltage_per_cell-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Min. voltage per cell', + 'max': 3.8, + 'min': 2.4, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_min_voltage_per_cell', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_state[number.pinecil_motion_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_motion_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion sensitivity', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_accel_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[number.pinecil_motion_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Motion sensitivity', + 'max': 9, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.pinecil_motion_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_state[number.pinecil_power_delivery_timeout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5.0, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_power_delivery_timeout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power Delivery timeout', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_pd_timeout', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_power_delivery_timeout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Pinecil Power Delivery timeout', + 'max': 5.0, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_power_delivery_timeout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_state[number.pinecil_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 120, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_power_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power limit', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_power_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Power limit', + 'max': 120, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_state[number.pinecil_quick_charge_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 22.0, + 'min': 9.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_quick_charge_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Quick Charge voltage', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_qc_max_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_quick_charge_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Pinecil Quick Charge voltage', + 'max': 22.0, + 'min': 9.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_quick_charge_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- # name: test_state[number.pinecil_setpoint_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -56,3 +784,284 @@ 'state': '300', }) # --- +# name: test_state[number.pinecil_short_press_temperature_step-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 50, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_short_press_temperature_step', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Short-press temperature step', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_temp_increment_short', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_short_press_temperature_step-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Short-press temperature step', + 'max': 50, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_short_press_temperature_step', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_state[number.pinecil_shutdown_timeout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_shutdown_timeout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Shutdown timeout', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_shutdown_timeout', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_shutdown_timeout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Pinecil Shutdown timeout', + 'max': 60, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_shutdown_timeout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_state[number.pinecil_sleep_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 450, + 'min': 10, + 'mode': , + 'step': 10, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_sleep_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sleep temperature', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_sleep_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_sleep_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pinecil Sleep temperature', + 'max': 450, + 'min': 10, + 'mode': , + 'step': 10, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_sleep_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- +# name: test_state[number.pinecil_sleep_timeout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 15, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_sleep_timeout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sleep timeout', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_sleep_timeout', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_sleep_timeout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Sleep timeout', + 'max': 15, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_sleep_timeout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_state[number.pinecil_voltage_divider-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 900, + 'min': 360, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_voltage_divider', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voltage divider', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_voltage_div', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[number.pinecil_voltage_divider-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Voltage divider', + 'max': 900, + 'min': 360, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.pinecil_voltage_divider', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '600', + }) +# --- diff --git a/tests/components/iron_os/snapshots/test_select.ambr b/tests/components/iron_os/snapshots/test_select.ambr new file mode 100644 index 00000000000..e3989fbf863 --- /dev/null +++ b/tests/components/iron_os/snapshots/test_select.ambr @@ -0,0 +1,524 @@ +# serializer version: 1 +# name: test_state[select.pinecil_animation_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'slow', + 'medium', + 'fast', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_animation_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Animation speed', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_animation_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_animation_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Animation speed', + 'options': list([ + 'off', + 'slow', + 'medium', + 'fast', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_animation_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'medium', + }) +# --- +# name: test_state[select.pinecil_boot_logo_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'seconds_1', + 'seconds_2', + 'seconds_3', + 'seconds_4', + 'seconds_5', + 'loop', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_boot_logo_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Boot logo duration', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_logo_duration', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_boot_logo_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Boot logo duration', + 'options': list([ + 'off', + 'seconds_1', + 'seconds_2', + 'seconds_3', + 'seconds_4', + 'seconds_5', + 'loop', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_boot_logo_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'loop', + }) +# --- +# name: test_state[select.pinecil_button_locking_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'boost_only', + 'full_locking', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_button_locking_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Button locking mode', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_locking_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_button_locking_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Button locking mode', + 'options': list([ + 'off', + 'boost_only', + 'full_locking', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_button_locking_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'full_locking', + }) +# --- +# name: test_state[select.pinecil_display_orientation_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'right_handed', + 'left_handed', + 'auto', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_display_orientation_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display orientation mode', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_orientation_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_display_orientation_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Display orientation mode', + 'options': list([ + 'right_handed', + 'left_handed', + 'auto', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_display_orientation_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'right_handed', + }) +# --- +# name: test_state[select.pinecil_power_delivery_3_1_epr-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'on', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_power_delivery_3_1_epr', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power Delivery 3.1 EPR', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_usb_pd_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_power_delivery_3_1_epr-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Power Delivery 3.1 EPR', + 'options': list([ + 'off', + 'on', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_power_delivery_3_1_epr', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_state[select.pinecil_power_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_battery', + 'battery_3s', + 'battery_4s', + 'battery_5s', + 'battery_6s', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_power_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power source', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_min_dc_voltage_cells', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_power_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Power source', + 'options': list([ + 'no_battery', + 'battery_3s', + 'battery_4s', + 'battery_5s', + 'battery_6s', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_power_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'battery_3s', + }) +# --- +# name: test_state[select.pinecil_scrolling_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'slow', + 'fast', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_scrolling_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Scrolling speed', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_desc_scroll_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_scrolling_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Scrolling speed', + 'options': list([ + 'slow', + 'fast', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_scrolling_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'fast', + }) +# --- +# name: test_state[select.pinecil_start_up_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'disabled', + 'soldering', + 'sleeping', + 'idle', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_start_up_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start-up behavior', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_autostart_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_start_up_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Start-up behavior', + 'options': list([ + 'disabled', + 'soldering', + 'sleeping', + 'idle', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_start_up_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_state[select.pinecil_temperature_display_unit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_temperature_display_unit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature display unit', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_temp_unit', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_temperature_display_unit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Temperature display unit', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_temperature_display_unit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'celsius', + }) +# --- diff --git a/tests/components/iron_os/snapshots/test_sensor.ambr b/tests/components/iron_os/snapshots/test_sensor.ambr index 64cb951dacc..0eb8e81fb4f 100644 --- a/tests/components/iron_os/snapshots/test_sensor.ambr +++ b/tests/components/iron_os/snapshots/test_sensor.ambr @@ -313,6 +313,15 @@ 'sleeping', 'settings', 'debug', + 'soldering_profile', + 'temperature_adjust', + 'usb_pd_debug', + 'thermal_runaway', + 'startup_logo', + 'cjc_calibration', + 'startup_warnings', + 'initialisation_done', + 'hibernating', ]), }), 'config_entry_id': , @@ -354,6 +363,15 @@ 'sleeping', 'settings', 'debug', + 'soldering_profile', + 'temperature_adjust', + 'usb_pd_debug', + 'thermal_runaway', + 'startup_logo', + 'cjc_calibration', + 'startup_warnings', + 'initialisation_done', + 'hibernating', ]), }), 'context': , @@ -502,7 +520,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 3, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -513,7 +531,7 @@ 'supported_features': 0, 'translation_key': , 'unique_id': 'c0:ff:ee:c0:ff:ee_tip_voltage', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[sensor.pinecil_raw_tip_voltage-state] @@ -522,7 +540,7 @@ 'device_class': 'voltage', 'friendly_name': 'Pinecil Raw tip voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.pinecil_raw_tip_voltage', @@ -537,7 +555,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -569,6 +589,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Pinecil Tip resistance', + 'state_class': , 'unit_of_measurement': 'Ω', }), 'context': , diff --git a/tests/components/iron_os/snapshots/test_switch.ambr b/tests/components/iron_os/snapshots/test_switch.ambr new file mode 100644 index 00000000000..f13cdcfe666 --- /dev/null +++ b/tests/components/iron_os/snapshots/test_switch.ambr @@ -0,0 +1,323 @@ +# serializer version: 1 +# name: test_switch_platform[switch.pinecil_animation_loop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_animation_loop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Animation loop', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_animation_loop', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_animation_loop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Animation loop', + }), + 'context': , + 'entity_id': 'switch.pinecil_animation_loop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_calibrate_cjc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_calibrate_cjc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Calibrate CJC', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_calibrate_cjc', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_calibrate_cjc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Calibrate CJC', + }), + 'context': , + 'entity_id': 'switch.pinecil_calibrate_cjc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_cool_down_screen_flashing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_cool_down_screen_flashing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cool down screen flashing', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_cooling_temp_blink', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_cool_down_screen_flashing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Cool down screen flashing', + }), + 'context': , + 'entity_id': 'switch.pinecil_cool_down_screen_flashing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_detailed_idle_screen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_detailed_idle_screen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Detailed idle screen', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_idle_screen_details', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_detailed_idle_screen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Detailed idle screen', + }), + 'context': , + 'entity_id': 'switch.pinecil_detailed_idle_screen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_detailed_solder_screen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_detailed_solder_screen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Detailed solder screen', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_solder_screen_details', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_detailed_solder_screen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Detailed solder screen', + }), + 'context': , + 'entity_id': 'switch.pinecil_detailed_solder_screen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_invert_screen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_invert_screen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Invert screen', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_display_invert', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_invert_screen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Invert screen', + }), + 'context': , + 'entity_id': 'switch.pinecil_invert_screen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_swap_buttons-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_swap_buttons', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Swap +/- buttons', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_invert_buttons', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_swap_buttons-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Swap +/- buttons', + }), + 'context': , + 'entity_id': 'switch.pinecil_swap_buttons', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/iron_os/test_binary_sensor.py b/tests/components/iron_os/test_binary_sensor.py new file mode 100644 index 00000000000..291fbf80573 --- /dev/null +++ b/tests/components/iron_os/test_binary_sensor.py @@ -0,0 +1,77 @@ +"""Tests for the Pinecil Binary Sensors.""" + +from collections.abc import AsyncGenerator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pynecil import LiveDataResponse +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import STATE_OFF, STATE_ON +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +async def binary_sensor_only() -> AsyncGenerator[None]: + """Enable only the binary sensor platform.""" + with patch( + "homeassistant.components.iron_os.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + yield + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_binary_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the Pinecil binary sensor platform.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "ble_device", "mock_pynecil" +) +async def test_tip_on_off( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test tip_connected binary sensor on/off states.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.states.get("binary_sensor.pinecil_soldering_tip").state == STATE_ON + + mock_pynecil.get_live_data.return_value = LiveDataResponse( + live_temp=479, + max_tip_temp_ability=460, + ) + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.pinecil_soldering_tip").state == STATE_OFF diff --git a/tests/components/iron_os/test_button.py b/tests/components/iron_os/test_button.py new file mode 100644 index 00000000000..ce8a3ca491e --- /dev/null +++ b/tests/components/iron_os/test_button.py @@ -0,0 +1,106 @@ +"""Tests for the IronOS button platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from pynecil import CharSetting, CommunicationError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def button_only() -> AsyncGenerator[None]: + """Enable only the button platform.""" + with patch( + "homeassistant.components.iron_os.PLATFORMS", + [Platform.BUTTON], + ): + yield + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_button_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the IronOS button platform.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "call_args"), + [ + ("button.pinecil_save_settings", (CharSetting.SETTINGS_SAVE, True)), + ("button.pinecil_restore_default_settings", (CharSetting.SETTINGS_RESET, True)), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_button_press( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + entity_id: str, + call_args: tuple[tuple[CharSetting, bool]], +) -> None: + """Test button press method.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_pynecil.write.assert_called_once_with(*call_args) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_button_press_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, +) -> None: + """Test button press method.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_pynecil.write.side_effect = CommunicationError + + with pytest.raises( + ServiceValidationError, + match="Failed to submit setting to device, try again later", + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.pinecil_save_settings"}, + blocking=True, + ) diff --git a/tests/components/iron_os/test_config_flow.py b/tests/components/iron_os/test_config_flow.py index 231ec6cc3d6..e1ac8fb9f00 100644 --- a/tests/components/iron_os/test_config_flow.py +++ b/tests/components/iron_os/test_config_flow.py @@ -4,6 +4,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock +import pytest + from homeassistant.components.iron_os import DOMAIN from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER from homeassistant.core import HomeAssistant @@ -11,9 +13,12 @@ from homeassistant.data_entry_flow import FlowResultType from .conftest import DEFAULT_NAME, PINECIL_SERVICE_INFO, USER_INPUT +from tests.common import MockConfigEntry -async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, discovery: MagicMock + +@pytest.mark.usefixtures("discovery") +async def test_async_step_user( + hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test the user config flow.""" result = await hass.config_entries.flow.async_init( @@ -32,10 +37,31 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("discovery") +async def test_async_step_user_device_added_between_steps( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test the device gets added via another flow between steps.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") async def test_form_no_device_discovered( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - discovery: MagicMock, + hass: HomeAssistant, discovery: MagicMock ) -> None: """Test setup with no device discoveries.""" discovery.return_value = [] @@ -48,7 +74,7 @@ async def test_form_no_device_discovered( async def test_async_step_bluetooth(hass: HomeAssistant) -> None: - """Test discovery via bluetooth..""" + """Test discovery via bluetooth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, @@ -64,3 +90,19 @@ async def test_async_step_bluetooth(hass: HomeAssistant) -> None: assert result["title"] == DEFAULT_NAME assert result["data"] == {} assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee" + + +async def test_async_step_bluetooth_devices_already_setup( + hass: HomeAssistant, config_entry: AsyncMock +) -> None: + """Test we can't start a flow if there is already a config entry.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=PINECIL_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/iron_os/test_diagnostics.py b/tests/components/iron_os/test_diagnostics.py new file mode 100644 index 00000000000..05f627e6bc6 --- /dev/null +++ b/tests/components/iron_os/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for IronOS diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/iron_os/test_init.py b/tests/components/iron_os/test_init.py index f7db2a813ec..4749e1b6199 100644 --- a/tests/components/iron_os/test_init.py +++ b/tests/components/iron_os/test_init.py @@ -1,14 +1,17 @@ """Test init of IronOS integration.""" +from datetime import timedelta from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from pynecil import CommunicationError import pytest from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("mock_pynecil", "ble_device") @@ -45,16 +48,44 @@ async def test_update_data_config_entry_not_ready( assert config_entry.state is ConfigEntryState.SETUP_RETRY -@pytest.mark.usefixtures("ble_device") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") async def test_setup_config_entry_not_ready( hass: HomeAssistant, config_entry: MockConfigEntry, mock_pynecil: AsyncMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test config entry not ready.""" + mock_pynecil.get_settings.side_effect = CommunicationError mock_pynecil.get_device_info.side_effect = CommunicationError config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_settings_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test skipping of settings on exception.""" + mock_pynecil.get_settings.side_effect = CommunicationError + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("number.pinecil_boost_temperature")) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py index 781492987ee..bdec922a88c 100644 --- a/tests/components/iron_os/test_number.py +++ b/tests/components/iron_os/test_number.py @@ -1,8 +1,10 @@ """Tests for the IronOS number platform.""" from collections.abc import AsyncGenerator +from datetime import timedelta from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory from pynecil import CharSetting, CommunicationError import pytest from syrupy.assertion import SnapshotAssertion @@ -18,11 +20,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture(autouse=True) -async def sensor_only() -> AsyncGenerator[None]: +async def number_only() -> AsyncGenerator[None]: """Enable only the number platform.""" with patch( "homeassistant.components.iron_os.PLATFORMS", @@ -39,6 +41,7 @@ async def test_state( config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test the IronOS number platform states.""" config_entry.add_to_hass(hass) @@ -47,14 +50,105 @@ async def test_state( assert config_entry.state is ConfigEntryState.LOADED + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.parametrize( + ("entity_id", "characteristic", "value", "expected_value"), + [ + ( + "number.pinecil_setpoint_temperature", + CharSetting.SETPOINT_TEMP, + 300, + 300, + ), + ( + "number.pinecil_boost_temperature", + CharSetting.BOOST_TEMP, + 420, + 420, + ), + ( + "number.pinecil_calibration_offset", + CharSetting.CALIBRATION_OFFSET, + 600, + 600, + ), + ( + "number.pinecil_display_brightness", + CharSetting.DISPLAY_BRIGHTNESS, + 3, + 3, + ), + ( + "number.pinecil_hall_effect_sensitivity", + CharSetting.HALL_SENSITIVITY, + 7, + 7, + ), + ( + "number.pinecil_keep_awake_pulse_delay", + CharSetting.KEEP_AWAKE_PULSE_DELAY, + 10.0, + 4, + ), + ( + "number.pinecil_keep_awake_pulse_duration", + CharSetting.KEEP_AWAKE_PULSE_DURATION, + 500, + 2, + ), + ( + "number.pinecil_keep_awake_pulse_intensity", + CharSetting.KEEP_AWAKE_PULSE_POWER, + 0.5, + 0.5, + ), + ( + "number.pinecil_long_press_temperature_step", + CharSetting.TEMP_INCREMENT_LONG, + 10, + 10, + ), + ( + "number.pinecil_min_voltage_per_cell", + CharSetting.MIN_VOLTAGE_PER_CELL, + 3.3, + 3.3, + ), + ("number.pinecil_motion_sensitivity", CharSetting.ACCEL_SENSITIVITY, 7, 7), + ( + "number.pinecil_power_delivery_timeout", + CharSetting.PD_NEGOTIATION_TIMEOUT, + 2.0, + 2.0, + ), + ("number.pinecil_power_limit", CharSetting.POWER_LIMIT, 120, 120), + ("number.pinecil_quick_charge_voltage", CharSetting.QC_IDEAL_VOLTAGE, 9.0, 9.0), + ( + "number.pinecil_short_press_temperature_step", + CharSetting.TEMP_INCREMENT_SHORT, + 1, + 1, + ), + ("number.pinecil_shutdown_timeout", CharSetting.SHUTDOWN_TIME, 10, 10), + ("number.pinecil_sleep_temperature", CharSetting.SLEEP_TEMP, 150, 150), + ("number.pinecil_sleep_timeout", CharSetting.SLEEP_TIMEOUT, 5, 5), + ("number.pinecil_voltage_divider", CharSetting.VOLTAGE_DIV, 600, 600), + ], +) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") async def test_set_value( hass: HomeAssistant, config_entry: MockConfigEntry, mock_pynecil: AsyncMock, + entity_id: str, + characteristic: CharSetting, + value: float, + expected_value: float, ) -> None: """Test the IronOS number platform set value service.""" @@ -67,12 +161,12 @@ async def test_set_value( await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - service_data={ATTR_VALUE: 300}, - target={ATTR_ENTITY_ID: "number.pinecil_setpoint_temperature"}, + service_data={ATTR_VALUE: value}, + target={ATTR_ENTITY_ID: entity_id}, blocking=True, ) assert len(mock_pynecil.write.mock_calls) == 1 - mock_pynecil.write.assert_called_once_with(CharSetting.SETPOINT_TEMP, 300) + mock_pynecil.write.assert_called_once_with(characteristic, expected_value) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") diff --git a/tests/components/iron_os/test_select.py b/tests/components/iron_os/test_select.py new file mode 100644 index 00000000000..8cc848dd4cb --- /dev/null +++ b/tests/components/iron_os/test_select.py @@ -0,0 +1,170 @@ +"""Tests for the IronOS select platform.""" + +from collections.abc import AsyncGenerator +from datetime import timedelta +from enum import Enum +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pynecil import ( + AnimationSpeed, + BatteryType, + CharSetting, + CommunicationError, + LockingMode, + LogoDuration, + ScreenOrientationMode, + ScrollSpeed, + TempUnit, + USBPDMode, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +async def select_only() -> AsyncGenerator[None]: + """Enable only the select platform.""" + with patch( + "homeassistant.components.iron_os.PLATFORMS", + [Platform.SELECT], + ): + yield + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the IronOS select platform states.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "option", "call_params"), + [ + ( + "select.pinecil_power_source", + "battery_3s", + (CharSetting.MIN_DC_VOLTAGE_CELLS, BatteryType.BATTERY_3S), + ), + ( + "select.pinecil_display_orientation_mode", + "right_handed", + (CharSetting.ORIENTATION_MODE, ScreenOrientationMode.RIGHT_HANDED), + ), + ( + "select.pinecil_animation_speed", + "medium", + (CharSetting.ANIMATION_SPEED, AnimationSpeed.MEDIUM), + ), + ( + "select.pinecil_temperature_display_unit", + "fahrenheit", + (CharSetting.TEMP_UNIT, TempUnit.FAHRENHEIT), + ), + ( + "select.pinecil_scrolling_speed", + "fast", + (CharSetting.DESC_SCROLL_SPEED, ScrollSpeed.FAST), + ), + ( + "select.pinecil_button_locking_mode", + "full_locking", + (CharSetting.LOCKING_MODE, LockingMode.FULL_LOCKING), + ), + ( + "select.pinecil_boot_logo_duration", + "loop", + (CharSetting.LOGO_DURATION, LogoDuration.LOOP), + ), + ( + "select.pinecil_power_delivery_3_1_epr", + "on", + (CharSetting.USB_PD_MODE, USBPDMode.ON), + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_select_option( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + entity_id: str, + option: str, + call_params: tuple[Enum, ...], +) -> None: + """Test the IronOS select option service.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + service_data={ATTR_OPTION: option}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert len(mock_pynecil.write.mock_calls) == 1 + mock_pynecil.write.assert_called_once_with(*call_params) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_select_option_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, +) -> None: + """Test the IronOS select option service exception.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_pynecil.write.side_effect = CommunicationError + + with pytest.raises( + ServiceValidationError, + match="Failed to submit setting to device, try again later", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + service_data={ATTR_OPTION: "battery_3s"}, + target={ATTR_ENTITY_ID: "select.pinecil_power_source"}, + blocking=True, + ) diff --git a/tests/components/iron_os/test_sensor.py b/tests/components/iron_os/test_sensor.py index 2f79487a7fd..fec111c5799 100644 --- a/tests/components/iron_os/test_sensor.py +++ b/tests/components/iron_os/test_sensor.py @@ -4,13 +4,13 @@ from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from pynecil import CommunicationError +from pynecil import CommunicationError, LiveDataResponse import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.iron_os.coordinator import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -71,3 +71,34 @@ async def test_sensors_unavailable( ) for entity_entry in entity_entries: assert hass.states.get(entity_entry.entity_id).state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "ble_device", "mock_pynecil" +) +async def test_tip_detection( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + ble_device: MagicMock, +) -> None: + """Test sensor state is unknown when tip is disconnected.""" + + mock_pynecil.get_live_data.return_value = LiveDataResponse( + live_temp=479, + max_tip_temp_ability=460, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + entities = { + "sensor.pinecil_tip_temperature", + "sensor.pinecil_max_tip_temperature", + "sensor.pinecil_raw_tip_voltage", + "sensor.pinecil_tip_resistance", + } + for entity_id in entities: + assert hass.states.get(entity_id).state == STATE_UNKNOWN diff --git a/tests/components/iron_os/test_switch.py b/tests/components/iron_os/test_switch.py new file mode 100644 index 00000000000..d52c3fd333b --- /dev/null +++ b/tests/components/iron_os/test_switch.py @@ -0,0 +1,145 @@ +"""Tests for the IronOS switch platform.""" + +from collections.abc import AsyncGenerator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pynecil import CharSetting, CommunicationError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +async def switch_only() -> AsyncGenerator[None]: + """Enable only the switch platform.""" + with patch( + "homeassistant.components.iron_os.PLATFORMS", + [Platform.SWITCH], + ): + yield + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_switch_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the IronOS switch platform.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "target"), + [ + ("switch.pinecil_animation_loop", CharSetting.ANIMATION_LOOP), + ("switch.pinecil_calibrate_cjc", CharSetting.CALIBRATE_CJC), + ("switch.pinecil_cool_down_screen_flashing", CharSetting.COOLING_TEMP_BLINK), + ("switch.pinecil_detailed_idle_screen", CharSetting.IDLE_SCREEN_DETAILS), + ("switch.pinecil_detailed_solder_screen", CharSetting.SOLDER_SCREEN_DETAILS), + ("switch.pinecil_invert_screen", CharSetting.DISPLAY_INVERT), + ("switch.pinecil_swap_buttons", CharSetting.INVERT_BUTTONS), + ], +) +@pytest.mark.parametrize( + ("service", "value"), + [ + (SERVICE_TOGGLE, False), + (SERVICE_TURN_OFF, False), + (SERVICE_TURN_ON, True), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_turn_on_off_toggle( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + freezer: FrozenDateTimeFactory, + service: str, + value: bool, + entity_id: str, + target: CharSetting, +) -> None: + """Test the IronOS switch turn on/off, toggle services.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert len(mock_pynecil.write.mock_calls) == 1 + mock_pynecil.write.assert_called_once_with(target, value) + + +@pytest.mark.parametrize( + "service", + [SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON], +) +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "ble_device", "mock_pynecil" +) +async def test_turn_on_off_toggle_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + service: str, +) -> None: + """Test the IronOS switch turn on/off, toggle service exceptions.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_pynecil.write.side_effect = CommunicationError + + with pytest.raises( + ServiceValidationError, + match="Failed to submit setting to device, try again later", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + service, + service_data={ATTR_ENTITY_ID: "switch.pinecil_animation_loop"}, + blocking=True, + ) diff --git a/tests/components/iron_os/test_update.py b/tests/components/iron_os/test_update.py index 7a2650ba7a3..47f3197da0e 100644 --- a/tests/components/iron_os/test_update.py +++ b/tests/components/iron_os/test_update.py @@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, patch -from aiogithubapi import GitHubException +from pynecil import UpdateException import pytest from syrupy.assertion import SnapshotAssertion @@ -26,7 +26,7 @@ async def update_only() -> AsyncGenerator[None]: yield -@pytest.mark.usefixtures("mock_pynecil", "ble_device", "mock_githubapi") +@pytest.mark.usefixtures("mock_pynecil", "ble_device", "mock_ironosupdate") async def test_update( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -60,11 +60,11 @@ async def test_update( async def test_update_unavailable( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_githubapi: AsyncMock, + mock_ironosupdate: AsyncMock, ) -> None: """Test update entity unavailable on error.""" - mock_githubapi.repos.releases.latest.side_effect = GitHubException + mock_ironosupdate.latest_release.side_effect = UpdateException config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/ista_ecotrend/test_statistics.py b/tests/components/ista_ecotrend/test_statistics.py index 21877f686df..aa4f71037c4 100644 --- a/tests/components/ista_ecotrend/test_statistics.py +++ b/tests/components/ista_ecotrend/test_statistics.py @@ -41,7 +41,7 @@ async def test_statistics_import( # Test that consumption statistics for 2 months have been added for entity in entities: - statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix("sensor.")}" + statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix('sensor.')}" stats = await hass.async_add_executor_job( statistics_during_period, hass, @@ -70,7 +70,7 @@ async def test_statistics_import( await async_wait_recording_done(hass) for entity in entities: - statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix("sensor.")}" + statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix('sensor.')}" stats = await hass.async_add_executor_job( statistics_during_period, hass, diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 2bc1fff222f..3a688f450d0 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -7,7 +7,6 @@ from pyisy import ISYConnectionError, ISYInvalidAuthError import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp from homeassistant.components.isy994.const import ( CONF_TLS_VER, DOMAIN, @@ -18,6 +17,12 @@ from homeassistant.config_entries import SOURCE_DHCP, SOURCE_IGNORE, SOURCE_SSDP from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -255,13 +260,13 @@ async def test_form_ssdp_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -274,13 +279,13 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -322,13 +327,13 @@ async def test_form_ssdp_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -353,13 +358,13 @@ async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -386,13 +391,13 @@ async def test_form_ssdp_existing_entry_with_alternate_port( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -417,13 +422,13 @@ async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"https://3.3.3.3/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -440,7 +445,7 @@ async def test_form_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -476,7 +481,7 @@ async def test_form_dhcp_with_polisy(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="polisy", macaddress=MOCK_POLISY_MAC, @@ -516,7 +521,7 @@ async def test_form_dhcp_with_eisy(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="eisy", macaddress=MOCK_MAC, @@ -564,7 +569,7 @@ async def test_form_dhcp_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -594,7 +599,7 @@ async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant) -> N result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -620,7 +625,7 @@ async def test_form_dhcp_existing_ignored_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, diff --git a/tests/components/ituran/__init__.py b/tests/components/ituran/__init__.py new file mode 100644 index 00000000000..52fccaad138 --- /dev/null +++ b/tests/components/ituran/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Ituran integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ituran/conftest.py b/tests/components/ituran/conftest.py new file mode 100644 index 00000000000..5093cc301a1 --- /dev/null +++ b/tests/components/ituran/conftest.py @@ -0,0 +1,87 @@ +"""Mocks for the Ituran integration.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, PropertyMock, patch +from zoneinfo import ZoneInfo + +import pytest + +from homeassistant.components.ituran.const import ( + CONF_ID_OR_PASSPORT, + CONF_MOBILE_ID, + CONF_PHONE_NUMBER, + DOMAIN, +) + +from .const import MOCK_CONFIG_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ituran.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=f"Ituran {MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT]}", + domain=DOMAIN, + data={ + CONF_ID_OR_PASSPORT: MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + CONF_PHONE_NUMBER: MOCK_CONFIG_DATA[CONF_PHONE_NUMBER], + CONF_MOBILE_ID: MOCK_CONFIG_DATA[CONF_MOBILE_ID], + }, + unique_id=MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + ) + + +class MockVehicle: + """Mock vehicle.""" + + def __init__(self) -> None: + """Initialize mock vehicle.""" + self.license_plate = "12345678" + self.make = "mock make" + self.model = "mock model" + self.mileage = 1000 + self.speed = 20 + self.gps_coordinates = (25.0, -71.0) + self.address = "Bermuda Triangle" + self.heading = 150 + self.last_update = datetime( + 2024, 1, 1, 0, 0, 0, tzinfo=ZoneInfo("Asia/Jerusalem") + ) + self.battery_voltage = 12.0 + + +@pytest.fixture +def mock_ituran() -> Generator[AsyncMock]: + """Return a mocked PalazzettiClient.""" + with ( + patch( + "homeassistant.components.ituran.coordinator.Ituran", + autospec=True, + ) as ituran, + patch( + "homeassistant.components.ituran.config_flow.Ituran", + new=ituran, + ), + ): + mock_ituran = ituran.return_value + mock_ituran.is_authenticated.return_value = False + mock_ituran.authenticate.return_value = True + mock_ituran.get_vehicles.return_value = [MockVehicle()] + type(mock_ituran).mobile_id = PropertyMock( + return_value=MOCK_CONFIG_DATA[CONF_MOBILE_ID] + ) + + yield mock_ituran diff --git a/tests/components/ituran/const.py b/tests/components/ituran/const.py new file mode 100644 index 00000000000..b566caebbbe --- /dev/null +++ b/tests/components/ituran/const.py @@ -0,0 +1,24 @@ +"""Constants for tests of the Ituran component.""" + +from typing import Any + +from homeassistant.components.ituran.const import ( + CONF_ID_OR_PASSPORT, + CONF_MOBILE_ID, + CONF_PHONE_NUMBER, + DOMAIN, +) + +MOCK_CONFIG_DATA: dict[str, str] = { + CONF_ID_OR_PASSPORT: "12345678", + CONF_PHONE_NUMBER: "0501234567", + CONF_MOBILE_ID: "0123456789abcdef", +} + +MOCK_CONFIG_ENTRY: dict[str, Any] = { + "domain": DOMAIN, + "entry_id": "1", + "source": "user", + "title": MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + "data": MOCK_CONFIG_DATA, +} diff --git a/tests/components/ituran/snapshots/test_device_tracker.ambr b/tests/components/ituran/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..3b650f7927f --- /dev/null +++ b/tests/components/ituran/snapshots/test_device_tracker.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_device_tracker[device_tracker.mock_model-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.mock_model', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'car', + 'unique_id': '12345678-device_tracker', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[device_tracker.mock_model-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model', + 'gps_accuracy': 0, + 'latitude': 25.0, + 'longitude': -71.0, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.mock_model', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/ituran/snapshots/test_init.ambr b/tests/components/ituran/snapshots/test_init.ambr new file mode 100644 index 00000000000..1e64ef9e850 --- /dev/null +++ b/tests/components/ituran/snapshots/test_init.ambr @@ -0,0 +1,35 @@ +# serializer version: 1 +# name: test_device + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ituran', + '12345678', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'mock make', + 'model': 'mock model', + 'model_id': None, + 'name': 'mock model', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '12345678', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/ituran/snapshots/test_sensor.ambr b/tests/components/ituran/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c1512de912f --- /dev/null +++ b/tests/components/ituran/snapshots/test_sensor.ambr @@ -0,0 +1,297 @@ +# serializer version: 1 +# name: test_sensor[sensor.mock_model_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_address', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Address', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'address', + 'unique_id': '12345678-address', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.mock_model_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model Address', + }), + 'context': , + 'entity_id': 'sensor.mock_model_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Bermuda Triangle', + }) +# --- +# name: test_sensor[sensor.mock_model_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '12345678-battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.mock_model_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'mock model Battery voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_sensor[sensor.mock_model_heading-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_heading', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heading', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heading', + 'unique_id': '12345678-heading', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[sensor.mock_model_heading-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model Heading', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.mock_model_heading', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- +# name: test_sensor[sensor.mock_model_last_update_from_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_last_update_from_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last update from vehicle', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_update_from_vehicle', + 'unique_id': '12345678-last_update_from_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.mock_model_last_update_from_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'mock model Last update from vehicle', + }), + 'context': , + 'entity_id': 'sensor.mock_model_last_update_from_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-12-31T22:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.mock_model_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': '12345678-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.mock_model_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'mock model Mileage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_sensor[sensor.mock_model_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Speed', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.mock_model_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'mock model Speed', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- diff --git a/tests/components/ituran/test_config_flow.py b/tests/components/ituran/test_config_flow.py new file mode 100644 index 00000000000..19253103ad7 --- /dev/null +++ b/tests/components/ituran/test_config_flow.py @@ -0,0 +1,254 @@ +"""Test the Ituran config flow.""" + +from unittest.mock import AsyncMock + +from pyituran.exceptions import IturanApiError, IturanAuthError +import pytest + +from homeassistant.components.ituran.const import ( + CONF_ID_OR_PASSPORT, + CONF_MOBILE_ID, + CONF_OTP, + CONF_PHONE_NUMBER, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration +from .const import MOCK_CONFIG_DATA + +from tests.common import MockConfigEntry + + +async def __do_successful_user_step( + hass: HomeAssistant, result: ConfigFlowResult, mock_ituran: AsyncMock +): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ID_OR_PASSPORT: MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + CONF_PHONE_NUMBER: MOCK_CONFIG_DATA[CONF_PHONE_NUMBER], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {} + + return result + + +async def __do_successful_otp_step( + hass: HomeAssistant, + result: ConfigFlowResult, + mock_ituran: AsyncMock, +): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_OTP: "123456", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Ituran {MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT]}" + assert result["data"][CONF_ID_OR_PASSPORT] == MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT] + assert result["data"][CONF_PHONE_NUMBER] == MOCK_CONFIG_DATA[CONF_PHONE_NUMBER] + assert result["data"][CONF_MOBILE_ID] is not None + assert result["result"].unique_id == MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT] + assert len(mock_ituran.is_authenticated.mock_calls) > 0 + assert len(mock_ituran.authenticate.mock_calls) > 0 + + return result + + +async def test_full_user_flow( + hass: HomeAssistant, mock_ituran: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await __do_successful_user_step(hass, result, mock_ituran) + await __do_successful_otp_step(hass, result, mock_ituran) + + +async def test_invalid_auth( + hass: HomeAssistant, mock_ituran: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test invalid credentials configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_ituran.request_otp.side_effect = IturanAuthError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ID_OR_PASSPORT: MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + CONF_PHONE_NUMBER: MOCK_CONFIG_DATA[CONF_PHONE_NUMBER], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + mock_ituran.request_otp.side_effect = None + result = await __do_successful_user_step(hass, result, mock_ituran) + await __do_successful_otp_step(hass, result, mock_ituran) + + +async def test_invalid_otp( + hass: HomeAssistant, mock_ituran: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test invalid OTP configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await __do_successful_user_step(hass, result, mock_ituran) + + mock_ituran.authenticate.side_effect = IturanAuthError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_OTP: "123456", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_otp"} + + mock_ituran.authenticate.side_effect = None + await __do_successful_otp_step(hass, result, mock_ituran) + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [(IturanApiError, "cannot_connect"), (Exception, "unknown")], +) +async def test_errors( + hass: HomeAssistant, + mock_ituran: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test connection errors during configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_ituran.request_otp.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ID_OR_PASSPORT: MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + CONF_PHONE_NUMBER: MOCK_CONFIG_DATA[CONF_PHONE_NUMBER], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + mock_ituran.request_otp.side_effect = None + result = await __do_successful_user_step(hass, result, mock_ituran) + + mock_ituran.authenticate.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_OTP: "123456", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_ituran.authenticate.side_effect = None + await __do_successful_otp_step(hass, result, mock_ituran) + + +async def test_already_authenticated( + hass: HomeAssistant, mock_ituran: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test user already authenticated configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_ituran.is_authenticated.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ID_OR_PASSPORT: MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT], + CONF_PHONE_NUMBER: MOCK_CONFIG_DATA[CONF_PHONE_NUMBER], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Ituran {MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT]}" + assert result["data"][CONF_ID_OR_PASSPORT] == MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT] + assert result["data"][CONF_PHONE_NUMBER] == MOCK_CONFIG_DATA[CONF_PHONE_NUMBER] + assert result["data"][CONF_MOBILE_ID] == MOCK_CONFIG_DATA[CONF_MOBILE_ID] + assert result["result"].unique_id == MOCK_CONFIG_DATA[CONF_ID_OR_PASSPORT] + + +async def test_reauth( + hass: HomeAssistant, + mock_ituran: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthenticating.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await __do_successful_user_step(hass, result, mock_ituran) + await __do_successful_otp_step(hass, result, mock_ituran) + + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_OTP: "123456", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/imgw_pib/test_binary_sensor.py b/tests/components/ituran/test_device_tracker.py similarity index 51% rename from tests/components/imgw_pib/test_binary_sensor.py rename to tests/components/ituran/test_device_tracker.py index 185d4b18575..4fe92154e91 100644 --- a/tests/components/imgw_pib/test_binary_sensor.py +++ b/tests/components/ituran/test_device_tracker.py @@ -1,65 +1,62 @@ -"""Test the IMGW-PIB binary sensor platform.""" +"""Test the Ituran device_tracker.""" from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from imgw_pib import ApiError -from syrupy import SnapshotAssertion +from pyituran.exceptions import IturanApiError +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.imgw_pib.const import UPDATE_INTERVAL +from homeassistant.components.ituran.const import UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import setup_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -ENTITY_ID = "binary_sensor.river_name_station_name_flood_alarm" - -async def test_binary_sensor( +async def test_device_tracker( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, - mock_imgw_pib_client: AsyncMock, + mock_ituran: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test states of the binary sensor.""" - with patch("homeassistant.components.imgw_pib.PLATFORMS", [Platform.BINARY_SENSOR]): - await init_integration(hass, mock_config_entry) + """Test state of device_tracker.""" + with patch("homeassistant.components.ituran.PLATFORMS", [Platform.DEVICE_TRACKER]): + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_availability( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - mock_imgw_pib_client: AsyncMock, + mock_ituran: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Ensure that we mark the entities unavailable correctly when service is offline.""" - await init_integration(hass, mock_config_entry) + """Test device is marked as unavailable when we can't reach the Ituran service.""" + entity_id = "device_tracker.mock_model" + await setup_integration(hass, mock_config_entry) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE - assert state.state == "off" - mock_imgw_pib_client.get_hydrological_data.side_effect = ApiError("API Error") + mock_ituran.get_vehicles.side_effect = IturanApiError freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNAVAILABLE - mock_imgw_pib_client.get_hydrological_data.side_effect = None + mock_ituran.get_vehicles.side_effect = None freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE - assert state.state == "off" diff --git a/tests/components/ituran/test_init.py b/tests/components/ituran/test_init.py new file mode 100644 index 00000000000..3dfe946cdf9 --- /dev/null +++ b/tests/components/ituran/test_init.py @@ -0,0 +1,113 @@ +"""Tests for the Ituran integration.""" + +from unittest.mock import AsyncMock + +from pyituran.exceptions import IturanApiError, IturanAuthError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ituran: AsyncMock, +) -> None: + """Test the Ituran configuration entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ituran: AsyncMock, + snapshot: SnapshotAssertion, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the device information.""" + await setup_integration(hass, mock_config_entry) + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert device_entries == snapshot + + +async def test_remove_stale_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ituran: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that devices not returned by the service are removed.""" + await setup_integration(hass, mock_config_entry) + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + + mock_ituran.get_vehicles.return_value = [] + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 0 + + +async def test_recover_from_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ituran: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Verify we can recover from service Errors.""" + + await setup_integration(hass, mock_config_entry) + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + + mock_ituran.get_vehicles.side_effect = IturanApiError + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + + mock_ituran.get_vehicles.side_effect = IturanAuthError + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + + mock_ituran.get_vehicles.side_effect = None + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 diff --git a/tests/components/ituran/test_sensor.py b/tests/components/ituran/test_sensor.py new file mode 100644 index 00000000000..a057f59b81f --- /dev/null +++ b/tests/components/ituran/test_sensor.py @@ -0,0 +1,76 @@ +"""Test the Ituran device_tracker.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyituran.exceptions import IturanApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ituran.const import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state of sensor.""" + with patch("homeassistant.components.ituran.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor is marked as unavailable when we can't reach the Ituran service.""" + entities = [ + "sensor.mock_model_address", + "sensor.mock_model_battery_voltage", + "sensor.mock_model_heading", + "sensor.mock_model_last_update_from_vehicle", + "sensor.mock_model_mileage", + "sensor.mock_model_speed", + ] + + await setup_integration(hass, mock_config_entry) + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = IturanApiError + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = None + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/kaleidescape/__init__.py b/tests/components/kaleidescape/__init__.py index 8182cb73743..6700218f8d4 100644 --- a/tests/components/kaleidescape/__init__.py +++ b/tests/components/kaleidescape/__init__.py @@ -1,13 +1,16 @@ """Tests for Kaleidescape integration.""" -from homeassistant.components import ssdp -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) MOCK_HOST = "127.0.0.1" MOCK_SERIAL = "123456" MOCK_NAME = "Theater" -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOST}", diff --git a/tests/components/keenetic_ndms2/__init__.py b/tests/components/keenetic_ndms2/__init__.py index 8ca91d00386..dc0c89e8ea6 100644 --- a/tests/components/keenetic_ndms2/__init__.py +++ b/tests/components/keenetic_ndms2/__init__.py @@ -1,6 +1,5 @@ """Tests for the Keenetic NDMS2 component.""" -from homeassistant.components import ssdp from homeassistant.components.keenetic_ndms2 import const from homeassistant.const import ( CONF_HOST, @@ -9,6 +8,11 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_USERNAME, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) MOCK_NAME = "Keenetic Ultra 2030" MOCK_IP = "0.0.0.0" @@ -30,12 +34,12 @@ MOCK_OPTIONS = { const.CONF_INTERFACES: ["Home", "VPS0"], } -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_NAME, + ATTR_UPNP_UDN: "uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + ATTR_UPNP_FRIENDLY_NAME: MOCK_NAME, }, ) diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index 18bacc3a32c..7ddcdf38ed6 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -8,11 +8,15 @@ from ndms2_client.client import InterfaceInfo, RouterInfo import pytest from homeassistant import config_entries -from homeassistant.components import keenetic_ndms2 as keenetic, ssdp +from homeassistant.components import keenetic_ndms2 as keenetic from homeassistant.components.keenetic_ndms2 import const from homeassistant.const import CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, +) from . import MOCK_DATA, MOCK_NAME, MOCK_OPTIONS, MOCK_SSDP_DISCOVERY_INFO @@ -200,7 +204,7 @@ async def test_ssdp_ignored(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=keenetic.DOMAIN, source=config_entries.SOURCE_IGNORE, - unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN], + unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN], ) entry.add_to_hass(hass) @@ -222,7 +226,7 @@ async def test_ssdp_update_host(hass: HomeAssistant) -> None: domain=keenetic.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS, - unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN], + unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN], ) entry.add_to_hass(hass) @@ -247,7 +251,7 @@ async def test_ssdp_reject_no_udn(hass: HomeAssistant) -> None: discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) discovery_info.upnp = {**discovery_info.upnp} - discovery_info.upnp.pop(ssdp.ATTR_UPNP_UDN) + discovery_info.upnp.pop(ATTR_UPNP_UDN) result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, @@ -264,7 +268,7 @@ async def test_ssdp_reject_non_keenetic(hass: HomeAssistant) -> None: discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) discovery_info.upnp = {**discovery_info.upnp} - discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Suspicious device" + discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] = "Suspicious device" result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_SSDP}, diff --git a/tests/components/kira/test_remote.py b/tests/components/kira/test_remote.py index ff3b28617d3..ad443fa3154 100644 --- a/tests/components/kira/test_remote.py +++ b/tests/components/kira/test_remote.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock from homeassistant.components.kira import remote as kira from homeassistant.core import HomeAssistant +from tests.common import MockEntityPlatform + SERVICE_SEND_COMMAND = "send_command" TEST_CONFIG = {kira.DOMAIN: {"devices": [{"host": "127.0.0.1", "port": 17324}]}} @@ -28,6 +30,8 @@ def test_service_call(hass: HomeAssistant) -> None: kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) assert len(DEVICES) == 1 remote = DEVICES[0] + remote.hass = hass + remote.platform = MockEntityPlatform(hass) assert remote.name == "kira" diff --git a/tests/components/kira/test_sensor.py b/tests/components/kira/test_sensor.py index fe0fc95a918..3bd46f18765 100644 --- a/tests/components/kira/test_sensor.py +++ b/tests/components/kira/test_sensor.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock, patch from homeassistant.components.kira import sensor as kira from homeassistant.core import HomeAssistant +from tests.common import MockEntityPlatform + TEST_CONFIG = {kira.DOMAIN: {"sensors": [{"host": "127.0.0.1", "port": 17324}]}} DISCOVERY_INFO = {"name": "kira", "device": "kira"} @@ -29,6 +31,8 @@ def test_kira_sensor_callback( kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) assert len(DEVICES) == 1 sensor = DEVICES[0] + sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) assert sensor.name == "kira" diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py new file mode 100644 index 00000000000..9e46845e1cb --- /dev/null +++ b/tests/components/kitchen_sink/test_backup.py @@ -0,0 +1,220 @@ +"""Test the Kitchen Sink backup platform.""" + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import patch + +import pytest + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, + Folder, +) +from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import instance_id +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def backup_only() -> AsyncGenerator[None]: + """Enable only the backup platform. + + The backup platform is not an entity platform. + """ + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_integration(hass: HomeAssistant) -> AsyncGenerator[None]: + """Set up Kitchen Sink integration.""" + with patch("homeassistant.components.backup.is_hassio", return_value=False): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agents": [{"agent_id": "backup.local"}]} + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backups"] == [ + { + "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "agent_ids": ["kitchen_sink.syncer"], + "backup_id": "abc123", + "database_included": False, + "date": "1970-01-01T00:00:00Z", + "failed_agent_ids": [], + "folders": ["media", "share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Kitchen sink syncer", + "protected": False, + "size": 1234, + "with_automatic_settings": None, + } + ] + + +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a backup.""" + client = await hass_client() + + resp = await client.get("/api/backup/download/abc123?agent_id=kitchen_sink.syncer") + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + hass_supervisor_access_token: str, +) -> None: + """Test agent upload backup.""" + ws_client = await hass_ws_client(hass, hass_supervisor_access_token) + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={ + "instance_id": await instance_id.async_get(hass), + "with_automatic_settings": False, + }, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0.0, + ) + + with ( + patch("pathlib.Path.open"), + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + ): + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=kitchen_sink.syncer", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + + await ws_client.send_json_auto_id({"type": "backup/info"}) + response = await ws_client.receive_json() + + assert response["success"] + backup_list = response["result"]["backups"] + assert len(backup_list) == 2 + assert backup_list[1] == { + "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "agent_ids": ["kitchen_sink.syncer"], + "backup_id": "test-backup", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "failed_agent_ids": [], + "folders": ["media", "share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 0.0, + "with_automatic_settings": False, + } + + +async def test_agent_delete_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert f"Deleted backup {backup_id}" in caplog.text + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + backup_list = response["result"]["backups"] + assert not backup_list diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index c0ec1dd9b9a..4e50836bb79 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -174,12 +174,12 @@ class KNXTestKit: ) telegram = self._outgoing_telegrams.pop(0) - assert isinstance( - telegram.payload, apci_type - ), f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" - assert ( - telegram.destination_address == _expected_ga - ), f"Group address mismatch in {telegram} - Expected: {group_address}" + assert isinstance(telegram.payload, apci_type), ( + f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" + ) + assert telegram.destination_address == _expected_ga, ( + f"Group address mismatch in {telegram} - Expected: {group_address}" + ) if payload is not None: assert ( telegram.payload.value.value == payload # type: ignore[attr-defined] @@ -335,7 +335,7 @@ async def create_ui_entity( hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], ) -> KnxEntityGenerator: - """Return a helper to create a KNX entities via WS. + """Return a helper to create KNX entities via WS. The KNX integration must be set up before using the helper. """ diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 2187721a518..8ed79f837bb 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -7,6 +7,7 @@ import pytest from xknx.exceptions.exception import CommunicationError, InvalidSecureConfiguration from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.io.gateway_scanner import GatewayDescriptor +from xknx.knxip.dib import TunnelingSlotStatus from xknx.secure.keyring import sync_load_keyring from xknx.telegram import IndividualAddress @@ -105,6 +106,7 @@ def _gateway_descriptor( port: int, supports_tunnelling_tcp: bool = False, requires_secure: bool = False, + slots: bool = True, ) -> GatewayDescriptor: """Get mock gw descriptor.""" descriptor = GatewayDescriptor( @@ -120,6 +122,12 @@ def _gateway_descriptor( ) descriptor.tunnelling_requires_secure = requires_secure descriptor.routing_requires_secure = requires_secure + if supports_tunnelling_tcp and slots: + descriptor.tunnelling_slots = { + IndividualAddress("1.0.240"): TunnelingSlotStatus(True, True, True), + IndividualAddress("1.0.241"): TunnelingSlotStatus(True, True, False), + IndividualAddress("1.0.242"): TunnelingSlotStatus(True, True, True), + } return descriptor @@ -791,12 +799,14 @@ async def test_tunneling_setup_for_multiple_found_gateways( hass: HomeAssistant, knx_setup ) -> None: """Test tunneling if multiple gateways are found.""" - gateway = _gateway_descriptor("192.168.0.1", 3675) - gateway2 = _gateway_descriptor("192.168.1.100", 3675) + gateway_udp = _gateway_descriptor("192.168.0.1", 3675) + gateway_tcp = _gateway_descriptor("192.168.1.100", 3675, True) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: - gateway_scanner_mock.return_value = GatewayScannerMock([gateway, gateway2]) + gateway_scanner_mock.return_value = GatewayScannerMock( + [gateway_udp, gateway_tcp] + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -815,7 +825,7 @@ async def test_tunneling_setup_for_multiple_found_gateways( result = await hass.config_entries.flow.async_configure( tunnel_flow["flow_id"], - {CONF_KNX_GATEWAY: str(gateway)}, + {CONF_KNX_GATEWAY: str(gateway_udp)}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { @@ -833,6 +843,110 @@ async def test_tunneling_setup_for_multiple_found_gateways( knx_setup.assert_called_once() +async def test_tunneling_setup_tcp_endpoint_select_skip( + hass: HomeAssistant, knx_setup +) -> None: + """Test tunneling TCP endpoint selection skipped if no slot info found.""" + gateway_udp = _gateway_descriptor("192.168.0.1", 3675) + gateway_tcp_no_slots = _gateway_descriptor("192.168.1.100", 3675, True, slots=False) + with patch( + "homeassistant.components.knx.config_flow.GatewayScanner" + ) as gateway_scanner_mock: + gateway_scanner_mock.return_value = GatewayScannerMock( + [gateway_udp, gateway_tcp_no_slots] + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + tunnel_flow = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + assert tunnel_flow["type"] is FlowResultType.FORM + assert tunnel_flow["step_id"] == "tunnel" + assert not tunnel_flow["errors"] + + result = await hass.config_entries.flow.async_configure( + tunnel_flow["flow_id"], + {CONF_KNX_GATEWAY: str(gateway_tcp_no_slots)}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, + CONF_HOST: "192.168.1.100", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", + CONF_KNX_ROUTE_BACK: False, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, + } + knx_setup.assert_called_once() + + +async def test_tunneling_setup_tcp_endpoint_select( + hass: HomeAssistant, knx_setup +) -> None: + """Test tunneling TCP endpoint selection.""" + gateway_tcp = _gateway_descriptor("192.168.1.100", 3675, True) + with patch( + "homeassistant.components.knx.config_flow.GatewayScanner" + ) as gateway_scanner_mock: + gateway_scanner_mock.return_value = GatewayScannerMock([gateway_tcp]) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + tunnel_flow = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + assert tunnel_flow["type"] is FlowResultType.FORM + assert tunnel_flow["step_id"] == "tunnel" + assert not tunnel_flow["errors"] + + endpoint_flow = await hass.config_entries.flow.async_configure( + tunnel_flow["flow_id"], + {CONF_KNX_GATEWAY: str(gateway_tcp)}, + ) + + assert endpoint_flow["type"] is FlowResultType.FORM + assert endpoint_flow["step_id"] == "tcp_tunnel_endpoint" + assert not endpoint_flow["errors"] + + result = await hass.config_entries.flow.async_configure( + endpoint_flow["flow_id"], + {CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.242"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.0.242 @ 1.0.0 - Test @ 192.168.1.100:3675" + assert result["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, + CONF_HOST: "192.168.1.100", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", + CONF_KNX_ROUTE_BACK: False, + CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.242", + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, + } + knx_setup.assert_called_once() + + @pytest.mark.parametrize( "gateway", [ @@ -1319,6 +1433,64 @@ async def test_options_flow_secure_manual_to_keyfile( knx_setup.assert_called_once() +async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: + """Test options flow changing routing settings.""" + mock_config_entry = MockConfigEntry( + title="KNX", + domain="knx", + data={ + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + }, + ) + gateway = _gateway_descriptor("192.168.0.1", 3676) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + with patch( + "homeassistant.components.knx.config_flow.GatewayScanner" + ) as gateway_scanner_mock: + gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) + result = await hass.config_entries.options.async_configure( + menu_step["flow_id"], + {"next_step_id": "connection_type"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "connection_type" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "routing" + assert result2["errors"] == {} + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + { + CONF_KNX_INDIVIDUAL_ADDRESS: "2.0.4", + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert mock_config_entry.data == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, + CONF_KNX_LOCAL_IP: None, + CONF_KNX_INDIVIDUAL_ADDRESS: "2.0.4", + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, + } + knx_setup.assert_called_once() + + async def test_options_communication_settings( hass: HomeAssistant, knx_setup, mock_config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index 48cc46ef1ee..75cd5d1eb21 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -1,7 +1,9 @@ """Test KNX init.""" +from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from xknx.io import ( DEFAULT_MCAST_GRP, @@ -11,7 +13,10 @@ from xknx.io import ( SecureConfig, ) -from homeassistant.components.knx.config_flow import DEFAULT_ROUTING_IA +from homeassistant.components.knx.config_flow import ( + DEFAULT_ENTRY_DATA, + DEFAULT_ROUTING_IA, +) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, CONF_KNX_CONNECTION_TYPE, @@ -40,12 +45,13 @@ from homeassistant.components.knx.const import ( KNXConfigEntryData, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant +from . import KnxEntityGenerator from .conftest import KNXTestKit -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize( @@ -262,6 +268,79 @@ async def test_init_connection_handling( ) +async def _init_switch_and_wait_for_first_state_updater_run( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, + config_entry_data: KNXConfigEntryData, +) -> None: + """Return a config entry with default data.""" + config_entry = MockConfigEntry( + title="KNX", domain=KNX_DOMAIN, data=config_entry_data + ) + knx.mock_config_entry = config_entry + await knx.setup_integration({}) + await create_ui_entity( + platform=Platform.SWITCH, + knx_data={ + "ga_switch": {"write": "1/1/1", "state": "2/2/2"}, + "respond_to_read": True, + "sync_state": True, # True uses xknx default state updater + "invert": False, + }, + ) + # created entity sends read-request to KNX bus on connection + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", True) + + freezer.tick(timedelta(minutes=59)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + await knx.assert_no_telegram() + + freezer.tick(timedelta(minutes=1)) # 60 minutes passed + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + +async def test_default_state_updater_enabled( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default state updater is applied to xknx device instances.""" + config_entry = DEFAULT_ENTRY_DATA | KNXConfigEntryData( + connection_type=CONF_KNX_AUTOMATIC, # missing in default data + state_updater=True, + ) + await _init_switch_and_wait_for_first_state_updater_run( + hass, knx, create_ui_entity, freezer, config_entry + ) + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", True) + + +async def test_default_state_updater_disabled( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default state updater is applied to xknx device instances.""" + config_entry = DEFAULT_ENTRY_DATA | KNXConfigEntryData( + connection_type=CONF_KNX_AUTOMATIC, # missing in default data + state_updater=False, + ) + await _init_switch_and_wait_for_first_state_updater_run( + hass, knx, create_ui_entity, freezer, config_entry + ) + await knx.assert_no_telegram() + + async def test_async_remove_entry( hass: HomeAssistant, knx: KNXTestKit, @@ -282,7 +361,7 @@ async def test_async_remove_entry( patch("pathlib.Path.rmdir") as rmdir_mock, ): assert await hass.config_entries.async_remove(config_entry.entry_id) - assert unlink_mock.call_count == 3 + assert unlink_mock.call_count == 4 rmdir_mock.assert_called_once() assert hass.config_entries.async_entries() == [] diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py index e56ba03b7e5..cc8acbaebf6 100644 --- a/tests/components/kodi/util.py +++ b/tests/components/kodi/util.py @@ -2,8 +2,8 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.kodi.const import DEFAULT_SSL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = { "host": "1.1.1.1", @@ -17,7 +17,7 @@ TEST_CREDENTIALS = {"username": "username", "password": "password"} TEST_WS_PORT = {"ws_port": 9090} UUID = "11111111-1111-1111-1111-111111111111" -TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], port=8080, @@ -28,7 +28,7 @@ TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( ) -TEST_DISCOVERY_WO_UUID = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY_WO_UUID = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], port=8080, diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py index 5865616c544..c9fa70de256 100644 --- a/tests/components/konnected/test_config_flow.py +++ b/tests/components/konnected/test_config_flow.py @@ -5,10 +5,11 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import konnected, ssdp +from homeassistant.components import konnected from homeassistant.components.konnected import config_flow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry @@ -116,7 +117,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -141,7 +142,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -160,7 +161,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -175,7 +176,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -193,7 +194,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -217,7 +218,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -343,7 +344,7 @@ async def test_import_ssdp_host_user_finish(hass: HomeAssistant, mock_panel) -> ssdp_result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://0.0.0.0:1234/Device.xml", @@ -390,7 +391,7 @@ async def test_ssdp_already_configured(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://0.0.0.0:1234/Device.xml", @@ -470,7 +471,7 @@ async def test_ssdp_host_update(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.1.1.1:1234/Device.xml", diff --git a/tests/components/lacrosse_view/test_config_flow.py b/tests/components/lacrosse_view/test_config_flow.py index 9ca7fb78bdd..f953d9a3841 100644 --- a/tests/components/lacrosse_view/test_config_flow.py +++ b/tests/components/lacrosse_view/test_config_flow.py @@ -30,7 +30,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: ), patch( "lacrosse_view.LaCrosse.get_locations", - return_value=[Location(id=1, name="Test")], + return_value=[Location(id="1", name="Test")], ), ): result2 = await hass.config_entries.flow.async_configure( @@ -206,7 +206,7 @@ async def test_already_configured_device( ), patch( "lacrosse_view.LaCrosse.get_locations", - return_value=[Location(id=1, name="Test")], + return_value=[Location(id="1", name="Test")], ), ): result2 = await hass.config_entries.flow.async_configure( @@ -262,7 +262,7 @@ async def test_reauth(hass: HomeAssistant) -> None: patch("lacrosse_view.LaCrosse.login", return_value=True), patch( "lacrosse_view.LaCrosse.get_locations", - return_value=[Location(id=1, name="Test")], + return_value=[Location(id="1", name="Test")], ), ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index d6d59cf9ebc..658e0dd96bc 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from bleak.backends.device import BLEDevice from pylamarzocco.const import FirmwareType, MachineModel, SteamLevel -from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest @@ -135,7 +135,10 @@ def mock_lamarzocco(device_fixture: MachineModel) -> Generator[MagicMock]: serial_number=serial_number, name=serial_number, ) - config = load_json_object_fixture("config.json", DOMAIN) + if device_fixture == MachineModel.LINEA_MINI: + config = load_json_object_fixture("config_mini.json", DOMAIN) + else: + config = load_json_object_fixture("config.json", DOMAIN) statistics = json.loads(load_fixture("statistics.json", DOMAIN)) dummy_machine.parse_config(config) @@ -143,7 +146,7 @@ def mock_lamarzocco(device_fixture: MachineModel) -> Generator[MagicMock]: with ( patch( - "homeassistant.components.lamarzocco.coordinator.LaMarzoccoMachine", + "homeassistant.components.lamarzocco.LaMarzoccoMachine", autospec=True, ) as lamarzocco_mock, ): diff --git a/tests/components/lamarzocco/fixtures/config_mini.json b/tests/components/lamarzocco/fixtures/config_mini.json new file mode 100644 index 00000000000..22533a94872 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/config_mini.json @@ -0,0 +1,116 @@ +{ + "version": "v1", + "preinfusionModesAvailable": ["ByDoseType"], + "machineCapabilities": [ + { + "family": "LINEA", + "groupsNumber": 1, + "coffeeBoilersNumber": 1, + "hasCupWarmer": false, + "steamBoilersNumber": 1, + "teaDosesNumber": 1, + "machineModes": ["BrewingMode", "StandBy"], + "schedulingType": "smartWakeUpSleep" + } + ], + "machine_sn": "Sn01239157", + "machine_hw": "0", + "isPlumbedIn": false, + "isBackFlushEnabled": false, + "standByTime": 0, + "tankStatus": true, + "settings": [], + "recipes": [ + { + "id": "Recipe1", + "dose_mode": "Mass", + "recipe_doses": [ + { "id": "A", "target": 32 }, + { "id": "B", "target": 45 } + ] + } + ], + "recipeAssignment": [ + { + "dose_index": "DoseA", + "recipe_id": "Recipe1", + "recipe_dose": "A", + "group": "Group1" + } + ], + "groupCapabilities": [ + { + "capabilities": { + "groupType": "AV_Group", + "groupNumber": "Group1", + "boilerId": "CoffeeBoiler1", + "hasScale": false, + "hasFlowmeter": false, + "numberOfDoses": 1 + }, + "doses": [ + { + "groupNumber": "Group1", + "doseIndex": "DoseA", + "doseType": "MassType", + "stopTarget": 32 + } + ], + "doseMode": { "groupNumber": "Group1", "brewingType": "ManualType" } + } + ], + "machineMode": "StandBy", + "teaDoses": { "DoseA": { "doseIndex": "DoseA", "stopTarget": 0 } }, + "scale": { + "connected": true, + "address": "44:b7:d0:74:5f:90", + "name": "LMZ-123A45", + "battery": 64 + }, + "boilers": [ + { "id": "SteamBoiler", "isEnabled": false, "target": 0, "current": 0 }, + { "id": "CoffeeBoiler1", "isEnabled": true, "target": 89, "current": 42 } + ], + "boilerTargetTemperature": { "SteamBoiler": 0, "CoffeeBoiler1": 89 }, + "preinfusionMode": { + "Group1": { + "groupNumber": "Group1", + "preinfusionStyle": "PreinfusionByDoseType" + } + }, + "preinfusionSettings": { + "mode": "TypeB", + "Group1": [ + { + "groupNumber": "Group1", + "doseType": "DoseA", + "preWetTime": 2, + "preWetHoldTime": 3 + } + ] + }, + "wakeUpSleepEntries": [ + { + "id": "T6aLl42", + "days": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ], + "steam": false, + "enabled": false, + "timeOn": "24:0", + "timeOff": "24:0" + } + ], + "smartStandBy": { "mode": "LastBrewing", "minutes": 10, "enabled": true }, + "clock": "2024-08-31T14:47:45", + "firmwareVersions": [ + { "name": "machine_firmware", "fw_version": "2.12" }, + { "name": "gateway_firmware", "fw_version": "v3.6-rc4" } + ] +} diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr index cda285a7106..47bca8dcb63 100644 --- a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -1,19 +1,5 @@ # serializer version: 1 -# name: test_binary_sensors[GS012345_backflush_active-binary_sensor] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'running', - 'friendly_name': 'GS012345 Backflush active', - }), - 'context': , - 'entity_id': 'binary_sensor.gs012345_backflush_active', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[GS012345_backflush_active-entry] +# name: test_binary_sensors[binary_sensor.gs012345_backflush_active-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -46,21 +32,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[GS012345_brewing_active-binary_sensor] +# name: test_binary_sensors[binary_sensor.gs012345_backflush_active-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'running', - 'friendly_name': 'GS012345 Brewing active', + 'friendly_name': 'GS012345 Backflush active', }), 'context': , - 'entity_id': 'binary_sensor.gs012345_brewing_active', + 'entity_id': 'binary_sensor.gs012345_backflush_active', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensors[GS012345_brewing_active-entry] +# name: test_binary_sensors[binary_sensor.gs012345_brewing_active-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -93,21 +79,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[GS012345_water_tank_empty-binary_sensor] +# name: test_binary_sensors[binary_sensor.gs012345_brewing_active-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'GS012345 Water tank empty', + 'device_class': 'running', + 'friendly_name': 'GS012345 Brewing active', }), 'context': , - 'entity_id': 'binary_sensor.gs012345_water_tank_empty', + 'entity_id': 'binary_sensor.gs012345_brewing_active', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensors[GS012345_water_tank_empty-entry] +# name: test_binary_sensors[binary_sensor.gs012345_water_tank_empty-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -140,3 +126,64 @@ 'unit_of_measurement': None, }) # --- +# name: test_binary_sensors[binary_sensor.gs012345_water_tank_empty-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'GS012345 Water tank empty', + }), + 'context': , + 'entity_id': 'binary_sensor.gs012345_water_tank_empty', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_scale_connectivity[Linea Mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'LMZ-123A45 Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.lmz_123a45_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_scale_connectivity[Linea Mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lmz_123a45_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'LM012345_connected', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index b185557bd08..b1d8140b2ce 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -3,6 +3,7 @@ dict({ 'config': dict({ 'backflush_enabled': False, + 'bbw_settings': None, 'boilers': dict({ 'CoffeeBoiler1': dict({ 'current_temperature': 96.5, @@ -44,6 +45,7 @@ }), }), 'prebrew_mode': 'TypeB', + 'scale': None, 'smart_standby': dict({ 'enabled': True, 'minutes': 10, diff --git a/tests/components/lamarzocco/snapshots/test_init.ambr b/tests/components/lamarzocco/snapshots/test_init.ambr new file mode 100644 index 00000000000..67aa0b8bea8 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_init.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'bluetooth', + 'aa:bb:cc:dd:ee:ff', + ), + tuple( + 'mac', + '00:00:00:00:00:00', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'lamarzocco', + 'GS012345', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'La Marzocco', + 'model': , + 'model_id': , + 'name': 'GS012345', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'GS012345', + 'suggested_area': None, + 'sw_version': '1.40', + 'via_device_id': None, + }) +# --- +# name: test_scale_device[Linea Mini] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'lamarzocco', + '44:b7:d0:74:5f:90', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Acaia', + 'model': 'Lunar', + 'model_id': 'Y.301', + 'name': 'LMZ-123A45', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index b7e42bb425f..49e4713aab1 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -657,7 +657,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '3', }) # --- # name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini].1 @@ -771,7 +771,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '3', }) # --- # name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini].1 @@ -885,7 +885,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '3', }) # --- # name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Linea Mini].1 @@ -983,3 +983,113 @@ 'unit_of_measurement': , }) # --- +# name: test_set_target[Linea Mini-1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LMZ-123A45 Brew by weight target 1', + 'max': 100, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.lmz_123a45_brew_by_weight_target_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_set_target[Linea Mini-1].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.lmz_123a45_brew_by_weight_target_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brew by weight target 1', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'scale_target_key', + 'unique_id': 'LM012345_scale_target_key1', + 'unit_of_measurement': None, + }) +# --- +# name: test_set_target[Linea Mini-2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LMZ-123A45 Brew by weight target 2', + 'max': 100, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.lmz_123a45_brew_by_weight_target_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45', + }) +# --- +# name: test_set_target[Linea Mini-2].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.lmz_123a45_brew_by_weight_target_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brew by weight target 2', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'scale_target_key', + 'unique_id': 'LM012345_scale_target_key2', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_select.ambr b/tests/components/lamarzocco/snapshots/test_select.ambr index 46fa55eff13..325409a0b7f 100644 --- a/tests/components/lamarzocco/snapshots/test_select.ambr +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -1,4 +1,59 @@ # serializer version: 1 +# name: test_active_bbw_recipe[Linea Mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LMZ-123A45 Active brew by weight recipe', + 'options': list([ + 'a', + 'b', + ]), + }), + 'context': , + 'entity_id': 'select.lmz_123a45_active_brew_by_weight_recipe', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'a', + }) +# --- +# name: test_active_bbw_recipe[Linea Mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'a', + 'b', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.lmz_123a45_active_brew_by_weight_recipe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active brew by weight recipe', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_bbw', + 'unique_id': 'LM012345_active_bbw', + 'unit_of_measurement': None, + }) +# --- # name: test_pre_brew_infusion_select[GS3 AV] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index da1efbf1eaa..723f9738e1c 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -1,5 +1,56 @@ # serializer version: 1 -# name: test_sensors[GS012345_current_coffee_temperature-entry] +# name: test_scale_battery[Linea Mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'LMZ-123A45 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.lmz_123a45_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '64', + }) +# --- +# name: test_scale_battery[Linea Mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lmz_123a45_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'LM012345_scale_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.gs012345_current_coffee_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -37,7 +88,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[GS012345_current_coffee_temperature-sensor] +# name: test_sensors[sensor.gs012345_current_coffee_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -53,7 +104,7 @@ 'state': '96.5', }) # --- -# name: test_sensors[GS012345_current_steam_temperature-entry] +# name: test_sensors[sensor.gs012345_current_steam_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -91,7 +142,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[GS012345_current_steam_temperature-sensor] +# name: test_sensors[sensor.gs012345_current_steam_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -107,7 +158,7 @@ 'state': '123.800003051758', }) # --- -# name: test_sensors[GS012345_shot_timer-entry] +# name: test_sensors[sensor.gs012345_shot_timer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -142,7 +193,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[GS012345_shot_timer-sensor] +# name: test_sensors[sensor.gs012345_shot_timer-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -158,7 +209,7 @@ 'state': '0', }) # --- -# name: test_sensors[GS012345_total_coffees_made-entry] +# name: test_sensors[sensor.gs012345_total_coffees_made-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -193,7 +244,7 @@ 'unit_of_measurement': 'drinks', }) # --- -# name: test_sensors[GS012345_total_coffees_made-sensor] +# name: test_sensors[sensor.gs012345_total_coffees_made-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS012345 Total coffees made', @@ -208,7 +259,7 @@ 'state': '1047', }) # --- -# name: test_sensors[GS012345_total_flushes_made-entry] +# name: test_sensors[sensor.gs012345_total_flushes_made-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -243,7 +294,7 @@ 'unit_of_measurement': 'drinks', }) # --- -# name: test_sensors[GS012345_total_flushes_made-sensor] +# name: test_sensors[sensor.gs012345_total_flushes_made-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS012345 Total flushes made', diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr index 084b54b3f3a..72fe41c1392 100644 --- a/tests/components/lamarzocco/snapshots/test_switch.ambr +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -91,56 +91,7 @@ 'state': 'on', }) # --- -# name: test_device - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '00:00:00:00:00:00', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'lamarzocco', - 'GS012345', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'La Marzocco', - 'model': , - 'model_id': , - 'name': 'GS012345', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': 'GS012345', - 'suggested_area': None, - 'sw_version': '1.40', - 'via_device_id': None, - }) -# --- -# name: test_switches[-set_power-kwargs0] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345', - }), - 'context': , - 'entity_id': 'switch.gs012345', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_switches[-set_power-kwargs0].1 +# name: test_switches[switch.gs012345-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -173,20 +124,112 @@ 'unit_of_measurement': None, }) # --- -# name: test_switches[_smart_standby_enabled-set_smart_standby-kwargs2] +# name: test_switches[switch.gs012345-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Smart standby enabled', + 'friendly_name': 'GS012345', }), 'context': , - 'entity_id': 'switch.gs012345_smart_standby_enabled', + 'entity_id': 'switch.gs012345', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_switches[_smart_standby_enabled-set_smart_standby-kwargs2].1 +# name: test_switches[switch.gs012345_auto_on_off_axfz5bj-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.gs012345_auto_on_off_axfz5bj', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto on/off (aXFz5bJ)', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_on_off', + 'unique_id': 'GS012345_auto_on_off_aXFz5bJ', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.gs012345_auto_on_off_axfz5bj-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Auto on/off (aXFz5bJ)', + }), + 'context': , + 'entity_id': 'switch.gs012345_auto_on_off_axfz5bj', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.gs012345_auto_on_off_os2oswx-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.gs012345_auto_on_off_os2oswx', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto on/off (Os2OswX)', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_on_off', + 'unique_id': 'GS012345_auto_on_off_Os2OswX', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.gs012345_auto_on_off_os2oswx-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Auto on/off (Os2OswX)', + }), + 'context': , + 'entity_id': 'switch.gs012345_auto_on_off_os2oswx', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.gs012345_smart_standby_enabled-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -219,20 +262,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_switches[_steam_boiler-set_steam-kwargs1] +# name: test_switches[switch.gs012345_smart_standby_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Steam boiler', + 'friendly_name': 'GS012345 Smart standby enabled', }), 'context': , - 'entity_id': 'switch.gs012345_steam_boiler', + 'entity_id': 'switch.gs012345_smart_standby_enabled', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_switches[_steam_boiler-set_steam-kwargs1].1 +# name: test_switches[switch.gs012345_steam_boiler-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -265,3 +308,16 @@ 'unit_of_measurement': None, }) # --- +# name: test_switches[switch.gs012345_steam_boiler-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Steam boiler', + }), + 'context': , + 'entity_id': 'switch.gs012345_steam_boiler', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr index 46fa4cff815..40f47a783d7 100644 --- a/tests/components/lamarzocco/snapshots/test_update.ambr +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -1,31 +1,5 @@ # serializer version: 1 -# name: test_update_entites[gateway_firmware-gateway] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'auto_update': False, - 'device_class': 'firmware', - 'display_precision': 0, - 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', - 'friendly_name': 'GS012345 Gateway firmware', - 'in_progress': False, - 'installed_version': 'v3.1-rc4', - 'latest_version': 'v3.5-rc3', - 'release_summary': None, - 'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/', - 'skipped_version': None, - 'supported_features': , - 'title': None, - 'update_percentage': None, - }), - 'context': , - 'entity_id': 'update.gs012345_gateway_firmware', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_update_entites[gateway_firmware-gateway].1 +# name: test_update[update.gs012345_gateway_firmware-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -58,17 +32,17 @@ 'unit_of_measurement': None, }) # --- -# name: test_update_entites[machine_firmware-machine] +# name: test_update[update.gs012345_gateway_firmware-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'auto_update': False, 'device_class': 'firmware', 'display_precision': 0, 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', - 'friendly_name': 'GS012345 Machine firmware', + 'friendly_name': 'GS012345 Gateway firmware', 'in_progress': False, - 'installed_version': '1.40', - 'latest_version': '1.55', + 'installed_version': 'v3.1-rc4', + 'latest_version': 'v3.5-rc3', 'release_summary': None, 'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/', 'skipped_version': None, @@ -77,14 +51,14 @@ 'update_percentage': None, }), 'context': , - 'entity_id': 'update.gs012345_machine_firmware', + 'entity_id': 'update.gs012345_gateway_firmware', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_update_entites[machine_firmware-machine].1 +# name: test_update[update.gs012345_machine_firmware-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -117,3 +91,29 @@ 'unit_of_measurement': None, }) # --- +# name: test_update[update.gs012345_machine_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', + 'friendly_name': 'GS012345 Machine firmware', + 'in_progress': False, + 'installed_version': '1.40', + 'latest_version': '1.55', + 'release_summary': None, + 'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.gs012345_machine_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py index 956bfe90dd4..d50d0ad9f84 100644 --- a/tests/components/lamarzocco/test_binary_sensor.py +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -1,25 +1,22 @@ """Tests for La Marzocco binary sensors.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory +from pylamarzocco.const import MachineModel from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.models import LaMarzoccoScale +import pytest from syrupy import SnapshotAssertion -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import async_init_integration -from tests.common import MockConfigEntry, async_fire_time_changed - -BINARY_SENSORS = ( - "brewing_active", - "backflush_active", - "water_tank_empty", -) +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_binary_sensors( @@ -31,19 +28,11 @@ async def test_binary_sensors( ) -> None: """Test the La Marzocco binary sensors.""" - await async_init_integration(hass, mock_config_entry) - - serial_number = mock_lamarzocco.serial_number - - for binary_sensor in BINARY_SENSORS: - state = hass.states.get(f"binary_sensor.{serial_number}_{binary_sensor}") - assert state - assert state == snapshot(name=f"{serial_number}_{binary_sensor}-binary_sensor") - - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry.device_id - assert entry == snapshot(name=f"{serial_number}_{binary_sensor}-entry") + with patch( + "homeassistant.components.lamarzocco.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await async_init_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_brew_active_does_not_exists( @@ -98,3 +87,68 @@ async def test_sensor_going_unavailable( state = hass.states.get(brewing_active_sensor) assert state assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) +async def test_scale_connectivity( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the scale binary sensors.""" + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.lmz_123a45_connectivity") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + +@pytest.mark.parametrize( + "device_fixture", + [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA], +) +async def test_other_models_no_scale_connectivity( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Ensure the other models don't have a connectivity sensor.""" + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.lmz_123a45_connectivity") + assert state is None + + +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) +async def test_connectivity_on_new_scale_added( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure the connectivity binary sensor for a new scale is added automatically.""" + + mock_lamarzocco.config.scale = None + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.scale_123a45_connectivity") + assert state is None + + mock_lamarzocco.config.scale = LaMarzoccoScale( + connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50 + ) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.scale_123a45_connectivity") + assert state diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index f8103ac3054..02ade8f2b9c 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -8,7 +8,6 @@ from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import CONF_USE_BLUETOOTH, DOMAIN from homeassistant.config_entries import ( @@ -28,6 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import USER_INPUT, async_init_integration, get_bluetooth_service_info @@ -381,6 +381,26 @@ async def test_bluetooth_discovery( } +async def test_bluetooth_discovery_already_configured( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, + mock_setup_entry: Generator[AsyncMock], + mock_config_entry: MockConfigEntry, +) -> None: + """Test bluetooth discovery.""" + mock_config_entry.add_to_hass(hass) + + service_info = get_bluetooth_service_info( + mock_lamarzocco.model, mock_lamarzocco.serial_number + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_bluetooth_discovery_errors( hass: HomeAssistant, mock_lamarzocco: MagicMock, @@ -493,6 +513,27 @@ async def test_dhcp_discovery( } +async def test_dhcp_discovery_abort_on_hostname_changed( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery aborts when hostname was changed manually.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.1.42", + hostname="custom_name", + macaddress="00:00:00:00:00:00", + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_dhcp_already_configured_and_update( hass: HomeAssistant, mock_lamarzocco: MagicMock, diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 75c3019afb4..09ebc462952 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -1,11 +1,13 @@ """Test initialization of lamarzocco.""" +from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch -from pylamarzocco.const import FirmwareType +from freezegun.api import FrozenDateTimeFactory +from pylamarzocco.const import FirmwareType, MachineModel from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful import pytest -from websockets.protocol import State +from syrupy import SnapshotAssertion from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import DOMAIN @@ -19,11 +21,15 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from . import USER_INPUT, async_init_integration, get_bluetooth_service_info -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_load_unload_config_entry( @@ -32,9 +38,7 @@ async def test_load_unload_config_entry( mock_lamarzocco: MagicMock, ) -> None: """Test loading and unloading the integration.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await async_init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -52,9 +56,7 @@ async def test_config_entry_not_ready( """Test the La Marzocco configuration entry not ready.""" mock_lamarzocco.get_config.side_effect = RequestNotSuccessful("") - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await async_init_integration(hass, mock_config_entry) assert len(mock_lamarzocco.get_config.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -67,9 +69,7 @@ async def test_invalid_auth( ) -> None: """Test auth error during setup.""" mock_lamarzocco.get_config.side_effect = AuthFail("") - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await async_init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR assert len(mock_lamarzocco.get_config.mock_calls) == 1 @@ -170,9 +170,7 @@ async def test_bluetooth_is_set_from_discovery( "homeassistant.components.lamarzocco.async_discovered_service_info", return_value=[service_info], ) as discovery, - patch( - "homeassistant.components.lamarzocco.coordinator.LaMarzoccoMachine" - ) as init_device, + patch("homeassistant.components.lamarzocco.LaMarzoccoMachine") as init_device, ): await async_init_integration(hass, mock_config_entry) discovery.assert_called_once() @@ -195,8 +193,11 @@ async def test_websocket_closed_on_unload( ) as local_client: client = local_client.return_value client.websocket = AsyncMock() - client.websocket.state = State.OPEN + await async_init_integration(hass, mock_config_entry) + mock_lamarzocco.websocket_connect.assert_called_once() + + client.websocket.closed = False hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() client.websocket.close.assert_called_once() @@ -220,3 +221,78 @@ async def test_gateway_version_issue( issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue(DOMAIN, "unsupported_gateway_firmware") assert (issue is not None) == issue_exists + + +async def test_device( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the device.""" + + await async_init_integration(hass, mock_config_entry) + + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_MAC: "aa:bb:cc:dd:ee:ff"}, + ) + + state = hass.states.get(f"switch.{mock_lamarzocco.serial_number}") + assert state + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + + device = device_registry.async_get(entry.device_id) + assert device + assert device == snapshot + + +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) +async def test_scale_device( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the device.""" + + await async_init_integration(hass, mock_config_entry) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_lamarzocco.config.scale.address)} + ) + assert device + assert device == snapshot + + +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) +async def test_remove_stale_scale( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure stale scale is cleaned up.""" + + await async_init_integration(hass, mock_config_entry) + + scale_address = mock_lamarzocco.config.scale.address + + device = device_registry.async_get_device(identifiers={(DOMAIN, scale_address)}) + assert device + + mock_lamarzocco.config.scale = None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, scale_address)}) + assert device is None diff --git a/tests/components/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py index 710a0220e06..65c5e264f22 100644 --- a/tests/components/lamarzocco/test_number.py +++ b/tests/components/lamarzocco/test_number.py @@ -1,8 +1,10 @@ """Tests for the La Marzocco number entities.""" +from datetime import timedelta from typing import Any from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from pylamarzocco.const import ( KEYS_PER_MODEL, BoilerType, @@ -11,6 +13,7 @@ from pylamarzocco.const import ( PrebrewMode, ) from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.models import LaMarzoccoScale import pytest from syrupy import SnapshotAssertion @@ -26,7 +29,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from . import async_init_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize( @@ -444,3 +447,91 @@ async def test_number_error( blocking=True, ) assert exc_info.value.translation_key == "number_exception_key" + + +@pytest.mark.parametrize("physical_key", [PhysicalKey.A, PhysicalKey.B]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) +async def test_set_target( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + physical_key: PhysicalKey, +) -> None: + """Test the La Marzocco set target sensors.""" + + await async_init_integration(hass, mock_config_entry) + + entity_name = f"number.lmz_123a45_brew_by_weight_target_{int(physical_key)}" + + state = hass.states.get(entity_name) + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_name, + ATTR_VALUE: 42, + }, + blocking=True, + ) + + mock_lamarzocco.set_bbw_recipe_target.assert_called_once_with(physical_key, 42) + + +@pytest.mark.parametrize( + "device_fixture", + [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA], +) +async def test_other_models_no_scale_set_target( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Ensure the other models don't have a set target numbers.""" + await async_init_integration(hass, mock_config_entry) + + for i in range(1, 3): + state = hass.states.get(f"number.lmz_123a45_brew_by_weight_target_{i}") + assert state is None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) +async def test_set_target_on_new_scale_added( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure the set target numbers for a new scale are added automatically.""" + + mock_lamarzocco.config.scale = None + await async_init_integration(hass, mock_config_entry) + + for i in range(1, 3): + state = hass.states.get(f"number.scale_123a45_brew_by_weight_target_{i}") + assert state is None + + mock_lamarzocco.config.scale = LaMarzoccoScale( + connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50 + ) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for i in range(1, 3): + state = hass.states.get(f"number.scale_123a45_brew_by_weight_target_{i}") + assert state diff --git a/tests/components/lamarzocco/test_select.py b/tests/components/lamarzocco/test_select.py index 24b96f84f37..3bfb579e6d4 100644 --- a/tests/components/lamarzocco/test_select.py +++ b/tests/components/lamarzocco/test_select.py @@ -1,9 +1,18 @@ """Tests for the La Marzocco select entities.""" +from datetime import timedelta from unittest.mock import MagicMock -from pylamarzocco.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel +from freezegun.api import FrozenDateTimeFactory +from pylamarzocco.const import ( + MachineModel, + PhysicalKey, + PrebrewMode, + SmartStandbyMode, + SteamLevel, +) from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.models import LaMarzoccoScale import pytest from syrupy import SnapshotAssertion @@ -17,9 +26,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -pytestmark = pytest.mark.usefixtures("init_integration") +from . import async_init_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + +pytest.mark.usefixtures("init_integration") +@pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MICRA]) async def test_steam_boiler_level( hass: HomeAssistant, @@ -69,6 +83,7 @@ async def test_steam_boiler_level_none( assert state is None +@pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "device_fixture", [MachineModel.LINEA_MICRA, MachineModel.GS3_AV, MachineModel.LINEA_MINI], @@ -106,6 +121,7 @@ async def test_pre_brew_infusion_select( mock_lamarzocco.set_prebrew_mode.assert_called_once_with(mode=PrebrewMode.PREBREW) +@pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "device_fixture", [MachineModel.GS3_MP], @@ -121,6 +137,7 @@ async def test_pre_brew_infusion_select_none( assert state is None +@pytest.mark.usefixtures("init_integration") async def test_smart_standby_mode( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -155,6 +172,7 @@ async def test_smart_standby_mode( ) +@pytest.mark.usefixtures("init_integration") async def test_select_errors( hass: HomeAssistant, mock_lamarzocco: MagicMock, @@ -179,3 +197,77 @@ async def test_select_errors( blocking=True, ) assert exc_info.value.translation_key == "select_option_error" + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) +async def test_active_bbw_recipe( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco active bbw recipe select.""" + + state = hass.states.get("select.lmz_123a45_active_brew_by_weight_recipe") + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.lmz_123a45_active_brew_by_weight_recipe", + ATTR_OPTION: "b", + }, + blocking=True, + ) + + mock_lamarzocco.set_active_bbw_recipe.assert_called_once_with(PhysicalKey.B) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "device_fixture", + [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA], +) +async def test_other_models_no_active_bbw_select( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Ensure the other models don't have a battery sensor.""" + + state = hass.states.get("select.lmz_123a45_active_brew_by_weight_recipe") + assert state is None + + +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) +async def test_active_bbw_select_on_new_scale_added( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure the active bbw select for a new scale is added automatically.""" + + mock_lamarzocco.config.scale = None + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get("select.scale_123a45_active_brew_by_weight_recipe") + assert state is None + + mock_lamarzocco.config.scale = LaMarzoccoScale( + connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50 + ) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("select.scale_123a45_active_brew_by_weight_recipe") + assert state diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 6f14d52d1fc..3385e2b3891 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -1,26 +1,21 @@ """Tests for La Marzocco sensors.""" -from unittest.mock import MagicMock +from datetime import timedelta +from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from pylamarzocco.const import MachineModel +from pylamarzocco.models import LaMarzoccoScale import pytest from syrupy import SnapshotAssertion -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import async_init_integration -from tests.common import MockConfigEntry - -SENSORS = ( - "total_coffees_made", - "total_flushes_made", - "shot_timer", - "current_coffee_temperature", - "current_steam_temperature", -) +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_sensors( @@ -32,19 +27,9 @@ async def test_sensors( ) -> None: """Test the La Marzocco sensors.""" - serial_number = mock_lamarzocco.serial_number - - await async_init_integration(hass, mock_config_entry) - - for sensor in SENSORS: - state = hass.states.get(f"sensor.{serial_number}_{sensor}") - assert state - assert state == snapshot(name=f"{serial_number}_{sensor}-sensor") - - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry.device_id - assert entry == snapshot(name=f"{serial_number}_{sensor}-entry") + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SENSOR]): + await async_init_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_shot_timer_not_exists( @@ -85,3 +70,68 @@ async def test_no_steam_linea_mini( serial_number = mock_lamarzocco.serial_number state = hass.states.get(f"sensor.{serial_number}_current_temp_steam") assert state is None + + +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) +async def test_scale_battery( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the scale battery sensor.""" + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.lmz_123a45_battery") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + +@pytest.mark.parametrize( + "device_fixture", + [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA], +) +async def test_other_models_no_scale_battery( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Ensure the other models don't have a battery sensor.""" + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.lmz_123a45_battery") + assert state is None + + +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) +async def test_battery_on_new_scale_added( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure the battery sensor for a new scale is added automatically.""" + + mock_lamarzocco.config.scale = None + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.lmz_123a45_battery") + assert state is None + + mock_lamarzocco.config.scale = LaMarzoccoScale( + connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50 + ) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.scale_123a45_battery") + assert state diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index 5c6d1cb1e42..d8370ad8575 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -1,7 +1,7 @@ """Tests for La Marzocco switches.""" from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pylamarzocco.exceptions import RequestNotSuccessful import pytest @@ -12,14 +12,27 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import WAKE_UP_SLEEP_ENTRY_IDS, async_init_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_switches( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco switches.""" + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SWITCH]): + await async_init_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( @@ -38,12 +51,10 @@ from tests.common import MockConfigEntry ), ], ) -async def test_switches( +async def test_switches_actions( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, entity_name: str, method_name: str, kwargs: dict[str, Any], @@ -55,14 +66,6 @@ async def test_switches( control_fn = getattr(mock_lamarzocco, method_name) - state = hass.states.get(f"switch.{serial_number}{entity_name}") - assert state - assert state == snapshot - - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot - await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -88,30 +91,6 @@ async def test_switches( control_fn.assert_called_with(enabled=True, **kwargs) -async def test_device( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test the device for one switch.""" - - await async_init_integration(hass, mock_config_entry) - - state = hass.states.get(f"switch.{mock_lamarzocco.serial_number}") - assert state - - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry.device_id - - device = device_registry.async_get(entry.device_id) - assert device - assert device == snapshot - - async def test_auto_on_off_switches( hass: HomeAssistant, mock_lamarzocco: MagicMock, diff --git a/tests/components/lamarzocco/test_update.py b/tests/components/lamarzocco/test_update.py index aef37d7c921..4089ffa297a 100644 --- a/tests/components/lamarzocco/test_update.py +++ b/tests/components/lamarzocco/test_update.py @@ -1,6 +1,6 @@ """Tests for the La Marzocco Update Entities.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pylamarzocco.const import FirmwareType from pylamarzocco.exceptions import RequestNotSuccessful @@ -8,12 +8,27 @@ import pytest from syrupy import SnapshotAssertion from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -pytestmark = pytest.mark.usefixtures("init_integration") +from . import async_init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_update( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco updates.""" + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.UPDATE]): + await async_init_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( @@ -26,8 +41,7 @@ pytestmark = pytest.mark.usefixtures("init_integration") async def test_update_entites( hass: HomeAssistant, mock_lamarzocco: MagicMock, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, entity_name: str, component: FirmwareType, ) -> None: @@ -35,13 +49,7 @@ async def test_update_entites( serial_number = mock_lamarzocco.serial_number - state = hass.states.get(f"update.{serial_number}_{entity_name}") - assert state - assert state == snapshot - - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot + await async_init_integration(hass, mock_config_entry) await hass.services.async_call( UPDATE_DOMAIN, @@ -65,10 +73,14 @@ async def test_update_entites( async def test_update_error( hass: HomeAssistant, mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, attr: str, value: bool | Exception, ) -> None: """Test error during update.""" + + await async_init_integration(hass, mock_config_entry) + state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_machine_firmware") assert state diff --git a/tests/components/lametric/conftest.py b/tests/components/lametric/conftest.py index e8ba727f3db..da86d1bc4de 100644 --- a/tests/components/lametric/conftest.py +++ b/tests/components/lametric/conftest.py @@ -6,7 +6,6 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from demetriek import CloudDevice, Device -from pydantic import parse_raw_as # pylint: disable=no-name-in-module import pytest from homeassistant.components.application_credentials import ( @@ -18,7 +17,7 @@ from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_fixture, load_json_array_fixture @pytest.fixture(autouse=True) @@ -50,8 +49,8 @@ def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.lametric.async_setup_entry", return_value=True - ) as mock_setup: - yield mock_setup + ): + yield @pytest.fixture @@ -61,9 +60,10 @@ def mock_lametric_cloud() -> Generator[MagicMock]: "homeassistant.components.lametric.config_flow.LaMetricCloud", autospec=True ) as lametric_mock: lametric = lametric_mock.return_value - lametric.devices.return_value = parse_raw_as( - list[CloudDevice], load_fixture("cloud_devices.json", DOMAIN) - ) + lametric.devices.return_value = [ + CloudDevice.from_dict(cloud_device) + for cloud_device in load_json_array_fixture("cloud_devices.json", DOMAIN) + ] yield lametric @@ -89,7 +89,7 @@ def mock_lametric(device_fixture: str) -> Generator[MagicMock]: lametric = lametric_mock.return_value lametric.api_key = "mock-api-key" lametric.host = "127.0.0.1" - lametric.device.return_value = Device.parse_raw( + lametric.device.return_value = Device.from_json( load_fixture(f"{device_fixture}.json", DOMAIN) ) yield lametric diff --git a/tests/components/lametric/fixtures/computer_powered.json b/tests/components/lametric/fixtures/computer_powered.json new file mode 100644 index 00000000000..0465dd4dd3a --- /dev/null +++ b/tests/components/lametric/fixtures/computer_powered.json @@ -0,0 +1,68 @@ +{ + "audio": { + "available": true, + "volume": 53, + "volume_limit": { + "max": 100, + "min": 0 + }, + "volume_range": { + "max": 100, + "min": 0 + } + }, + "bluetooth": { + "active": false, + "address": "40:F4:C9:AA:AA:AA", + "available": true, + "discoverable": true, + "mac": "40:F4:C9:AA:AA:AA", + "name": "LM8367", + "pairable": false + }, + "display": { + "brightness": 75, + "brightness_limit": { + "max": 76, + "min": 2 + }, + "brightness_mode": "manual", + "brightness_range": { + "max": 100, + "min": 0 + }, + "height": 8, + "on": true, + "screensaver": { + "enabled": true, + "modes": { + "time_based": { + "enabled": false + }, + "when_dark": { + "enabled": true + } + }, + "widget": "1_com.lametric.clock" + }, + "type": "mixed", + "width": 37 + }, + "id": "67790", + "mode": "manual", + "model": "sa8", + "name": "TIME", + "os_version": "3.1.3", + "serial_number": "SA840700836700W00BAA", + "wifi": { + "active": true, + "mac": "40:F4:C9:AA:AA:AA", + "available": true, + "encryption": "WPA", + "ssid": "My wifi", + "ip": "10.0.0.99", + "mode": "dhcp", + "netmask": "255.255.255.0", + "rssi": 78 + } +} diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index cadd0e37566..d8f21424216 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -2,6 +2,7 @@ # name: test_diagnostics dict({ 'audio': dict({ + 'available': True, 'volume': 100, 'volume_limit': dict({ 'range_max': 100, @@ -23,9 +24,21 @@ 'device_id': '**REDACTED**', 'display': dict({ 'brightness': 100, + 'brightness_limit': dict({ + 'range_max': 100, + 'range_min': 2, + }), 'brightness_mode': 'auto', + 'brightness_range': dict({ + 'range_max': 100, + 'range_min': 0, + }), 'display_type': 'mixed', 'height': 8, + 'on': None, + 'screensaver': dict({ + 'enabled': False, + }), 'width': 37, }), 'mode': 'auto', diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index 04efeaac87f..cc8c1379fe0 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -52,6 +52,7 @@ async def test_button_app_next( assert device_entry.model_id == "LM 37X8" assert device_entry.name == "Frenck's LaMetric" assert device_entry.sw_version == "2.2.2" + assert device_entry.serial_number == "SA110405124500W00BS9" assert device_entry.hw_version is None await hass.services.async_call( diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index 3fbe606c7f1..c0fb98f1908 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -13,18 +13,18 @@ from demetriek import ( ) import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.lametric.const import DOMAIN -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_SERIAL, - SsdpServiceInfo, -) from homeassistant.config_entries import SOURCE_DHCP, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -41,12 +41,11 @@ SSDP_DISCOVERY_INFO = SsdpServiceInfo( ) -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") async def test_full_cloud_import_flow_multiple_devices( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, mock_lametric_cloud: MagicMock, mock_lametric: MagicMock, ) -> None: @@ -55,25 +54,24 @@ async def test_full_cloud_import_flow_multiple_devices( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "choice_enter_manual_or_fetch_cloud" - assert result.get("menu_options") == ["pick_implementation", "manual_entry"] - flow_id = result["flow_id"] + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choice_enter_manual_or_fetch_cloud" + assert result["menu_options"] == ["pick_implementation", "manual_entry"] - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) - assert result2.get("type") is FlowResultType.EXTERNAL_STEP - assert result2.get("url") == ( + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( "https://developer.lametric.com/api/v2/oauth2/authorize" "?response_type=code&client_id=client" "&redirect_uri=https://example.com/auth/external/callback" @@ -96,37 +94,37 @@ async def test_full_cloud_import_flow_multiple_devices( }, ) - result3 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result3.get("type") is FlowResultType.FORM - assert result3.get("step_id") == "cloud_select_device" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_select_device" - result4 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_DEVICE: "SA110405124500W00BS9"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: "SA110405124500W00BS9"} ) - assert result4.get("type") is FlowResultType.CREATE_ENTRY - assert result4.get("title") == "Frenck's LaMetric" - assert result4.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result4 - assert result4["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") async def test_full_cloud_import_flow_single_device( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, mock_lametric_cloud: MagicMock, mock_lametric: MagicMock, ) -> None: @@ -135,25 +133,24 @@ async def test_full_cloud_import_flow_single_device( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "choice_enter_manual_or_fetch_cloud" - assert result.get("menu_options") == ["pick_implementation", "manual_entry"] - flow_id = result["flow_id"] + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choice_enter_manual_or_fetch_cloud" + assert result["menu_options"] == ["pick_implementation", "manual_entry"] - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) - assert result2.get("type") is FlowResultType.EXTERNAL_STEP - assert result2.get("url") == ( + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( "https://developer.lametric.com/api/v2/oauth2/authorize" "?response_type=code&client_id=client" "&redirect_uri=https://example.com/auth/external/callback" @@ -181,27 +178,28 @@ async def test_full_cloud_import_flow_single_device( mock_lametric_cloud.devices.return_value = [ mock_lametric_cloud.devices.return_value[0] ] - result3 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_setup_entry") async def test_full_manual( hass: HomeAssistant, - mock_setup_entry: MagicMock, mock_lametric: MagicMock, ) -> None: """Check a full flow manual entry.""" @@ -209,31 +207,34 @@ async def test_full_manual( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "choice_enter_manual_or_fetch_cloud" - assert result.get("menu_options") == ["pick_implementation", "manual_entry"] - flow_id = result["flow_id"] + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choice_enter_manual_or_fetch_cloud" + assert result["menu_options"] == ["pick_implementation", "manual_entry"] - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "manual_entry" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_entry" - result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"}, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 @@ -241,15 +242,12 @@ async def test_full_manual( notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] assert notification.model.sound == Sound(sound=NotificationSound.WIN) - assert len(mock_setup_entry.mock_calls) == 1 - -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") async def test_full_ssdp_with_cloud_import( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, mock_lametric_cloud: MagicMock, mock_lametric: MagicMock, ) -> None: @@ -258,25 +256,24 @@ async def test_full_ssdp_with_cloud_import( DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY_INFO ) - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "choice_enter_manual_or_fetch_cloud" - assert result.get("menu_options") == ["pick_implementation", "manual_entry"] - flow_id = result["flow_id"] + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choice_enter_manual_or_fetch_cloud" + assert result["menu_options"] == ["pick_implementation", "manual_entry"] - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) - assert result2.get("type") is FlowResultType.EXTERNAL_STEP - assert result2.get("url") == ( + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( "https://developer.lametric.com/api/v2/oauth2/authorize" "?response_type=code&client_id=client" "&redirect_uri=https://example.com/auth/external/callback" @@ -299,27 +296,27 @@ async def test_full_ssdp_with_cloud_import( }, ) - result3 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_setup_entry") async def test_full_ssdp_manual_entry( hass: HomeAssistant, - mock_setup_entry: MagicMock, mock_lametric: MagicMock, ) -> None: """Check a full flow triggered by SSDP, with manual API key entry.""" @@ -327,35 +324,35 @@ async def test_full_ssdp_manual_entry( DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY_INFO ) - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "choice_enter_manual_or_fetch_cloud" - assert result.get("menu_options") == ["pick_implementation", "manual_entry"] - flow_id = result["flow_id"] + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choice_enter_manual_or_fetch_cloud" + assert result["menu_options"] == ["pick_implementation", "manual_entry"] - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "manual_entry" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_entry" - result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "mock-api-key"} ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( @@ -385,8 +382,8 @@ async def test_ssdp_abort_invalid_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=data ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == reason + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason @pytest.mark.usefixtures("current_request_with_host") @@ -404,16 +401,15 @@ async def test_cloud_import_updates_existing_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - flow_id = result["flow_id"] await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) @@ -428,14 +424,14 @@ async def test_cloud_import_updates_existing_entry( "expires_in": 60, }, ) - await hass.config_entries.flow.async_configure(flow_id) + await hass.config_entries.flow.async_configure(result["flow_id"]) - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_DEVICE: "SA110405124500W00BS9"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: "SA110405124500W00BS9"} ) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", @@ -458,18 +454,18 @@ async def test_manual_updates_existing_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - flow_id = result["flow_id"] await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) - result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"}, ) - assert result3.get("type") is FlowResultType.ABORT - assert result3.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", @@ -490,8 +486,8 @@ async def test_discovery_updates_existing_entry( DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY_INFO ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-from-fixture", @@ -510,16 +506,15 @@ async def test_cloud_abort_no_devices( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - flow_id = result["flow_id"] await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) @@ -537,14 +532,15 @@ async def test_cloud_abort_no_devices( # Stage there are no devices mock_lametric_cloud.devices.return_value = [] - result2 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "no_devices" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices" assert len(mock_lametric_cloud.devices.mock_calls) == 1 +@pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( ("side_effect", "reason"), [ @@ -557,7 +553,6 @@ async def test_cloud_abort_no_devices( async def test_manual_errors( hass: HomeAssistant, mock_lametric: MagicMock, - mock_setup_entry: MagicMock, side_effect: Exception, reason: str, ) -> None: @@ -565,46 +560,47 @@ async def test_manual_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - flow_id = result["flow_id"] await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) mock_lametric.device.side_effect = side_effect - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"}, ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "manual_entry" - assert result2.get("errors") == {"base": reason} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_entry" + assert result["errors"] == {"base": reason} assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 mock_lametric.device.side_effect = None - result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"}, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric.device.mock_calls) == 2 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") @pytest.mark.parametrize( ("side_effect", "reason"), [ @@ -618,7 +614,6 @@ async def test_cloud_errors( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, mock_lametric_cloud: MagicMock, mock_lametric: MagicMock, side_effect: Exception, @@ -628,16 +623,15 @@ async def test_cloud_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - flow_id = result["flow_id"] await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) @@ -652,41 +646,41 @@ async def test_cloud_errors( "expires_in": 60, }, ) - await hass.config_entries.flow.async_configure(flow_id) + await hass.config_entries.flow.async_configure(result["flow_id"]) mock_lametric.device.side_effect = side_effect - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_DEVICE: "SA110405124500W00BS9"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: "SA110405124500W00BS9"} ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "cloud_select_device" - assert result2.get("errors") == {"base": reason} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_select_device" + assert result["errors"] == {"base": reason} assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 mock_lametric.device.side_effect = None - result3 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_DEVICE: "SA110405124500W00BS9"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: "SA110405124500W00BS9"} ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Frenck's LaMetric" - assert result3.get("data") == { + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.title == "Frenck's LaMetric" + assert config_entry.unique_id == "SA110405124500W00BS9" + assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert "result" in result3 - assert result3["result"].unique_id == "SA110405124500W00BS9" + assert not config_entry.options assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 2 assert len(mock_lametric.notify.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 async def test_dhcp_discovery_updates_entry( @@ -706,8 +700,8 @@ async def test_dhcp_discovery_updates_entry( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data == { CONF_API_KEY: "mock-from-fixture", CONF_HOST: "127.0.0.42", @@ -732,8 +726,8 @@ async def test_dhcp_unknown_device( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "unknown" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" @pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") @@ -750,16 +744,14 @@ async def test_reauth_cloud_import( result = await mock_config_entry.start_reauth_flow(hass) - flow_id = result["flow_id"] - await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) @@ -776,10 +768,10 @@ async def test_reauth_cloud_import( }, ) - result2 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", @@ -806,16 +798,14 @@ async def test_reauth_cloud_abort_device_not_found( result = await mock_config_entry.start_reauth_flow(hass) - flow_id = result["flow_id"] - await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "pick_implementation"} + result["flow_id"], user_input={"next_step_id": "pick_implementation"} ) state = config_entry_oauth2_flow._encode_jwt( hass, { - "flow_id": flow_id, + "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) @@ -832,10 +822,10 @@ async def test_reauth_cloud_abort_device_not_found( }, ) - result2 = await hass.config_entries.flow.async_configure(flow_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "reauth_device_not_found" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_device_not_found" assert len(mock_lametric_cloud.devices.mock_calls) == 1 assert len(mock_lametric.device.mock_calls) == 0 @@ -853,18 +843,16 @@ async def test_reauth_manual( result = await mock_config_entry.start_reauth_flow(hass) - flow_id = result["flow_id"] - await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "mock-api-key"} ) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", @@ -887,18 +875,16 @@ async def test_reauth_manual_sky( result = await mock_config_entry.start_reauth_flow(hass) - flow_id = result["flow_id"] - await hass.config_entries.flow.async_configure( - flow_id, user_input={"next_step_id": "manual_entry"} + result["flow_id"], user_input={"next_step_id": "manual_entry"} ) - result2 = await hass.config_entries.flow.async_configure( - flow_id, user_input={CONF_API_KEY: "mock-api-key"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "mock-api-key"} ) - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert mock_config_entry.data == { CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key", diff --git a/tests/components/lametric/test_init.py b/tests/components/lametric/test_init.py index 7352721e992..2fd8219ea51 100644 --- a/tests/components/lametric/test_init.py +++ b/tests/components/lametric/test_init.py @@ -74,7 +74,7 @@ async def test_config_entry_authentication_failed( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "choice_enter_manual_or_fetch_cloud" + assert flow["step_id"] == "choice_enter_manual_or_fetch_cloud" assert flow.get("handler") == DOMAIN assert "context" in flow diff --git a/tests/components/lametric/test_notify.py b/tests/components/lametric/test_notify.py index a46d97f8f81..d30a8c86543 100644 --- a/tests/components/lametric/test_notify.py +++ b/tests/components/lametric/test_notify.py @@ -100,7 +100,7 @@ async def test_notification_options( assert len(notification.model.frames) == 1 frame = notification.model.frames[0] assert type(frame) is Simple - assert frame.icon == 1234 + assert frame.icon == "1234" assert frame.text == "The secret of getting ahead is getting started" diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index 681abf850d2..6e052603c24 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -42,7 +42,7 @@ async def test_brightness( assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness" assert state.attributes.get(ATTR_MAX) == 100 - assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MIN) == 2 assert state.attributes.get(ATTR_STEP) == 1 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "100" @@ -62,6 +62,7 @@ async def test_brightness( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( @@ -183,3 +184,16 @@ async def test_number_connection_error( state = hass.states.get("number.frenck_s_lametric_volume") assert state assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_fixture", ["computer_powered"]) +async def test_computer_powered_devices( + hass: HomeAssistant, + mock_lametric: MagicMock, +) -> None: + """Test Brightness is properly limited for computer powered devices.""" + state = hass.states.get("number.time_brightness") + assert state + assert state.state == "75" + assert state.attributes[ATTR_MIN] == 2 + assert state.attributes[ATTR_MAX] == 76 diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py index 6b3fa291e9c..e4b9870f52b 100644 --- a/tests/components/lametric/test_select.py +++ b/tests/components/lametric/test_select.py @@ -55,6 +55,7 @@ async def test_brightness_mode( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( diff --git a/tests/components/lametric/test_sensor.py b/tests/components/lametric/test_sensor.py index 8dff11fb450..08b289e2425 100644 --- a/tests/components/lametric/test_sensor.py +++ b/tests/components/lametric/test_sensor.py @@ -48,4 +48,5 @@ async def test_wifi_signal( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" diff --git a/tests/components/lametric/test_services.py b/tests/components/lametric/test_services.py index d3fbd0a18e0..b9b5c4c8b3a 100644 --- a/tests/components/lametric/test_services.py +++ b/tests/components/lametric/test_services.py @@ -190,7 +190,7 @@ async def test_service_message( assert len(notification.model.frames) == 1 frame = notification.model.frames[0] assert type(frame) is Simple - assert frame.icon == 6916 + assert frame.icon == "6916" assert frame.text == "Meow!" mock_lametric.notify.side_effect = LaMetricError diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 367d5605e06..64ebe22e98b 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -57,6 +57,7 @@ async def test_bluetooth( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index 3c5979c3c36..d8dee472946 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -16,7 +16,6 @@ from homeassistant.components.lcn.helpers import AddressType, generate_unique_id from homeassistant.const import CONF_ADDRESS, CONF_DEVICES, CONF_ENTITIES, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture @@ -134,15 +133,6 @@ async def init_integration( return lcn_connection -async def setup_component(hass: HomeAssistant) -> None: - """Set up the LCN component.""" - fixture_filename = "lcn/config.json" - config_data = json.loads(load_fixture(fixture_filename)) - - await async_setup_component(hass, DOMAIN, config_data) - await hass.async_block_till_done() - - def get_device( hass: HomeAssistant, entry: MockConfigEntry, address: AddressType ) -> dr.DeviceEntry: diff --git a/tests/components/lcn/fixtures/config.json b/tests/components/lcn/fixtures/config.json deleted file mode 100644 index ed3e3500900..00000000000 --- a/tests/components/lcn/fixtures/config.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "lcn": { - "connections": [ - { - "host": "192.168.2.41", - "port": 4114, - "username": "lcn", - "password": "lcn", - "sk_num_tries": 0, - "dim_mode": "steps200", - "name": "pchk" - }, - { - "name": "myhome", - "host": "192.168.2.42", - "port": 4114, - "username": "lcn", - "password": "lcn", - "sk_num_tries": 0, - "dim_mode": "steps200" - } - ], - "lights": [ - { - "name": "Light_Output1", - "address": "pchk.s0.m7", - "output": "output1", - "dimmable": true, - "transition": 5 - }, - { - "name": "Light_Output2", - "address": "pchk.s0.m7", - "output": "output2", - "dimmable": false, - "transition": 0 - }, - { - "name": "Light_Relay1", - "address": "s0.m7", - "output": "relay1" - }, - { - "name": "Light_Relay3", - "address": "myhome.s0.m7", - "output": "relay3" - }, - { - "name": "Light_Relay4", - "address": "myhome.s0.m7", - "output": "relay4" - } - ], - "switches": [ - { - "name": "Switch_Output1", - "address": "s0.m7", - "output": "output1" - }, - { - "name": "Switch_Output2", - "address": "s0.m7", - "output": "output2" - }, - { - "name": "Switch_Relay1", - "address": "s0.m7", - "output": "relay1" - }, - { - "name": "Switch_Relay2", - "address": "s0.m7", - "output": "relay2" - }, - { - "name": "Switch_Group5", - "address": "s0.g5", - "output": "relay1" - } - ], - "covers": [ - { - "name": "Cover_Ouputs", - "address": "s0.m7", - "motor": "outputs", - "reverse_time": "rt1200" - }, - { - "name": "Cover_Relays", - "address": "s0.m7", - "motor": "motor1" - } - ], - "climates": [ - { - "name": "Climate1", - "address": "s0.m7", - "source": "var1", - "setpoint": "r1varsetpoint", - "lockable": true, - "min_temp": 0, - "max_temp": 40, - "unit_of_measurement": "°C" - } - ], - "scenes": [ - { - "name": "Romantic", - "address": "s0.m7", - "register": 0, - "scene": 0, - "outputs": ["output1", "output2", "relay1"] - }, - { - "name": "Romantic Transition", - "address": "s0.m7", - "register": 0, - "scene": 1, - "outputs": ["output1", "output2", "relay1"], - "transition": 10 - } - ], - "binary_sensors": [ - { - "name": "Sensor_LockRegulator1", - "address": "s0.m7", - "source": "r1varsetpoint" - }, - { - "name": "Binary_Sensor1", - "address": "s0.m7", - "source": "binsensor1" - }, - { - "name": "Sensor_KeyLock", - "address": "s0.m7", - "source": "a5" - } - ], - "sensors": [ - { - "name": "Sensor_Var1", - "address": "s0.m7", - "source": "var1", - "unit_of_measurement": "°C" - }, - { - "name": "Sensor_Setpoint1", - "address": "s0.m7", - "source": "r1varsetpoint", - "unit_of_measurement": "°C" - }, - { - "name": "Sensor_Led6", - "address": "s0.m7", - "source": "led6" - }, - { - "name": "Sensor_LogicOp1", - "address": "s0.m7", - "source": "logicop1" - } - ] - } -} diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index 7ba263bd597..7bac7cc9e81 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -107,7 +107,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> blocking=True, ) - lock_regulator.assert_awaited_with(0, True) + lock_regulator.assert_awaited_with(0, True, -1) state = hass.states.get("climate.climate1") assert state is not None @@ -124,7 +124,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> blocking=True, ) - lock_regulator.assert_awaited_with(0, True) + lock_regulator.assert_awaited_with(0, True, -1) state = hass.states.get("climate.climate1") assert state is not None diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index b7967c247ec..478f2c0949e 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -2,7 +2,12 @@ from unittest.mock import patch -from pypck.connection import PchkAuthenticationError, PchkLicenseError +from pypck.connection import ( + PchkAuthenticationError, + PchkConnectionFailedError, + PchkConnectionRefusedError, + PchkLicenseError, +) import pytest from homeassistant import config_entries, data_entry_flow @@ -98,7 +103,8 @@ async def test_step_user_existing_host( [ (PchkAuthenticationError, {CONF_BASE: "authentication_error"}), (PchkLicenseError, {CONF_BASE: "license_error"}), - (TimeoutError, {CONF_BASE: "connection_refused"}), + (PchkConnectionFailedError, {CONF_BASE: "connection_refused"}), + (PchkConnectionRefusedError, {CONF_BASE: "connection_refused"}), ], ) async def test_step_user_error( @@ -149,7 +155,8 @@ async def test_step_reconfigure(hass: HomeAssistant, entry: MockConfigEntry) -> [ (PchkAuthenticationError, {CONF_BASE: "authentication_error"}), (PchkLicenseError, {CONF_BASE: "license_error"}), - (TimeoutError, {CONF_BASE: "connection_refused"}), + (PchkConnectionFailedError, {CONF_BASE: "connection_refused"}), + (PchkConnectionRefusedError, {CONF_BASE: "connection_refused"}), ], ) async def test_step_reconfigure_error( diff --git a/tests/components/lcn/test_events.py b/tests/components/lcn/test_events.py index c6c3559e821..93998e24a89 100644 --- a/tests/components/lcn/test_events.py +++ b/tests/components/lcn/test_events.py @@ -150,19 +150,3 @@ async def test_dont_fire_on_non_module_input( await lcn_connection.async_process_input(inp) await hass.async_block_till_done() assert len(events) == 0 - - -async def test_dont_fire_on_unknown_module( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test for no event is fired if an input from an unknown module is received.""" - lcn_connection = await init_integration(hass, entry) - inp = ModStatusAccessControl( - LcnAddr(0, 10, False), # unknown module - periphery=AccessControlPeriphery.FINGERPRINT, - code="aabbcc", - ) - events = async_capture_events(hass, LCN_FINGERPRINT) - await lcn_connection.async_process_input(inp) - await hass.async_block_till_done() - assert len(events) == 0 diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index 2327635e356..4bb8d023d3f 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -2,7 +2,14 @@ from unittest.mock import Mock, patch -from pypck.connection import PchkAuthenticationError, PchkLicenseError +from pypck.connection import ( + PchkAuthenticationError, + PchkConnectionFailedError, + PchkConnectionRefusedError, + PchkLcnNotConnectedError, + PchkLicenseError, +) +from pypck.lcn_defs import LcnEvent import pytest from homeassistant import config_entries @@ -84,21 +91,46 @@ async def test_async_setup_entry_update( @pytest.mark.parametrize( - "exception", [PchkAuthenticationError, PchkLicenseError, TimeoutError] + "exception", + [ + PchkAuthenticationError, + PchkLicenseError, + PchkConnectionRefusedError, + PchkConnectionFailedError, + PchkLcnNotConnectedError, + ], ) -async def test_async_setup_entry_raises_authentication_error( +async def test_async_setup_entry_fails( hass: HomeAssistant, entry: MockConfigEntry, exception: Exception ) -> None: - """Test that an authentication error is handled properly.""" - with patch( - "homeassistant.components.lcn.PchkConnectionManager.async_connect", - side_effect=exception, + """Test that an error is handled properly.""" + with ( + patch( + "homeassistant.components.lcn.PchkConnectionManager.async_connect", + side_effect=exception, + ), ): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_ERROR + assert entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + "event", + [LcnEvent.CONNECTION_LOST, LcnEvent.PING_TIMEOUT, LcnEvent.BUS_DISCONNECTED], +) +async def test_async_entry_reload_on_host_event_received( + hass: HomeAssistant, entry: MockConfigEntry, event: LcnEvent +) -> None: + """Test for config entry reload on certain host event received.""" + lcn_connection = await init_integration(hass, entry) + with patch( + "homeassistant.config_entries.ConfigEntries.async_schedule_reload" + ) as async_schedule_reload: + lcn_connection.fire_event(event) + async_schedule_reload.assert_called_with(entry.entry_id) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index a4ea559cd72..cd97e3484e3 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -26,22 +26,37 @@ from homeassistant.components.lcn.services import LcnService from homeassistant.const import ( CONF_ADDRESS, CONF_BRIGHTNESS, + CONF_DEVICE_ID, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant +import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import ( MockConfigEntry, MockModuleConnection, - MockPchkConnectionManager, + get_device, init_integration, ) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_output_abs(hass: HomeAssistant, entry: MockConfigEntry) -> None: +def device_config( + hass: HomeAssistant, entry: MockConfigEntry, config_type: str +) -> dict[str, str]: + """Return test device config depending on type.""" + if config_type == CONF_ADDRESS: + return {CONF_ADDRESS: "pchk.s0.m7"} + return {CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id} + + +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) +async def test_service_output_abs( + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, +) -> None: """Test output_abs service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -51,7 +66,7 @@ async def test_service_output_abs(hass: HomeAssistant, entry: MockConfigEntry) - DOMAIN, LcnService.OUTPUT_ABS, { - CONF_ADDRESS: "pchk.s0.m7", + **device_config(hass, entry, config_type), CONF_OUTPUT: "output1", CONF_BRIGHTNESS: 100, CONF_TRANSITION: 5, @@ -62,8 +77,12 @@ async def test_service_output_abs(hass: HomeAssistant, entry: MockConfigEntry) - dim_output.assert_awaited_with(0, 100, 9) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_output_rel(hass: HomeAssistant, entry: MockConfigEntry) -> None: +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) +async def test_service_output_rel( + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, +) -> None: """Test output_rel service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -73,7 +92,7 @@ async def test_service_output_rel(hass: HomeAssistant, entry: MockConfigEntry) - DOMAIN, LcnService.OUTPUT_REL, { - CONF_ADDRESS: "pchk.s0.m7", + **device_config(hass, entry, config_type), CONF_OUTPUT: "output1", CONF_BRIGHTNESS: 25, }, @@ -83,9 +102,11 @@ async def test_service_output_rel(hass: HomeAssistant, entry: MockConfigEntry) - rel_output.assert_awaited_with(0, 25) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_output_toggle( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, ) -> None: """Test output_toggle service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -96,7 +117,7 @@ async def test_service_output_toggle( DOMAIN, LcnService.OUTPUT_TOGGLE, { - CONF_ADDRESS: "pchk.s0.m7", + **device_config(hass, entry, config_type), CONF_OUTPUT: "output1", CONF_TRANSITION: 5, }, @@ -106,8 +127,12 @@ async def test_service_output_toggle( toggle_output.assert_awaited_with(0, 9) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_relays(hass: HomeAssistant, entry: MockConfigEntry) -> None: +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) +async def test_service_relays( + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, +) -> None: """Test relays service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -116,7 +141,7 @@ async def test_service_relays(hass: HomeAssistant, entry: MockConfigEntry) -> No await hass.services.async_call( DOMAIN, LcnService.RELAYS, - {CONF_ADDRESS: "pchk.s0.m7", CONF_STATE: "0011TT--"}, + {**device_config(hass, entry, config_type), CONF_STATE: "0011TT--"}, blocking=True, ) @@ -126,8 +151,12 @@ async def test_service_relays(hass: HomeAssistant, entry: MockConfigEntry) -> No control_relays.assert_awaited_with(relay_states) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_led(hass: HomeAssistant, entry: MockConfigEntry) -> None: +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) +async def test_service_led( + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, +) -> None: """Test led service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -136,7 +165,11 @@ async def test_service_led(hass: HomeAssistant, entry: MockConfigEntry) -> None: await hass.services.async_call( DOMAIN, LcnService.LED, - {CONF_ADDRESS: "pchk.s0.m7", CONF_LED: "led6", CONF_STATE: "blink"}, + { + **device_config(hass, entry, config_type), + CONF_LED: "led6", + CONF_STATE: "blink", + }, blocking=True, ) @@ -146,8 +179,12 @@ async def test_service_led(hass: HomeAssistant, entry: MockConfigEntry) -> None: control_led.assert_awaited_with(led, led_state) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_var_abs(hass: HomeAssistant, entry: MockConfigEntry) -> None: +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) +async def test_service_var_abs( + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, +) -> None: """Test var_abs service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -157,7 +194,7 @@ async def test_service_var_abs(hass: HomeAssistant, entry: MockConfigEntry) -> N DOMAIN, LcnService.VAR_ABS, { - CONF_ADDRESS: "pchk.s0.m7", + **device_config(hass, entry, config_type), CONF_VARIABLE: "var1", CONF_VALUE: 75, CONF_UNIT_OF_MEASUREMENT: "%", @@ -170,8 +207,12 @@ async def test_service_var_abs(hass: HomeAssistant, entry: MockConfigEntry) -> N ) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_var_rel(hass: HomeAssistant, entry: MockConfigEntry) -> None: +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) +async def test_service_var_rel( + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, +) -> None: """Test var_rel service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -181,7 +222,7 @@ async def test_service_var_rel(hass: HomeAssistant, entry: MockConfigEntry) -> N DOMAIN, LcnService.VAR_REL, { - CONF_ADDRESS: "pchk.s0.m7", + **device_config(hass, entry, config_type), CONF_VARIABLE: "var1", CONF_VALUE: 10, CONF_UNIT_OF_MEASUREMENT: "%", @@ -198,8 +239,12 @@ async def test_service_var_rel(hass: HomeAssistant, entry: MockConfigEntry) -> N ) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_var_reset(hass: HomeAssistant, entry: MockConfigEntry) -> None: +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) +async def test_service_var_reset( + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, +) -> None: """Test var_reset service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -208,16 +253,18 @@ async def test_service_var_reset(hass: HomeAssistant, entry: MockConfigEntry) -> await hass.services.async_call( DOMAIN, LcnService.VAR_RESET, - {CONF_ADDRESS: "pchk.s0.m7", CONF_VARIABLE: "var1"}, + {**device_config(hass, entry, config_type), CONF_VARIABLE: "var1"}, blocking=True, ) var_reset.assert_awaited_with(pypck.lcn_defs.Var["VAR1"]) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_lock_regulator( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, ) -> None: """Test lock_regulator service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -228,7 +275,7 @@ async def test_service_lock_regulator( DOMAIN, LcnService.LOCK_REGULATOR, { - CONF_ADDRESS: "pchk.s0.m7", + **device_config(hass, entry, config_type), CONF_SETPOINT: "r1varsetpoint", CONF_STATE: True, }, @@ -238,8 +285,12 @@ async def test_service_lock_regulator( lock_regulator.assert_awaited_with(0, True) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_send_keys(hass: HomeAssistant, entry: MockConfigEntry) -> None: +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) +async def test_service_send_keys( + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, +) -> None: """Test send_keys service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -248,7 +299,11 @@ async def test_service_send_keys(hass: HomeAssistant, entry: MockConfigEntry) -> await hass.services.async_call( DOMAIN, LcnService.SEND_KEYS, - {CONF_ADDRESS: "pchk.s0.m7", CONF_KEYS: "a1a5d8", CONF_STATE: "hit"}, + { + **device_config(hass, entry, config_type), + CONF_KEYS: "a1a5d8", + CONF_STATE: "hit", + }, blocking=True, ) @@ -260,9 +315,11 @@ async def test_service_send_keys(hass: HomeAssistant, entry: MockConfigEntry) -> send_keys.assert_awaited_with(keys, pypck.lcn_defs.SendKeyCommand["HIT"]) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_send_keys_hit_deferred( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, ) -> None: """Test send_keys (hit_deferred) service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -281,7 +338,7 @@ async def test_service_send_keys_hit_deferred( DOMAIN, LcnService.SEND_KEYS, { - CONF_ADDRESS: "pchk.s0.m7", + **device_config(hass, entry, config_type), CONF_KEYS: "a1a5d8", CONF_TIME: 5, CONF_TIME_UNIT: "s", @@ -304,7 +361,7 @@ async def test_service_send_keys_hit_deferred( DOMAIN, LcnService.SEND_KEYS, { - CONF_ADDRESS: "pchk.s0.m7", + **device_config(hass, entry, config_type), CONF_KEYS: "a1a5d8", CONF_STATE: "make", CONF_TIME: 5, @@ -314,8 +371,12 @@ async def test_service_send_keys_hit_deferred( ) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_lock_keys(hass: HomeAssistant, entry: MockConfigEntry) -> None: +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) +async def test_service_lock_keys( + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, +) -> None: """Test lock_keys service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -324,7 +385,11 @@ async def test_service_lock_keys(hass: HomeAssistant, entry: MockConfigEntry) -> await hass.services.async_call( DOMAIN, LcnService.LOCK_KEYS, - {CONF_ADDRESS: "pchk.s0.m7", CONF_TABLE: "a", CONF_STATE: "0011TT--"}, + { + **device_config(hass, entry, config_type), + CONF_TABLE: "a", + CONF_STATE: "0011TT--", + }, blocking=True, ) @@ -334,9 +399,11 @@ async def test_service_lock_keys(hass: HomeAssistant, entry: MockConfigEntry) -> lock_keys.assert_awaited_with(0, lock_states) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_lock_keys_tab_a_temporary( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, ) -> None: """Test lock_keys (tab_a_temporary) service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -350,7 +417,7 @@ async def test_service_lock_keys_tab_a_temporary( DOMAIN, LcnService.LOCK_KEYS, { - CONF_ADDRESS: "pchk.s0.m7", + **device_config(hass, entry, config_type), CONF_STATE: "0011TT--", CONF_TIME: 10, CONF_TIME_UNIT: "s", @@ -376,7 +443,7 @@ async def test_service_lock_keys_tab_a_temporary( DOMAIN, LcnService.LOCK_KEYS, { - CONF_ADDRESS: "pchk.s0.m7", + **device_config(hass, entry, config_type), CONF_TABLE: "b", CONF_STATE: "0011TT--", CONF_TIME: 10, @@ -386,8 +453,12 @@ async def test_service_lock_keys_tab_a_temporary( ) -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_dyn_text(hass: HomeAssistant, entry: MockConfigEntry) -> None: +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) +async def test_service_dyn_text( + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, +) -> None: """Test dyn_text service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -396,15 +467,23 @@ async def test_service_dyn_text(hass: HomeAssistant, entry: MockConfigEntry) -> await hass.services.async_call( DOMAIN, LcnService.DYN_TEXT, - {CONF_ADDRESS: "pchk.s0.m7", CONF_ROW: 1, CONF_TEXT: "text in row 1"}, + { + **device_config(hass, entry, config_type), + CONF_ROW: 1, + CONF_TEXT: "text in row 1", + }, blocking=True, ) dyn_text.assert_awaited_with(0, "text in row 1") -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_pck(hass: HomeAssistant, entry: MockConfigEntry) -> None: +@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) +async def test_service_pck( + hass: HomeAssistant, + entry: MockConfigEntry, + config_type: str, +) -> None: """Test pck service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -413,14 +492,13 @@ async def test_service_pck(hass: HomeAssistant, entry: MockConfigEntry) -> None: await hass.services.async_call( DOMAIN, LcnService.PCK, - {CONF_ADDRESS: "pchk.s0.m7", CONF_PCK: "PIN4"}, + {**device_config(hass, entry, config_type), CONF_PCK: "PIN4"}, blocking=True, ) pck.assert_awaited_with("PIN4") -@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_called_with_invalid_host_id( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -437,3 +515,20 @@ async def test_service_called_with_invalid_host_id( ) pck.assert_not_awaited() + + +async def test_service_with_deprecated_address_parameter( + hass: HomeAssistant, entry: MockConfigEntry, issue_registry: ir.IssueRegistry +) -> None: + """Test service puts issue in registry if called with address parameter.""" + await async_setup_component(hass, "persistent_notification", {}) + await init_integration(hass, entry) + + await hass.services.async_call( + DOMAIN, + LcnService.PCK, + {CONF_ADDRESS: "pchk.s0.m7", CONF_PCK: "PIN4"}, + blocking=True, + ) + + assert issue_registry.async_get_issue(DOMAIN, "deprecated_address_parameter") diff --git a/tests/components/led_ble/test_config_flow.py b/tests/components/led_ble/test_config_flow.py index c22c62e2fb1..674700aebd9 100644 --- a/tests/components/led_ble/test_config_flow.py +++ b/tests/components/led_ble/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from bleak import BleakError +from led_ble import CharacteristicMissingError from homeassistant import config_entries from homeassistant.components.led_ble.const import DOMAIN @@ -202,6 +203,35 @@ async def test_user_step_unknown_exception(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_step_not_supported(hass: HomeAssistant) -> None: + """Test user step with a non supported device.""" + with patch( + "homeassistant.components.led_ble.config_flow.async_discovered_service_info", + return_value=[LED_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.led_ble.config_flow.LEDBLE.update", + side_effect=CharacteristicMissingError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: LED_BLE_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "not_supported" + + async def test_bluetooth_step_success(hass: HomeAssistant) -> None: """Test bluetooth step success path.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/lektrico/conftest.py b/tests/components/lektrico/conftest.py index fd840b0c290..0b120cd6e23 100644 --- a/tests/components/lektrico/conftest.py +++ b/tests/components/lektrico/conftest.py @@ -8,13 +8,13 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.lektrico.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.const import ( ATTR_HW_VERSION, ATTR_SERIAL_NUMBER, CONF_HOST, CONF_TYPE, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py new file mode 100644 index 00000000000..f7686f815fe --- /dev/null +++ b/tests/components/letpot/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the LetPot integration.""" + +from letpot.models import AuthenticationInfo + +AUTHENTICATION = AuthenticationInfo( + access_token="access_token", + access_token_expires=0, + refresh_token="refresh_token", + refresh_token_expires=0, + user_id="a1b2c3d4e5f6a1b2c3d4e5f6", + email="email@example.com", +) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py new file mode 100644 index 00000000000..4cd7ef442a6 --- /dev/null +++ b/tests/components/letpot/conftest.py @@ -0,0 +1,46 @@ +"""Common fixtures for the LetPot tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.letpot.const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL + +from . import AUTHENTICATION + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.letpot.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=AUTHENTICATION.email, + data={ + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + }, + unique_id=AUTHENTICATION.user_id, + ) diff --git a/tests/components/letpot/test_config_flow.py b/tests/components/letpot/test_config_flow.py new file mode 100644 index 00000000000..0ec1bd95d91 --- /dev/null +++ b/tests/components/letpot/test_config_flow.py @@ -0,0 +1,317 @@ +"""Test the LetPot config flow.""" + +import dataclasses +from typing import Any +from unittest.mock import AsyncMock, patch + +from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +import pytest + +from homeassistant.components.letpot.const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import AUTHENTICATION + +from tests.common import MockConfigEntry + + +def _assert_result_success(result: Any) -> None: + """Assert successful end of flow result, creating an entry.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == AUTHENTICATION.email + assert result["data"] == { + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert result["result"].unique_id == AUTHENTICATION.user_id + + +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test full flow with success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=AUTHENTICATION, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + _assert_result_success(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (LetPotAuthenticationException, "invalid_auth"), + (LetPotConnectionException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_flow_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow with exception during login and recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Retry to show recovery. + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=AUTHENTICATION, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + _assert_result_success(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_duplicate( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test flow aborts when trying to add a previously added account.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=AUTHENTICATION, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reauth_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauth flow with success.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace( + AUTHENTICATION, + access_token="new_access_token", + refresh_token="new_refresh_token", + ) + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: "new_access_token", + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: "new_refresh_token", + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (LetPotAuthenticationException, "invalid_auth"), + (LetPotConnectionException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauth flow with exception during login and recovery.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Retry to show recovery. + updated_auth = dataclasses.replace( + AUTHENTICATION, + access_token="new_access_token", + refresh_token="new_refresh_token", + ) + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: "new_access_token", + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: "new_refresh_token", + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_reauth_different_user_id_new( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauth flow with different, new user ID updating the existing entry.""" + mock_config_entry.add_to_hass(hass) + config_entries = hass.config_entries.async_entries() + assert len(config_entries) == 1 + assert config_entries[0].unique_id == AUTHENTICATION.user_id + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace(AUTHENTICATION, user_id="new_user_id") + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: "new_user_id", + CONF_EMAIL: AUTHENTICATION.email, + } + config_entries = hass.config_entries.async_entries() + assert len(config_entries) == 1 + assert config_entries[0].unique_id == "new_user_id" + + +async def test_reauth_different_user_id_existing( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauth flow with different, existing user ID aborting.""" + mock_config_entry.add_to_hass(hass) + mock_other = MockConfigEntry( + domain=DOMAIN, title="email2@example.com", data={}, unique_id="other_user_id" + ) + mock_other.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace(AUTHENTICATION, user_id="other_user_id") + with patch( + "homeassistant.components.letpot.config_flow.LetPotClient.login", + return_value=updated_auth, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(hass.config_entries.async_entries()) == 2 diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 387df916eba..2c58b109e61 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -203,3 +203,146 @@ 'state': '24', }) # --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Schedule turn-off', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_stop', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test air conditioner Schedule turn-off', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_start', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test air conditioner Schedule turn-on', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_absolute_to_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test air conditioner Schedule turn-on', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-10-10T13:14:00+00:00', + }) +# --- \ No newline at end of file diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py index 02b91b4771b..e1f1a7ed93d 100644 --- a/tests/components/lg_thinq/test_sensor.py +++ b/tests/components/lg_thinq/test_sensor.py @@ -1,5 +1,6 @@ """Tests for the LG Thinq sensor platform.""" +from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest @@ -15,6 +16,7 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.freeze_time(datetime(2024, 10, 10, tzinfo=UTC)) async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -23,6 +25,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" + hass.config.time_zone = "UTC" with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index d1a6920f84a..e2a35bcb1b1 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -8,7 +8,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.lifx import DOMAIN from homeassistant.components.lifx.config_flow import LifXConfigFlow from homeassistant.components.lifx.const import CONF_SERIAL @@ -16,6 +15,11 @@ from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import async_setup_component from . import ( @@ -362,7 +366,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ) @@ -385,7 +389,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -402,7 +406,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -416,19 +420,19 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), @@ -476,19 +480,19 @@ async def test_discovered_by_dhcp_or_discovery( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), @@ -520,19 +524,19 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 084ea0c674b..58843d63f9a 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components import lifx from homeassistant.components.lifx import DOMAIN -from homeassistant.components.lifx.const import ATTR_POWER +from homeassistant.components.lifx.const import _ATTR_COLOR_TEMP, ATTR_POWER from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES from homeassistant.components.lifx.manager import ( ATTR_CLOUD_SATURATION_MAX, @@ -25,17 +25,16 @@ from homeassistant.components.lifx.manager import ( SERVICE_EFFECT_MORPH, SERVICE_EFFECT_MOVE, SERVICE_EFFECT_SKY, + SERVICE_PAINT_THEME, ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_MODE, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, @@ -1047,6 +1046,104 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: bulb.set_power.reset_mock() +@pytest.mark.usefixtures("mock_discovery") +async def test_paint_theme_service(hass: HomeAssistant) -> None: + """Test the firmware flame and morph effects on a matrix device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.power_level = 0 + bulb.color = [65535, 65535, 65535, 65535] + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_PAINT_THEME, + {ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 4, ATTR_THEME: "autumn"}, + blocking=True, + ) + + bulb.power_level = 65535 + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_color.calls) == 1 + call_dict = bulb.set_color.calls[0][1] + call_dict.pop("callb") + assert call_dict["value"] in [ + (5643, 65535, 32768, 3500), + (15109, 65535, 32768, 3500), + (8920, 65535, 32768, 3500), + (10558, 65535, 32768, 3500), + ] + assert call_dict["duration"] == 4000 + bulb.set_color.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_PAINT_THEME, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TRANSITION: 6, + ATTR_PALETTE: [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ], + }, + blocking=True, + ) + + bulb.power_level = 65535 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_color.calls) == 1 + call_dict = bulb.set_color.calls[0][1] + call_dict.pop("callb") + hue = round(call_dict["value"][0] / 65535 * 360) + sat = round(call_dict["value"][1] / 65535 * 100) + bri = call_dict["value"][2] >> 8 + kel = call_dict["value"][3] + assert (hue, sat, bri, kel) in [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ] + assert call_dict["duration"] == 6000 + + bulb.set_color.reset_mock() + bulb.set_power.reset_mock() + + async def test_color_light_with_temp( hass: HomeAssistant, mock_effect_conductor ) -> None: @@ -1264,7 +1361,7 @@ async def test_white_bulb(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 400}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) assert bulb.set_color.calls[0][0][0] == [32000, 0, 32000, 2500] @@ -1719,7 +1816,7 @@ async def test_lifx_set_state_color(hass: HomeAssistant) -> None: async def test_lifx_set_state_kelvin(hass: HomeAssistant) -> None: - """Test set_state works with old and new kelvin parameter names.""" + """Test set_state works with kelvin parameter names.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL ) @@ -1748,15 +1845,6 @@ async def test_lifx_set_state_kelvin(hass: HomeAssistant) -> None: assert bulb.set_power.calls[0][0][0] is False bulb.set_power.reset_mock() - await hass.services.async_call( - DOMAIN, - "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 255, ATTR_KELVIN: 3500}, - blocking=True, - ) - assert bulb.set_color.calls[0][0][0] == [32000, 0, 65535, 3500] - bulb.set_color.reset_mock() - await hass.services.async_call( DOMAIN, "set_state", @@ -1769,7 +1857,7 @@ async def test_lifx_set_state_kelvin(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, "set_state", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 255, ATTR_COLOR_TEMP: 400}, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 255, _ATTR_COLOR_TEMP: 400}, blocking=True, ) assert bulb.set_color.calls[0][0][0] == [32000, 0, 65535, 2500] diff --git a/tests/components/light/common.py b/tests/components/light/common.py index ba095a03642..77411cd637d 100644 --- a/tests/components/light/common.py +++ b/tests/components/light/common.py @@ -10,11 +10,10 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_PROFILE, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, @@ -22,6 +21,8 @@ from homeassistant.components.light import ( ATTR_TRANSITION, ATTR_WHITE, ATTR_XY_COLOR, + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, DOMAIN, ColorMode, LightEntity, @@ -34,54 +35,10 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.loader import bind_hass from tests.common import MockToggleEntity -@bind_hass -def turn_on( - hass: HomeAssistant, - entity_id: str = ENTITY_MATCH_ALL, - transition: float | None = None, - brightness: int | None = None, - brightness_pct: float | None = None, - rgb_color: tuple[int, int, int] | None = None, - rgbw_color: tuple[int, int, int, int] | None = None, - rgbww_color: tuple[int, int, int, int, int] | None = None, - xy_color: tuple[float, float] | None = None, - hs_color: tuple[float, float] | None = None, - color_temp: int | None = None, - kelvin: int | None = None, - profile: str | None = None, - flash: str | None = None, - effect: str | None = None, - color_name: str | None = None, - white: bool | None = None, -) -> None: - """Turn all or specified light on.""" - hass.add_job( - async_turn_on, - hass, - entity_id, - transition, - brightness, - brightness_pct, - rgb_color, - rgbw_color, - rgbww_color, - xy_color, - hs_color, - color_temp, - kelvin, - profile, - flash, - effect, - color_name, - white, - ) - - async def async_turn_on( hass: HomeAssistant, entity_id: str = ENTITY_MATCH_ALL, @@ -93,8 +50,7 @@ async def async_turn_on( rgbww_color: tuple[int, int, int, int, int] | None = None, xy_color: tuple[float, float] | None = None, hs_color: tuple[float, float] | None = None, - color_temp: int | None = None, - kelvin: int | None = None, + color_temp_kelvin: int | None = None, profile: str | None = None, flash: str | None = None, effect: str | None = None, @@ -115,8 +71,7 @@ async def async_turn_on( (ATTR_RGBWW_COLOR, rgbww_color), (ATTR_XY_COLOR, xy_color), (ATTR_HS_COLOR, hs_color), - (ATTR_COLOR_TEMP, color_temp), - (ATTR_KELVIN, kelvin), + (ATTR_COLOR_TEMP_KELVIN, color_temp_kelvin), (ATTR_FLASH, flash), (ATTR_EFFECT, effect), (ATTR_COLOR_NAME, color_name), @@ -128,17 +83,6 @@ async def async_turn_on( await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data, blocking=True) -@bind_hass -def turn_off( - hass: HomeAssistant, - entity_id: str = ENTITY_MATCH_ALL, - transition: float | None = None, - flash: str | None = None, -) -> None: - """Turn all or specified light off.""" - hass.add_job(async_turn_off, hass, entity_id, transition, flash) - - async def async_turn_off( hass: HomeAssistant, entity_id: str = ENTITY_MATCH_ALL, @@ -159,43 +103,6 @@ async def async_turn_off( await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data, blocking=True) -@bind_hass -def toggle( - hass: HomeAssistant, - entity_id: str = ENTITY_MATCH_ALL, - transition: float | None = None, - brightness: int | None = None, - brightness_pct: float | None = None, - rgb_color: tuple[int, int, int] | None = None, - xy_color: tuple[float, float] | None = None, - hs_color: tuple[float, float] | None = None, - color_temp: int | None = None, - kelvin: int | None = None, - profile: str | None = None, - flash: str | None = None, - effect: str | None = None, - color_name: str | None = None, -) -> None: - """Toggle all or specified light.""" - hass.add_job( - async_toggle, - hass, - entity_id, - transition, - brightness, - brightness_pct, - rgb_color, - xy_color, - hs_color, - color_temp, - kelvin, - profile, - flash, - effect, - color_name, - ) - - async def async_toggle( hass: HomeAssistant, entity_id: str = ENTITY_MATCH_ALL, @@ -205,8 +112,7 @@ async def async_toggle( rgb_color: tuple[int, int, int] | None = None, xy_color: tuple[float, float] | None = None, hs_color: tuple[float, float] | None = None, - color_temp: int | None = None, - kelvin: int | None = None, + color_temp_kelvin: int | None = None, profile: str | None = None, flash: str | None = None, effect: str | None = None, @@ -224,8 +130,7 @@ async def async_toggle( (ATTR_RGB_COLOR, rgb_color), (ATTR_XY_COLOR, xy_color), (ATTR_HS_COLOR, hs_color), - (ATTR_COLOR_TEMP, color_temp), - (ATTR_KELVIN, kelvin), + (ATTR_COLOR_TEMP_KELVIN, color_temp_kelvin), (ATTR_FLASH, flash), (ATTR_EFFECT, effect), (ATTR_COLOR_NAME, color_name), @@ -249,8 +154,8 @@ TURN_ON_ARG_TO_COLOR_MODE = { class MockLight(MockToggleEntity, LightEntity): """Mock light class.""" - _attr_max_color_temp_kelvin = 6500 - _attr_min_color_temp_kelvin = 2000 + _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN + _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN supported_features = 0 brightness = None diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 61e7f4e6c29..6d0337f37a5 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1,5 +1,6 @@ """The tests for the Light component.""" +from types import ModuleType from typing import Literal from unittest.mock import MagicMock, mock_open, patch @@ -20,6 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.helpers import frame from homeassistant.setup import async_setup_component import homeassistant.util.color as color_util @@ -29,6 +31,9 @@ from tests.common import ( MockEntityPlatform, MockUser, async_mock_service, + help_test_all, + import_and_test_deprecated_constant, + import_and_test_deprecated_constant_enum, setup_test_component_platform, ) @@ -1383,7 +1388,7 @@ async def test_light_state_off(hass: HomeAssistant) -> None: "hs_color": None, "rgb_color": None, "xy_color": None, - "max_color_temp_kelvin": 6500, + "max_color_temp_kelvin": 6535, "max_mireds": 500, "min_color_temp_kelvin": 2000, "min_mireds": 153, @@ -2065,7 +2070,7 @@ async def test_light_service_call_color_temp_conversion(hass: HomeAssistant) -> assert entity1.min_mireds == 153 assert entity1.max_mireds == 500 assert entity1.min_color_temp_kelvin == 2000 - assert entity1.max_color_temp_kelvin == 6500 + assert entity1.max_color_temp_kelvin == 6535 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -2078,7 +2083,7 @@ async def test_light_service_call_color_temp_conversion(hass: HomeAssistant) -> assert state.attributes["min_mireds"] == 153 assert state.attributes["max_mireds"] == 500 assert state.attributes["min_color_temp_kelvin"] == 2000 - assert state.attributes["max_color_temp_kelvin"] == 6500 + assert state.attributes["max_color_temp_kelvin"] == 6535 state = hass.states.get(entity1.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] @@ -2621,7 +2626,9 @@ def test_filter_supported_color_modes() -> None: assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS} -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated supported features ints.""" class MockLightEntityEntity(light.LightEntity): @@ -2631,6 +2638,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> return 1 entity = MockLightEntityEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass, domain="test", platform_name="test") assert entity.supported_features_compat is light.LightEntityFeature(1) assert "MockLightEntityEntity" in caplog.text assert "is using deprecated supported features values" in caplog.text @@ -2649,7 +2658,7 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> (light.ColorMode.ONOFF, {light.ColorMode.ONOFF}, False), ], ) -def test_report_no_color_mode( +async def test_report_no_color_mode( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2665,6 +2674,8 @@ def test_report_no_color_mode( _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = "does not report a color mode" assert (expected_warning in caplog.text) is warning_expected @@ -2677,7 +2688,7 @@ def test_report_no_color_mode( (light.ColorMode.ONOFF, {light.ColorMode.ONOFF}, False), ], ) -def test_report_no_color_modes( +async def test_report_no_color_modes( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2693,6 +2704,8 @@ def test_report_no_color_modes( _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = "does not set supported color modes" assert (expected_warning in caplog.text) is warning_expected @@ -2723,7 +2736,7 @@ def test_report_no_color_modes( (light.ColorMode.HS, {light.ColorMode.BRIGHTNESS}, "effect", True), ], ) -def test_report_invalid_color_mode( +async def test_report_invalid_color_mode( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2741,6 +2754,8 @@ def test_report_invalid_color_mode( _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = f"set to unsupported color mode {color_mode}" assert (expected_warning in caplog.text) is warning_expected @@ -2802,3 +2817,195 @@ def test_report_invalid_color_modes( entity._async_calculate_state() expected_warning = "sets invalid supported color modes" assert (expected_warning in caplog.text) is warning_expected + + +@pytest.mark.parametrize( + ("attributes", "expected_warnings", "expected_values"), + [ + ( + { + "_attr_color_temp_kelvin": 4000, + "_attr_min_color_temp_kelvin": 3000, + "_attr_max_color_temp_kelvin": 5000, + }, + {"current": False, "warmest": False, "coldest": False}, + # Just highlighting that the attributes match the + # converted kelvin values, not the mired properties + (3000, 4000, 5000, 200, 250, 333, 153, None, 500), + ), + ( + {"_attr_color_temp": 350, "_attr_min_mireds": 300, "_attr_max_mireds": 400}, + {"current": True, "warmest": True, "coldest": True}, + (2500, 2857, 3333, 300, 350, 400, 300, 350, 400), + ), + ( + {}, + {"current": False, "warmest": True, "coldest": True}, + (2000, None, 6535, 153, None, 500, 153, None, 500), + ), + ], + ids=["with_kelvin", "with_mired_values", "with_mired_defaults"], +) +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +def test_missing_kelvin_property_warnings( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + attributes: dict[str, int | None], + expected_warnings: dict[str, bool], + expected_values: tuple[int, int | None, int], +) -> None: + """Test missing kelvin properties.""" + + class MockLightEntityEntity(light.LightEntity): + _attr_color_mode = light.ColorMode.COLOR_TEMP + _attr_is_on = True + _attr_supported_features = light.LightEntityFeature.EFFECT + _attr_supported_color_modes = {light.ColorMode.COLOR_TEMP} + platform = MockEntityPlatform(hass, platform_name="test") + + entity = MockLightEntityEntity() + for k, v in attributes.items(): + setattr(entity, k, v) + + state = entity._async_calculate_state() + for warning, expected in expected_warnings.items(): + assert ( + f"is using mireds for {warning} light color temperature" in caplog.text + ) is expected, f"Expected {expected} for '{warning}'" + + assert state.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN] == expected_values[0] + assert state.attributes[light.ATTR_COLOR_TEMP_KELVIN] == expected_values[1] + assert state.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN] == expected_values[2] + assert state.attributes[light.ATTR_MIN_MIREDS] == expected_values[3] + assert state.attributes[light.ATTR_COLOR_TEMP] == expected_values[4] + assert state.attributes[light.ATTR_MAX_MIREDS] == expected_values[5] + assert entity.min_mireds == expected_values[6] + assert entity.color_temp == expected_values[7] + assert entity.max_mireds == expected_values[8] + + +@pytest.mark.parametrize( + "module", + [light], +) +def test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + help_test_all(module) + + +@pytest.mark.parametrize( + ("constant_name", "constant_value", "constant_replacement"), + [ + ("SUPPORT_BRIGHTNESS", 1, "supported_color_modes"), + ("SUPPORT_COLOR_TEMP", 2, "supported_color_modes"), + ("SUPPORT_COLOR", 16, "supported_color_modes"), + ("ATTR_COLOR_TEMP", "color_temp", "kelvin equivalent (ATTR_COLOR_TEMP_KELVIN)"), + ("ATTR_KELVIN", "kelvin", "ATTR_COLOR_TEMP_KELVIN"), + ( + "ATTR_MIN_MIREDS", + "min_mireds", + "kelvin equivalent (ATTR_MAX_COLOR_TEMP_KELVIN)", + ), + ( + "ATTR_MAX_MIREDS", + "max_mireds", + "kelvin equivalent (ATTR_MIN_COLOR_TEMP_KELVIN)", + ), + ], +) +def test_deprecated_light_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + constant_value: int | str, + constant_replacement: str, +) -> None: + """Test deprecated light constants.""" + import_and_test_deprecated_constant( + caplog, light, constant_name, constant_replacement, constant_value, "2026.1" + ) + + +@pytest.mark.parametrize( + "entity_feature", + list(light.LightEntityFeature), +) +def test_deprecated_support_light_constants_enums( + caplog: pytest.LogCaptureFixture, + entity_feature: light.LightEntityFeature, +) -> None: + """Test deprecated support light constants.""" + import_and_test_deprecated_constant_enum( + caplog, light, entity_feature, "SUPPORT_", "2026.1" + ) + + +@pytest.mark.parametrize( + "entity_feature", + list(light.ColorMode), +) +def test_deprecated_color_mode_constants_enums( + caplog: pytest.LogCaptureFixture, + entity_feature: light.LightEntityFeature, +) -> None: + """Test deprecated support light constants.""" + import_and_test_deprecated_constant_enum( + caplog, light, entity_feature, "COLOR_MODE_", "2026.1" + ) + + +async def test_deprecated_turn_on_arguments( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test color temp conversion in service calls.""" + entity = MockLight("Test_ct", STATE_ON, {light.ColorMode.COLOR_TEMP}) + setup_test_component_platform(hass, light.DOMAIN, [entity]) + + assert await async_setup_component( + hass, light.DOMAIN, {light.DOMAIN: {"platform": "test"}} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + + caplog.clear() + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [entity.entity_id], + "color_temp": 200, + }, + blocking=True, + ) + assert "Got `color_temp` argument in `turn_on` service" in caplog.text + _, data = entity.last_call("turn_on") + assert data == {"color_temp": 200, "color_temp_kelvin": 5000} + + caplog.clear() + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [entity.entity_id], + "kelvin": 5000, + }, + blocking=True, + ) + assert "Got `kelvin` argument in `turn_on` service" in caplog.text + _, data = entity.last_call("turn_on") + assert data == {"color_temp": 200, "color_temp_kelvin": 5000} + + caplog.clear() + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [entity.entity_id], + "color_temp_kelvin": 5000, + }, + blocking=True, + ) + _, data = entity.last_call("turn_on") + assert data == {"color_temp": 200, "color_temp_kelvin": 5000} + assert "argument in `turn_on` service" not in caplog.text diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index f3f87ff6074..d53ece61170 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -9,17 +9,17 @@ import pytest from homeassistant.components import light from homeassistant.components.light import ( + _DEPRECATED_ATTR_COLOR_TEMP, + _DEPRECATED_ATTR_MAX_MIREDS, + _DEPRECATED_ATTR_MIN_MIREDS, ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MAX_MIREDS, ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_MIN_MIREDS, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -66,8 +66,8 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: - assert ATTR_MIN_MIREDS not in state.attributes - assert ATTR_MAX_MIREDS not in state.attributes + assert _DEPRECATED_ATTR_MIN_MIREDS.value not in state.attributes + assert _DEPRECATED_ATTR_MAX_MIREDS.value not in state.attributes assert ATTR_SUPPORTED_COLOR_MODES not in state.attributes assert ATTR_EFFECT_LIST not in state.attributes assert ATTR_FRIENDLY_NAME in state.attributes @@ -75,7 +75,7 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) assert ATTR_MIN_COLOR_TEMP_KELVIN not in state.attributes assert ATTR_BRIGHTNESS not in state.attributes assert ATTR_COLOR_MODE not in state.attributes - assert ATTR_COLOR_TEMP not in state.attributes + assert _DEPRECATED_ATTR_COLOR_TEMP.value not in state.attributes assert ATTR_COLOR_TEMP_KELVIN not in state.attributes assert ATTR_EFFECT not in state.attributes assert ATTR_HS_COLOR not in state.attributes diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index aa698129915..987e97c6eb2 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -10,7 +10,7 @@ from tests.common import async_mock_service VALID_BRIGHTNESS = {"brightness": 180} VALID_EFFECT = {"effect": "random"} -VALID_COLOR_TEMP = {"color_temp": 240} +VALID_COLOR_TEMP_KELVIN = {"color_temp_kelvin": 4200} VALID_HS_COLOR = {"hs_color": (345, 75)} VALID_RGB_COLOR = {"rgb_color": (255, 63, 111)} VALID_RGBW_COLOR = {"rgbw_color": (255, 63, 111, 10)} @@ -19,7 +19,7 @@ VALID_XY_COLOR = {"xy_color": (0.59, 0.274)} NONE_BRIGHTNESS = {"brightness": None} NONE_EFFECT = {"effect": None} -NONE_COLOR_TEMP = {"color_temp": None} +NONE_COLOR_TEMP_KELVIN = {"color_temp_kelvin": None} NONE_HS_COLOR = {"hs_color": None} NONE_RGB_COLOR = {"rgb_color": None} NONE_RGBW_COLOR = {"rgbw_color": None} @@ -34,7 +34,7 @@ async def test_reproducing_states( hass.states.async_set("light.entity_off", "off", {}) hass.states.async_set("light.entity_bright", "on", VALID_BRIGHTNESS) hass.states.async_set("light.entity_effect", "on", VALID_EFFECT) - hass.states.async_set("light.entity_temp", "on", VALID_COLOR_TEMP) + hass.states.async_set("light.entity_temp", "on", VALID_COLOR_TEMP_KELVIN) hass.states.async_set("light.entity_hs", "on", VALID_HS_COLOR) hass.states.async_set("light.entity_rgb", "on", VALID_RGB_COLOR) hass.states.async_set("light.entity_xy", "on", VALID_XY_COLOR) @@ -49,7 +49,7 @@ async def test_reproducing_states( State("light.entity_off", "off"), State("light.entity_bright", "on", VALID_BRIGHTNESS), State("light.entity_effect", "on", VALID_EFFECT), - State("light.entity_temp", "on", VALID_COLOR_TEMP), + State("light.entity_temp", "on", VALID_COLOR_TEMP_KELVIN), State("light.entity_hs", "on", VALID_HS_COLOR), State("light.entity_rgb", "on", VALID_RGB_COLOR), State("light.entity_xy", "on", VALID_XY_COLOR), @@ -73,7 +73,7 @@ async def test_reproducing_states( State("light.entity_xy", "off"), State("light.entity_off", "on", VALID_BRIGHTNESS), State("light.entity_bright", "on", VALID_EFFECT), - State("light.entity_effect", "on", VALID_COLOR_TEMP), + State("light.entity_effect", "on", VALID_COLOR_TEMP_KELVIN), State("light.entity_temp", "on", VALID_HS_COLOR), State("light.entity_hs", "on", VALID_RGB_COLOR), State("light.entity_rgb", "on", VALID_XY_COLOR), @@ -92,7 +92,7 @@ async def test_reproducing_states( expected_bright["entity_id"] = "light.entity_bright" expected_calls.append(expected_bright) - expected_effect = dict(VALID_COLOR_TEMP) + expected_effect = dict(VALID_COLOR_TEMP_KELVIN) expected_effect["entity_id"] = "light.entity_effect" expected_calls.append(expected_effect) @@ -146,7 +146,7 @@ async def test_filter_color_modes( """Test filtering of parameters according to color mode.""" hass.states.async_set("light.entity", "off", {}) all_colors = { - **VALID_COLOR_TEMP, + **VALID_COLOR_TEMP_KELVIN, **VALID_HS_COLOR, **VALID_RGB_COLOR, **VALID_RGBW_COLOR, @@ -162,7 +162,7 @@ async def test_filter_color_modes( ) expected_map = { - light.ColorMode.COLOR_TEMP: {**VALID_BRIGHTNESS, **VALID_COLOR_TEMP}, + light.ColorMode.COLOR_TEMP: {**VALID_BRIGHTNESS, **VALID_COLOR_TEMP_KELVIN}, light.ColorMode.BRIGHTNESS: VALID_BRIGHTNESS, light.ColorMode.HS: {**VALID_BRIGHTNESS, **VALID_HS_COLOR}, light.ColorMode.ONOFF: {**VALID_BRIGHTNESS}, @@ -193,12 +193,76 @@ async def test_filter_color_modes( assert len(turn_on_calls) == 1 +async def test_filter_color_modes_missing_attributes( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test warning on missing attribute when filtering for color mode.""" + color_mode = light.ColorMode.COLOR_TEMP + hass.states.async_set("light.entity", "off", {}) + expected_log = ( + "Color mode color_temp specified " + "but attribute color_temp_kelvin missing for: light.entity" + ) + expected_fallback_log = "using color_temp (mireds) as fallback" + + turn_on_calls = async_mock_service(hass, "light", "turn_on") + + all_colors = { + **VALID_COLOR_TEMP_KELVIN, + **VALID_HS_COLOR, + **VALID_RGB_COLOR, + **VALID_RGBW_COLOR, + **VALID_RGBWW_COLOR, + **VALID_XY_COLOR, + **VALID_BRIGHTNESS, + } + + # Test missing `color_temp_kelvin` attribute + stored_attributes = {**all_colors} + stored_attributes.pop("color_temp_kelvin") + caplog.clear() + await async_reproduce_state( + hass, + [State("light.entity", "on", {**stored_attributes, "color_mode": color_mode})], + ) + assert len(turn_on_calls) == 0 + assert expected_log in caplog.text + assert expected_fallback_log not in caplog.text + + # Test with deprecated `color_temp` attribute + stored_attributes["color_temp"] = 250 + expected = {"brightness": 180, "color_temp_kelvin": 4000} + caplog.clear() + await async_reproduce_state( + hass, + [State("light.entity", "on", {**stored_attributes, "color_mode": color_mode})], + ) + + assert len(turn_on_calls) == 1 + assert expected_log in caplog.text + assert expected_fallback_log in caplog.text + + # Test with correct `color_temp_kelvin` attribute + expected = {"brightness": 180, "color_temp_kelvin": 4200} + caplog.clear() + turn_on_calls.clear() + await async_reproduce_state( + hass, + [State("light.entity", "on", {**all_colors, "color_mode": color_mode})], + ) + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "light" + assert dict(turn_on_calls[0].data) == {"entity_id": "light.entity", **expected} + assert expected_log not in caplog.text + assert expected_fallback_log not in caplog.text + + @pytest.mark.parametrize( "saved_state", [ NONE_BRIGHTNESS, NONE_EFFECT, - NONE_COLOR_TEMP, + NONE_COLOR_TEMP_KELVIN, NONE_HS_COLOR, NONE_RGB_COLOR, NONE_RGBW_COLOR, diff --git a/tests/components/light/test_significant_change.py b/tests/components/light/test_significant_change.py index 87a60b58325..cf03f37228e 100644 --- a/tests/components/light/test_significant_change.py +++ b/tests/components/light/test_significant_change.py @@ -2,7 +2,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ) @@ -26,10 +26,10 @@ async def test_significant_change() -> None: # Color temp assert not async_check_significant_change( - None, "on", {ATTR_COLOR_TEMP: 60}, "on", {ATTR_COLOR_TEMP: 64} + None, "on", {ATTR_COLOR_TEMP_KELVIN: 2000}, "on", {ATTR_COLOR_TEMP_KELVIN: 2049} ) assert async_check_significant_change( - None, "on", {ATTR_COLOR_TEMP: 60}, "on", {ATTR_COLOR_TEMP: 65} + None, "on", {ATTR_COLOR_TEMP_KELVIN: 2000}, "on", {ATTR_COLOR_TEMP_KELVIN: 2050} ) # Effect diff --git a/tests/components/linkplay/test_config_flow.py b/tests/components/linkplay/test_config_flow.py index 3fd1fbea95e..adf6aa601ae 100644 --- a/tests/components/linkplay/test_config_flow.py +++ b/tests/components/linkplay/test_config_flow.py @@ -7,11 +7,11 @@ from linkplay.exceptions import LinkPlayRequestException import pytest from homeassistant.components.linkplay.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import HOST, HOST_REENTRY, NAME, UUID diff --git a/tests/components/linkplay/test_diagnostics.py b/tests/components/linkplay/test_diagnostics.py index 369142978a3..de60b7ecb3a 100644 --- a/tests/components/linkplay/test_diagnostics.py +++ b/tests/components/linkplay/test_diagnostics.py @@ -31,8 +31,10 @@ async def test_diagnostics( patch.object(LinkPlayMultiroom, "update_status", return_value=None), ): endpoints = [ - LinkPlayApiEndpoint(protocol="https", endpoint=HOST, session=None), - LinkPlayApiEndpoint(protocol="http", endpoint=HOST, session=None), + LinkPlayApiEndpoint( + protocol="https", port=443, endpoint=HOST, session=None + ), + LinkPlayApiEndpoint(protocol="http", port=80, endpoint=HOST, session=None), ] for endpoint in endpoints: mock_session.get( diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index 8849392b3dd..b29fa753801 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -90,6 +90,14 @@ ROBOT_4_DATA = { "isUSBPowerOn": True, "USBFaultStatus": "CLEAR", "isDFIPartialFull": True, + "isLaserDirty": False, + "surfaceType": "TILE", + "hopperStatus": None, + "scoopsSavedCount": 3769, + "isHopperRemoved": None, + "optimalLitterLevel": 450, + "litterLevelPercentage": 0.7, + "litterLevelState": "OPTIMAL", } FEEDER_ROBOT_DATA = { "id": 1, diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index 181e4fc1a90..5cd97e5937d 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -9,10 +9,9 @@ from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Robot from pylitterbot.exceptions import InvalidCommandException import pytest -from homeassistant.components import litterrobot from homeassistant.core import HomeAssistant -from .common import CONFIG, FEEDER_ROBOT_DATA, ROBOT_4_DATA, ROBOT_DATA +from .common import CONFIG, DOMAIN, FEEDER_ROBOT_DATA, ROBOT_4_DATA, ROBOT_DATA from tests.common import MockConfigEntry @@ -117,16 +116,16 @@ def mock_account_with_side_effects() -> MagicMock: async def setup_integration( hass: HomeAssistant, mock_account: MagicMock, platform_domain: str | None = None ) -> MockConfigEntry: - """Load a Litter-Robot platform with the provided hub.""" + """Load a Litter-Robot platform with the provided coordinator.""" entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.litterrobot.hub.Account", + "homeassistant.components.litterrobot.coordinator.Account", return_value=mock_account, ), patch( diff --git a/tests/components/litterrobot/test_binary_sensor.py b/tests/components/litterrobot/test_binary_sensor.py index 69b3f7ce3ab..3fe72aef7e3 100644 --- a/tests/components/litterrobot/test_binary_sensor.py +++ b/tests/components/litterrobot/test_binary_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock import pytest from homeassistant.components.binary_sensor import ( - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) from homeassistant.const import ATTR_DEVICE_CLASS @@ -21,7 +21,7 @@ async def test_binary_sensors( mock_account: MagicMock, ) -> None: """Tests binary sensors.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, BINARY_SENSOR_DOMAIN) state = hass.states.get("binary_sensor.test_sleeping") assert state.state == "off" diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py index 9420d3cb8a8..2eadafb0d0c 100644 --- a/tests/components/litterrobot/test_config_flow.py +++ b/tests/components/litterrobot/test_config_flow.py @@ -6,7 +6,6 @@ from pylitterbot import Account from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException from homeassistant import config_entries -from homeassistant.components import litterrobot from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -49,14 +48,14 @@ async def test_form(hass: HomeAssistant, mock_account) -> None: async def test_already_configured(hass: HomeAssistant) -> None: """Test we handle already configured.""" MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=CONFIG[litterrobot.DOMAIN], + data=CONFIG[DOMAIN], ) assert result["type"] is FlowResultType.ABORT @@ -119,8 +118,8 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None: async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: """Test the reauth flow.""" entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ) entry.add_to_hass(hass) @@ -141,7 +140,7 @@ async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + user_input={CONF_PASSWORD: CONFIG[DOMAIN][CONF_PASSWORD]}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -151,8 +150,8 @@ async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> None: """Test the reauth flow fails and recovers.""" entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ) entry.add_to_hass(hass) @@ -167,7 +166,7 @@ async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + user_input={CONF_PASSWORD: CONFIG[DOMAIN][CONF_PASSWORD]}, ) assert result["type"] is FlowResultType.FORM @@ -185,7 +184,7 @@ async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + user_input={CONF_PASSWORD: CONFIG[DOMAIN][CONF_PASSWORD]}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index 21b16097603..e42bdb048b7 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -5,11 +5,10 @@ from unittest.mock import MagicMock, patch from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException import pytest -from homeassistant.components import litterrobot from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_START, - STATE_DOCKED, + VacuumActivity, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID @@ -17,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .common import CONFIG, VACUUM_ENTITY_ID +from .common import CONFIG, DOMAIN, VACUUM_ENTITY_ID from .conftest import setup_integration from tests.common import MockConfigEntry @@ -30,7 +29,7 @@ async def test_unload_entry(hass: HomeAssistant, mock_account: MagicMock) -> Non vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum - assert vacuum.state == STATE_DOCKED + assert vacuum.state == VacuumActivity.DOCKED await hass.services.async_call( VACUUM_DOMAIN, @@ -57,13 +56,13 @@ async def test_entry_not_setup( ) -> None: """Test being able to handle config entry not setup.""" entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ) entry.add_to_hass(hass) with patch( - "homeassistant.components.litterrobot.hub.Account.connect", + "homeassistant.components.litterrobot.coordinator.Account.connect", side_effect=side_effect, ): await hass.config_entries.async_setup(entry.entry_id) @@ -91,7 +90,7 @@ async def test_device_remove_devices( dead_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(litterrobot.DOMAIN, "test-serial", "remove-serial")}, + identifiers={(DOMAIN, "test-serial", "remove-serial")}, ) response = await client.remove_device(dead_device_entry.id, config_entry.entry_id) assert response["success"] diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index 48ec1bb06a5..b4902a56e63 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) from homeassistant.const import ATTR_ENTITY_ID, EntityCategory @@ -26,7 +26,7 @@ async def test_wait_time_select( hass: HomeAssistant, mock_account, entity_registry: er.EntityRegistry ) -> None: """Tests the wait time select entity.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SELECT_DOMAIN) select = hass.states.get(SELECT_ENTITY_ID) assert select @@ -41,7 +41,7 @@ async def test_wait_time_select( data[ATTR_OPTION] = wait_time await hass.services.async_call( - PLATFORM_DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, data, blocking=True, @@ -52,7 +52,7 @@ async def test_wait_time_select( async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> None: """Tests the wait time select entity with invalid value.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SELECT_DOMAIN) select = hass.states.get(SELECT_ENTITY_ID) assert select @@ -61,7 +61,7 @@ async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> No with pytest.raises(ServiceValidationError): await hass.services.async_call( - PLATFORM_DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, data, blocking=True, @@ -75,7 +75,7 @@ async def test_panel_brightness_select( entity_registry: er.EntityRegistry, ) -> None: """Tests the wait time select entity.""" - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, SELECT_DOMAIN) select = hass.states.get(PANEL_BRIGHTNESS_ENTITY_ID) assert select @@ -94,7 +94,7 @@ async def test_panel_brightness_select( data[ATTR_OPTION] = option await hass.services.async_call( - PLATFORM_DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, data, blocking=True, diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 735ee6653aa..0255e0e6a8a 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -8,22 +8,18 @@ from unittest.mock import MagicMock from pylitterbot import Robot import pytest -from homeassistant.components.litterrobot import DOMAIN from homeassistant.components.litterrobot.vacuum import SERVICE_SET_SLEEP_MODE from homeassistant.components.vacuum import ( - ATTR_STATUS, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as VACUUM_DOMAIN, SERVICE_START, SERVICE_STOP, - STATE_DOCKED, - STATE_ERROR, - STATE_PAUSED, + VacuumActivity, ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir -from .common import VACUUM_ENTITY_ID +from .common import DOMAIN, VACUUM_ENTITY_ID from .conftest import setup_integration VACUUM_UNIQUE_ID = "LR3C012345-litter_box" @@ -39,45 +35,33 @@ async def test_vacuum( """Tests the vacuum entity was set up.""" entity_registry.async_get_or_create( - PLATFORM_DOMAIN, + VACUUM_DOMAIN, DOMAIN, VACUUM_UNIQUE_ID, - suggested_object_id=VACUUM_ENTITY_ID.replace(PLATFORM_DOMAIN, ""), + suggested_object_id=VACUUM_ENTITY_ID.replace(VACUUM_DOMAIN, ""), ) ent_reg_entry = entity_registry.async_get(VACUUM_ENTITY_ID) assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, VACUUM_DOMAIN) assert len(entity_registry.entities) == 1 assert hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum - assert vacuum.state == STATE_DOCKED - assert vacuum.attributes["is_sleeping"] is False + assert vacuum.state == VacuumActivity.DOCKED ent_reg_entry = entity_registry.async_get(VACUUM_ENTITY_ID) assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID -async def test_vacuum_status_when_sleeping( - hass: HomeAssistant, mock_account_with_sleeping_robot: MagicMock -) -> None: - """Tests the vacuum status when sleeping.""" - await setup_integration(hass, mock_account_with_sleeping_robot, PLATFORM_DOMAIN) - - vacuum = hass.states.get(VACUUM_ENTITY_ID) - assert vacuum - assert vacuum.attributes.get(ATTR_STATUS) == "Ready (Sleeping)" - - async def test_no_robots( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_account_with_no_robots: MagicMock, ) -> None: """Tests the vacuum entity was set up.""" - entry = await setup_integration(hass, mock_account_with_no_robots, PLATFORM_DOMAIN) + entry = await setup_integration(hass, mock_account_with_no_robots, VACUUM_DOMAIN) assert not hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) @@ -91,29 +75,32 @@ async def test_vacuum_with_error( hass: HomeAssistant, mock_account_with_error: MagicMock ) -> None: """Tests a vacuum entity with an error.""" - await setup_integration(hass, mock_account_with_error, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_error, VACUUM_DOMAIN) vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum - assert vacuum.state == STATE_ERROR + assert vacuum.state == VacuumActivity.ERROR @pytest.mark.parametrize( ("robot_data", "expected_state"), [ - ({"displayCode": "DC_CAT_DETECT"}, STATE_DOCKED), - ({"isDFIFull": True}, STATE_ERROR), - ({"robotCycleState": "CYCLE_STATE_CAT_DETECT"}, STATE_PAUSED), + ({"displayCode": "DC_CAT_DETECT"}, VacuumActivity.DOCKED), + ({"isDFIFull": True}, VacuumActivity.ERROR), + ( + {"robotCycleState": "CYCLE_STATE_CAT_DETECT"}, + VacuumActivity.PAUSED, + ), ], ) -async def test_vacuum_states( +async def test_activities( hass: HomeAssistant, mock_account_with_litterrobot_4: MagicMock, robot_data: dict[str, str | bool], expected_state: str, ) -> None: """Test sending commands to the switch.""" - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, VACUUM_DOMAIN) robot: Robot = mock_account_with_litterrobot_4.robots[0] robot._update_data(robot_data, partial=True) @@ -146,18 +133,18 @@ async def test_commands( issue_registry: ir.IssueRegistry, ) -> None: """Test sending commands to the vacuum.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, VACUUM_DOMAIN) vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum - assert vacuum.state == STATE_DOCKED + assert vacuum.state == VacuumActivity.DOCKED extra = extra or {} data = {ATTR_ENTITY_ID: VACUUM_ENTITY_ID, **extra.get("data", {})} issues = extra.get("issues", set()) await hass.services.async_call( - COMPONENT_SERVICE_DOMAIN.get(service, PLATFORM_DOMAIN), + COMPONENT_SERVICE_DOMAIN.get(service, VACUUM_DOMAIN), service, data, blocking=True, diff --git a/tests/components/livisi/test_config_flow.py b/tests/components/livisi/test_config_flow.py index 9f492b9a45a..cffae711d28 100644 --- a/tests/components/livisi/test_config_flow.py +++ b/tests/components/livisi/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aiolivisi import errors as livisi_errors +from livisi import errors as livisi_errors import pytest from homeassistant.components.livisi.const import DOMAIN diff --git a/tests/components/local_calendar/snapshots/test_diagnostics.ambr b/tests/components/local_calendar/snapshots/test_diagnostics.ambr index a70b9d7438b..14dc94ef919 100644 --- a/tests/components/local_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/local_calendar/snapshots/test_diagnostics.ambr @@ -6,12 +6,12 @@ PRODID:-//homeassistant.io//local_calendar 1.0//EN VERSION:*** BEGIN:VEVENT - DTSTAMP:20230313T190500 + DTSTAMP:20230313T190500Z UID:*** DTSTART:19970714T110000 DTEND:19970714T220000 SUMMARY:*** - CREATED:20230313T190500 + CREATED:20230313T190500Z RRULE:FREQ=DAILY SEQUENCE:*** END:VEVENT diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index ddfdf4249bd..0eb48aa3060 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -281,7 +281,7 @@ async def test_import_from_yaml_fails( assert not hass.states.get("camera.config_test") issue = issue_registry.async_get_issue( - DOMAIN, f"no_access_path_{slugify("mock.file")}" + DOMAIN, f"no_access_path_{slugify('mock.file')}" ) assert issue assert issue.translation_key == "no_access_path" diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index a80aa78cec2..68af8c7d482 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -405,8 +405,7 @@ def _create_tuples( @pytest.mark.parametrize( ("enum", "constant_prefix", "remove_in_version"), - _create_tuples(lock.LockEntityFeature, "SUPPORT_", "2025.1") - + _create_tuples(lock.LockState, "STATE_", "2025.10"), + _create_tuples(lock.LockState, "STATE_", "2025.10"), ) def test_deprecated_constants( caplog: pytest.LogCaptureFixture, @@ -418,20 +417,3 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum( caplog, lock, enum, constant_prefix, remove_in_version ) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockLockEntity(lock.LockEntity): - _attr_supported_features = 1 - - entity = MockLockEntity() - assert entity.supported_features is lock.LockEntityFeature(1) - assert "MockLockEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "LockEntityFeature.OPEN" in caplog.text - caplog.clear() - assert entity.supported_features is lock.LockEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index afa8b7fcde5..abb118467f4 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -35,7 +35,7 @@ class MockRow: self.event_data = json.dumps(data, cls=JSONEncoder) self.data = data self.time_fired = dt_util.utcnow() - self.time_fired_ts = dt_util.utc_to_timestamp(self.time_fired) + self.time_fired_ts = self.time_fired.timestamp() self.context_parent_id_bin = ( ulid_to_bytes_or_none(context.parent_id) if context else None ) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 8ac7dde67ab..841c8ed1247 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -330,7 +330,7 @@ def create_state_changed_event_from_old_new( row_id=1, event_type=PSEUDO_EVENT_STATE_CHANGED, event_data="{}", - time_fired_ts=dt_util.utc_to_timestamp(event_time_fired), + time_fired_ts=event_time_fired.timestamp(), context_id_bin=None, context_user_id_bin=None, context_parent_id_bin=None, diff --git a/tests/components/lookin/__init__.py b/tests/components/lookin/__init__.py index cea0f969893..64dc1220683 100644 --- a/tests/components/lookin/__init__.py +++ b/tests/components/lookin/__init__.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch from aiolookin import Climate, Device, Remote -from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo DEVICE_ID = "98F33163" MODULE = "homeassistant.components.lookin" diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index d59ed60796b..6f7da09fa0d 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -8,16 +8,16 @@ import aiohttp from loqedAPI import loqed from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.loqed.const import DOMAIN from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -zeroconf_data = zeroconf.ZeroconfServiceInfo( +zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.12.34"), ip_addresses=[ip_address("192.168.12.34")], hostname="LOQED-ffeeddccbbaa.local", diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 14d93d8302f..f56ff4371e6 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -12,16 +12,6 @@ from homeassistant.setup import async_setup_component from tests.typing import WebSocketGenerator -@pytest.fixture -def mock_onboarding_not_done() -> Generator[MagicMock]: - """Mock that Home Assistant is currently onboarding.""" - with patch( - "homeassistant.components.onboarding.async_is_onboarded", - return_value=False, - ) as mock_onboarding: - yield mock_onboarding - - @pytest.fixture def mock_onboarding_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" @@ -32,15 +22,6 @@ def mock_onboarding_done() -> Generator[MagicMock]: yield mock_onboarding -@pytest.fixture -def mock_add_onboarding_listener() -> Generator[MagicMock]: - """Mock that Home Assistant is currently onboarding.""" - with patch( - "homeassistant.components.onboarding.async_add_listener", - ) as mock_add_onboarding_listener: - yield mock_add_onboarding_listener - - async def test_create_dashboards_when_onboarded( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -57,42 +38,3 @@ async def test_create_dashboards_when_onboarded( response = await client.receive_json() assert response["success"] assert response["result"] == [] - - -async def test_create_dashboards_when_not_onboarded( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - hass_storage: dict[str, Any], - mock_add_onboarding_listener, - mock_onboarding_not_done, -) -> None: - """Test we automatically create dashboards when not onboarded.""" - client = await hass_ws_client(hass) - - assert await async_setup_component(hass, "lovelace", {}) - - # Call onboarding listener - mock_add_onboarding_listener.mock_calls[0][1][1]() - await hass.async_block_till_done() - - # List dashboards - await client.send_json_auto_id({"type": "lovelace/dashboards/list"}) - response = await client.receive_json() - assert response["success"] - assert response["result"] == [ - { - "icon": "mdi:map", - "id": "map", - "mode": "storage", - "require_admin": False, - "show_in_sidebar": True, - "title": "Map", - "url_path": "map", - } - ] - - # List map dashboard config - await client.send_json_auto_id({"type": "lovelace/config", "url_path": "map"}) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {"strategy": {"type": "map"}} diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index b2edaa07155..cc80bc08817 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -10,7 +10,6 @@ from pylutron_caseta.smartbridge import Smartbridge import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.lutron_caseta import DOMAIN import homeassistant.components.lutron_caseta.config_flow as CasetaConfigFlow from homeassistant.components.lutron_caseta.const import ( @@ -23,6 +22,7 @@ from homeassistant.components.lutron_caseta.const import ( from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ENTRY_MOCK_DATA, MockBridge @@ -421,7 +421,7 @@ async def test_zeroconf_host_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", @@ -449,7 +449,7 @@ async def test_zeroconf_lutron_id_already_configured(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", @@ -472,7 +472,7 @@ async def test_zeroconf_not_lutron_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="notlutron-abc.local.", @@ -500,7 +500,7 @@ async def test_zeroconf(hass: HomeAssistant, source, tmp_path: Path) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", diff --git a/tests/components/madvr/snapshots/test_binary_sensors.ambr b/tests/components/madvr/snapshots/test_binary_sensor.ambr similarity index 100% rename from tests/components/madvr/snapshots/test_binary_sensors.ambr rename to tests/components/madvr/snapshots/test_binary_sensor.ambr diff --git a/tests/components/madvr/snapshots/test_sensors.ambr b/tests/components/madvr/snapshots/test_sensor.ambr similarity index 100% rename from tests/components/madvr/snapshots/test_sensors.ambr rename to tests/components/madvr/snapshots/test_sensor.ambr diff --git a/tests/components/madvr/test_binary_sensors.py b/tests/components/madvr/test_binary_sensor.py similarity index 100% rename from tests/components/madvr/test_binary_sensors.py rename to tests/components/madvr/test_binary_sensor.py diff --git a/tests/components/madvr/test_sensors.py b/tests/components/madvr/test_sensor.py similarity index 100% rename from tests/components/madvr/test_sensors.py rename to tests/components/madvr/test_sensor.py diff --git a/tests/components/mastodon/snapshots/test_sensor.ambr b/tests/components/mastodon/snapshots/test_sensor.ambr index 3e6e41796f6..c8df8cdab19 100644 --- a/tests/components/mastodon/snapshots/test_sensor.ambr +++ b/tests/components/mastodon/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'followers', 'unique_id': 'trwnh_mastodon_social_followers', - 'unit_of_measurement': None, + 'unit_of_measurement': 'accounts', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_followers-state] @@ -39,6 +39,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mastodon @trwnh@mastodon.social Followers', 'state_class': , + 'unit_of_measurement': 'accounts', }), 'context': , 'entity_id': 'sensor.mastodon_trwnh_mastodon_social_followers', @@ -80,7 +81,7 @@ 'supported_features': 0, 'translation_key': 'following', 'unique_id': 'trwnh_mastodon_social_following', - 'unit_of_measurement': None, + 'unit_of_measurement': 'accounts', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_following-state] @@ -88,6 +89,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mastodon @trwnh@mastodon.social Following', 'state_class': , + 'unit_of_measurement': 'accounts', }), 'context': , 'entity_id': 'sensor.mastodon_trwnh_mastodon_social_following', @@ -129,7 +131,7 @@ 'supported_features': 0, 'translation_key': 'posts', 'unique_id': 'trwnh_mastodon_social_posts', - 'unit_of_measurement': None, + 'unit_of_measurement': 'posts', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_posts-state] @@ -137,6 +139,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mastodon @trwnh@mastodon.social Posts', 'state_class': , + 'unit_of_measurement': 'posts', }), 'context': , 'entity_id': 'sensor.mastodon_trwnh_mastodon_social_posts', diff --git a/tests/components/mastodon/test_config_flow.py b/tests/components/mastodon/test_config_flow.py index 33f73812348..4b022df2ca2 100644 --- a/tests/components/mastodon/test_config_flow.py +++ b/tests/components/mastodon/test_config_flow.py @@ -6,7 +6,7 @@ from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError import pytest from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -160,53 +160,3 @@ async def test_duplicate( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_import_flow( - hass: HomeAssistant, - mock_mastodon_client: AsyncMock, - mock_setup_entry: AsyncMock, -) -> None: - """Test importing yaml config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_BASE_URL: "https://mastodon.social", - CONF_CLIENT_ID: "import_client_id", - CONF_CLIENT_SECRET: "import_client_secret", - CONF_ACCESS_TOKEN: "import_access_token", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - - -@pytest.mark.parametrize( - ("exception", "error"), - [ - (MastodonNetworkError, "network_error"), - (MastodonUnauthorizedError, "unauthorized_error"), - (Exception, "unknown"), - ], -) -async def test_import_flow_abort( - hass: HomeAssistant, - mock_mastodon_client: AsyncMock, - mock_setup_entry: AsyncMock, - exception: Exception, - error: str, -) -> None: - """Test importing yaml config abort.""" - mock_mastodon_client.account_verify_credentials.side_effect = exception - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_BASE_URL: "https://mastodon.social", - CONF_CLIENT_ID: "import_client_id", - CONF_CLIENT_SECRET: "import_client_secret", - CONF_ACCESS_TOKEN: "import_access_token", - }, - ) - assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/matrix/test_commands.py b/tests/components/matrix/test_commands.py index dabee74fdc3..ea0805b920a 100644 --- a/tests/components/matrix/test_commands.py +++ b/tests/components/matrix/test_commands.py @@ -42,9 +42,8 @@ class CommandTestParameters: Commands that are named with 'Subset' are expected not to be read from Room A. """ - if ( - self.expected_event_data_extra is None - or "Subset" in self.expected_event_data_extra["command"] + if self.expected_event_data_extra is None or ( + "Subset" in self.expected_event_data_extra["command"] and self.room_id not in SUBSET_ROOMS ): return None diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index bbafec48e10..4e078f86939 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -104,6 +104,7 @@ async def integration_fixture( "pressure_sensor", "room_airconditioner", "silabs_dishwasher", + "silabs_laundrywasher", "smoke_detector", "switch_unit", "temperature_sensor", diff --git a/tests/components/matter/fixtures/config_entry_diagnostics.json b/tests/components/matter/fixtures/config_entry_diagnostics.json index 000b0d4e2e6..8cc9d068caf 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics.json @@ -647,7 +647,7 @@ "1/768/16390": 0, "1/768/16394": 31, "1/768/16395": 0, - "1/768/16396": 65279, + "1/768/16396": 0, "1/768/16397": 0, "1/768/16400": 0, "1/768/65532": 31, diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 95447783bbc..28c93de5e11 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -460,7 +460,7 @@ "1/768/16390": 0, "1/768/16394": 31, "1/768/16395": 0, - "1/768/16396": 65279, + "1/768/16396": 0, "1/768/16397": 0, "1/768/16400": 0, "1/768/65532": 31, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 1d1d450e1f0..5600a7e801b 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -444,7 +444,7 @@ "1/768/16390": 0, "1/768/16394": 31, "1/768/16395": 0, - "1/768/16396": 65279, + "1/768/16396": 0, "1/768/16397": 0, "1/768/16400": 0, "1/768/65532": 31, diff --git a/tests/components/matter/fixtures/nodes/door_lock.json b/tests/components/matter/fixtures/nodes/door_lock.json index b6231e04af4..acd327ac56c 100644 --- a/tests/components/matter/fixtures/nodes/door_lock.json +++ b/tests/components/matter/fixtures/nodes/door_lock.json @@ -495,7 +495,7 @@ "1/257/48": 3, "1/257/49": 10, "1/257/51": false, - "1/257/65532": 3507, + "1/257/65532": 0, "1/257/65533": 6, "1/257/65528": [12, 15, 18, 28, 35, 37], "1/257/65529": [ diff --git a/tests/components/matter/fixtures/nodes/multi_endpoint_light.json b/tests/components/matter/fixtures/nodes/multi_endpoint_light.json index e3a01da9e7c..3b9be24d9ab 100644 --- a/tests/components/matter/fixtures/nodes/multi_endpoint_light.json +++ b/tests/components/matter/fixtures/nodes/multi_endpoint_light.json @@ -1620,7 +1620,7 @@ "6/768/16385": 0, "6/768/16394": 25, "6/768/16395": 0, - "6/768/16396": 65279, + "6/768/16396": 0, "6/768/16397": 0, "6/768/16400": 0, "6/768/65532": 25, diff --git a/tests/components/matter/fixtures/nodes/onoff_light_alt_name.json b/tests/components/matter/fixtures/nodes/onoff_light_alt_name.json index 46575640adf..ac462cd7951 100644 --- a/tests/components/matter/fixtures/nodes/onoff_light_alt_name.json +++ b/tests/components/matter/fixtures/nodes/onoff_light_alt_name.json @@ -384,7 +384,7 @@ "1/768/16390": 0, "1/768/16394": 31, "1/768/16395": 0, - "1/768/16396": 65279, + "1/768/16396": 0, "1/768/16397": 0, "1/768/16400": 0, "1/768/65532": 31, diff --git a/tests/components/matter/fixtures/nodes/onoff_light_no_name.json b/tests/components/matter/fixtures/nodes/onoff_light_no_name.json index a6c73564af0..19cd58bf5cb 100644 --- a/tests/components/matter/fixtures/nodes/onoff_light_no_name.json +++ b/tests/components/matter/fixtures/nodes/onoff_light_no_name.json @@ -384,7 +384,7 @@ "1/768/16390": 0, "1/768/16394": 31, "1/768/16395": 0, - "1/768/16396": 65279, + "1/768/16396": 0, "1/768/16397": 0, "1/768/16400": 0, "1/768/65532": 31, diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json new file mode 100644 index 00000000000..4d26dfb03aa --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -0,0 +1,909 @@ +{ + "node_id": 29, + "date_commissioned": "2024-10-19T19:49:36.900186", + "last_interview": "2024-10-20T09:26:38.517535", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [ + 29, 31, 40, 42, 43, 44, 45, 48, 49, 50, 51, 52, 53, 60, 62, 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 4 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "LaundryWasher", + "0/40/4": 32773, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "", + "0/40/16": false, + "0/40/18": "DC840FF79F5DBFCE", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 16973824, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/45/0": 1, + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "p0jbsOzJRNw=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/9": 10, + "0/49/10": 4, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "MyHome", + "1": true, + "2": null, + "3": null, + "4": "GstaSerJSho=", + "5": [], + "6": [ + "/cS6oCynAAGilSC/p+bVSg==", + "/QANuACgAAAAAAD//gDIAA==", + "/QANuACgAABL3TOUNF1NGw==", + "/oAAAAAAAAAYy1pJ6slKGg==" + ], + "7": 4 + } + ], + "0/51/1": 10, + "0/51/2": 1934, + "0/51/3": 17, + "0/51/4": 6, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [ + { + "0": 8, + "1": "shell", + "3": 324 + }, + { + "0": 3, + "1": "UART", + "3": 127 + }, + { + "0": 2, + "1": "OT Stack", + "3": 719 + }, + { + "0": 9, + "1": "LaundryW", + "3": 767 + }, + { + "0": 12, + "1": "Bluetoot", + "3": 174 + }, + { + "0": 1, + "1": "Bluetoot", + "3": 294 + }, + { + "0": 11, + "1": "Bluetoot", + "3": 216 + }, + { + "0": 6, + "1": "Tmr Svc", + "3": 586 + }, + { + "0": 5, + "1": "IDLE", + "3": 264 + }, + { + "0": 7, + "1": "CHIP", + "3": 699 + } + ], + "0/52/1": 99808, + "0/52/2": 17592, + "0/52/3": 4294959166, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome", + "0/53/3": 4660, + "0/53/4": 12054125955590472924, + "0/53/5": "QP0ADbgAoAAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 7100699097952925053, + "1": 23, + "2": 15360, + "3": 222256, + "4": 71507, + "5": 2, + "6": -83, + "7": -90, + "8": 56, + "9": 3, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 9656160343072683744, + "1": 16, + "2": 17408, + "3": 211448, + "4": 95936, + "5": 3, + "6": -53, + "7": -59, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 5926511551178228101, + "1": 0, + "2": 19456, + "3": 420246, + "4": 89821, + "5": 3, + "6": -57, + "7": -56, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3029834005214616809, + "1": 8, + "2": 22528, + "3": 125241, + "4": 91286, + "5": 3, + "6": -73, + "7": -81, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17459145101989614194, + "1": 7, + "2": 26624, + "3": 1426216, + "4": 36884, + "5": 3, + "6": -39, + "7": -39, + "8": 34, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17503311195895696084, + "1": 30, + "2": 29696, + "3": 577028, + "4": 98083, + "5": 2, + "6": -84, + "7": -85, + "8": 65, + "9": 20, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8241705229565301122, + "1": 19, + "2": 57344, + "3": 488092, + "4": 55364, + "5": 3, + "6": -48, + "7": -48, + "8": 1, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 7100699097952925053, + "1": 15360, + "2": 15, + "3": 22, + "4": 1, + "5": 2, + "6": 2, + "7": 23, + "8": true, + "9": true + }, + { + "0": 9656160343072683744, + "1": 17408, + "2": 17, + "3": 19, + "4": 1, + "5": 3, + "6": 3, + "7": 16, + "8": true, + "9": true + }, + { + "0": 5926511551178228101, + "1": 19456, + "2": 19, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 0, + "8": true, + "9": true + }, + { + "0": 3029834005214616809, + "1": 22528, + "2": 22, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 8, + "8": true, + "9": true + }, + { + "0": 17459145101989614194, + "1": 26624, + "2": 26, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 7, + "8": true, + "9": true + }, + { + "0": 17503311195895696084, + "1": 29696, + "2": 29, + "3": 26, + "4": 1, + "5": 2, + "6": 2, + "7": 30, + "8": true, + "9": true + }, + { + "0": 0, + "1": 51200, + "2": 50, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + }, + { + "0": 8241705229565301122, + "1": 57344, + "2": 56, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 19, + "8": true, + "9": true + } + ], + "0/53/9": 1348153998, + "0/53/10": 68, + "0/53/11": 49, + "0/53/12": 120, + "0/53/13": 56, + "0/53/14": 1, + "0/53/15": 0, + "0/53/16": 1, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 18798, + "0/53/23": 18683, + "0/53/24": 115, + "0/53/25": 18699, + "0/53/26": 18492, + "0/53/27": 115, + "0/53/28": 18814, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 15745, + "0/53/34": 207, + "0/53/35": 0, + "0/53/36": 71, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 7183, + "0/53/40": 6295, + "0/53/41": 886, + "0/53/42": 6140, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 1041, + "0/53/50": 0, + "0/53/51": 2, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": 65536, + "0/53/57": 0, + "0/53/58": 0, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 2, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRHRgkBwEkCAEwCUEEJu8N93WFULw4vts483kDAExYc3VhKuaWdmpdJnF5pDcls+y34i6RfchubiU77BJq8zo9VGn6J59mVROTzKgr0DcKNQEoARgkAgE2AwQCBAEYMAQUbJ+53QmsxXf2iP0oL4td/BQFi0gwBRRT9HTfU5Nds+HA8j+/MRP+0pVyIxgwC0BFzpzN0Z0DdN+oPUwK87jzZ8amzJxWlmbnW/Q+j1Z4ziWsFy3yLAsgKYL4nOexZZSqvlEvzMhpstndmh1eGYZfGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyT62Yt4qMI+MorlmQ/Hxh2CpLetznVknlAbhvYAwTexpSxp9GnhR09SrcUhz3mOb0eZa2TylqcnPBhHJ2Ih2RTcKNQEpARgkAmAwBBRT9HTfU5Nds+HA8j+/MRP+0pVyIzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQI/Kc38hQyK7AkT7/pN4hiYW3LoWKT3NA43+ssMJoVpDcaZ989GXBQKIbHKbBEXzUQ1J8wfL7l2pL0Z8Lso9JwgY", + "254": 4 + } + ], + "0/62/1": [ + { + "1": "BIrruNo7r0gX6j6lq1dDi5zeK3jxcTavjt2o4adCCSCYtbxOakfb7C3GXqgV4LzulFSinbewmYkdqFBHqm5pxvU=", + "2": 4939, + "3": 2, + "4": 29, + "5": "", + "254": 4 + } + ], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBP2G+CskBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQIrLt7Uq3S9HEe7apdzYSR+j3BLWNXSTLWD4YbrdyYLpm6xqHDV/NPARcIp4skZdtz91WwFBDfuS4jO5aVoER1sY", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEE0j40vjcb6ZsmtBR/I0rB3ZIfAA8lPeWCTxG7nPSbNpepe18XwLidhFIHKmvtZWDZ3Hl3MM9NBB+LAZlCFq/edjcKNQEpARgkAmAwBBS7EfW886qYxvWeWjpA/G/CjDuwEDAFFLsR9bzzqpjG9Z5aOkD8b8KMO7AQGDALQIgQgt5asUGXO0ZyTWWKdjAmBSoJAzRMuD4Z+tQYZanQ3s0OItL07MU2In6uyXhjNBfjJlRqon780lhjTsm2Y+8Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEiuu42juvSBfqPqWrV0OLnN4rePFxNq+O3ajhp0IJIJi1vE5qR9vsLcZeqBXgvO6UVKKdt7CZiR2oUEeqbmnG9TcKNQEpARgkAmAwBBTjAjvCZO2QpJyarhRj7T8yYjarAzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQE7hTxTRg92QOxwA1hK3xv8DaxvxL71r6ZHcNRzug9wNnonJ+NC84SFKvKDxwcBxHYqFdIyDiDgwJNTQIBgasmIY" + ], + "0/62/5": 4, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "0": "room", + "1": "bedroom 2" + }, + { + "0": "orientation", + "1": "North" + }, + { + "0": "floor", + "1": "2" + }, + { + "0": "direction", + "1": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 115, + "1": 1 + } + ], + "1/29/1": [3, 29, 30, 81, 83, 86, 96], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/30/0": [], + "1/30/65532": 0, + "1/30/65533": 1, + "1/30/65528": [], + "1/30/65529": [], + "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/81/0": null, + "1/81/1": null, + "1/81/65532": null, + "1/81/65533": 2, + "1/81/65528": [1], + "1/81/65529": [0], + "1/81/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/83/0": ["Off", "Low", "Medium", "High"], + "1/83/1": 0, + "1/83/2": 0, + "1/83/3": [1, 2], + "1/83/65532": 3, + "1/83/65533": 1, + "1/83/65528": [], + "1/83/65529": [], + "1/83/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/86/0": 0, + "1/86/1": 0, + "1/86/2": 0, + "1/86/3": 0, + "1/86/4": 1, + "1/86/5": ["Cold", "Colors", "Whites"], + "1/86/65532": 2, + "1/86/65533": 1, + "1/86/65528": [], + "1/86/65529": [0], + "1/86/65531": [4, 5, 65528, 65529, 65531, 65532, 65533], + "1/96/0": null, + "1/96/1": null, + "1/96/3": [ + { + "0": 0 + }, + { + "0": 1 + }, + { + "0": 2 + }, + { + "0": 3 + } + ], + "1/96/4": 0, + "1/96/5": { + "0": 0 + }, + "1/96/65532": 0, + "1/96/65533": 1, + "1/96/65528": [4], + "1/96/65529": [0, 1, 2, 3], + "1/96/65531": [0, 1, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 1296, + "1": 1 + } + ], + "2/29/1": [29, 144, 145, 156], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/144/0": 2, + "2/144/1": 3, + "2/144/2": [ + { + "0": 5, + "1": true, + "2": -50000000, + "3": 50000000, + "4": [ + { + "0": -50000000, + "1": -10000000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -9999999, + "1": 9999999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 10000000, + "1": 50000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": -100000, + "3": 100000, + "4": [ + { + "0": -100000, + "1": -5000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -4999, + "1": 4999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 5000, + "1": 100000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": -500000, + "3": 500000, + "4": [ + { + "0": -500000, + "1": -100000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -99999, + "1": 99999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 100000, + "1": 500000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "2/144/3": [ + { + "0": 0, + "1": 0, + "2": 300, + "7": 129, + "8": 129, + "9": 129, + "10": 129 + }, + { + "0": 1, + "1": 0, + "2": 500, + "7": 129, + "8": 129, + "9": 129, + "10": 129 + }, + { + "0": 2, + "1": 0, + "2": 1000, + "7": 129, + "8": 129, + "9": 129, + "10": 129 + } + ], + "2/144/4": 120000, + "2/144/5": 0, + "2/144/6": 0, + "2/144/7": 0, + "2/144/8": 0, + "2/144/9": 0, + "2/144/10": 0, + "2/144/11": 120000, + "2/144/12": 0, + "2/144/13": 0, + "2/144/14": 60, + "2/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/17": 9800, + "2/144/18": 0, + "2/144/65532": 31, + "2/144/65533": 1, + "2/144/65528": [], + "2/144/65529": [], + "2/144/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 65528, + 65529, 65531, 65532, 65533 + ], + "2/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 1000000000000000, + "4": [ + { + "0": 0, + "1": 1000000000000000, + "2": 500, + "3": 50 + } + ] + }, + "2/145/1": { + "0": 0, + "1": 1900, + "2": 1936, + "3": 1900222, + "4": 1936790 + }, + "2/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "2/145/65532": 5, + "2/145/65533": 1, + "2/145/65528": [], + "2/145/65529": [], + "2/145/65531": [0, 1, 5, 65528, 65529, 65531, 65532, 65533], + "2/156/0": [0, 1, 2], + "2/156/1": null, + "2/156/65532": 12, + "2/156/65533": 1, + "2/156/65528": [], + "2/156/65529": [], + "2/156/65531": [0, 1, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index 2e3367121e9..82dcc166f13 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -46,53 +46,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[door_lock][binary_sensor.mock_door_lock_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.mock_door_lock_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Door', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-LockDoorStateSensor-257-3', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[door_lock][binary_sensor.mock_door_lock_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Mock Door Lock Door', - }), - 'context': , - 'entity_id': 'binary_sensor.mock_door_lock_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensors[door_lock_with_unbolt][binary_sensor.mock_door_lock_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 10792b58d28..bcba0da808e 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -2340,6 +2340,237 @@ 'state': 'unknown', }) # --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.laundrywasher_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'LaundryWasher Identify', + }), + 'context': , + 'entity_id': 'button.laundrywasher_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_pause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_pause', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pause', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStatePauseButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_pause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Pause', + }), + 'context': , + 'entity_id': 'button.laundrywasher_pause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_resume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_resume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Resume', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'resume', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateResumeButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_resume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Resume', + }), + 'context': , + 'entity_id': 'button.laundrywasher_resume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateStartButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Start', + }), + 'context': , + 'entity_id': 'button.laundrywasher_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateStopButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Stop', + }), + 'context': , + 'entity_id': 'button.laundrywasher_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[smoke_detector][button.smoke_sensor_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 9d51bb92e51..7e06b6f501d 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -388,6 +388,63 @@ 'state': '1.0', }) # --- +# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 25, + 'min': -25, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.eve_thermo_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-EveTemperatureOffset-513-16', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Eve Thermo Temperature offset', + 'max': 25, + 'min': -25, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.eve_thermo_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_numbers[eve_weather_sensor][number.eve_weather_altitude_above_sea_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 663b0cdaf51..19a90503086 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -546,6 +546,61 @@ 'state': 'previous', }) # --- +# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.eve_thermo_temperature_display_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature display mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_mode', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Thermo Temperature display mode', + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.eve_thermo_temperature_display_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Celsius', + }) +# --- # name: test_selects[extended_color_light][select.mock_extended_color_light_lighting-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1514,6 +1569,114 @@ 'state': 'unknown', }) # --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mode', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterLaundryWasherMode-81-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Mode', + 'options': list([ + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_temperature_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Cold', + 'Colors', + 'Whites', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_temperature_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature level', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_level', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-TemperatureControlSelectedTemperatureLevel-86-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_temperature_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Temperature level', + 'options': list([ + 'Cold', + 'Colors', + 'Whites', + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Colors', + }) +# --- # name: test_selects[switch_unit][select.mock_switchunit_power_on_behavior_on_startup-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1573,6 +1736,61 @@ 'state': 'previous', }) # --- +# name: test_selects[thermostat][select.longan_link_hvac_temperature_display_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.longan_link_hvac_temperature_display_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature display mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_mode', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[thermostat][select.longan_link_hvac_temperature_display_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Longan link HVAC Temperature display mode', + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.longan_link_hvac_temperature_display_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Celsius', + }) +# --- # name: test_selects[vacuum_cleaner][select.mock_vacuum_clean_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 96346b906c3..205cba68d7c 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1219,6 +1219,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1489,6 +1492,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1543,6 +1549,9 @@ 'sensor': dict({ 'suggested_display_precision': 3, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1597,6 +1606,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1651,6 +1663,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1800,6 +1815,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1825,7 +1843,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.05', + 'state': '3.050', }) # --- # name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery-entry] @@ -2058,6 +2076,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2426,6 +2447,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2480,6 +2504,9 @@ 'sensor': dict({ 'suggested_display_precision': 3, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2596,6 +2623,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2650,6 +2680,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2678,6 +2711,294 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'LaundryWasher Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalEnergyMeasurementCumulativeEnergyImported-145-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'LaundryWasher Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.laundrywasher_operational_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_state', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalState-96-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'LaundryWasher Operational state', + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + ]), + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementWatt-144-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'LaundryWasher Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'LaundryWasher Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- # name: test_sensors[smoke_detector][sensor.smoke_sensor_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2729,6 +3050,52 @@ 'state': '94', }) # --- +# name: test_sensors[smoke_detector][sensor.smoke_sensor_battery_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smoke_sensor_battery_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[smoke_detector][sensor.smoke_sensor_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke sensor Battery type', + }), + 'context': , + 'entity_id': 'sensor.smoke_sensor_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'CR123A', + }) +# --- # name: test_sensors[smoke_detector][sensor.smoke_sensor_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2752,6 +3119,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2777,7 +3147,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '0.000', }) # --- # name: test_sensors[temperature_sensor][sensor.mock_temperature_sensor_temperature-entry] diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 9396dccd245..612e81580a5 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -187,6 +187,52 @@ 'state': 'off', }) # --- +# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eve_thermo_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-EveTrvChildLock-516-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Thermo Child lock', + }), + 'context': , + 'entity_id': 'switch.eve_thermo_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[on_off_plugin_unit][switch.mock_onoffpluginunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 7ae483162bf..cddee975ac8 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -4,6 +4,7 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode +from matter_server.common.models import EventType import pytest from syrupy import SnapshotAssertion @@ -115,3 +116,34 @@ async def test_battery_sensor( state = hass.states.get(entity_id) assert state assert state.state == "on" + + +@pytest.mark.parametrize("node_fixture", ["door_lock"]) +async def test_optional_sensor_from_featuremap( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test discovery of optional doorsensor in doorlock featuremap.""" + entity_id = "binary_sensor.mock_door_lock_door" + state = hass.states.get(entity_id) + assert state is None + + # update the feature map to include the optional door sensor feature + # and fire a node updated event + set_node_attribute(matter_node, 1, 257, 65532, 32) + await trigger_subscription_callback( + hass, matter_client, event=EventType.NODE_UPDATED, data=matter_node + ) + # this should result in a new binary sensor entity being discovered + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + # now test the reverse, by removing the feature from the feature map + set_node_attribute(matter_node, 1, 257, 65532, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/257/65532", 0) + ) + state = hass.states.get(entity_id) + assert state is None diff --git a/tests/components/matter/test_config_flow.py b/tests/components/matter/test_config_flow.py index eed776c132e..24243fa2038 100644 --- a/tests/components/matter/test_config_flow.py +++ b/tests/components/matter/test_config_flow.py @@ -14,10 +14,10 @@ import pytest from homeassistant import config_entries from homeassistant.components.matter.const import ADDON_SLUG, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index ffe996fd840..3643aa83fca 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -103,3 +103,44 @@ async def test_attribute_select_entities( await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_list_select_entities( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test ListSelect entities are discovered and working from a laundrywasher fixture.""" + state = hass.states.get("select.laundrywasher_temperature_level") + assert state + assert state.state == "Colors" + assert state.attributes["options"] == ["Cold", "Colors", "Whites"] + # Change temperature_level + set_node_attribute(matter_node, 1, 86, 4, 0) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_temperature_level") + assert state.state == "Cold" + # test select option + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.laundrywasher_temperature_level", + "option": "Whites", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.TemperatureControl.Commands.SetTemperature( + targetTemperatureLevel=2 + ), + ) + # test that an invalid value (e.g. 253) leads to an unknown state + set_node_attribute(matter_node, 1, 86, 4, 253) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_temperature_level") + assert state.state == "unknown" diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 27eb7da2c71..630809a957d 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -174,6 +174,32 @@ async def test_battery_sensor_voltage( assert entry.entity_category == EntityCategory.DIAGNOSTIC +@pytest.mark.parametrize("node_fixture", ["smoke_detector"]) +async def test_battery_sensor_description( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test battery replacement description sensor.""" + state = hass.states.get("sensor.smoke_sensor_battery_type") + assert state + assert state.state == "CR123A" + + set_node_attribute(matter_node, 1, 47, 19, "CR2032") + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.smoke_sensor_battery_type") + assert state + assert state.state == "CR2032" + + # case with a empty string to check if the attribute is indeed ignored + set_node_attribute(matter_node, 1, 47, 19, "") + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.smoke_sensor_battery_type") is None + + @pytest.mark.parametrize("node_fixture", ["eve_thermo"]) async def test_eve_thermo_sensor( hass: HomeAssistant, diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index d7a6a700cde..11451c715c3 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion @@ -110,3 +111,57 @@ async def test_power_switch(hass: HomeAssistant, matter_node: MatterNode) -> Non assert state assert state.state == "off" assert state.attributes["friendly_name"] == "Room AirConditioner Power" + + +@pytest.mark.parametrize("node_fixture", ["eve_thermo"]) +async def test_numeric_switch( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test numeric switch entity is discovered and working using an Eve Thermo fixture .""" + state = hass.states.get("switch.eve_thermo_child_lock") + assert state + assert state.state == "off" + # name should be derived from description attribute + assert state.attributes["friendly_name"] == "Eve Thermo Child lock" + # test attribute changes + set_node_attribute(matter_node, 1, 516, 1, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("switch.eve_thermo_child_lock") + assert state.state == "on" + set_node_attribute(matter_node, 1, 516, 1, 0) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("switch.eve_thermo_child_lock") + assert state.state == "off" + # test switch service + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.eve_thermo_child_lock"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args_list[0] == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout, + ), + value=1, + ) + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.eve_thermo_child_lock"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[1] == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout, + ), + value=0, + ) diff --git a/tests/components/matter/test_vacuum.py b/tests/components/matter/test_vacuum.py index 86f7542395a..1b33f6a2fe2 100644 --- a/tests/components/matter/test_vacuum.py +++ b/tests/components/matter/test_vacuum.py @@ -8,8 +8,10 @@ import pytest from syrupy import SnapshotAssertion from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, HomeAssistantError +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from .common import ( set_node_attribute, @@ -35,6 +37,8 @@ async def test_vacuum_actions( matter_node: MatterNode, ) -> None: """Test vacuum entity actions.""" + # Fetch translations + await async_setup_component(hass, "homeassistant", {}) entity_id = "vacuum.mock_vacuum" state = hass.states.get(entity_id) assert state @@ -96,8 +100,8 @@ async def test_vacuum_actions( # test stop action # stop command is not supported by the vacuum fixture with pytest.raises( - HomeAssistantError, - match="Entity vacuum.mock_vacuum does not support this service.", + ServiceNotSupported, + match="Entity vacuum.mock_vacuum does not support action vacuum.stop", ): await hass.services.async_call( "vacuum", diff --git a/tests/components/mcp_server/__init__.py b/tests/components/mcp_server/__init__.py new file mode 100644 index 00000000000..6bdcc66de17 --- /dev/null +++ b/tests/components/mcp_server/__init__.py @@ -0,0 +1 @@ +"""Tests for the Model Context Protocol Server integration.""" diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py new file mode 100644 index 00000000000..149073f3645 --- /dev/null +++ b/tests/components/mcp_server/conftest.py @@ -0,0 +1,35 @@ +"""Common fixtures for the Model Context Protocol Server tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.mcp_server.const import DOMAIN +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.mcp_server.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Fixture to load the integration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + }, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/mcp_server/test_config_flow.py b/tests/components/mcp_server/test_config_flow.py new file mode 100644 index 00000000000..3b9f5bee663 --- /dev/null +++ b/tests/components/mcp_server/test_config_flow.py @@ -0,0 +1,41 @@ +"""Test the Model Context Protocol Server config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.mcp_server.const import DOMAIN +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.mark.parametrize( + "params", + [ + {}, + {CONF_LLM_HASS_API: "assist"}, + ], +) +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, params: dict[str, Any] +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + params, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Assist" + assert len(mock_setup_entry.mock_calls) == 1 + assert result["data"] == {CONF_LLM_HASS_API: "assist"} diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py new file mode 100644 index 00000000000..a71bf42acc8 --- /dev/null +++ b/tests/components/mcp_server/test_http.py @@ -0,0 +1,405 @@ +"""Test the Model Context Protocol Server init module.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from http import HTTPStatus +import json +import logging + +import aiohttp +import mcp +import mcp.client.session +import mcp.client.sse +from mcp.shared.exceptions import McpError +import pytest + +from homeassistant.components.conversation import DOMAIN as CONVERSATION_DOMAIN +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.mcp_server.http import MESSAGES_API, SSE_API +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_LLM_HASS_API, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, setup_test_component_platform +from tests.components.light.common import MockLight +from tests.typing import ClientSessionGenerator + +_LOGGER = logging.getLogger(__name__) + +TEST_ENTITY = "light.kitchen" +INITIALIZE_MESSAGE = { + "jsonrpc": "2.0", + "id": "request-id-1", + "method": "initialize", + "params": { + "protocolVersion": "1.0", + "capabilities": {}, + "clientInfo": { + "name": "test", + "version": "1", + }, + }, +} +EVENT_PREFIX = "event: " +DATA_PREFIX = "data: " + + +@pytest.fixture +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the config entry.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.fixture(autouse=True) +async def mock_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + setup_integration: None, +) -> None: + """Fixture to expose entities to the conversation agent.""" + entity = MockLight("kitchen", STATE_OFF) + entity.entity_id = TEST_ENTITY + setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + {LIGHT_DOMAIN: [{"platform": "test"}]}, + ) + + async_expose_entity(hass, CONVERSATION_DOMAIN, TEST_ENTITY, True) + + +async def sse_response_reader( + response: aiohttp.ClientResponse, +) -> AsyncGenerator[tuple[str, str]]: + """Read SSE responses from the server and emit event messages. + + SSE responses are formatted as: + event: event-name + data: event-data + and this function emits each event-name and event-data as a tuple. + """ + it = aiter(response.content) + while True: + line = (await anext(it)).decode() + if not line.startswith(EVENT_PREFIX): + raise ValueError("Expected event") + event = line[len(EVENT_PREFIX) :].strip() + line = (await anext(it)).decode() + if not line.startswith(DATA_PREFIX): + raise ValueError("Expected data") + data = line[len(DATA_PREFIX) :].strip() + line = (await anext(it)).decode() + assert line == "\r\n" + yield event, data + + +async def test_http_sse( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test SSE endpoint can be used to receive MCP messages.""" + + client = await hass_client() + + # Start an SSE session + response = await client.get(SSE_API) + assert response.status == HTTPStatus.OK + + # Decode a single SSE response that sends the messages endpoint + reader = sse_response_reader(response) + event, endpoint_url = await anext(reader) + assert event == "endpoint" + + # Send an initialize message on the messages endpoint + response = await client.post(endpoint_url, json=INITIALIZE_MESSAGE) + assert response.status == HTTPStatus.OK + + # Decode the initialize response event message from the SSE stream + event, data = await anext(reader) + assert event == "message" + message = json.loads(data) + assert message.get("jsonrpc") == "2.0" + assert message.get("id") == "request-id-1" + assert "serverInfo" in message.get("result", {}) + assert "protocolVersion" in message.get("result", {}) + + +async def test_http_messages_missing_session_id( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test the tools list endpoint.""" + + client = await hass_client() + response = await client.post(MESSAGES_API.format(session_id="invalid-session-id")) + assert response.status == HTTPStatus.NOT_FOUND + response_data = await response.text() + assert response_data == "Could not find session ID 'invalid-session-id'" + + +async def test_http_messages_invalid_message_format( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test the tools list endpoint.""" + + client = await hass_client() + response = await client.get(SSE_API) + assert response.status == HTTPStatus.OK + reader = sse_response_reader(response) + event, endpoint_url = await anext(reader) + assert event == "endpoint" + + response = await client.post(endpoint_url, json={"invalid": "message"}) + assert response.status == HTTPStatus.BAD_REQUEST + response_data = await response.text() + assert response_data == "Could not parse message" + + +async def test_http_sse_multiple_config_entries( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test the SSE endpoint will fail with multiple config entries. + + This cannot happen in practice as the integration only supports a single + config entry, but this is added for test coverage. + """ + + config_entry = MockConfigEntry( + domain="mcp_server", data={CONF_LLM_HASS_API: "llm-api-id"} + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + client = await hass_client() + + # Attempt to start an SSE session will fail + response = await client.get(SSE_API) + assert response.status == HTTPStatus.NOT_FOUND + response_data = await response.text() + assert "Found multiple Model Context Protocol" in response_data + + +async def test_http_sse_no_config_entry( + hass: HomeAssistant, + setup_integration: None, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the SSE endpoint fails with a missing config entry.""" + + await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED + + client = await hass_client() + + # Start an SSE session + response = await client.get(SSE_API) + assert response.status == HTTPStatus.NOT_FOUND + response_data = await response.text() + assert "Model Context Protocol server is not configured" in response_data + + +async def test_http_messages_no_config_entry( + hass: HomeAssistant, + setup_integration: None, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the message endpoint will fail if the config entry is unloaded.""" + + client = await hass_client() + + # Start an SSE session + response = await client.get(SSE_API) + assert response.status == HTTPStatus.OK + reader = sse_response_reader(response) + event, endpoint_url = await anext(reader) + assert event == "endpoint" + + # Invalidate the session by unloading the config entry + await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED + + # Reload the config entry and ensure the session is not found + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + response = await client.post(endpoint_url, json=INITIALIZE_MESSAGE) + assert response.status == HTTPStatus.NOT_FOUND + response_data = await response.text() + assert "Could not find session ID" in response_data + + +async def test_http_requires_authentication( + hass: HomeAssistant, + setup_integration: None, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test the SSE endpoint requires authentication.""" + + client = await hass_client_no_auth() + + response = await client.get(SSE_API) + assert response.status == HTTPStatus.UNAUTHORIZED + + response = await client.post(MESSAGES_API.format(session_id="session-id")) + assert response.status == HTTPStatus.UNAUTHORIZED + + +@pytest.fixture +async def mcp_sse_url(hass_client: ClientSessionGenerator) -> str: + """Fixture to get the MCP integration SSE URL.""" + client = await hass_client() + return str(client.make_url(SSE_API)) + + +@asynccontextmanager +async def mcp_session( + mcp_sse_url: str, + hass_supervisor_access_token: str, +) -> AsyncGenerator[mcp.client.session.ClientSession]: + """Create an MCP session.""" + + headers = {"Authorization": f"Bearer {hass_supervisor_access_token}"} + + async with ( + mcp.client.sse.sse_client(mcp_sse_url, headers=headers) as streams, + mcp.client.session.ClientSession(*streams) as session, + ): + await session.initialize() + yield session + + +async def test_mcp_tools_list( + hass: HomeAssistant, + setup_integration: None, + mcp_sse_url: str, + hass_supervisor_access_token: str, +) -> None: + """Test the tools list endpoint.""" + + async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + result = await session.list_tools() + + # Pick a single arbitrary tool and test that description and parameters + # are converted correctly. + tool = next(iter(tool for tool in result.tools if tool.name == "HassTurnOn")) + assert tool.name == "HassTurnOn" + assert tool.description == "Turns on/opens a device or entity" + assert tool.inputSchema + assert tool.inputSchema.get("type") == "object" + properties = tool.inputSchema.get("properties") + assert properties.get("name") == {"type": "string"} + + +async def test_mcp_tool_call( + hass: HomeAssistant, + setup_integration: None, + mcp_sse_url: str, + hass_supervisor_access_token: str, +) -> None: + """Test the tool call endpoint.""" + + state = hass.states.get("light.kitchen") + assert state + assert state.state == STATE_OFF + + async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + result = await session.call_tool( + name="HassTurnOn", + arguments={"name": "kitchen"}, + ) + + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + # The content is the raw tool call payload + content = json.loads(result.content[0].text) + assert content.get("data", {}).get("success") + assert not content.get("data", {}).get("failed") + + # Verify tool call invocation + state = hass.states.get("light.kitchen") + assert state + assert state.state == STATE_ON + + +async def test_mcp_tool_call_failed( + hass: HomeAssistant, + setup_integration: None, + mcp_sse_url: str, + hass_supervisor_access_token: str, +) -> None: + """Test the tool call endpoint with a failure.""" + + async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + result = await session.call_tool( + name="HassTurnOn", + arguments={"name": "backyard"}, + ) + + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert "Error calling tool" in result.content[0].text + + +async def test_prompt_list( + hass: HomeAssistant, + setup_integration: None, + mcp_sse_url: str, + hass_supervisor_access_token: str, +) -> None: + """Test the list prompt endpoint.""" + + async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + result = await session.list_prompts() + + assert len(result.prompts) == 1 + prompt = result.prompts[0] + assert prompt.name == "Assist" + assert prompt.description == "Default prompt for the Home Assistant LLM API Assist" + + +async def test_prompt_get( + hass: HomeAssistant, + setup_integration: None, + mcp_sse_url: str, + hass_supervisor_access_token: str, +) -> None: + """Test the get prompt endpoint.""" + + async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + result = await session.get_prompt(name="Assist") + + assert result.description == "Default prompt for the Home Assistant LLM API Assist" + assert len(result.messages) == 1 + assert result.messages[0].role == "assistant" + assert result.messages[0].content.type == "text" + assert "When controlling Home Assistant" in result.messages[0].content.text + + +async def test_get_unknwon_prompt( + hass: HomeAssistant, + setup_integration: None, + mcp_sse_url: str, + hass_supervisor_access_token: str, +) -> None: + """Test the get prompt endpoint.""" + + async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: + with pytest.raises(McpError): + await session.get_prompt(name="Unknown") diff --git a/tests/components/mcp_server/test_init.py b/tests/components/mcp_server/test_init.py new file mode 100644 index 00000000000..af6a8a55e59 --- /dev/null +++ b/tests/components/mcp_server/test_init.py @@ -0,0 +1,15 @@ +"""Test the Model Context Protocol Server init module.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_init(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test the integration is initialized and can be unloaded cleanly.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index ecb5d1d6cd1..a694c72fcf6 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -15,7 +15,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '229', + 'mealplan_id': 229, 'recipe': dict({ 'description': 'The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -42,7 +42,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': '230', + 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -67,7 +67,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '222', + 'mealplan_id': 222, 'recipe': dict({ 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -92,7 +92,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '221', + 'mealplan_id': 221, 'recipe': dict({ 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -117,7 +117,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '219', + 'mealplan_id': 219, 'recipe': dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -142,7 +142,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': '217', + 'mealplan_id': 217, 'recipe': dict({ 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -167,7 +167,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '212', + 'mealplan_id': 212, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -192,7 +192,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': '211', + 'mealplan_id': 211, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -217,7 +217,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '196', + 'mealplan_id': 196, 'recipe': dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -242,7 +242,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': '195', + 'mealplan_id': 195, 'recipe': dict({ 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -267,7 +267,7 @@ '__type': "", 'isoformat': '2024-01-21', }), - 'mealplan_id': '1', + 'mealplan_id': 1, 'recipe': None, 'title': 'Aquavite', 'user_id': '6caa6e4d-521f-4ef4-9ed7-388bdd63f47d', @@ -283,7 +283,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '226', + 'mealplan_id': 226, 'recipe': dict({ 'description': 'Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -308,7 +308,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '224', + 'mealplan_id': 224, 'recipe': dict({ 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -333,7 +333,7 @@ '__type': "", 'isoformat': '2024-01-22', }), - 'mealplan_id': '216', + 'mealplan_id': 216, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -360,7 +360,7 @@ '__type': "", 'isoformat': '2024-01-23', }), - 'mealplan_id': '220', + 'mealplan_id': 220, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -385,15 +385,15 @@ 'checked': False, 'disable_amount': True, 'display': '2 Apples', - 'food_id': 'None', + 'food_id': None, 'is_food': False, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': 'Apples', 'position': 0, 'quantity': 2.0, - 'unit_id': 'None', + 'unit_id': None, }), dict({ 'checked': False, @@ -402,7 +402,7 @@ 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', 'is_food': True, 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 1, @@ -416,12 +416,12 @@ 'food_id': '96801494-4e26-4148-849a-8155deb76327', 'is_food': True, 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 2, 'quantity': 0.0, - 'unit_id': 'None', + 'unit_id': None, }), ]), 'shopping_list': dict({ @@ -435,15 +435,15 @@ 'checked': False, 'disable_amount': True, 'display': '2 Apples', - 'food_id': 'None', + 'food_id': None, 'is_food': False, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': 'Apples', 'position': 0, 'quantity': 2.0, - 'unit_id': 'None', + 'unit_id': None, }), dict({ 'checked': False, @@ -452,7 +452,7 @@ 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', 'is_food': True, 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 1, @@ -466,12 +466,12 @@ 'food_id': '96801494-4e26-4148-849a-8155deb76327', 'is_food': True, 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 2, 'quantity': 0.0, - 'unit_id': 'None', + 'unit_id': None, }), ]), 'shopping_list': dict({ @@ -485,15 +485,15 @@ 'checked': False, 'disable_amount': True, 'display': '2 Apples', - 'food_id': 'None', + 'food_id': None, 'is_food': False, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': 'Apples', 'position': 0, 'quantity': 2.0, - 'unit_id': 'None', + 'unit_id': None, }), dict({ 'checked': False, @@ -502,7 +502,7 @@ 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', 'is_food': True, 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 1, @@ -516,12 +516,12 @@ 'food_id': '96801494-4e26-4148-849a-8155deb76327', 'is_food': True, 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', - 'label_id': 'None', + 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', 'note': '', 'position': 2, 'quantity': 0.0, - 'unit_id': 'None', + 'unit_id': None, }), ]), 'shopping_list': dict({ diff --git a/tests/components/mealie/snapshots/test_sensor.ambr b/tests/components/mealie/snapshots/test_sensor.ambr index d52ffc9a79a..e645cf4c45f 100644 --- a/tests/components/mealie/snapshots/test_sensor.ambr +++ b/tests/components/mealie/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'categories', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_categories', - 'unit_of_measurement': None, + 'unit_of_measurement': 'categories', }) # --- # name: test_entities[sensor.mealie_categories-state] @@ -39,6 +39,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Categories', 'state_class': , + 'unit_of_measurement': 'categories', }), 'context': , 'entity_id': 'sensor.mealie_categories', @@ -80,7 +81,7 @@ 'supported_features': 0, 'translation_key': 'recipes', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_recipes', - 'unit_of_measurement': None, + 'unit_of_measurement': 'recipes', }) # --- # name: test_entities[sensor.mealie_recipes-state] @@ -88,6 +89,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Recipes', 'state_class': , + 'unit_of_measurement': 'recipes', }), 'context': , 'entity_id': 'sensor.mealie_recipes', @@ -129,7 +131,7 @@ 'supported_features': 0, 'translation_key': 'tags', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_tags', - 'unit_of_measurement': None, + 'unit_of_measurement': 'tags', }) # --- # name: test_entities[sensor.mealie_tags-state] @@ -137,6 +139,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Tags', 'state_class': , + 'unit_of_measurement': 'tags', }), 'context': , 'entity_id': 'sensor.mealie_tags', @@ -178,7 +181,7 @@ 'supported_features': 0, 'translation_key': 'tools', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_tools', - 'unit_of_measurement': None, + 'unit_of_measurement': 'tools', }) # --- # name: test_entities[sensor.mealie_tools-state] @@ -186,6 +189,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Tools', 'state_class': , + 'unit_of_measurement': 'tools', }), 'context': , 'entity_id': 'sensor.mealie_tools', @@ -227,7 +231,7 @@ 'supported_features': 0, 'translation_key': 'users', 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_users', - 'unit_of_measurement': None, + 'unit_of_measurement': 'users', }) # --- # name: test_entities[sensor.mealie_users-state] @@ -235,6 +239,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mealie Users', 'state_class': , + 'unit_of_measurement': 'users', }), 'context': , 'entity_id': 'sensor.mealie_users', diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 93b5f2cad1d..56626c7b5c4 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -199,7 +199,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': '230', + 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -221,7 +221,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '229', + 'mealplan_id': 229, 'recipe': dict({ 'description': 'The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -243,7 +243,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '226', + 'mealplan_id': 226, 'recipe': dict({ 'description': 'Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -265,7 +265,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '224', + 'mealplan_id': 224, 'recipe': dict({ 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -287,7 +287,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '222', + 'mealplan_id': 222, 'recipe': dict({ 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -309,7 +309,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '221', + 'mealplan_id': 221, 'recipe': dict({ 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -331,7 +331,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '220', + 'mealplan_id': 220, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -353,7 +353,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '219', + 'mealplan_id': 219, 'recipe': dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -375,7 +375,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': '217', + 'mealplan_id': 217, 'recipe': dict({ 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -397,7 +397,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': '216', + 'mealplan_id': 216, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -419,7 +419,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '212', + 'mealplan_id': 212, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -441,7 +441,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': '211', + 'mealplan_id': 211, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -463,7 +463,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), - 'mealplan_id': '196', + 'mealplan_id': 196, 'recipe': dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -485,7 +485,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), - 'mealplan_id': '195', + 'mealplan_id': 195, 'recipe': dict({ 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -507,7 +507,7 @@ 'group_id': '3931df86-0679-4579-8c63-4bedc9ca9a85', 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 21), - 'mealplan_id': '1', + 'mealplan_id': 1, 'recipe': None, 'title': 'Aquavite', 'user_id': '6caa6e4d-521f-4ef4-9ed7-388bdd63f47d', @@ -714,7 +714,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), - 'mealplan_id': '230', + 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -740,7 +740,33 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), - 'mealplan_id': '230', + 'mealplan_id': 230, + 'recipe': dict({ + 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'image': 'AiIo', + 'name': 'Zoete aardappel curry traybake', + 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', + 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', + 'recipe_yield': '2 servings', + 'slug': 'zoete-aardappel-curry-traybake', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + 'title': None, + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + }) +# --- +# name: test_service_set_mealplan[payload2-kwargs2] + dict({ + 'mealplan': dict({ + 'description': None, + 'entry_type': , + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'mealplan_date': datetime.date(2024, 1, 22), + 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', @@ -766,7 +792,7 @@ 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), - 'mealplan_id': '230', + 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', diff --git a/tests/components/mealie/test_calendar.py b/tests/components/mealie/test_calendar.py index d11fe5d2354..cca4fcca673 100644 --- a/tests/components/mealie/test_calendar.py +++ b/tests/components/mealie/test_calendar.py @@ -4,9 +4,10 @@ from datetime import date from http import HTTPStatus from unittest.mock import AsyncMock, patch +from aiomealie import MealplanResponse from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_OFF, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -40,13 +41,28 @@ async def test_entities( mock_mealie_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test the API returns the calendar.""" + """Test the calendar entities.""" with patch("homeassistant.components.mealie.PLATFORMS", [Platform.CALENDAR]): await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +async def test_no_meal_planned( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the calendar handles no meal planned.""" + mock_mealie_client.get_mealplans.return_value = MealplanResponse([]) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("calendar.mealie_dinner").state == STATE_OFF + + async def test_api_events( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/mealie/test_config_flow.py b/tests/components/mealie/test_config_flow.py index 15c629ec3da..628f0290f43 100644 --- a/tests/components/mealie/test_config_flow.py +++ b/tests/components/mealie/test_config_flow.py @@ -85,6 +85,40 @@ async def test_flow_errors( assert result["type"] is FlowResultType.CREATE_ENTRY +async def test_ingress_host( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test disallow ingress host.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "http://homeassistant/hassio/ingress/db21ed7f_mealie", + CONF_API_TOKEN: "token", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "ingress_url"} + + mock_mealie_client.get_user_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "http://homeassistant:9001", CONF_API_TOKEN: "token"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + @pytest.mark.parametrize( ("version"), [ diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 1c8c6f19de7..63668379490 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -250,6 +250,12 @@ async def test_service_set_random_mealplan( }, {"recipe_id": None, "note_title": "Note Title", "note_text": "Note Text"}, ), + ( + { + ATTR_NOTE_TITLE: "Note Title", + }, + {"recipe_id": None, "note_title": "Note Title", "note_text": None}, + ), ], ) async def test_service_set_mealplan( diff --git a/tests/components/mealie/test_todo.py b/tests/components/mealie/test_todo.py index 920cfc47397..e7942887099 100644 --- a/tests/components/mealie/test_todo.py +++ b/tests/components/mealie/test_todo.py @@ -1,9 +1,9 @@ """Tests for the Mealie todo.""" from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, call, patch -from aiomealie import MealieError, ShoppingListsResponse +from aiomealie import MealieError, MutateShoppingItem, ShoppingListsResponse from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -18,7 +18,7 @@ from homeassistant.components.todo import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -29,6 +29,7 @@ from tests.common import ( load_fixture, snapshot_platform, ) +from tests.typing import WebSocketGenerator async def test_entities( @@ -45,23 +46,38 @@ async def test_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_add_todo_list_item( +@pytest.mark.parametrize( + ("service", "data", "method"), + [ + (TodoServices.ADD_ITEM, {ATTR_ITEM: "Soda"}, "add_shopping_item"), + ( + TodoServices.UPDATE_ITEM, + {ATTR_ITEM: "aubergine", ATTR_RENAME: "Eggplant", ATTR_STATUS: "completed"}, + "update_shopping_item", + ), + (TodoServices.REMOVE_ITEM, {ATTR_ITEM: "aubergine"}, "delete_shopping_item"), + ], +) +async def test_todo_actions( hass: HomeAssistant, mock_mealie_client: AsyncMock, mock_config_entry: MockConfigEntry, + service: str, + data: dict[str, str], + method: str, ) -> None: - """Test for adding a To-do Item.""" + """Test todo actions.""" await setup_integration(hass, mock_config_entry) await hass.services.async_call( TODO_DOMAIN, - TodoServices.ADD_ITEM, - {ATTR_ITEM: "Soda"}, + service, + data, target={ATTR_ENTITY_ID: "todo.mealie_supermarket"}, blocking=True, ) - mock_mealie_client.add_shopping_item.assert_called_once() + getattr(mock_mealie_client, method).assert_called_once() async def test_add_todo_list_item_error( @@ -74,7 +90,9 @@ async def test_add_todo_list_item_error( mock_mealie_client.add_shopping_item.side_effect = MealieError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="An error occurred adding an item to Supermarket" + ): await hass.services.async_call( TODO_DOMAIN, TodoServices.ADD_ITEM, @@ -84,25 +102,6 @@ async def test_add_todo_list_item_error( ) -async def test_update_todo_list_item( - hass: HomeAssistant, - mock_mealie_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test for updating a To-do Item.""" - await setup_integration(hass, mock_config_entry) - - await hass.services.async_call( - TODO_DOMAIN, - TodoServices.UPDATE_ITEM, - {ATTR_ITEM: "aubergine", ATTR_RENAME: "Eggplant", ATTR_STATUS: "completed"}, - target={ATTR_ENTITY_ID: "todo.mealie_supermarket"}, - blocking=True, - ) - - mock_mealie_client.update_shopping_item.assert_called_once() - - async def test_update_todo_list_item_error( hass: HomeAssistant, mock_mealie_client: AsyncMock, @@ -113,7 +112,9 @@ async def test_update_todo_list_item_error( mock_mealie_client.update_shopping_item.side_effect = MealieError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="An error occurred updating an item in Supermarket" + ): await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, @@ -123,23 +124,24 @@ async def test_update_todo_list_item_error( ) -async def test_delete_todo_list_item( +async def test_update_non_existent_item( hass: HomeAssistant, mock_mealie_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test for deleting a To-do Item.""" + """Test for updating a non-existent To-do Item.""" await setup_integration(hass, mock_config_entry) - await hass.services.async_call( - TODO_DOMAIN, - TodoServices.REMOVE_ITEM, - {ATTR_ITEM: "aubergine"}, - target={ATTR_ENTITY_ID: "todo.mealie_supermarket"}, - blocking=True, - ) - - mock_mealie_client.delete_shopping_item.assert_called_once() + with pytest.raises( + ServiceValidationError, match="Unable to find to-do list item: eggplant" + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + {ATTR_ITEM: "eggplant", ATTR_RENAME: "Aubergine", ATTR_STATUS: "completed"}, + target={ATTR_ENTITY_ID: "todo.mealie_supermarket"}, + blocking=True, + ) async def test_delete_todo_list_item_error( @@ -153,7 +155,9 @@ async def test_delete_todo_list_item_error( mock_mealie_client.delete_shopping_item = AsyncMock() mock_mealie_client.delete_shopping_item.side_effect = MealieError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="An error occurred deleting an item in Supermarket" + ): await hass.services.async_call( TODO_DOMAIN, TodoServices.REMOVE_ITEM, @@ -163,6 +167,172 @@ async def test_delete_todo_list_item_error( ) +async def test_moving_todo_item( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test for moving a To-do Item to place.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + "entity_id": "todo.mealie_supermarket", + "uid": "f45430f7-3edf-45a9-a50f-73bb375090be", + "previous_uid": "84d8fd74-8eb0-402e-84b6-71f251bfb7cc", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("success") + assert resp.get("result") is None + + assert mock_mealie_client.update_shopping_item.call_count == 3 + calls = mock_mealie_client.update_shopping_item.mock_calls + + assert calls[0] == call( + "84d8fd74-8eb0-402e-84b6-71f251bfb7cc", + MutateShoppingItem( + item_id="84d8fd74-8eb0-402e-84b6-71f251bfb7cc", + list_id="9ce096fe-ded2-4077-877d-78ba450ab13e", + note="", + display=None, + checked=False, + position=0, + is_food=True, + disable_amount=None, + quantity=1.0, + label_id=None, + food_id="09322430-d24c-4b1a-abb6-22b6ed3a88f5", + unit_id="7bf539d4-fc78-48bc-b48e-c35ccccec34a", + ), + ) + + assert calls[1] == call( + "f45430f7-3edf-45a9-a50f-73bb375090be", + MutateShoppingItem( + item_id="f45430f7-3edf-45a9-a50f-73bb375090be", + list_id="9ce096fe-ded2-4077-877d-78ba450ab13e", + note="Apples", + display=None, + checked=False, + position=1, + is_food=False, + disable_amount=None, + quantity=2.0, + label_id=None, + food_id=None, + unit_id=None, + ), + ) + + assert calls[2] == call( + "69913b9a-7c75-4935-abec-297cf7483f88", + MutateShoppingItem( + item_id="69913b9a-7c75-4935-abec-297cf7483f88", + list_id="9ce096fe-ded2-4077-877d-78ba450ab13e", + note="", + display=None, + checked=False, + position=2, + is_food=True, + disable_amount=None, + quantity=0.0, + label_id=None, + food_id="96801494-4e26-4148-849a-8155deb76327", + unit_id=None, + ), + ) + + +async def test_not_moving_todo_item( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test for moving a To-do Item to the same place.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + "entity_id": "todo.mealie_supermarket", + "uid": "f45430f7-3edf-45a9-a50f-73bb375090be", + "previous_uid": "f45430f7-3edf-45a9-a50f-73bb375090be", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("success") + assert resp.get("result") is None + + assert mock_mealie_client.update_shopping_item.call_count == 0 + + +async def test_moving_todo_item_invalid_uid( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test for moving a To-do Item to place with invalid UID.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + "entity_id": "todo.mealie_supermarket", + "uid": "cheese", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("success") is False + assert resp.get("result") is None + assert resp["error"]["code"] == "failed" + assert resp["error"]["message"] == "Item cheese not found" + + assert mock_mealie_client.update_shopping_item.call_count == 0 + + +async def test_moving_todo_item_invalid_previous_uid( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test for moving a To-do Item to place with invalid previous UID.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + "entity_id": "todo.mealie_supermarket", + "uid": "f45430f7-3edf-45a9-a50f-73bb375090be", + "previous_uid": "cheese", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("success") is False + assert resp.get("result") is None + assert resp["error"]["code"] == "failed" + assert resp["error"]["message"] == "Item cheese not found" + + assert mock_mealie_client.update_shopping_item.call_count == 0 + + async def test_runtime_management( hass: HomeAssistant, mock_mealie_client: AsyncMock, diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 47f0530f0ff..9db2621f84f 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -21,7 +21,11 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import help_test_all, import_and_test_deprecated_constant_enum +from tests.common import ( + MockEntityPlatform, + help_test_all, + import_and_test_deprecated_constant_enum, +) from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -97,6 +101,54 @@ def test_deprecated_constants_const( ) +@pytest.mark.parametrize( + "property_suffix", + [ + "play", + "pause", + "stop", + "seek", + "volume_set", + "volume_mute", + "previous_track", + "next_track", + "play_media", + "select_source", + "select_sound_mode", + "clear_playlist", + "shuffle_set", + "grouping", + ], +) +def test_support_properties(hass: HomeAssistant, property_suffix: str) -> None: + """Test support_*** properties explicitly.""" + + all_features = media_player.MediaPlayerEntityFeature(653887) + feature = media_player.MediaPlayerEntityFeature[property_suffix.upper()] + + entity1 = MediaPlayerEntity() + entity1.hass = hass + entity1.platform = MockEntityPlatform(hass) + entity1._attr_supported_features = media_player.MediaPlayerEntityFeature(0) + entity2 = MediaPlayerEntity() + entity2.hass = hass + entity2.platform = MockEntityPlatform(hass) + entity2._attr_supported_features = all_features + entity3 = MediaPlayerEntity() + entity3.hass = hass + entity3.platform = MockEntityPlatform(hass) + entity3._attr_supported_features = feature + entity4 = MediaPlayerEntity() + entity4.hass = hass + entity4.platform = MockEntityPlatform(hass) + entity4._attr_supported_features = all_features - feature + + assert getattr(entity1, f"support_{property_suffix}") is False + assert getattr(entity2, f"support_{property_suffix}") is True + assert getattr(entity3, f"support_{property_suffix}") is True + assert getattr(entity4, f"support_{property_suffix}") is False + + async def test_get_image_http( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: @@ -112,8 +164,7 @@ async def test_get_image_http( client = await hass_client_no_auth() with patch( - "homeassistant.components.media_player.MediaPlayerEntity." - "async_get_media_image", + "homeassistant.components.media_player.MediaPlayerEntity.async_get_media_image", return_value=(b"image", "image/jpeg"), ): resp = await client.get(state.attributes["entity_picture"]) @@ -409,7 +460,9 @@ async def test_get_async_get_browse_image_quoting( mock_browse_image.assert_called_with("album", media_content_id, None) -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated supported features ints.""" class MockMediaPlayerEntity(MediaPlayerEntity): @@ -419,6 +472,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> return 1 entity = MockMediaPlayerEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert entity.supported_features_compat is MediaPlayerEntityFeature(1) assert "MockMediaPlayerEntity" in caplog.text assert "is using deprecated supported features values" in caplog.text diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index bf0026b8c6c..a47e6422bf8 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -147,5 +147,5 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) - assert unload_entry.call_count == 2 + assert unload_entry.call_count == 3 assert entry.entry_id not in hass.data[mill.DOMAIN] diff --git a/tests/components/mochad/test_switch.py b/tests/components/mochad/test_switch.py index 9fea3b5c14c..d7875246fac 100644 --- a/tests/components/mochad/test_switch.py +++ b/tests/components/mochad/test_switch.py @@ -9,6 +9,8 @@ from homeassistant.components.mochad import switch as mochad from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockEntityPlatform + @pytest.fixture(autouse=True) def pymochad_mock(): @@ -25,7 +27,9 @@ def switch_mock(hass: HomeAssistant) -> mochad.MochadSwitch: """Mock switch.""" controller_mock = mock.MagicMock() dev_dict = {"address": "a1", "name": "fake_switch"} - return mochad.MochadSwitch(hass, controller_mock, dev_dict) + entity = mochad.MochadSwitch(hass, controller_mock, dev_dict) + entity.platform = MockEntityPlatform(hass) + return entity async def test_setup_adds_proper_devices(hass: HomeAssistant) -> None: diff --git a/tests/components/modbus/fixtures/configuration_2.yaml b/tests/components/modbus/fixtures/configuration_2.yaml new file mode 100644 index 00000000000..3f7b062c4cb --- /dev/null +++ b/tests/components/modbus/fixtures/configuration_2.yaml @@ -0,0 +1,12 @@ +modbus: + type: "tcp" + host: "testHost" + port: 5001 + name: "testModbus" + sensors: + - name: "dummy" + address: 117 + slave: 0 + - name: "dummy_2" + address: 118 + slave: 1 diff --git a/tests/components/modbus/fixtures/configuration_empty.yaml b/tests/components/modbus/fixtures/configuration_empty.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 24293377174..e1c0e08a113 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -422,9 +422,9 @@ async def test_virtual_binary_sensor( assert hass.states.get(ENTITY_ID).state == expected for i, slave in enumerate(slaves): - entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i+1}".replace(" ", "_") + entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i + 1}".replace(" ", "_") assert hass.states.get(entity_id).state == slave - unique_id = f"{SLAVE_UNIQUE_ID}_{i+1}" + unique_id = f"{SLAVE_UNIQUE_ID}_{i + 1}" entry = entity_registry.async_get(entity_id) assert entry.unique_id == unique_id diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index d34846639b5..b5bc9b02808 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -24,6 +24,8 @@ from homeassistant.components.climate import ( SERVICE_SET_HVAC_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -54,6 +56,8 @@ from homeassistant.components.modbus.const import ( CONF_HVAC_MODE_OFF, CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_VALUES, + CONF_HVAC_OFF_VALUE, + CONF_HVAC_ON_VALUE, CONF_HVAC_ONOFF_REGISTER, CONF_MAX_TEMP, CONF_MIN_TEMP, @@ -362,6 +366,47 @@ async def test_config_hvac_onoff_register(hass: HomeAssistant, mock_modbus) -> N assert HVACMode.AUTO in state.attributes[ATTR_HVAC_MODES] +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 11, + CONF_HVAC_ON_VALUE: 0xAA, + CONF_HVAC_OFF_VALUE: 0xFF, + } + ], + }, + ], +) +async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for On/Off register values.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_modbus.write_register.assert_called_with(11, value=0xAA, slave=10) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_modbus.write_register.assert_called_with(11, value=0xFF, slave=10) + + @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 3b8a76f5606..e105818d193 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -19,13 +19,12 @@ from unittest import mock from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException -from pymodbus.pdu import ExceptionResponse, IllegalFunctionRequest +from pymodbus.pdu import ExceptionResponse import pytest import voluptuous as vol from homeassistant import config as hass_config from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.modbus import async_reset_platform from homeassistant.components.modbus.const import ( ATTR_ADDRESS, ATTR_HUB, @@ -820,7 +819,6 @@ SERVICE = "service" [ {VALUE: ReadResult([0x0001]), DATA: ""}, {VALUE: ExceptionResponse(0x06), DATA: "Pymodbus:"}, - {VALUE: IllegalFunctionRequest(0x06), DATA: "Pymodbus:"}, {VALUE: ModbusException("fail write_"), DATA: "Pymodbus:"}, ], ) @@ -848,6 +846,13 @@ async def test_pb_service_write( CALL_TYPE_WRITE_REGISTERS: mock_modbus_with_pymodbus.write_registers, } + value_arg_name = { + CALL_TYPE_WRITE_COIL: "value", + CALL_TYPE_WRITE_COILS: "values", + CALL_TYPE_WRITE_REGISTER: "value", + CALL_TYPE_WRITE_REGISTERS: "values", + } + data = { ATTR_HUB: TEST_MODBUS_NAME, do_slave: 17, @@ -860,10 +865,12 @@ async def test_pb_service_write( func_name[do_write[FUNC]].return_value = do_return[VALUE] await hass.services.async_call(DOMAIN, do_write[SERVICE], data, blocking=True) assert func_name[do_write[FUNC]].called - assert func_name[do_write[FUNC]].call_args[0] == ( - data[ATTR_ADDRESS], - data[do_write[DATA]], - ) + assert func_name[do_write[FUNC]].call_args.args == (data[ATTR_ADDRESS],) + assert func_name[do_write[FUNC]].call_args.kwargs == { + "slave": 17, + value_arg_name[do_write[FUNC]]: data[do_write[DATA]], + } + if do_return[DATA]: assert any(message.startswith("Pymodbus:") for message in caplog.messages) @@ -928,7 +935,6 @@ async def mock_modbus_read_pymodbus_fixture( ("do_return", "do_exception", "do_expect_state", "do_expect_value"), [ (ReadResult([1]), None, STATE_ON, "1"), - (IllegalFunctionRequest(0x99), None, STATE_UNAVAILABLE, STATE_UNAVAILABLE), (ExceptionResponse(0x99), None, STATE_UNAVAILABLE, STATE_UNAVAILABLE), ( ReadResult([1]), @@ -1161,22 +1167,61 @@ async def test_integration_reload( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus, - freezer: FrozenDateTimeFactory, ) -> None: """Run test for integration reload.""" caplog.set_level(logging.DEBUG) caplog.clear() - yaml_path = get_fixture_path("configuration.yaml", "modbus") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + yaml_path = get_fixture_path("configuration.yaml", DOMAIN) with mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) await hass.async_block_till_done() - for _ in range(4): - freezer.tick(timedelta(seconds=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() assert "Modbus reloading" in caplog.text + state_sensor_1 = hass.states.get("sensor.dummy") + state_sensor_2 = hass.states.get("sensor.dummy_2") + assert state_sensor_1 + assert not state_sensor_2 + + caplog.clear() + yaml_path = get_fixture_path("configuration_2.yaml", DOMAIN) + with mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + assert "Modbus reloading" in caplog.text + state_sensor_1 = hass.states.get("sensor.dummy") + state_sensor_2 = hass.states.get("sensor.dummy_2") + assert state_sensor_1 + assert state_sensor_2 + + caplog.clear() + yaml_path = get_fixture_path("configuration_empty.yaml", DOMAIN) + with mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + assert "Modbus not present anymore" in caplog.text + state_sensor_1 = hass.states.get("sensor.dummy") + state_sensor_2 = hass.states.get("sensor.dummy_2") + assert not state_sensor_1 + assert not state_sensor_2 @pytest.mark.parametrize("do_config", [{}]) @@ -1231,7 +1276,54 @@ async def test_no_entities(hass: HomeAssistant) -> None: assert await async_setup_component(hass, DOMAIN, config) is False -async def test_reset_platform(hass: HomeAssistant) -> None: - """Run test for async_reset_platform.""" - await async_reset_platform(hass, "modbus") - assert DOMAIN not in hass.data +@pytest.mark.parametrize( + ("do_config", "expected_slave_value"), + [ + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + }, + ], + }, + 1, + ), + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + CONF_SLAVE: 0, + }, + ], + }, + 0, + ), + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 6, + }, + ], + }, + 6, + ), + ], +) +async def test_check_default_slave( + hass: HomeAssistant, + mock_modbus, + do_config, + mock_do_cycle, + expected_slave_value: int, +) -> None: + """Test default slave.""" + assert mock_modbus.read_holding_registers.mock_calls + first_call = mock_modbus.read_holding_registers.mock_calls[0] + assert first_call.kwargs["slave"] == expected_slave_value diff --git a/tests/components/modem_callerid/test_config_flow.py b/tests/components/modem_callerid/test_config_flow.py index 2ae4d6659e7..280deadc733 100644 --- a/tests/components/modem_callerid/test_config_flow.py +++ b/tests/components/modem_callerid/test_config_flow.py @@ -10,10 +10,11 @@ from homeassistant.config_entries import SOURCE_USB, SOURCE_USER from homeassistant.const import CONF_DEVICE, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from . import com_port, patch_config_flow_modem -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device=phone_modem.DEFAULT_PORT, pid="1340", vid="0572", diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 5b10d4d729e..4ec5e92cd72 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import MagicMock, patch import aiohttp from aiomodernforms import ModernFormsConnectionError -from homeassistant.components import zeroconf from homeassistant.components.modern_forms.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import init_integration @@ -66,7 +66,7 @@ async def test_full_zeroconf_flow_implementation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -138,7 +138,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -170,7 +170,7 @@ async def test_zeroconf_confirm_connection_error( CONF_HOST: "example.com", CONF_NAME: "test", }, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.com.", @@ -220,7 +220,7 @@ async def test_zeroconf_with_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/mold_indicator/test_config_flow.py b/tests/components/mold_indicator/test_config_flow.py index 9df0e18d9ed..bb8362b5e0d 100644 --- a/tests/components/mold_indicator/test_config_flow.py +++ b/tests/components/mold_indicator/test_config_flow.py @@ -70,6 +70,9 @@ async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", CONF_CALIBRATION_FACTOR: 3.0, }, ) diff --git a/tests/components/monzo/test_sensor.py b/tests/components/monzo/test_sensor.py index bf88ce14931..a57466fdbd4 100644 --- a/tests/components/monzo/test_sensor.py +++ b/tests/components/monzo/test_sensor.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory +from monzopy import InvalidMonzoAPIResponseError import pytest from syrupy import SnapshotAssertion @@ -123,15 +124,22 @@ async def test_update_failed( monzo: AsyncMock, polling_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test all entities.""" await setup_integration(hass, polling_config_entry) - monzo.user_account.accounts.side_effect = Exception + monzo.user_account.accounts.side_effect = InvalidMonzoAPIResponseError( + {"acc_id": None}, "account_id" + ) freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done() + assert "Invalid Monzo API response." in caplog.text + assert "account_id" in caplog.text + assert "acc_id" in caplog.text + entity_id = await async_get_entity_id( hass, TEST_ACCOUNTS[0]["id"], ACCOUNT_SENSORS[0] ) diff --git a/tests/components/motion_blinds/test_config_flow.py b/tests/components/motion_blinds/test_config_flow.py index 77171b06ad6..821e4fa0278 100644 --- a/tests/components/motion_blinds/test_config_flow.py +++ b/tests/components/motion_blinds/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.motion_blinds import const from homeassistant.components.motion_blinds.config_flow import DEFAULT_GATEWAY_NAME from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -346,7 +346,7 @@ async def test_config_flow_invalid_interface(hass: HomeAssistant) -> None: async def test_dhcp_flow(hass: HomeAssistant) -> None: """Successful flow from DHCP discovery.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, @@ -380,7 +380,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: async def test_dhcp_flow_abort(hass: HomeAssistant) -> None: """Test that DHCP discovery aborts if not Motionblinds.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, @@ -400,7 +400,7 @@ async def test_dhcp_flow_abort(hass: HomeAssistant) -> None: async def test_dhcp_flow_abort_invalid_response(hass: HomeAssistant) -> None: """Test that DHCP discovery aborts if device responded with invalid data.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, diff --git a/tests/components/motioneye/__init__.py b/tests/components/motioneye/__init__.py index 842d862a222..c403f9f072f 100644 --- a/tests/components/motioneye/__init__.py +++ b/tests/components/motioneye/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry TEST_CONFIG_ENTRY_ID = "74565ad414754616000674c87bdc876c" -TEST_URL = f"http://test:{DEFAULT_PORT+1}" +TEST_URL = f"http://test:{DEFAULT_PORT + 1}" TEST_CAMERA_ID = 100 TEST_CAMERA_NAME = "Test Camera" TEST_CAMERA_ENTITY_ID = "camera.test_camera" diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index da6fbae32a3..ed7dae26663 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -2,8 +2,8 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" PORT = 23 @@ -21,7 +21,7 @@ MOCK_USER_INPUT = { CONF_PORT: PORT, } -MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = ZeroconfServiceInfo( type=TVM_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), @@ -31,7 +31,7 @@ MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo( properties={"txtvers": "1", "model": "TVM 7675"}, ) -MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = ZeroconfServiceInfo( type=TVM_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), diff --git a/tests/components/mpd/test_config_flow.py b/tests/components/mpd/test_config_flow.py index d17bef60446..af2ba7562a8 100644 --- a/tests/components/mpd/test_config_flow.py +++ b/tests/components/mpd/test_config_flow.py @@ -7,8 +7,8 @@ import mpd import pytest from homeassistant.components.mpd.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -107,85 +107,3 @@ async def test_existing_entry( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_full_import_flow( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_mpd_client: AsyncMock, -) -> None: - """Test the happy flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_HOST: "192.168.0.1", - CONF_PORT: 6600, - CONF_PASSWORD: "test123", - CONF_NAME: "My PC", - }, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "My PC" - assert result["data"] == { - CONF_HOST: "192.168.0.1", - CONF_PORT: 6600, - CONF_PASSWORD: "test123", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("exception", "error"), - [ - (TimeoutError, "cannot_connect"), - (gaierror, "cannot_connect"), - (mpd.ConnectionError, "cannot_connect"), - (OSError, "cannot_connect"), - (Exception, "unknown"), - ], -) -async def test_import_errors( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_mpd_client: AsyncMock, - exception: Exception, - error: str, -) -> None: - """Test we handle errors correctly.""" - mock_mpd_client.password.side_effect = exception - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_HOST: "192.168.0.1", - CONF_PORT: 6600, - CONF_PASSWORD: "test123", - CONF_NAME: "My PC", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == error - - -async def test_existing_entry_import( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test we abort if an entry already exists.""" - mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_HOST: "192.168.0.1", - CONF_PORT: 6600, - CONF_PASSWORD: "test123", - CONF_NAME: "My PC", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 22f0416a2c6..2a1e4012f51 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -18,7 +18,6 @@ from tests.common import MockConfigEntry from tests.typing import MqttMockPahoClient ENTRY_DEFAULT_BIRTH_MESSAGE = { - mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: { mqtt.ATTR_TOPIC: "homeassistant/status", mqtt.ATTR_PAYLOAD: "online", @@ -77,6 +76,7 @@ def mock_debouncer(hass: HomeAssistant) -> Generator[asyncio.Event]: async def setup_with_birth_msg_client_mock( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, mqtt_client_mock: MqttMockPahoClient, ) -> AsyncGenerator[MqttMockPahoClient]: """Test sending birth message.""" @@ -89,6 +89,9 @@ async def setup_with_birth_msg_client_mock( entry = MockConfigEntry( domain=mqtt.DOMAIN, data=mqtt_config_entry_data or {mqtt.CONF_BROKER: "test-broker"}, + options=mqtt_config_entry_options or {}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 164c164cdfc..ad64b39a480 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -105,6 +105,8 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: mqtt.CONF_BROKER: "test-broker", mqtt.CONF_DISCOVERY: False, }, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) @@ -132,7 +134,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: await hass.async_block_till_done(wait_background_tasks=True) -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) async def test_publish( hass: HomeAssistant, setup_with_birth_msg_client_mock: MqttMockPahoClient ) -> None: @@ -1022,8 +1024,8 @@ async def test_unsubscribe_race( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_restore_subscriptions_on_reconnect( hass: HomeAssistant, @@ -1045,15 +1047,22 @@ async def test_restore_subscriptions_on_reconnect( mqtt_client_mock.reset_mock() mqtt_client_mock.on_disconnect(None, None, 0) + # Test to subscribe orther topic while the client is not connected + await mqtt.async_subscribe(hass, "test/other", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + assert ("test/other", 0) not in help_all_subscribe_calls(mqtt_client_mock) + mock_debouncer.clear() mqtt_client_mock.on_connect(None, None, None, 0) await mock_debouncer.wait() + # Assert all subscriptions are performed at the broker assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) + assert ("test/other", 0) in help_all_subscribe_calls(mqtt_client_mock) @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_restore_all_active_subscriptions_on_reconnect( hass: HomeAssistant, @@ -1093,8 +1102,8 @@ async def test_restore_all_active_subscriptions_on_reconnect( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_subscribed_at_highest_qos( hass: HomeAssistant, @@ -1129,7 +1138,12 @@ async def test_initial_setup_logs_error( mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test for setup failure if initial client connection fails.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) mqtt_client_mock.connect.side_effect = MagicMock(return_value=1) try: @@ -1232,7 +1246,12 @@ async def test_publish_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test publish error.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) # simulate an Out of memory error @@ -1374,7 +1393,10 @@ async def test_handle_mqtt_timeout_on_callback( ) entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"} + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -1396,17 +1418,29 @@ async def test_handle_mqtt_timeout_on_callback( assert not mock_debouncer.is_set() +@pytest.mark.parametrize( + "exception", + [ + OSError("Connection error"), + paho_mqtt.WebsocketConnectionError("Connection error"), + ], +) async def test_setup_raises_config_entry_not_ready_if_no_connect_broker( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, exception: Exception ) -> None: """Test for setup failure if connection to broker is missing.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with patch( "homeassistant.components.mqtt.async_client.AsyncMQTTClient" ) as mock_client: - mock_client().connect = MagicMock(side_effect=OSError("Connection error")) + mock_client().connect = MagicMock(side_effect=exception) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert "Failed to connect to MQTT server due to exception:" in caplog.text @@ -1481,17 +1515,19 @@ async def test_tls_version( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: { - mqtt.ATTR_TOPIC: "birth", - mqtt.ATTR_PAYLOAD: "birth", - mqtt.ATTR_QOS: 0, - mqtt.ATTR_RETAIN: False, + ( + {mqtt.CONF_BROKER: "mock-broker"}, + { + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "birth", + mqtt.ATTR_PAYLOAD: "birth", + mqtt.ATTR_QOS: 0, + mqtt.ATTR_RETAIN: False, + } }, - } + ) ], ) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @@ -1501,11 +1537,18 @@ async def test_custom_birth_message( hass: HomeAssistant, mock_debouncer: asyncio.Event, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test sending birth message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1519,7 +1562,7 @@ async def test_custom_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_default_birth_message( @@ -1534,8 +1577,8 @@ async def test_default_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_BIRTH_MESSAGE: {}})], ) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) @@ -1545,10 +1588,17 @@ async def test_no_birth_message( record_calls: MessageCallbackType, mock_debouncer: asyncio.Event, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test disabling birth message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) mock_debouncer.clear() @@ -1568,20 +1618,27 @@ async def test_no_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [ENTRY_DEFAULT_BIRTH_MESSAGE], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, ENTRY_DEFAULT_BIRTH_MESSAGE)], ) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.2) async def test_delayed_birth_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test sending birth message does not happen until Home Assistant starts.""" hass.set_state(CoreState.starting) await hass.async_block_till_done() birth = asyncio.Event() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1605,7 +1662,7 @@ async def test_delayed_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_subscription_done_when_birth_message_is_sent( @@ -1623,26 +1680,37 @@ async def test_subscription_done_when_birth_message_is_sent( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_WILL_MESSAGE: { - mqtt.ATTR_TOPIC: "death", - mqtt.ATTR_PAYLOAD: "death", - mqtt.ATTR_QOS: 0, - mqtt.ATTR_RETAIN: False, + ( + { + mqtt.CONF_BROKER: "mock-broker", }, - } + { + mqtt.CONF_WILL_MESSAGE: { + mqtt.ATTR_TOPIC: "death", + mqtt.ATTR_PAYLOAD: "death", + mqtt.ATTR_QOS: 0, + mqtt.ATTR_RETAIN: False, + }, + }, + ) ], ) async def test_custom_will_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test will message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1664,16 +1732,23 @@ async def test_default_will_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_WILL_MESSAGE: {}}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_WILL_MESSAGE: {}})], ) async def test_no_will_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test will message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1683,7 +1758,7 @@ async def test_no_will_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE | {mqtt.CONF_DISCOVERY: False}], ) async def test_mqtt_subscribes_topics_on_connect( @@ -1716,7 +1791,7 @@ async def test_mqtt_subscribes_topics_on_connect( assert ("still/pending", 1) in subscribe_calls -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) async def test_mqtt_subscribes_wildcard_topics_in_correct_order( hass: HomeAssistant, mock_debouncer: asyncio.Event, @@ -1775,7 +1850,7 @@ async def test_mqtt_subscribes_wildcard_topics_in_correct_order( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE | {mqtt.CONF_DISCOVERY: False}], ) async def test_mqtt_discovery_not_subscribes_when_disabled( @@ -1808,7 +1883,7 @@ async def test_mqtt_discovery_not_subscribes_when_disabled( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_mqtt_subscribes_in_single_call( @@ -1834,7 +1909,7 @@ async def test_mqtt_subscribes_in_single_call( ] -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) @patch("homeassistant.components.mqtt.client.MAX_SUBSCRIBES_PER_CALL", 2) @patch("homeassistant.components.mqtt.client.MAX_UNSUBSCRIBES_PER_CALL", 2) async def test_mqtt_subscribes_and_unsubscribes_in_chunks( @@ -1874,10 +1949,18 @@ async def test_mqtt_subscribes_and_unsubscribes_in_chunks( assert len(mqtt_client_mock.unsubscribe.mock_calls[1][1][0]) == 2 +@pytest.mark.parametrize( + "exception", + [ + OSError, + paho_mqtt.WebsocketConnectionError, + ], +) async def test_auto_reconnect( hass: HomeAssistant, setup_with_birth_msg_client_mock: MqttMockPahoClient, caplog: pytest.LogCaptureFixture, + exception: Exception, ) -> None: """Test reconnection is automatically done.""" mqtt_client_mock = setup_with_birth_msg_client_mock @@ -1888,7 +1971,7 @@ async def test_auto_reconnect( mqtt_client_mock.on_disconnect(None, None, 0) await hass.async_block_till_done() - mqtt_client_mock.reconnect.side_effect = OSError("foo") + mqtt_client_mock.reconnect.side_effect = exception("foo") async_fire_time_changed( hass, utcnow() + timedelta(seconds=RECONNECT_INTERVAL_SECONDS) ) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 95a26daf562..a34907adbaf 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -68,7 +68,7 @@ DEFAULT_CONFIG_DEVICE_INFO_MAC = { _SENTINEL = object() -DISCOVERY_COUNT = len(MQTT) +DISCOVERY_COUNT = sum(len(discovery_topic) for discovery_topic in MQTT.values()) DEVICE_DISCOVERY_COUNT = 2 type _MqttMessageType = list[tuple[str, str]] @@ -1887,7 +1887,12 @@ async def help_test_reloadable( mqtt.DOMAIN: {domain: [old_config_1, old_config_2]}, } # Start the MQTT entry with the old config - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) mqtt_client_mock.connect.return_value = 0 with patch("homeassistant.config.load_yaml_config_file", return_value=old_config): diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index e99063b088b..072998f9b8d 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -43,6 +43,28 @@ ADD_ON_DISCOVERY_INFO = { MOCK_CLIENT_CERT = b"## mock client certificate file ##" MOCK_CLIENT_KEY = b"## mock key file ##" +MOCK_ENTRY_DATA = { + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 1234, + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} +MOCK_ENTRY_OPTIONS = { + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/online", + mqtt.ATTR_PAYLOAD: "online", + mqtt.ATTR_QOS: 1, + mqtt.ATTR_RETAIN: True, + }, + mqtt.CONF_WILL_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/offline", + mqtt.ATTR_PAYLOAD: "offline", + mqtt.ATTR_QOS: 2, + mqtt.ATTR_RETAIN: False, + }, +} + @pytest.fixture(autouse=True) def mock_finish_setup() -> Generator[MagicMock]: @@ -243,8 +265,10 @@ async def test_user_connection_works( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } + # Check we have the latest Config Entry version + assert result["result"].version == 1 + assert result["result"].minor_version == 2 # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 # Check config entry got setup @@ -283,7 +307,6 @@ async def test_user_connection_works_with_supervisor( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 @@ -324,7 +347,6 @@ async def test_user_v5_connection_works( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == { "broker": "another-broker", - "discovery": True, "port": 2345, "protocol": "5", } @@ -383,14 +405,12 @@ async def test_manual_config_set( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } # Check we tried the connection, with precedence for config entry settings mock_try_connection.assert_called_once_with( { "broker": "127.0.0.1", "port": 1883, - "discovery": True, }, ) # Check config entry got setup @@ -401,7 +421,11 @@ async def test_manual_config_set( async def test_user_single_instance(hass: HomeAssistant) -> None: """Test we only allow a single config flow.""" - MockConfigEntry(domain="mqtt").add_to_hass(hass) + MockConfigEntry( + domain="mqtt", + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( "mqtt", context={"source": config_entries.SOURCE_USER} @@ -412,7 +436,11 @@ async def test_user_single_instance(hass: HomeAssistant) -> None: async def test_hassio_already_configured(hass: HomeAssistant) -> None: """Test we only allow a single config flow.""" - MockConfigEntry(domain="mqtt").add_to_hass(hass) + MockConfigEntry( + domain="mqtt", + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( "mqtt", context={"source": config_entries.SOURCE_HASSIO} @@ -424,7 +452,10 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: async def test_hassio_ignored(hass: HomeAssistant) -> None: """Test we supervisor discovered instance can be ignored.""" MockConfigEntry( - domain=mqtt.DOMAIN, source=config_entries.SOURCE_IGNORE + domain=mqtt.DOMAIN, + source=config_entries.SOURCE_IGNORE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -934,43 +965,19 @@ async def test_addon_not_installed_failures( async def test_option_flow( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - mock_try_connection: MagicMock, ) -> None: """Test config flow options.""" with patch( "homeassistant.config.async_hass_config_yaml", AsyncMock(return_value={}) ) as yaml_mock: - mqtt_mock = await mqtt_mock_entry() - mock_try_connection.return_value = True + await mqtt_mock_entry() config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - hass.config_entries.async_update_entry( - config_entry, - data={ - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - }, - ) - - mqtt_mock.async_connect.reset_mock() result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" await hass.async_block_till_done() - assert mqtt_mock.async_connect.call_count == 0 yaml_mock.reset_mock() @@ -992,12 +999,10 @@ async def test_option_flow( }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {} - assert config_entry.data == { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", + await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.data == {mqtt.CONF_BROKER: "mock-broker"} + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: { @@ -1015,8 +1020,7 @@ async def test_option_flow( } await hass.async_block_till_done() - assert config_entry.title == "another-broker" - # assert that the entry was reloaded with the new config + # assert that the entry was reloaded with the new config assert yaml_mock.await_count @@ -1071,7 +1075,7 @@ async def test_bad_certificate( test_input.pop(mqtt.CONF_CLIENT_KEY) mqtt_mock = await mqtt_mock_entry() - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] # Add at least one advanced option to get the full form hass.config_entries.async_update_entry( config_entry, @@ -1088,11 +1092,11 @@ async def test_bad_certificate( mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "another-broker", @@ -1109,14 +1113,14 @@ async def test_bad_certificate( test_input["set_ca_cert"] = set_ca_cert test_input["tls_insecure"] = tls_insecure - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) if test_error is not None: assert result["errors"]["base"] == test_error return - assert result["errors"] == {} + assert "errors" not in result @pytest.mark.parametrize( @@ -1148,7 +1152,7 @@ async def test_keepalive_validation( mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] # Add at least one advanced option to get the full form hass.config_entries.async_update_entry( config_entry, @@ -1161,22 +1165,23 @@ async def test_keepalive_validation( mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" if error: with pytest.raises(vol.Invalid): - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) return - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) - assert not result["errors"] + assert "errors" not in result + assert result["reason"] == "reconfigure_successful" async def test_disable_birth_will( @@ -1186,7 +1191,7 @@ async def test_disable_birth_will( mock_reload_after_entry_update: MagicMock, ) -> None: """Test disabling birth and will.""" - mqtt_mock = await mqtt_mock_entry() + await mqtt_mock_entry() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] hass.config_entries.async_update_entry( @@ -1199,26 +1204,10 @@ async def test_disable_birth_will( await hass.async_block_till_done() mock_reload_after_entry_update.reset_mock() - mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" - await hass.async_block_till_done() - assert mqtt_mock.async_connect.call_count == 0 result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -1238,12 +1227,14 @@ async def test_disable_birth_will( }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {} - assert config_entry.data == { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", + assert result["data"] == { + "birth_message": {}, + "discovery": True, + "discovery_prefix": "homeassistant", + "will_message": {}, + } + assert config_entry.data == {mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234} + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: {}, @@ -1270,6 +1261,8 @@ async def test_invalid_discovery_prefix( data={ mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, + }, + options={ mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", }, @@ -1280,16 +1273,6 @@ async def test_invalid_discovery_prefix( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" await hass.async_block_till_done() @@ -1308,6 +1291,8 @@ async def test_invalid_discovery_prefix( assert config_entry.data == { mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, + } + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", } @@ -1356,6 +1341,8 @@ async def test_option_flow_default_suggested_values( CONF_PORT: 1234, CONF_USERNAME: "user", CONF_PASSWORD: "pass", + }, + options={ mqtt.CONF_DISCOVERY: True, mqtt.CONF_BIRTH_MESSAGE: { mqtt.ATTR_TOPIC: "ha_state/online", @@ -1371,37 +1358,13 @@ async def test_option_flow_default_suggested_values( }, }, ) + await hass.async_block_till_done() # Test default/suggested values from config result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - defaults = { - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - } - suggested = { - CONF_USERNAME: "user", - CONF_PASSWORD: PWD_NOT_CHANGED, - } - for key, value in defaults.items(): - assert get_default(result["data_schema"].schema, key) == value - for key, value in suggested.items(): - assert get_suggested(result["data_schema"].schema, key) == value - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "us3r", - CONF_PASSWORD: "p4ss", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" defaults = { - mqtt.CONF_DISCOVERY: True, "birth_qos": 1, "birth_retain": True, "will_qos": 2, @@ -1421,7 +1384,6 @@ async def test_option_flow_default_suggested_values( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - mqtt.CONF_DISCOVERY: False, "birth_topic": "ha_state/onl1ne", "birth_payload": "onl1ne", "birth_qos": 2, @@ -1437,28 +1399,8 @@ async def test_option_flow_default_suggested_values( # Test updated default/suggested values from config result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - defaults = { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - } - suggested = { - CONF_USERNAME: "us3r", - CONF_PASSWORD: PWD_NOT_CHANGED, - } - for key, value in defaults.items(): - assert get_default(result["data_schema"].schema, key) == value - for key, value in suggested.items(): - assert get_suggested(result["data_schema"].schema, key) == value - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" defaults = { - mqtt.CONF_DISCOVERY: False, "birth_qos": 2, "birth_retain": False, "will_qos": 1, @@ -1478,7 +1420,6 @@ async def test_option_flow_default_suggested_values( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - mqtt.CONF_DISCOVERY: True, "birth_topic": "ha_state/onl1ne", "birth_payload": "onl1ne", "birth_qos": 2, @@ -1496,7 +1437,8 @@ async def test_option_flow_default_suggested_values( @pytest.mark.parametrize( - ("advanced_options", "step_id"), [(False, "options"), (True, "broker")] + ("advanced_options", "flow_result"), + [(False, FlowResultType.ABORT), (True, FlowResultType.FORM)], ) @pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_skipping_advanced_options( @@ -1504,41 +1446,35 @@ async def test_skipping_advanced_options( mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, advanced_options: bool, - step_id: str, + flow_result: FlowResultType, ) -> None: """Test advanced options option.""" test_input = { mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345, - "advanced_options": advanced_options, } + if advanced_options: + test_input["advanced_options"] = True mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - # Initiate with a basic setup - hass.config_entries.async_update_entry( - config_entry, - data={ - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - }, - ) - + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init( - config_entry.entry_id, context={"show_advanced_options": True} + result = await config_entry.start_reconfigure_flow( + hass, show_advanced_options=advanced_options ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" - result = await hass.config_entries.options.async_configure( + assert ("advanced_options" in result["data_schema"].schema) == advanced_options + + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) - assert result["step_id"] == step_id + assert result["type"] is flow_result @pytest.mark.parametrize( @@ -1582,7 +1518,12 @@ async def test_step_reauth( """Test that the reauth step works.""" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=test_input) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=test_input, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1658,7 +1599,12 @@ async def test_step_hassio_reauth( addon_info["hostname"] = "core-mosquitto" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=entry_data) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=entry_data, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1740,7 +1686,12 @@ async def test_step_hassio_reauth_no_discovery_info( addon_info["hostname"] = "core-mosquitto" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=entry_data) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=entry_data, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1762,11 +1713,15 @@ async def test_step_hassio_reauth_no_discovery_info( mock_try_connection.assert_not_called() -async def test_options_user_connection_fails( +async def test_reconfigure_user_connection_fails( hass: HomeAssistant, mock_try_connection_time_out: MagicMock ) -> None: """Test if connection cannot be made.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1775,11 +1730,11 @@ async def test_options_user_connection_fails( CONF_PORT: 1234, }, ) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM mock_try_connection_time_out.reset_mock() - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={mqtt.CONF_BROKER: "bad-broker", CONF_PORT: 2345}, ) @@ -1800,7 +1755,11 @@ async def test_options_bad_birth_message_fails( hass: HomeAssistant, mock_try_connection: MqttMockPahoClient ) -> None: """Test bad birth message.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1813,13 +1772,6 @@ async def test_options_bad_birth_message_fails( mock_try_connection.return_value = True result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" @@ -1841,7 +1793,11 @@ async def test_options_bad_will_message_fails( hass: HomeAssistant, mock_try_connection: MagicMock ) -> None: """Test bad will message.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1854,13 +1810,6 @@ async def test_options_bad_will_message_fails( mock_try_connection.return_value = True result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" @@ -1878,15 +1827,16 @@ async def test_options_bad_will_message_fails( } -@pytest.mark.parametrize( - "hass_config", [{"mqtt": {"sensor": [{"state_topic": "some-topic"}]}}] -) @pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") async def test_try_connection_with_advanced_parameters( hass: HomeAssistant, mock_try_connection_success: MqttMockPahoClient ) -> None: """Test config flow with advanced parameters from config.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1920,7 +1870,7 @@ async def test_try_connection_with_advanced_parameters( ) # Test default/suggested values from config - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" defaults = { @@ -1944,9 +1894,8 @@ async def test_try_connection_with_advanced_parameters( assert get_suggested(result["data_schema"].schema, k) == v # test we can change username and password - # as it was configured as auto in configuration.yaml is is migrated now mock_try_connection_success.reset_mock() - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "another-broker", @@ -1961,9 +1910,8 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_WS_HEADERS: '{"h3": "v3"}', }, ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - assert result["step_id"] == "options" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" await hass.async_block_till_done() # check if the username and password was set from config flow and not from configuration.yaml @@ -1987,12 +1935,6 @@ async def test_try_connection_with_advanced_parameters( "/new/path", {"h3": "v3"}, ) - # Accept default option - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() @@ -2005,7 +1947,11 @@ async def test_setup_with_advanced_settings( """Test config flow setup with advanced parameters.""" file_id = mock_process_uploaded_file.file_id - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -2017,15 +1963,13 @@ async def test_setup_with_advanced_settings( mock_try_connection.return_value = True - result = await hass.config_entries.options.async_init( - config_entry.entry_id, context={"show_advanced_options": True} - ) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" assert result["data_schema"].schema["advanced_options"] # first iteration, basic settings - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2049,7 +1993,7 @@ async def test_setup_with_advanced_settings( assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema # second iteration, advanced settings with request for client cert - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2080,7 +2024,7 @@ async def test_setup_with_advanced_settings( assert result["data_schema"].schema[mqtt.CONF_WS_HEADERS] # third iteration, advanced settings with client cert and key set and bad json payload - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2105,7 +2049,7 @@ async def test_setup_with_advanced_settings( # fourth iteration, advanced settings with client cert and key set # and correct json payload for ws_headers - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2124,17 +2068,8 @@ async def test_setup_with_advanced_settings( }, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "options" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" # Check config entry result assert config_entry.data == { @@ -2153,8 +2088,6 @@ async def test_setup_with_advanced_settings( "header_2": "content_header_2", }, mqtt.CONF_CERTIFICATE: "auto", - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", } @@ -2162,8 +2095,12 @@ async def test_setup_with_advanced_settings( async def test_change_websockets_transport_to_tcp( hass: HomeAssistant, mock_try_connection: MagicMock ) -> None: - """Test option flow setup with websockets transport settings.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + """Test reconfiguration flow changing websockets transport settings.""" + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -2178,7 +2115,7 @@ async def test_change_websockets_transport_to_tcp( mock_try_connection.return_value = True - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" assert result["data_schema"].schema["transport"] @@ -2186,7 +2123,7 @@ async def test_change_websockets_transport_to_tcp( assert result["data_schema"].schema["ws_headers"] # Change transport to tcp - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2196,23 +2133,153 @@ async def test_change_websockets_transport_to_tcp( mqtt.CONF_WS_PATH: "/some_path", }, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "options" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" # Check config entry result assert config_entry.data == { mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, mqtt.CONF_TRANSPORT: "tcp", - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", } + + +@pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, + mqtt.CONF_WS_PATH: "/some_path", + } + ], +) +async def test_reconfigure_flow_form( + hass: HomeAssistant, + mock_try_connection: MagicMock, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reconfigure flow.""" + await mqtt_mock_entry() + entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + result = await entry.start_reconfigure_flow(hass, show_advanced_options=True) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "10.10.10,10", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}', + mqtt.CONF_WS_PATH: "/some_new_path", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + mqtt.CONF_BROKER: "10.10.10,10", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, + mqtt.CONF_WS_PATH: "/some_new_path", + } + await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.mark.parametrize( + ( + "version", + "minor_version", + "data", + "options", + "expected_version", + "expected_minor_version", + ), + [ + (1, 1, MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS, {}, 1, 2), + (1, 2, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 1, 2), + (1, 3, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 1, 3), + ], +) +@pytest.mark.usefixtures("mock_reload_after_entry_update") +async def test_migrate_config_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + version: int, + minor_version: int, + data: dict[str, Any], + options: dict[str, Any], + expected_version: int, + expected_minor_version: int, +) -> None: + """Test migrating a config entry.""" + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Mock to a migratable or compatbible config entry version + hass.config_entries.async_update_entry( + config_entry, + data=data, + options=options, + version=version, + minor_version=minor_version, + ) + await hass.async_block_till_done() + # Start MQTT + await mqtt_mock_entry() + await hass.async_block_till_done() + assert ( + config_entry.data | config_entry.options == MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS + ) + assert config_entry.version == expected_version + assert config_entry.minor_version == expected_minor_version + + +@pytest.mark.parametrize( + ( + "version", + "minor_version", + "data", + "options", + "expected_version", + "expected_minor_version", + ), + [ + (2, 1, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 2, 1), + ], +) +@pytest.mark.usefixtures("mock_reload_after_entry_update") +async def test_migrate_of_incompatible_config_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + version: int, + minor_version: int, + data: dict[str, Any], + options: dict[str, Any], + expected_version: int, + expected_minor_version: int, +) -> None: + """Test migrating a config entry.""" + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Mock an incompatible config entry version + hass.config_entries.async_update_entry( + config_entry, + data=data, + options=options, + version=version, + minor_version=minor_version, + ) + await hass.async_block_till_done() + assert config_entry.version == expected_version + assert config_entry.minor_version == expected_minor_version + + # Try to start MQTT with incompatible config entry + with pytest.raises(AssertionError): + await mqtt_mock_entry() + + assert config_entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 009a0315029..5cdfb14a5cf 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -2,6 +2,7 @@ import json from typing import Any +from unittest.mock import patch import pytest from pytest_unordered import unordered @@ -1692,14 +1693,19 @@ async def test_trigger_debug_info( assert debug_info_data["triggers"][0]["discovery_data"]["payload"] == config2 -@pytest.mark.usefixtures("mqtt_mock") +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.UNSUBSCRIBE_COOLDOWN", 0.0) async def test_unload_entry( hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, service_calls: list[ServiceCall], device_registry: dr.DeviceRegistry, ) -> None: """Test unloading the MQTT entry.""" + await mqtt_mock_entry() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -1733,6 +1739,7 @@ async def test_unload_entry( ] }, ) + await hass.async_block_till_done() # Fake short press 1 async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index b8499ba5812..bbd60329d0a 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -17,10 +17,12 @@ from tests.components.diagnostics import ( ) from tests.typing import ClientSessionGenerator, MqttMockHAClientGenerator -default_config = { - "birth_message": {}, +default_entry_data = { "broker": "mock-broker", } +default_entry_options = { + "birth_message": {}, +} async def test_entry_diagnostics( @@ -38,7 +40,7 @@ async def test_entry_diagnostics( assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "connected": True, "devices": [], - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": {"entities": [], "triggers": []}, } @@ -123,7 +125,7 @@ async def test_entry_diagnostics( assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "connected": True, "devices": [expected_device], - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": expected_debug_info, } @@ -132,20 +134,24 @@ async def test_entry_diagnostics( ) == { "connected": True, "device": expected_device, - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": expected_debug_info, } @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: {}, - CONF_PASSWORD: "hunter2", - CONF_USERNAME: "my_user", - } + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PASSWORD: "hunter2", + CONF_USERNAME: "my_user", + }, + { + mqtt.CONF_BIRTH_MESSAGE: {}, + }, + ) ], ) async def test_redact_diagnostics( @@ -157,9 +163,12 @@ async def test_redact_diagnostics( ) -> None: """Test redacting diagnostics.""" mqtt_mock = await mqtt_mock_entry() - expected_config = dict(default_config) - expected_config["password"] = "**REDACTED**" - expected_config["username"] = "**REDACTED**" + expected_config = { + "data": dict(default_entry_data), + "options": dict(default_entry_options), + } + expected_config["data"]["password"] = "**REDACTED**" + expected_config["data"]["username"] = "**REDACTED**" config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] mqtt_mock.connected = True diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index e49e7a27c8d..982167feee1 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -195,8 +195,8 @@ async def mock_mqtt_flow( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_subscribing_config_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -356,7 +356,7 @@ async def test_invalid_device_discovery_config( async_fire_mqtt_message( hass, "homeassistant/device/bla/config", - '{ "o": {"name": "foobar"}, "dev": {"identifiers": ["ABDE03"]}, ' '"cmps": ""}', + '{ "o": {"name": "foobar"}, "dev": {"identifiers": ["ABDE03"]}, "cmps": ""}', ) await hass.async_block_till_done() assert ( @@ -1946,7 +1946,12 @@ async def test_cleanup_device_multiple_config_entries( mqtt_mock = await mqtt_mock_entry() ws_client = await hass_ws_client(hass) - config_entry = MockConfigEntry(domain="test", data={}) + config_entry = MockConfigEntry( + domain="test", + data={}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -2042,7 +2047,12 @@ async def test_cleanup_device_multiple_config_entries_mqtt( ) -> None: """Test discovered device is cleaned up when removed through MQTT.""" mqtt_mock = await mqtt_mock_entry() - config_entry = MockConfigEntry(domain="test", data={}) + config_entry = MockConfigEntry( + domain="test", + data={}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -2437,12 +2447,14 @@ async def test_no_implicit_state_topic_switch( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_DISCOVERY_PREFIX: "my_home/homeassistant/register", - } + ( + {mqtt.CONF_BROKER: "mock-broker"}, + { + mqtt.CONF_DISCOVERY_PREFIX: "my_home/homeassistant/register", + }, + ) ], ) async def test_complex_discovery_topic_prefix( @@ -2497,7 +2509,13 @@ async def test_mqtt_integration_discovery_flow_fitering_on_redundant_payload( """Handle birth message.""" birth.set() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=ENTRY_DEFAULT_BIRTH_MESSAGE) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + options=ENTRY_DEFAULT_BIRTH_MESSAGE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with ( patch( @@ -2562,7 +2580,13 @@ async def test_mqtt_discovery_flow_starts_once( """Handle birth message.""" birth.set() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=ENTRY_DEFAULT_BIRTH_MESSAGE) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + options=ENTRY_DEFAULT_BIRTH_MESSAGE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with ( @@ -2987,3 +3011,139 @@ async def test_shared_state_topic( state = hass.states.get(entity_id) assert state is not None assert state.state == "New state3" + + +@pytest.mark.parametrize("single_configs", [copy.deepcopy(TEST_SINGLE_CONFIGS)]) +async def test_discovery_with_late_via_device_discovery( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, + tag_mock: AsyncMock, + single_configs: list[tuple[str, dict[str, Any]]], +) -> None: + """Test a via device is available and the discovery of the via device is late.""" + await mqtt_mock_entry() + + await hass.async_block_till_done() + await hass.async_block_till_done() + + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is None + # Discovery single config schema + for discovery_topic, config in single_configs: + config["device"]["via_device"] = "id_via_very_unique" + payload = json.dumps(config) + async_fire_mqtt_message( + hass, + discovery_topic, + payload, + ) + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is not None + assert via_device_entry.name is None + + await hass.async_block_till_done() + + # Now discover the via device (a switch) + via_device_config = { + "name": None, + "command_topic": "test-switch-topic", + "unique_id": "very_unique_switch", + "device": {"identifiers": ["id_via_very_unique"], "name": "My Switch"}, + } + payload = json.dumps(via_device_config) + via_device_discovery_topic = "homeassistant/switch/very_unique/config" + async_fire_mqtt_message( + hass, + via_device_discovery_topic, + payload, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is not None + assert via_device_entry.name == "My Switch" + + await help_check_discovered_items(hass, device_registry, tag_mock) + + +@pytest.mark.parametrize("single_configs", [copy.deepcopy(TEST_SINGLE_CONFIGS)]) +async def test_discovery_with_late_via_device_update( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, + tag_mock: AsyncMock, + single_configs: list[tuple[str, dict[str, Any]]], +) -> None: + """Test a via device is available and the discovery of the via device is is set via an update.""" + await mqtt_mock_entry() + + await hass.async_block_till_done() + await hass.async_block_till_done() + + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is None + # Discovery single config schema without via device + for discovery_topic, config in single_configs: + payload = json.dumps(config) + async_fire_mqtt_message( + hass, + discovery_topic, + payload, + ) + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert via_device_entry is None + + # Resend the discovery update to set the via device + for discovery_topic, config in single_configs: + config["device"]["via_device"] = "id_via_very_unique" + payload = json.dumps(config) + async_fire_mqtt_message( + hass, + discovery_topic, + payload, + ) + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is not None + assert via_device_entry.name is None + + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Now discover the via device (a switch) + via_device_config = { + "name": None, + "command_topic": "test-switch-topic", + "unique_id": "very_unique_switch", + "device": {"identifiers": ["id_via_very_unique"], "name": "My Switch"}, + } + payload = json.dumps(via_device_config) + via_device_discovery_topic = "homeassistant/switch/very_unique/config" + async_fire_mqtt_message( + hass, + via_device_discovery_topic, + payload, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + via_device_entry = device_registry.async_get_device( + {("mqtt", "id_via_very_unique")} + ) + assert via_device_entry is not None + assert via_device_entry.name == "My Switch" + + await help_check_discovered_items(hass, device_registry, tag_mock) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 2ab664f5041..4e0873c6e1b 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -44,6 +44,7 @@ from homeassistant.util.dt import utcnow from tests.common import ( MockConfigEntry, MockEntity, + MockEntityPlatform, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -194,6 +195,7 @@ async def test_value_template_value(hass: HomeAssistant) -> None: # test value template with entity entity = Entity() entity.hass = hass + entity.platform = MockEntityPlatform(hass) entity.entity_id = "select.test" tpl = template.Template("{{ value_json.id }}", hass=hass) val_tpl = mqtt.MqttValueTemplate(tpl, entity=entity) @@ -218,6 +220,7 @@ async def test_value_template_fails(hass: HomeAssistant) -> None: """Test the rendering of MQTT value template fails.""" entity = MockEntity(entity_id="sensor.test") entity.hass = hass + entity.platform = MockEntityPlatform(hass) tpl = template.Template("{{ value_json.some_var * 2 }}", hass=hass) val_tpl = mqtt.MqttValueTemplate(tpl, entity=entity) with pytest.raises(MqttValueTemplateException) as exc: @@ -275,80 +278,6 @@ async def test_service_call_mqtt_entry_does_not_publish( ) -# The use of a topic_template in an mqtt publish action call -# has been deprecated with HA Core 2024.8.0 and will be removed with HA Core 2025.2.0 -async def test_mqtt_publish_action_call_with_topic_and_topic_template_does_not_publish( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the mqtt publish action call with topic/topic template. - - If both 'topic' and 'topic_template' are provided then fail. - """ - mqtt_mock = await mqtt_mock_entry() - topic = "test/topic" - topic_template = "test/{{ 'topic' }}" - with pytest.raises(vol.Invalid): - await hass.services.async_call( - mqtt.DOMAIN, - mqtt.SERVICE_PUBLISH, - { - mqtt.ATTR_TOPIC: topic, - mqtt.ATTR_TOPIC_TEMPLATE: topic_template, - mqtt.ATTR_PAYLOAD: "payload", - }, - blocking=True, - ) - assert not mqtt_mock.async_publish.called - - -# The use of a topic_template in an mqtt publish action call -# has been deprecated with HA Core 2024.8.0 and will be removed with HA Core 2025.2.0 -async def test_mqtt_action_call_with_invalid_topic_template_does_not_publish( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the mqtt publish action call with a problematic topic template.""" - mqtt_mock = await mqtt_mock_entry() - with pytest.raises(MqttCommandTemplateException) as exc: - await hass.services.async_call( - mqtt.DOMAIN, - mqtt.SERVICE_PUBLISH, - { - mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ 1 | no_such_filter }}", - mqtt.ATTR_PAYLOAD: "payload", - }, - blocking=True, - ) - assert str(exc.value) == ( - "TemplateError: TemplateAssertionError: No filter named 'no_such_filter'. " - "rendering template, template: " - "'test/{{ 1 | no_such_filter }}' and payload: None" - ) - assert not mqtt_mock.async_publish.called - - -# The use of a topic_template in an mqtt publish action call -# has been deprecated with HA Core 2024.8.0 and will be removed with HA Core 2025.2.0 -async def test_mqtt_publish_action_call_with_template_topic_renders_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the mqtt publish action call with rendered topic template. - - If 'topic_template' is provided and 'topic' is not, then render it. - """ - mqtt_mock = await mqtt_mock_entry() - await hass.services.async_call( - mqtt.DOMAIN, - mqtt.SERVICE_PUBLISH, - { - mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ 1+1 }}", - mqtt.ATTR_PAYLOAD: "payload", - }, - blocking=True, - ) - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0][0] == "test/2" - - async def test_service_call_with_template_topic_renders_invalid_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: @@ -357,84 +286,23 @@ async def test_service_call_with_template_topic_renders_invalid_topic( If a wildcard topic is rendered, then fail. """ mqtt_mock = await mqtt_mock_entry() - with pytest.raises(ServiceValidationError) as exc: + with pytest.raises(vol.Invalid) as exc: await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, { - mqtt.ATTR_TOPIC_TEMPLATE: "test/{{ '+' if True else 'topic' }}/topic", + mqtt.ATTR_TOPIC: "test/{{ '+' if True else 'topic' }}/topic", mqtt.ATTR_PAYLOAD: "payload", }, blocking=True, ) - assert str(exc.value) == ( - "Unable to publish: topic template `test/{{ '+' if True else 'topic' }}/topic` " - "produced an invalid topic `test/+/topic` after rendering " - "(Wildcards cannot be used in topic names)" + assert ( + str(exc.value) == "Wildcards cannot be used in topic names " + "for dictionary value @ data['topic']" ) assert not mqtt_mock.async_publish.called -# The use of a payload_template in an mqtt publish action call -# has been deprecated with HA Core 2024.8.0 and will be removed with HA Core 2025.2.0 -async def test_action_call_with_invalid_rendered_payload_template_doesnt_render_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the action call with unrendered payload template. - - If both 'payload' and 'payload_template' are provided then fail. - """ - mqtt_mock = await mqtt_mock_entry() - payload = "not a template" - payload_template = "a template" - with pytest.raises(vol.Invalid): - await hass.services.async_call( - mqtt.DOMAIN, - mqtt.SERVICE_PUBLISH, - { - mqtt.ATTR_TOPIC: "test/topic", - mqtt.ATTR_PAYLOAD: payload, - mqtt.ATTR_PAYLOAD_TEMPLATE: payload_template, - }, - blocking=True, - ) - assert not mqtt_mock.async_publish.called - - -# The use of a payload_template in an mqtt publish action call -# has been deprecated with HA Core 2024.8.0 and will be removed with HA Core 2025.2.0 -async def test_mqtt_publish_action_call_with_template_payload_renders_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the mqtt publish action call with rendered template. - - If 'payload_template' is provided and 'payload' is not, then render it. - """ - mqtt_mock = await mqtt_mock_entry() - await hass.services.async_call( - mqtt.DOMAIN, - mqtt.SERVICE_PUBLISH, - {mqtt.ATTR_TOPIC: "test/topic", mqtt.ATTR_PAYLOAD_TEMPLATE: "{{ 4+4 }}"}, - blocking=True, - ) - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0][1] == "8" - mqtt_mock.reset_mock() - - await hass.services.async_call( - mqtt.DOMAIN, - mqtt.SERVICE_PUBLISH, - { - mqtt.ATTR_TOPIC: "test/topic", - mqtt.ATTR_PAYLOAD_TEMPLATE: "{{ (4+4) | pack('B') }}", - }, - blocking=True, - ) - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0][1] == b"\x08" - mqtt_mock.reset_mock() - - @pytest.mark.parametrize( ("attr_payload", "payload", "evaluate_payload", "literal_eval_calls"), [ @@ -503,56 +371,6 @@ async def test_mqtt_publish_action_call_with_raw_data( assert len(literal_eval_mock.mock_calls) == literal_eval_calls -# The use of a payload_template in an mqtt publish action call -# has been deprecated with HA Core 2024.8.0 and will be removed with HA Core 2025.2.0 -async def test_publish_action_call_with_bad_payload_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the mqtt publish action call with a bad template does not publish.""" - mqtt_mock = await mqtt_mock_entry() - with pytest.raises(MqttCommandTemplateException) as exc: - await hass.services.async_call( - mqtt.DOMAIN, - mqtt.SERVICE_PUBLISH, - { - mqtt.ATTR_TOPIC: "test/topic", - mqtt.ATTR_PAYLOAD_TEMPLATE: "{{ 1 | bad }}", - }, - blocking=True, - ) - assert not mqtt_mock.async_publish.called - assert str(exc.value) == ( - "TemplateError: TemplateAssertionError: No filter named 'bad'. " - "rendering template, template: '{{ 1 | bad }}' and payload: None" - ) - - -# The use of a payload_template in an mqtt publish action call -# has been deprecated with HA Core 2024.8.0 and will be removed with HA Core 2025.2.0 -async def test_action_call_with_payload_doesnt_render_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the mqtt publish action call with an unrendered template. - - If both 'payload' and 'payload_template' are provided then fail. - """ - mqtt_mock = await mqtt_mock_entry() - payload = "not a template" - payload_template = "a template" - with pytest.raises(vol.Invalid): - await hass.services.async_call( - mqtt.DOMAIN, - mqtt.SERVICE_PUBLISH, - { - mqtt.ATTR_TOPIC: "test/topic", - mqtt.ATTR_PAYLOAD: payload, - mqtt.ATTR_PAYLOAD_TEMPLATE: payload_template, - }, - blocking=True, - ) - assert not mqtt_mock.async_publish.called - - async def test_service_call_with_ascii_qos_retain_flags( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: @@ -877,7 +695,12 @@ async def test_reload_entry_with_restored_subscriptions( ) -> None: """Test reloading the config entry with with subscriptions restored.""" # Setup the MQTT entry - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) with patch("homeassistant.config.load_yaml_config_file", return_value={}): @@ -982,7 +805,10 @@ async def test_default_entry_setting_are_applied( # Config entry data is incomplete but valid according the schema entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={"broker": "test-broker", "port": 1234} + domain=mqtt.DOMAIN, + data={"broker": "test-broker", "port": 1234}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) @@ -1721,59 +1547,6 @@ async def test_debug_info_qos_retain( } in messages -# The use of a payload_template in an mqtt publish action call -# has been deprecated with HA Core 2024.8.0 and will be removed with HA Core 2025.2.0 -async def test_publish_json_from_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the publishing of call to mqtt publish action.""" - mqtt_mock = await mqtt_mock_entry() - - test_str = "{'valid': 'python', 'invalid': 'json'}" - test_str_tpl = "{'valid': '{{ \"python\" }}', 'invalid': 'json'}" - - await async_setup_component( - hass, - "script", - { - "script": { - "test_script_payload": { - "sequence": { - "service": "mqtt.publish", - "data": {"topic": "test-topic", "payload": test_str_tpl}, - } - }, - "test_script_payload_template": { - "sequence": { - "service": "mqtt.publish", - "data": { - "topic": "test-topic", - "payload_template": test_str_tpl, - }, - } - }, - } - }, - ) - - await hass.services.async_call("script", "test_script_payload", blocking=True) - await hass.async_block_till_done() - - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0][1] == test_str - - mqtt_mock.async_publish.reset_mock() - assert not mqtt_mock.async_publish.called - - await hass.services.async_call( - "script", "test_script_payload_template", blocking=True - ) - await hass.async_block_till_done() - - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0][1] == test_str - - async def test_subscribe_connection_status( hass: HomeAssistant, mock_debouncer: asyncio.Event, @@ -1849,6 +1622,8 @@ async def test_unload_config_entry( entry = MockConfigEntry( domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index b11484d55fb..f8c66a3de1d 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -72,7 +72,7 @@ mqtt: payload_on: "on" payload_off: "off" -config with brightness and color temp +config with brightness and color temp (mired) mqtt: light: @@ -88,6 +88,23 @@ mqtt: payload_on: "on" payload_off: "off" +config with brightness and color temp (Kelvin) + +mqtt: + light: + - name: "Office Light Color Temp" + state_topic: "office/rgb1/light/status" + command_topic: "office/rgb1/light/switch" + brightness_state_topic: "office/rgb1/brightness/status" + brightness_command_topic: "office/rgb1/brightness/set" + brightness_scale: 99 + color_temp_kelvin: true + color_temp_state_topic: "office/rgb1/color_temp/status" + color_temp_command_topic: "office/rgb1/color_temp/set" + qos: 0 + payload_on: "on" + payload_off: "off" + config with brightness and effect mqtt: @@ -270,7 +287,7 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( assert state.state == STATE_UNKNOWN assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None assert state.attributes.get("rgbw_color") is None @@ -285,7 +302,7 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( assert state.state == STATE_ON assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None assert state.attributes.get("rgbw_color") is None @@ -305,6 +322,101 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("hass_config", "min_kelvin", "max_kelvin"), + [ + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + light.DEFAULT_MAX_KELVIN, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "min_mireds": 180, + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + 5555, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "max_mireds": 400, + }, + ), + ), + 2500, + light.DEFAULT_MAX_KELVIN, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "max_kelvin": 5555, + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + 5555, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "min_kelvin": 2500, + }, + ), + ), + 2500, + light.DEFAULT_MAX_KELVIN, + ), + ], +) +async def test_no_min_max_kelvin( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + min_kelvin: int, + max_kelvin: int, +) -> None: + """Test if there is no color and brightness if no topic.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test-topic", "ON") + state = hass.states.get("light.test") + assert state is not None and state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_MIN_COLOR_TEMP_KELVIN) == min_kelvin + assert state.attributes.get(light.ATTR_MAX_COLOR_TEMP_KELVIN) == max_kelvin + + @pytest.mark.parametrize( "hass_config", [ @@ -350,7 +462,7 @@ async def test_controlling_state_via_topic( assert state.state == STATE_UNKNOWN assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -366,7 +478,7 @@ async def test_controlling_state_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -431,6 +543,76 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + ("hass_config", "payload", "kelvin"), + [ + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_color_temp/status", + "command_topic": "test_light_color_temp/set", + "brightness_state_topic": "test_light_color_temp/brightness/status", + "brightness_command_topic": "test_light_color_temp/brightness/set", + "color_temp_state_topic": "test_light_color_temp/color_temp/status", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_kelvin": False, + } + } + }, + "300", + 3333, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_color_temp/status", + "command_topic": "test_light_color_temp/set", + "brightness_state_topic": "test_light_color_temp/brightness/status", + "brightness_command_topic": "test_light_color_temp/brightness/set", + "color_temp_state_topic": "test_light_color_temp/color_temp/status", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_kelvin": True, + } + } + }, + "3333", + 3333, + ), + ], + ids=["mireds", "kelvin"], +) +async def test_controlling_color_mode_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + payload: str, + kelvin: int, +) -> None: + """Test the controlling of the color mode state via topic.""" + color_modes = ["color_temp"] + + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get(light.ATTR_COLOR_MODE) is None + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "test_light_color_temp/status", "ON") + async_fire_mqtt_message(hass, "test_light_color_temp/brightness/status", "70") + async_fire_mqtt_message(hass, "test_light_color_temp/color_temp/status", payload) + light_state = hass.states.get("light.test") + assert light_state.attributes.get("brightness") == 70 + assert light_state.attributes["color_temp_kelvin"] == kelvin + assert light_state.attributes.get(light.ATTR_COLOR_MODE) == "color_temp" + assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + + @pytest.mark.parametrize( "hass_config", [ @@ -649,7 +831,7 @@ async def test_invalid_state_via_topic( assert state.attributes.get("rgbw_color") is None assert state.attributes.get("rgbww_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("xy_color") is None @@ -665,7 +847,7 @@ async def test_invalid_state_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") == (255, 255, 255) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") == "none" assert state.attributes.get("hs_color") == (0, 0) assert state.attributes.get("xy_color") == (0.323, 0.329) @@ -723,14 +905,14 @@ async def test_invalid_state_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") == (255, 255, 251) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") == 153 + assert state.attributes.get("color_temp_kelvin") == 6535 assert state.attributes.get("effect") == "none" assert state.attributes.get("hs_color") == (54.768, 1.6) assert state.attributes.get("xy_color") == (0.325, 0.333) async_fire_mqtt_message(hass, "test_light_rgb/color_temp/status", "") light_state = hass.states.get("light.test") - assert light_state.attributes["color_temp"] == 153 + assert light_state.attributes["color_temp_kelvin"] == 6535 @pytest.mark.parametrize( @@ -939,7 +1121,7 @@ async def test_controlling_state_via_topic_with_templates( hass, "test_light_rgb/color_temp/status", '{"hello": "300"}' ) state = hass.states.get("light.test") - assert state.attributes.get("color_temp") == 300 + assert state.attributes.get("color_temp_kelvin") == 3333 assert state.attributes.get(light.ATTR_COLOR_MODE) == "color_temp" assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes @@ -1008,7 +1190,7 @@ async def test_sending_mqtt_commands_and_optimistic( "brightness": 95, "hs_color": [100, 100], "effect": "random", - "color_temp": 100, + "color_temp_kelvin": 100000, "color_mode": "hs", }, ) @@ -1021,7 +1203,7 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get("brightness") == 95 assert state.attributes.get("hs_color") == (100, 100) assert state.attributes.get("effect") == "random" - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get(light.ATTR_COLOR_MODE) == "hs" assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes assert state.attributes.get(ATTR_ASSUMED_STATE) @@ -1148,7 +1330,7 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(light.ATTR_COLOR_MODE) == "xy" assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - await common.async_turn_on(hass, "light.test", color_temp=125) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=8000) mqtt_mock.async_publish.assert_has_calls( [ call("test_light_rgb/color_temp/set", "125", 2, False), @@ -1160,7 +1342,7 @@ async def test_sending_mqtt_commands_and_optimistic( state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 60 - assert state.attributes.get("color_temp") == 125 + assert state.attributes.get("color_temp_kelvin") == 8000 assert state.attributes.get(light.ATTR_COLOR_MODE) == "color_temp" assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes @@ -1295,25 +1477,47 @@ async def test_sending_mqtt_rgbww_command_with_template( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_color_temp/set", - "color_temp_command_topic": "test_light_color_temp/color_temp/set", - "color_temp_command_template": "{{ (1000 / value) | round(0) }}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_color_temp/set", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_command_template": "{{ (1000 / value) | round(0) }}", + "color_temp_kelvin": False, + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } } - } - } + }, + "10", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_color_temp/set", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_command_template": "{{ (0.5 * value) | round(0) }}", + "color_temp_kelvin": True, + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + }, + "5000", + ), ], + ids=["mireds", "kelvin"], ) async def test_sending_mqtt_color_temp_command_with_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, payload: str ) -> None: """Test the sending of Color Temp command with template.""" mqtt_mock = await mqtt_mock_entry() @@ -1321,19 +1525,19 @@ async def test_sending_mqtt_color_temp_command_with_template( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - await common.async_turn_on(hass, "light.test", color_temp=100) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=10000) mqtt_mock.async_publish.assert_has_calls( [ call("test_light_color_temp/set", "on", 0, False), - call("test_light_color_temp/color_temp/set", "10", 0, False), + call("test_light_color_temp/color_temp/set", payload, 0, False), ], any_order=True, ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes["color_temp"] == 100 + assert state.attributes["color_temp_kelvin"] == 10000 @pytest.mark.parametrize( @@ -2103,7 +2307,7 @@ async def test_explicit_color_mode( assert state.state == STATE_UNKNOWN assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -2119,7 +2323,7 @@ async def test_explicit_color_mode( assert state.state == STATE_ON assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -2248,7 +2452,7 @@ async def test_explicit_color_mode_templated( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("hs_color") is None assert state.attributes.get(light.ATTR_COLOR_MODE) is None assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes @@ -2258,7 +2462,7 @@ async def test_explicit_color_mode_templated( state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("hs_color") is None assert state.attributes.get(light.ATTR_COLOR_MODE) == "unknown" assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index f0da483e706..512e4091438 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -14,7 +14,7 @@ mqtt: rgb: true xy: true -Configuration with RGB, brightness, color temp and effect: +Configuration with RGB, brightness, color temp (mireds) and effect: mqtt: light: @@ -24,10 +24,11 @@ mqtt: command_topic: "home/rgb1/set" brightness: true color_temp: true + color_temp_kelvin: false effect: true rgb: true -Configuration with RGB, brightness and color temp: +Configuration with RGB, brightness and color temp (Kelvin): mqtt: light: @@ -38,6 +39,7 @@ mqtt: brightness: true rgb: true color_temp: true + color_temp_kelvin: true Configuration with RGB, brightness: @@ -399,43 +401,77 @@ async def test_fail_setup_if_color_modes_invalid( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "kelvin", "color_temp_payload_value"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light/set", - "state_topic": "test_light", - "color_mode": True, - "supported_color_modes": "color_temp", + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light/set", + "state_topic": "test_light", + "color_mode": True, + "color_temp_kelvin": False, + "supported_color_modes": "color_temp", + } } - } - } + }, + 5208, + 192, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light/set", + "state_topic": "test_light", + "color_mode": True, + "color_temp_kelvin": True, + "supported_color_modes": "color_temp", + } + } + }, + 5208, + 5208, + ), ], + ids=["mireds", "kelvin"], ) async def test_single_color_mode( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + kelvin: int, + color_temp_payload_value: int, ) -> None: """Test setup with single color_mode.""" await mqtt_mock_entry() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - await common.async_turn_on(hass, "light.test", brightness=50, color_temp=192) + await common.async_turn_on( + hass, "light.test", brightness=50, color_temp_kelvin=kelvin + ) + payload = { + "state": "ON", + "brightness": 50, + "color_mode": "color_temp", + "color_temp": color_temp_payload_value, + } async_fire_mqtt_message( hass, "test_light", - '{"state": "ON", "brightness": 50, "color_mode": "color_temp", "color_temp": 192}', + json_dumps(payload), ) color_modes = [light.ColorMode.COLOR_TEMP] state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - assert state.attributes.get(light.ATTR_COLOR_TEMP) == 192 + assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 5208 assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] @@ -454,15 +490,17 @@ async def test_turn_on_with_unknown_color_mode_optimistic( state = hass.states.get("light.test") assert state.attributes.get("color_mode") == light.ColorMode.UNKNOWN assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.state == STATE_ON # Turn on the light with brightness or color_temp attributes - await common.async_turn_on(hass, "light.test", brightness=50, color_temp=192) + await common.async_turn_on( + hass, "light.test", brightness=50, color_temp_kelvin=5208 + ) state = hass.states.get("light.test") assert state.attributes.get("color_mode") == light.ColorMode.COLOR_TEMP assert state.attributes.get("brightness") == 50 - assert state.attributes.get("color_temp") == 192 + assert state.attributes.get("color_temp_kelvin") == 5208 assert state.state == STATE_ON @@ -494,7 +532,7 @@ async def test_controlling_state_with_unknown_color_mode( ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get(light.ATTR_COLOR_TEMP) is None + assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) is None assert state.attributes.get(light.ATTR_BRIGHTNESS) is None assert state.attributes.get(light.ATTR_COLOR_MODE) == light.ColorMode.UNKNOWN @@ -507,7 +545,7 @@ async def test_controlling_state_with_unknown_color_mode( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get(light.ATTR_COLOR_TEMP) == 192 + assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 5208 assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 assert state.attributes.get(light.ATTR_COLOR_MODE) == light.ColorMode.COLOR_TEMP @@ -567,7 +605,7 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("xy_color") is None assert state.attributes.get("hs_color") is None @@ -578,7 +616,7 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.state == STATE_ON assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("xy_color") is None assert state.attributes.get("hs_color") is None @@ -632,7 +670,7 @@ async def test_controlling_state_via_topic( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("xy_color") is None assert state.attributes.get("hs_color") is None @@ -653,7 +691,7 @@ async def test_controlling_state_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") == (255, 255, 255) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") is None # rgb color has priority + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority assert state.attributes.get("effect") == "colorloop" assert state.attributes.get("xy_color") == (0.323, 0.329) assert state.attributes.get("hs_color") == (0.0, 0.0) @@ -677,7 +715,7 @@ async def test_controlling_state_via_topic( 249, ) # temp converted to color assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") == 155 + assert state.attributes.get("color_temp_kelvin") == 6451 assert state.attributes.get("effect") == "colorloop" assert state.attributes.get("xy_color") == (0.328, 0.333) # temp converted to color assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color @@ -723,12 +761,12 @@ async def test_controlling_state_via_topic( async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color_temp":155}') light_state = hass.states.get("light.test") - assert light_state.attributes.get("color_temp") == 155 + assert light_state.attributes.get("color_temp_kelvin") == 6451 # 155 mired async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color_temp":null}') light_state = hass.states.get("light.test") - assert light_state.attributes.get("color_temp") is None + assert light_state.attributes.get("color_temp_kelvin") is None async_fire_mqtt_message( hass, "test_light_rgb", '{"state":"ON", "effect":"colorloop"}' @@ -759,16 +797,121 @@ async def test_controlling_state_via_topic( assert light_state.state == STATE_OFF assert light_state.attributes.get("brightness") is None + # Simulate the lights color temp has been changed + # while it was switched off + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"OFF","color_temp":201}', + ) + light_state = hass.states.get("light.test") + assert light_state.state == STATE_OFF + # Color temp attribute is not exposed while the lamp is off + assert light_state.attributes.get("color_temp_kelvin") is None + # test previous zero brightness received was ignored and brightness is restored + # see if the latest color_temp value received is restored async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON"}') light_state = hass.states.get("light.test") assert light_state.attributes.get("brightness") == 128 + assert light_state.attributes.get("color_temp_kelvin") == 4975 # 201 mired + # A `0` brightness value is ignored when a light is turned on async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON","brightness":0}') light_state = hass.states.get("light.test") assert light_state.attributes.get("brightness") == 128 +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "brightness": True, + "color_temp": True, + "color_temp_kelvin": True, + "effect": True, + "rgb": True, + "xy": True, + "hs": True, + "qos": "0", + } + } + } + ], +) +async def test_controlling_state_color_temp_kelvin( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the controlling of the state via topic in Kelvin mode.""" + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + expected_features = ( + light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Turn on the light + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"ON",' + '"color":{"r":255,"g":255,"b":255},' + '"brightness":255,' + '"color_temp":155,' + '"effect":"colorloop"}', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "colorloop" + assert state.attributes.get("xy_color") == (0.323, 0.329) + assert state.attributes.get("hs_color") == (0.0, 0.0) + + # Turn on the light + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"ON",' + '"brightness":255,' + '"color":null,' + '"color_temp":6451,' # Kelvin + '"effect":"colorloop"}', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == ( + 255, + 253, + 249, + ) # temp converted to color + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") == 6451 + assert state.attributes.get("effect") == "colorloop" + assert state.attributes.get("xy_color") == (0.328, 0.333) # temp converted to color + assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color + + @pytest.mark.parametrize( "hass_config", [ @@ -794,7 +937,7 @@ async def test_controlling_state_via_topic2( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("brightness") is None assert state.attributes.get("color_mode") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -820,7 +963,7 @@ async def test_controlling_state_via_topic2( assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_mode") == "rgbww" - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") == "colorloop" assert state.attributes.get("hs_color") == (20.552, 70.98) assert state.attributes.get("rgb_color") == (255, 136, 74) @@ -886,7 +1029,7 @@ async def test_controlling_state_via_topic2( ) state = hass.states.get("light.test") assert state.attributes.get("color_mode") == "color_temp" - assert state.attributes.get("color_temp") == 155 + assert state.attributes.get("color_temp_kelvin") == 6451 # White async_fire_mqtt_message( @@ -965,7 +1108,7 @@ async def test_controlling_the_state_with_legacy_color_handling( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("brightness") is None assert state.attributes.get("color_mode") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -990,7 +1133,7 @@ async def test_controlling_the_state_with_legacy_color_handling( assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_mode") == "hs" - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") == (15.765, 100.0) assert state.attributes.get("rgb_color") == (255, 67, 0) @@ -1012,7 +1155,7 @@ async def test_controlling_the_state_with_legacy_color_handling( assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_mode") == "color_temp" - assert state.attributes.get("color_temp") == 353 + assert state.attributes.get("color_temp_kelvin") == 2832 assert state.attributes.get("effect") is None assert state.attributes.get("hs_color") == (28.125, 61.661) assert state.attributes.get("rgb_color") == (255, 171, 98) @@ -1053,7 +1196,7 @@ async def test_sending_mqtt_commands_and_optimistic( "brightness": 95, "hs_color": [100, 100], "effect": "random", - "color_temp": 100, + "color_temp_kelvin": 10000, }, ) mock_restore_cache(hass, (fake_state,)) @@ -1065,7 +1208,7 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get("brightness") == 95 assert state.attributes.get("hs_color") == (100, 100) assert state.attributes.get("effect") == "random" - assert state.attributes.get("color_temp") is None # hs_color has priority + assert state.attributes.get("color_temp_kelvin") is None # hs_color has priority color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes expected_features = ( @@ -1083,7 +1226,7 @@ async def test_sending_mqtt_commands_and_optimistic( state = hass.states.get("light.test") assert state.state == STATE_ON - await common.async_turn_on(hass, "light.test", color_temp=90) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=11111) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", @@ -1095,7 +1238,7 @@ async def test_sending_mqtt_commands_and_optimistic( state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == light.ColorMode.COLOR_TEMP - assert state.attributes.get("color_temp") == 90 + assert state.attributes.get("color_temp_kelvin") == 11111 await common.async_turn_off(hass, "light.test") @@ -1205,7 +1348,7 @@ async def test_sending_mqtt_commands_and_optimistic2( "on", { "brightness": 95, - "color_temp": 100, + "color_temp_kelvin": 10000, "color_mode": "rgb", "effect": "random", "hs_color": [100, 100], @@ -1223,7 +1366,7 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("brightness") == 95 assert state.attributes.get("color_mode") == "rgb" - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") == "random" assert state.attributes.get("hs_color") is None assert state.attributes.get("rgb_color") is None @@ -1244,7 +1387,7 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.state == STATE_ON # Turn the light on with color temperature - await common.async_turn_on(hass, "light.test", color_temp=90) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=11111) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator('{"state":"ON","color_temp":90}'), @@ -2181,7 +2324,9 @@ async def test_white_scale( ], ) async def test_invalid_values( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that invalid color/brightness/etc. values are ignored.""" await mqtt_mock_entry() @@ -2194,7 +2339,7 @@ async def test_invalid_values( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert not state.attributes.get(ATTR_ASSUMED_STATE) # Turn on the light @@ -2212,7 +2357,7 @@ async def test_invalid_values( assert state.state == STATE_ON assert state.attributes.get("rgb_color") == (255, 255, 255) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None # Empty color value async_fire_mqtt_message( hass, @@ -2277,17 +2422,21 @@ async def test_invalid_values( ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("color_temp") == 100 + assert state.attributes.get("color_temp_kelvin") == 10000 # Bad color temperature async_fire_mqtt_message( hass, "test_light_rgb", '{"state":"ON", "color_temp": "badValue"}' ) + assert ( + "Invalid color temp value 'badValue' received for entity light.test" + in caplog.text + ) # Color temperature should not have changed state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("color_temp") == 100 + assert state.attributes.get("color_temp_kelvin") == 10000 @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -2566,30 +2715,82 @@ async def test_entity_debug_info_message( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "min_kelvin", "max_kelvin"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_max_mireds/set", - "color_temp": True, - "max_mireds": 370, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "max_mireds": 370, # 2702 Kelvin + } } - } - } + }, + 2702, + light.DEFAULT_MAX_KELVIN, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "min_mireds": 150, # 6666 Kelvin + } + } + }, + light.DEFAULT_MIN_KELVIN, + 6666, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "min_kelvin": 2702, + } + } + }, + 2702, + light.DEFAULT_MAX_KELVIN, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "max_kelvin": 6666, + } + } + }, + light.DEFAULT_MIN_KELVIN, + 6666, + ), ], ) -async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +async def test_min_max_kelvin( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + min_kelvin: int, + max_kelvin: int, ) -> None: - """Test setting min_mireds and max_mireds.""" + """Test setting min_color_temp_kelvin and max_color_temp_kelvin.""" await mqtt_mock_entry() state = hass.states.get("light.test") - assert state.attributes.get("min_mireds") == 153 - assert state.attributes.get("max_mireds") == 370 + assert state.attributes.get("min_color_temp_kelvin") == min_kelvin + assert state.attributes.get("max_color_temp_kelvin") == max_kelvin @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 59fd3eb88ed..568d86f8bd9 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -179,25 +179,50 @@ async def test_rgb_light( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "kelvin", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "template", - "name": "test", - "command_topic": "test_light/set", - "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", - "command_off_template": "off", - "brightness_template": "{{ value.split(',')[1] }}", - "color_temp_template": "{{ value.split(',')[2] }}", + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light/set", + "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", + "command_off_template": "off", + "brightness_template": "{{ value.split(',')[1] }}", + "color_temp_template": "{{ value.split(',')[2] }}", + } } - } - } + }, + 5208, + "192", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light/set", + "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", + "command_off_template": "off", + "brightness_template": "{{ value.split(',')[1] }}", + "color_temp_template": "{{ value.split(',')[2] }}", + } + } + }, + 5208, + "5208", + ), ], + ids=["mireds", "kelvin"], ) async def test_single_color_mode( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + kelvin: int, + payload: str, ) -> None: """Test the color mode when we only have one supported color_mode.""" await mqtt_mock_entry() @@ -205,14 +230,16 @@ async def test_single_color_mode( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - await common.async_turn_on(hass, "light.test", brightness=50, color_temp=192) - async_fire_mqtt_message(hass, "test_light", "on,50,192") + await common.async_turn_on( + hass, "light.test", brightness=50, color_temp_kelvin=kelvin + ) + async_fire_mqtt_message(hass, "test_light", f"on,50,{payload}") color_modes = [light.ColorMode.COLOR_TEMP] state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - assert state.attributes.get(light.ATTR_COLOR_TEMP) == 192 + assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == kelvin assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] @@ -250,7 +277,7 @@ async def test_state_change_via_topic( assert state.state == STATE_UNKNOWN assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "test_light_rgb", "on") @@ -259,7 +286,7 @@ async def test_state_change_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None async_fire_mqtt_message(hass, "test_light_rgb", "off") @@ -314,7 +341,7 @@ async def test_state_brightness_color_effect_temp_change_via_topic( assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None assert state.attributes.get("effect") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert not state.attributes.get(ATTR_ASSUMED_STATE) # turn on the light @@ -324,7 +351,7 @@ async def test_state_brightness_color_effect_temp_change_via_topic( assert state.state == STATE_ON assert state.attributes.get("rgb_color") == (255, 128, 64) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") is None # rgb color has priority + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority assert state.attributes.get("effect") is None # turn on the light @@ -338,7 +365,7 @@ async def test_state_brightness_color_effect_temp_change_via_topic( 255, ) # temp converted to color assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") == 145 + assert state.attributes.get("color_temp_kelvin") == 6896 assert state.attributes.get("effect") is None assert state.attributes.get("xy_color") == (0.317, 0.317) # temp converted to color assert state.attributes.get("hs_color") == ( @@ -390,39 +417,80 @@ async def test_state_brightness_color_effect_temp_change_via_topic( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "kelvin", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "template", - "name": "test", - "command_topic": "test_light_rgb/set", - "command_on_template": "on," - "{{ brightness|d }}," - "{{ color_temp|d }}," - "{{ red|d }}-" - "{{ green|d }}-" - "{{ blue|d }}," - "{{ hue|d }}-" - "{{ sat|d }}", - "command_off_template": "off", - "effect_list": ["colorloop", "random"], - "optimistic": True, - "state_template": '{{ value.split(",")[0] }}', - "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].split("-")[0] }}', - "green_template": '{{ value.split(",")[3].split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].split("-")[2] }}', - "effect_template": '{{ value.split(",")[4] }}', - "qos": 2, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light_rgb/set", + "command_on_template": "on," + "{{ brightness|d }}," + "{{ color_temp|d }}," + "{{ red|d }}-" + "{{ green|d }}-" + "{{ blue|d }}," + "{{ hue|d }}-" + "{{ sat|d }}", + "command_off_template": "off", + "effect_list": ["colorloop", "random"], + "optimistic": True, + "state_template": '{{ value.split(",")[0] }}', + "color_temp_kelvin": False, + "color_temp_template": '{{ value.split(",")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', + "effect_template": '{{ value.split(",")[4] }}', + "qos": 2, + } } - } - } + }, + 14285, + "on,,70,--,-", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light_rgb/set", + "command_on_template": "on," + "{{ brightness|d }}," + "{{ color_temp|d }}," + "{{ red|d }}-" + "{{ green|d }}-" + "{{ blue|d }}," + "{{ hue|d }}-" + "{{ sat|d }}", + "command_off_template": "off", + "effect_list": ["colorloop", "random"], + "optimistic": True, + "state_template": '{{ value.split(",")[0] }}', + "color_temp_kelvin": True, + "color_temp_template": '{{ value.split(",")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', + "effect_template": '{{ value.split(",")[4] }}', + "qos": 2, + } + }, + }, + 14285, + "on,,14285,--,-", + ), ], + ids=["mireds", "kelvin"], ) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + kelvin: int, + payload: str, ) -> None: """Test the sending of command in optimistic mode.""" fake_state = State( @@ -432,7 +500,7 @@ async def test_sending_mqtt_commands_and_optimistic( "brightness": 95, "hs_color": [100, 100], "effect": "random", - "color_temp": 100, + "color_temp_kelvin": 10000, }, ) mock_restore_cache(hass, (fake_state,)) @@ -443,7 +511,7 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_ON assert state.attributes.get("hs_color") == (100, 100) assert state.attributes.get("effect") == "random" - assert state.attributes.get("color_temp") is None # hs_color has priority + assert state.attributes.get("color_temp_kelvin") is None # hs_color has priority assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_turn_off(hass, "light.test") @@ -463,14 +531,15 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_ON # Set color_temp - await common.async_turn_on(hass, "light.test", color_temp=70) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=kelvin) + # Assert mireds or Kelvin as payload mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,70,--,-", 2, False + "test_light_rgb/set", payload, 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("color_temp") == 70 + assert state.attributes.get("color_temp_kelvin") == kelvin # Set full brightness await common.async_turn_on(hass, "light.test", brightness=255) @@ -594,7 +663,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( assert state.state == STATE_UNKNOWN # Set color_temp - await common.async_turn_on(hass, "light.test", color_temp=70) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=14285) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", "on,,70,--,-", 0, False ) @@ -846,7 +915,7 @@ async def test_invalid_values( assert state.state == STATE_UNKNOWN assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None - assert state.attributes.get("color_temp") is None + assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("effect") is None assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -856,7 +925,7 @@ async def test_invalid_values( state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp") is None # hs_color has priority + assert state.attributes.get("color_temp_kelvin") is None # hs_color has priority assert state.attributes.get("rgb_color") == (255, 255, 255) assert state.attributes.get("effect") == "rainbow" @@ -885,14 +954,14 @@ async def test_invalid_values( async_fire_mqtt_message(hass, "test_light_rgb", "on,,215,None-None-None") state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("color_temp") == 215 + assert state.attributes.get("color_temp_kelvin") == 4651 # bad color temp values async_fire_mqtt_message(hass, "test_light_rgb", "on,,off,") # color temp should not have changed state = hass.states.get("light.test") - assert state.attributes.get("color_temp") == 215 + assert state.attributes.get("color_temp_kelvin") == 4651 # bad effect value async_fire_mqtt_message(hass, "test_light_rgb", "on,255,a-b-c,white") diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 5b7984cad62..d65f1a4d661 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -313,7 +313,12 @@ async def test_default_entity_and_device_name( hass.set_state(CoreState.starting) await hass.async_block_till_done() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "mock-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 48aaa11f672..7bdd39e81a7 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -29,6 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .test_common import ( help_custom_config, @@ -157,6 +158,101 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 15, + "max": 28, + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS.value, + } + } + } + ], +) +async def test_native_value_validation( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test state validation and native value conversion.""" + mqtt_mock = await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/state_number", "23.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + + # Test out of range validation + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + # Check if validation still works when changing unit system + hass.config.units = US_CUSTOMARY_SYSTEM + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "test/state_number", "24.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + + # Test out of range validation again + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 68}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with("test/cmd_number", "20", 0, False) + mqtt_mock.async_publish.reset_mock() + + @pytest.mark.parametrize( "hass_config", [ diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index 37bf6982b7a..dd72902056d 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -231,6 +231,8 @@ async def test_waiting_for_client_not_loaded( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -286,6 +288,8 @@ async def test_waiting_for_client_entry_fails( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -314,6 +318,8 @@ async def test_waiting_for_client_setup_fails( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -341,6 +347,8 @@ async def test_waiting_for_client_timeout( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -360,6 +368,8 @@ async def test_waiting_for_client_with_disabled_entry( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index fef62c33a93..c1c662048d7 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -27,8 +27,7 @@ from homeassistant.components.vacuum import ( SERVICE_RETURN_TO_BASE, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_DOCKED, + VacuumActivity, ) from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -313,7 +312,7 @@ async def test_status( }""" async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" assert state.attributes.get(ATTR_FAN_SPEED) == "max" @@ -326,7 +325,7 @@ async def test_status( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 assert state.attributes.get(ATTR_FAN_SPEED) == "min" @@ -366,7 +365,7 @@ async def test_no_fan_vacuum( }""" async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 @@ -380,7 +379,7 @@ async def test_no_fan_vacuum( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None @@ -394,7 +393,7 @@ async def test_no_fan_vacuum( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_DOCKED + assert state.state == VacuumActivity.DOCKED assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 307a928f2cc..7c0f9df751a 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -3,9 +3,10 @@ from __future__ import annotations from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from music_assistant_models.enums import EventType +from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track from music_assistant_models.player import Player from music_assistant_models.player_queue import PlayerQueue from syrupy import SnapshotAssertion @@ -29,7 +30,7 @@ def load_and_parse_fixture(fixture: str) -> dict[str, Any]: async def setup_integration_from_fixtures( hass: HomeAssistant, music_assistant_client: MagicMock, -) -> None: +) -> MockConfigEntry: """Set up MusicAssistant integration with fixture data.""" players = create_players_from_fixture() music_assistant_client.players._players = {x.player_id: x for x in players} @@ -42,9 +43,29 @@ async def setup_integration_from_fixtures( data={"url": MOCK_URL}, unique_id=music_assistant_client.server_info.server_id, ) + music = music_assistant_client.music + library_artists = create_library_artists_from_fixture() + music.get_library_artists = AsyncMock(return_value=library_artists) + library_artist_albums = create_library_artist_albums_from_fixture() + music.get_artist_albums = AsyncMock(return_value=library_artist_albums) + library_albums = create_library_albums_from_fixture() + music.get_library_albums = AsyncMock(return_value=library_albums) + library_album_tracks = create_library_album_tracks_from_fixture() + music.get_album_tracks = AsyncMock(return_value=library_album_tracks) + library_tracks = create_library_tracks_from_fixture() + music.get_library_tracks = AsyncMock(return_value=library_tracks) + library_playlists = create_library_playlists_from_fixture() + music.get_library_playlists = AsyncMock(return_value=library_playlists) + library_playlist_tracks = create_library_playlist_tracks_from_fixture() + music.get_playlist_tracks = AsyncMock(return_value=library_playlist_tracks) + library_radios = create_library_radios_from_fixture() + music.get_library_radios = AsyncMock(return_value=library_radios) + music.get_item_by_uri = AsyncMock() + config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + return config_entry def create_players_from_fixture() -> list[Player]: @@ -61,6 +82,54 @@ def create_player_queues_from_fixture() -> list[Player]: ] +def create_library_albums_from_fixture() -> list[Album]: + """Create MA Albums from fixture.""" + fixture_data = load_and_parse_fixture("library_albums") + return [Album.from_dict(album_data) for album_data in fixture_data] + + +def create_library_album_tracks_from_fixture() -> list[Track]: + """Create MA Tracks from fixture.""" + fixture_data = load_and_parse_fixture("library_album_tracks") + return [Track.from_dict(track_data) for track_data in fixture_data] + + +def create_library_tracks_from_fixture() -> list[Track]: + """Create MA Tracks from fixture.""" + fixture_data = load_and_parse_fixture("library_tracks") + return [Track.from_dict(track_data) for track_data in fixture_data] + + +def create_library_artists_from_fixture() -> list[Artist]: + """Create MA Artists from fixture.""" + fixture_data = load_and_parse_fixture("library_artists") + return [Artist.from_dict(artist_data) for artist_data in fixture_data] + + +def create_library_artist_albums_from_fixture() -> list[Album]: + """Create MA Albums from fixture.""" + fixture_data = load_and_parse_fixture("library_artist_albums") + return [Album.from_dict(album_data) for album_data in fixture_data] + + +def create_library_playlists_from_fixture() -> list[Playlist]: + """Create MA Playlists from fixture.""" + fixture_data = load_and_parse_fixture("library_playlists") + return [Playlist.from_dict(playlist_data) for playlist_data in fixture_data] + + +def create_library_playlist_tracks_from_fixture() -> list[Track]: + """Create MA Tracks from fixture.""" + fixture_data = load_and_parse_fixture("library_playlist_tracks") + return [Track.from_dict(track_data) for track_data in fixture_data] + + +def create_library_radios_from_fixture() -> list[Radio]: + """Create MA Radios from fixture.""" + fixture_data = load_and_parse_fixture("library_radios") + return [Radio.from_dict(radio_data) for radio_data in fixture_data] + + async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, diff --git a/tests/components/music_assistant/fixtures/library_album_tracks.json b/tests/components/music_assistant/fixtures/library_album_tracks.json new file mode 100644 index 00000000000..562ee84fe35 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_album_tracks.json @@ -0,0 +1,364 @@ +{ + "library_album_tracks": [ + { + "item_id": "247", + "provider": "library", + "name": "Le Mirage", + "version": "", + "sort_name": "mirage, le", + "uri": "library://track/247", + "external_ids": [["isrc", "FR10S1794640"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "70953631", + "provider_domain": "tidal", + "provider_instance": "tidal--63Pkq9Aw", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/70953631", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "Dana Murray", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 35, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 352, + "artists": [ + { + "item_id": 195, + "provider": "library", + "name": "Dana Jean Phoenix", + "version": "", + "sort_name": "dana jean phoenix", + "uri": "library://artist/195", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 95, + "provider": "library", + "name": "Synthwave (The 80S Revival)", + "version": "", + "sort_name": "synthwave (the 80s revival)", + "uri": "library://album/95", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 1 + }, + { + "item_id": "362", + "provider": "library", + "name": "Rabbit in the Headlights", + "version": "", + "sort_name": "rabbit in the headlights", + "uri": "library://track/362", + "external_ids": [["isrc", "GBLFP1645070"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "70953636", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/70953636", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "Michael Oakley", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 34, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 253, + "artists": [ + { + "item_id": 90, + "provider": "library", + "name": "Michael Oakley", + "version": "", + "sort_name": "michael oakley", + "uri": "library://artist/90", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 95, + "provider": "library", + "name": "Synthwave (The 80S Revival)", + "version": "", + "sort_name": "synthwave (the 80s revival)", + "uri": "library://album/95", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 6 + }, + { + "item_id": "1", + "provider": "library", + "name": "1988 Girls", + "version": "", + "sort_name": "1988 girls", + "uri": "library://track/1", + "external_ids": [["isrc", "DEBL60768604"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "70953637", + "provider_domain": "tidal", + "provider_instance": "tidal--56X5qDS7", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/70953637", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "Kiez Beats", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 14, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 258, + "artists": [ + { + "item_id": 110, + "provider": "library", + "name": "Futurecop!", + "version": "", + "sort_name": "futurecop!", + "uri": "library://artist/110", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 95, + "provider": "library", + "name": "Synthwave (The 80S Revival)", + "version": "", + "sort_name": "synthwave (the 80s revival)", + "uri": "library://album/95", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 7 + }, + { + "item_id": "495", + "provider": "library", + "name": "Timmy Goes to Space", + "version": "", + "sort_name": "timmy goes to space", + "uri": "library://track/495", + "external_ids": [["isrc", "NO2D81710001"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "70953643", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/70953643", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "Jens Kristian Espevik", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 4, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 212, + "artists": [ + { + "item_id": 453, + "provider": "library", + "name": "Mr. Maen", + "version": "", + "sort_name": "mr. maen", + "uri": "library://artist/453", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 95, + "provider": "library", + "name": "Synthwave (The 80S Revival)", + "version": "", + "sort_name": "synthwave (the 80s revival)", + "uri": "library://album/95", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 13 + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_albums.json b/tests/components/music_assistant/fixtures/library_albums.json new file mode 100644 index 00000000000..6936a96adc8 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_albums.json @@ -0,0 +1,148 @@ +{ + "library_albums": [ + { + "item_id": "396", + "provider": "library", + "name": "Synth Punk EP", + "version": "", + "sort_name": "synth punk ep", + "uri": "library://album/396", + "external_ids": [["barcode", "872133626743"]], + "media_type": "album", + "provider_mappings": [ + { + "item_id": "48563817", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/album/48563817", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/99c8bc2f/ed43/4fb2/adfb/e7e3157089d2/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "586446 Records DK", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 7, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "year": 2015, + "artists": [ + { + "item_id": 289, + "provider": "library", + "name": "A Space Love Adventure", + "version": "", + "sort_name": "space love adventure, a", + "uri": "library://artist/289", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album_type": "ep" + }, + { + "item_id": "95", + "provider": "library", + "name": "Synthwave (The 80S Revival)", + "version": "The 80S Revival", + "sort_name": "synthwave (the 80s revival)", + "uri": "library://album/95", + "external_ids": [["barcode", "3614974086112"]], + "media_type": "album", + "provider_mappings": [ + { + "item_id": "70953630", + "provider_domain": "tidal", + "provider_instance": "tidal--56X5qDS7", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/album/70953630", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/b7b1897c/57ed/4a31/83d7/9ab3df83183a/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "Kiez Beats", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 43, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "year": 2017, + "artists": [ + { + "item_id": 96, + "provider": "library", + "name": "Various Artists", + "version": "", + "sort_name": "various artists", + "uri": "library://artist/96", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album_type": "compilation" + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_artist_albums.json b/tests/components/music_assistant/fixtures/library_artist_albums.json new file mode 100644 index 00000000000..31885528734 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_artist_albums.json @@ -0,0 +1,88 @@ +{ + "library_artist_albums": [ + { + "item_id": "115", + "provider": "library", + "name": "A Sea of Stars", + "version": "", + "sort_name": "sea of stars, a", + "uri": "library://album/115", + "external_ids": [["barcode", "859741010126"]], + "media_type": "album", + "provider_mappings": [ + { + "item_id": "157401232", + "provider_domain": "tidal", + "provider_instance": "tidal--56X5qDS7", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/album/157401232", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/f55c749b/6642/40e3/a291/ff01fd2915cf/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "2021 NRW Records, under exclusive license to NewRetroWave, LLC", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 0, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "year": 2021, + "artists": [ + { + "item_id": 127, + "provider": "library", + "name": "W O L F C L U B", + "version": "", + "sort_name": "w o l f c l u b", + "uri": "library://artist/127", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + }, + { + "item_id": 128, + "provider": "library", + "name": "Dora Pereli", + "version": "", + "sort_name": "dora pereli", + "uri": "library://artist/128", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album_type": "single" + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_artists.json b/tests/components/music_assistant/fixtures/library_artists.json new file mode 100644 index 00000000000..803ce003b6c --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_artists.json @@ -0,0 +1,60 @@ +{ + "library_artists": [ + { + "item_id": "127", + "provider": "library", + "name": "W O L F C L U B", + "version": "", + "sort_name": "w o l f c l u b", + "uri": "library://artist/127", + "external_ids": [], + "media_type": "artist", + "provider_mappings": [ + { + "item_id": "8741977", + "provider_domain": "tidal", + "provider_instance": "tidal--56X5qDS7", + "available": 1, + "audio_format": { + "content_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": "https://tidal.com/artist/8741977", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/1e01cdb6/f15d/4d8b/8440/a047976c1cac/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_playlist_tracks.json b/tests/components/music_assistant/fixtures/library_playlist_tracks.json new file mode 100644 index 00000000000..1fb1c330957 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_playlist_tracks.json @@ -0,0 +1,262 @@ +{ + "library_playlist_tracks": [ + { + "item_id": "77616130", + "provider": "tidal--Ah76MuMg", + "name": "Won't Get Fooled Again", + "version": "", + "sort_name": "won't get fooled again", + "uri": "tidal--Ah76MuMg://track/77616130", + "external_ids": [["isrc", "GBUM71405419"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "77616130", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": true, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 24, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/77616130", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/3496a8ad/ea69/4d7e/bbda/045417ab59e1/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 1971 Polydor Ltd. (UK)", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 30, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": 0, + "duration": 516, + "artists": [ + { + "item_id": "24915", + "provider": "tidal--Ah76MuMg", + "name": "The Who", + "version": "", + "sort_name": "who, the", + "uri": "tidal--Ah76MuMg://artist/24915", + "external_ids": [], + "media_type": "artist", + "provider_mappings": [ + { + "item_id": "24915", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": true, + "audio_format": { + "content_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": "https://tidal.com/artist/24915", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/0f782232/18c8/40b7/bb13/91c6039e40e6/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null + } + ], + "album": { + "item_id": "77616121", + "provider": "tidal--Ah76MuMg", + "name": "Who's Next", + "version": "", + "sort_name": "who's next", + "uri": "tidal--Ah76MuMg://album/77616121", + "external_ids": [], + "media_type": "album", + "available": true, + "image": null + }, + "disc_number": 1, + "track_number": 9 + }, + { + "item_id": "153795", + "provider": "tidal--Ah76MuMg", + "name": "We're An American Band", + "version": "Remastered 2002", + "sort_name": "we're an american band", + "uri": "tidal--Ah76MuMg://track/153795", + "external_ids": [["isrc", "USCA20200334"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "153795", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": true, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/153795", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/a6d86e02/84c1/41f7/84f5/41be8571fc40/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2002 Capitol Records, LLC", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 48, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": 1, + "duration": 207, + "artists": [ + { + "item_id": "9380", + "provider": "tidal--Ah76MuMg", + "name": "Grand Funk Railroad", + "version": "", + "sort_name": "grand funk railroad", + "uri": "tidal--Ah76MuMg://artist/9380", + "external_ids": [], + "media_type": "artist", + "provider_mappings": [ + { + "item_id": "9380", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": true, + "audio_format": { + "content_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": "https://tidal.com/artist/9380", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/6535bf95/a06d/4d23/8262/604fa41d8126/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": false, + "position": null + } + ], + "album": { + "item_id": "153794", + "provider": "tidal--Ah76MuMg", + "name": "We're An American Band (Expanded Edition / Remastered 2002)", + "version": "", + "sort_name": "we're an american band (expanded edition / remastered 2002)", + "uri": "tidal--Ah76MuMg://album/153794", + "external_ids": [], + "media_type": "album", + "available": true, + "image": null + }, + "disc_number": 1, + "track_number": 1 + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_playlists.json b/tests/components/music_assistant/fixtures/library_playlists.json new file mode 100644 index 00000000000..7f88c5f3e24 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_playlists.json @@ -0,0 +1,63 @@ +{ + "library_playlists": [ + { + "item_id": "40", + "provider": "library", + "name": "1970s Rock Hits", + "version": "", + "sort_name": "1970s rock hits", + "uri": "library://playlist/40", + "external_ids": [], + "media_type": "playlist", + "provider_mappings": [ + { + "item_id": "30da0578-0ca0-4716-b66e-5f02bcd96702", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": "https://tidal.com/browse/playlist/30da0578-0ca0-4716-b66e-5f02bcd96702", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/95913801/41c1/4cc9/bf94/a0fba657bba5/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "owner": "TIDAL", + "is_editable": 0, + "cache_checksum": "2023-10-09 07: 09: 23.446000+00: 00" + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_radios.json b/tests/components/music_assistant/fixtures/library_radios.json new file mode 100644 index 00000000000..1a6a4666ce4 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_radios.json @@ -0,0 +1,66 @@ +{ + "library_radios": [ + { + "item_id": "1", + "provider": "library", + "name": "fm4 | ORF | HQ", + "version": "", + "sort_name": "fm4 | orf | hq", + "uri": "library://radio/1", + "external_ids": [], + "media_type": "radio", + "provider_mappings": [ + { + "item_id": "1e13ed4e-daa9-4728-8550-e08d89c1c8e7", + "provider_domain": "radiobrowser", + "provider_instance": "radiobrowser--FRc3pD3t", + "available": 1, + "audio_format": { + "content_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://tubestatic.orf.at/mojo/1_3/storyserver//tube/fm4/images/touch-icon-iphone-retina.png", + "provider": "radiobrowser", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": [ + { + "type": "website", + "url": "https://fm4.orf.at/" + } + ], + "performers": null, + "preview": null, + "popularity": 166, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 172800 + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_tracks.json b/tests/components/music_assistant/fixtures/library_tracks.json new file mode 100644 index 00000000000..c4ed83e9342 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_tracks.json @@ -0,0 +1,556 @@ +{ + "library_tracks": [ + { + "item_id": "456", + "provider": "library", + "name": "Tennessee Whiskey", + "version": "", + "sort_name": "tennessee whiskey", + "uri": "library://track/456", + "external_ids": [["isrc", "USUM71418088"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "44832786", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/44832786", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/4894ff62/9de2/4ed8/a7b9/69e217bbbdda/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2015 Mercury Records, a Division of UMG Recordings, Inc.", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 33, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 293, + "artists": [ + { + "item_id": 433, + "provider": "library", + "name": "Chris Stapleton", + "version": "", + "sort_name": "chris stapleton", + "uri": "library://artist/433", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 463, + "provider": "library", + "name": "Traveller", + "version": "", + "sort_name": "traveller", + "uri": "library://album/463", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/4894ff62/9de2/4ed8/a7b9/69e217bbbdda/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 3 + }, + { + "item_id": "467", + "provider": "library", + "name": "Thelma + Louise", + "version": "", + "sort_name": "thelma + louise", + "uri": "library://track/467", + "external_ids": [["isrc", "GBUM72104380"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "194027388", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 24, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/194027388", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/04fc7c3c/b814/4855/874c/a2e456205b65/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2021 Virgin Records Limited", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 20, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 137, + "artists": [ + { + "item_id": 81, + "provider": "library", + "name": "Bastille", + "version": "", + "sort_name": "bastille", + "uri": "library://artist/81", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 471, + "provider": "library", + "name": "Thelma + Louise", + "version": "", + "sort_name": "thelma + louise", + "uri": "library://album/471", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/04fc7c3c/b814/4855/874c/a2e456205b65/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 1 + }, + { + "item_id": "485", + "provider": "library", + "name": "They Don't Care About Us", + "version": "", + "sort_name": "they don't care about us", + "uri": "library://track/485", + "external_ids": [["isrc", "USSM19500629"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "5279069", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 24, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/5279069", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/a2fa5815/851d/4d2d/b6a7/17a365c838f9/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "(P) 1995 MJJ Productions Inc.", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 27, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 284, + "artists": [ + { + "item_id": 30, + "provider": "library", + "name": "Michael Jackson", + "version": "", + "sort_name": "michael jackson", + "uri": "library://artist/30", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 486, + "provider": "library", + "name": "HIStory - PAST, PRESENT AND FUTURE - BOOK I", + "version": "", + "sort_name": "history - past, present and future - book i", + "uri": "library://album/486", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/a2fa5815/851d/4d2d/b6a7/17a365c838f9/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 2, + "track_number": 2 + }, + { + "item_id": "486", + "provider": "library", + "name": "They Don't Give A F**** About Us", + "version": "", + "sort_name": "they don't give a f**** about us", + "uri": "library://track/486", + "external_ids": [["isrc", "USIR10211795"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "44066854", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/44066854", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": true, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/6b7b2b58/5dc2/4d0c/8979/7b30bb779d6f/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2002 Amaru Entertainment, Inc., Under exclusive license to Interscope Records", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 34, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 306, + "artists": [ + { + "item_id": 159, + "provider": "library", + "name": "2Pac", + "version": "", + "sort_name": "2pac", + "uri": "library://artist/159", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + }, + { + "item_id": 451, + "provider": "library", + "name": "The Outlawz", + "version": "", + "sort_name": "outlawz, the", + "uri": "library://artist/451", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 487, + "provider": "library", + "name": "Better Dayz", + "version": "", + "sort_name": "better dayz", + "uri": "library://album/487", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/6b7b2b58/5dc2/4d0c/8979/7b30bb779d6f/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 2, + "track_number": 13 + }, + { + "item_id": "487", + "provider": "library", + "name": "Things We Lost In The Fire", + "version": "TORN Remix", + "sort_name": "things we lost in the fire", + "uri": "library://track/487", + "external_ids": [["isrc", "GBUM71304903"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "22627902", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/22627902", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/de277fd3/cc29/4d63/a60f/13b501c5f3d0/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2013 Virgin Records Limited", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 10, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 323, + "artists": [ + { + "item_id": 81, + "provider": "library", + "name": "Bastille", + "version": "", + "sort_name": "bastille", + "uri": "library://artist/81", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 488, + "provider": "library", + "name": "Things We Lost In The Fire", + "version": "", + "sort_name": "things we lost in the fire", + "uri": "library://album/488", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/de277fd3/cc29/4d63/a60f/13b501c5f3d0/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 3 + }, + { + "item_id": "488", + "provider": "library", + "name": "Those Nights", + "version": "", + "sort_name": "those nights", + "uri": "library://track/488", + "external_ids": [["isrc", "GBUM71803866"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "110750762", + "provider_domain": "tidal", + "provider_instance": "tidal--Ah76MuMg", + "available": 1, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 24, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://tidal.com/track/110750762", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://resources.tidal.com/images/713805f3/c08c/4c0f/8199/d63e6badac0d/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "℗ 2019 Virgin Records Limited", + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": 21, + "release_date": null, + "languages": null, + "last_refresh": null + }, + "favorite": true, + "position": null, + "duration": 270, + "artists": [ + { + "item_id": 81, + "provider": "library", + "name": "Bastille", + "version": "", + "sort_name": "bastille", + "uri": "library://artist/81", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": 489, + "provider": "library", + "name": "Doom Days", + "version": "", + "sort_name": "doom days", + "uri": "library://album/489", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://resources.tidal.com/images/713805f3/c08c/4c0f/8199/d63e6badac0d/750x750.jpg", + "provider": "tidal", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 10 + } + ] +} diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index b7ff304a7ee..8a08a55dc45 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -16,11 +16,11 @@ "volume_set", "volume_mute", "pause", - "sync", + "set_members", "power", "enqueue" ], - "elapsed_time": 0, + "elapsed_time": null, "elapsed_time_last_updated": 0, "state": "idle", "volume_level": 20, @@ -57,7 +57,7 @@ "volume_set", "volume_mute", "pause", - "sync", + "set_members", "power", "enqueue" ], @@ -109,7 +109,7 @@ "volume_set", "volume_mute", "pause", - "sync", + "set_members", "power", "enqueue" ], diff --git a/tests/components/music_assistant/snapshots/test_actions.ambr b/tests/components/music_assistant/snapshots/test_actions.ambr new file mode 100644 index 00000000000..6c30ffc512c --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_actions.ambr @@ -0,0 +1,202 @@ +# serializer version: 1 +# name: test_get_library_action + dict({ + 'items': list([ + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Traveller', + 'uri': 'library://album/463', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Chris Stapleton', + 'uri': 'library://artist/433', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Tennessee Whiskey', + 'uri': 'library://track/456', + 'version': '', + }), + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Thelma + Louise', + 'uri': 'library://album/471', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Bastille', + 'uri': 'library://artist/81', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Thelma + Louise', + 'uri': 'library://track/467', + 'version': '', + }), + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'HIStory - PAST, PRESENT AND FUTURE - BOOK I', + 'uri': 'library://album/486', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Michael Jackson', + 'uri': 'library://artist/30', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': "They Don't Care About Us", + 'uri': 'library://track/485', + 'version': '', + }), + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Better Dayz', + 'uri': 'library://album/487', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': '2Pac', + 'uri': 'library://artist/159', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'The Outlawz', + 'uri': 'library://artist/451', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': "They Don't Give A F**** About Us", + 'uri': 'library://track/486', + 'version': '', + }), + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Things We Lost In The Fire', + 'uri': 'library://album/488', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Bastille', + 'uri': 'library://artist/81', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Things We Lost In The Fire', + 'uri': 'library://track/487', + 'version': 'TORN Remix', + }), + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Doom Days', + 'uri': 'library://album/489', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Bastille', + 'uri': 'library://artist/81', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Those Nights', + 'uri': 'library://track/488', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_search_action + dict({ + 'albums': list([ + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'A Space Love Adventure', + 'uri': 'library://artist/289', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synth Punk EP', + 'uri': 'library://album/396', + 'version': '', + }), + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Various Artists', + 'uri': 'library://artist/96', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synthwave (The 80S Revival)', + 'uri': 'library://album/95', + 'version': 'The 80S Revival', + }), + ]), + 'artists': list([ + ]), + 'playlists': list([ + ]), + 'radio': list([ + ]), + 'tracks': list([ + ]), + }) +# --- diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index e3d7a4a0cbc..6c5389dbd6a 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -188,3 +188,88 @@ 'state': 'off', }) # --- +# name: test_media_player_get_queue_action + dict({ + 'media_player.test_group_player_1': dict({ + 'active': True, + 'current_index': 26, + 'current_item': dict({ + 'duration': 536, + 'media_item': dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Use Your Illusion I', + 'uri': 'spotify://album/0CxPbTRARqKUYighiEY9Sz', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': "Guns N' Roses", + 'uri': 'spotify://artist/3qm84nBOXUEQ2vnTfUTTFC', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'November Rain', + 'uri': 'spotify://track/3YRCqOhFifThpSRFJ1VWFM', + 'version': '', + }), + 'name': "Guns N' Roses - November Rain", + 'queue_item_id': '5d95dc5be77e4f7eb4939f62cfef527b', + 'stream_details': dict({ + 'bit_depth': 16, + 'content_type': 'ogg', + 'item_id': '3YRCqOhFifThpSRFJ1VWFM', + 'provider': 'spotify', + 'sample_rate': 44100, + }), + 'stream_title': None, + }), + 'items': 1094, + 'name': 'Test Group Player 1', + 'next_item': dict({ + 'duration': 207, + 'media_item': dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'La Folie', + 'uri': 'qobuz://album/0724353468859', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'The Stranglers', + 'uri': 'qobuz://artist/26779', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Golden Brown', + 'uri': 'qobuz://track/1004735', + 'version': '', + }), + 'name': 'The Stranglers - Golden Brown', + 'queue_item_id': '990ae8f29cdf4fb588d679b115621f55', + 'stream_details': dict({ + 'bit_depth': 16, + 'content_type': 'flac', + 'item_id': '1004735', + 'provider': 'qobuz', + 'sample_rate': 44100, + }), + 'stream_title': None, + }), + 'queue_id': 'test_group_player_1', + 'repeat_mode': 'all', + 'shuffle_enabled': True, + }), + }) +# --- diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py new file mode 100644 index 00000000000..4d3917091c1 --- /dev/null +++ b/tests/components/music_assistant/test_actions.py @@ -0,0 +1,68 @@ +"""Test Music Assistant actions.""" + +from unittest.mock import AsyncMock, MagicMock + +from music_assistant_models.media_items import SearchResults +from syrupy import SnapshotAssertion + +from homeassistant.components.music_assistant.actions import ( + SERVICE_GET_LIBRARY, + SERVICE_SEARCH, +) +from homeassistant.components.music_assistant.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_FAVORITE, + ATTR_MEDIA_TYPE, + ATTR_SEARCH_NAME, + DOMAIN as MASS_DOMAIN, +) +from homeassistant.core import HomeAssistant + +from .common import create_library_albums_from_fixture, setup_integration_from_fixtures + + +async def test_search_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test music assistant search action.""" + entry = await setup_integration_from_fixtures(hass, music_assistant_client) + + music_assistant_client.music.search = AsyncMock( + return_value=SearchResults( + albums=create_library_albums_from_fixture(), + ) + ) + response = await hass.services.async_call( + MASS_DOMAIN, + SERVICE_SEARCH, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_SEARCH_NAME: "test", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +async def test_get_library_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test music assistant get_library action.""" + entry = await setup_integration_from_fixtures(hass, music_assistant_client) + response = await hass.services.async_call( + MASS_DOMAIN, + SERVICE_GET_LIBRARY, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_FAVORITE: False, + ATTR_MEDIA_TYPE: "track", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot diff --git a/tests/components/music_assistant/test_config_flow.py b/tests/components/music_assistant/test_config_flow.py index c700060889c..89cda62961b 100644 --- a/tests/components/music_assistant/test_config_flow.py +++ b/tests/components/music_assistant/test_config_flow.py @@ -15,10 +15,10 @@ import pytest from homeassistant.components.music_assistant.config_flow import CONF_URL from homeassistant.components.music_assistant.const import DEFAULT_NAME, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/music_assistant/test_media_browser.py b/tests/components/music_assistant/test_media_browser.py new file mode 100644 index 00000000000..96fd54962d8 --- /dev/null +++ b/tests/components/music_assistant/test_media_browser.py @@ -0,0 +1,65 @@ +"""Test Music Assistant media browser implementation.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.media_player import BrowseError, BrowseMedia, MediaType +from homeassistant.components.music_assistant.const import DOMAIN +from homeassistant.components.music_assistant.media_browser import ( + LIBRARY_ALBUMS, + LIBRARY_ARTISTS, + LIBRARY_PLAYLISTS, + LIBRARY_RADIO, + LIBRARY_TRACKS, + async_browse_media, +) +from homeassistant.core import HomeAssistant + +from .common import setup_integration_from_fixtures + + +@pytest.mark.parametrize( + ("media_content_id", "media_content_type", "expected"), + [ + (LIBRARY_PLAYLISTS, MediaType.PLAYLIST, "library://playlist/40"), + (LIBRARY_ARTISTS, MediaType.ARTIST, "library://artist/127"), + (LIBRARY_ALBUMS, MediaType.ALBUM, "library://album/396"), + (LIBRARY_TRACKS, MediaType.TRACK, "library://track/486"), + (LIBRARY_RADIO, DOMAIN, "library://radio/1"), + ("artist", MediaType.ARTIST, "library://album/115"), + ("album", MediaType.ALBUM, "library://track/247"), + ("playlist", DOMAIN, "tidal--Ah76MuMg://track/77616130"), + (None, None, "artists"), + ], +) +async def test_browse_media_root( + hass: HomeAssistant, + music_assistant_client: MagicMock, + media_content_id: str, + media_content_type: str, + expected: str, +) -> None: + """Test the async_browse_media method.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + state = hass.states.get(entity_id) + assert state + browse_item: BrowseMedia = await async_browse_media( + hass, music_assistant_client, media_content_id, media_content_type + ) + assert browse_item.children[0].media_content_id == expected + + +async def test_browse_media_not_found( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test the async_browse_media method when media is not found.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + state = hass.states.get(entity_id) + assert state + + with pytest.raises(BrowseError, match="Media not found: unknown / unknown"): + await async_browse_media(hass, music_assistant_client, "unknown", "unknown") diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 2054ce1e6aa..25dfcd22c72 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -2,9 +2,15 @@ from unittest.mock import MagicMock, call +from music_assistant_models.enums import MediaType, QueueOption +from music_assistant_models.media_items import Track +import pytest from syrupy import SnapshotAssertion +from syrupy.filters import paths from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, + ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_REPEAT, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SHUFFLE, @@ -12,7 +18,27 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, + SERVICE_JOIN, + SERVICE_UNJOIN, ) +from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN +from homeassistant.components.music_assistant.media_player import ( + ATTR_ALBUM, + ATTR_ANNOUNCE_VOLUME, + ATTR_ARTIST, + ATTR_AUTO_PLAY, + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_RADIO_MODE, + ATTR_SOURCE_PLAYER, + ATTR_URL, + ATTR_USE_PRE_ANNOUNCE, + SERVICE_GET_QUEUE, + SERVICE_PLAY_ANNOUNCEMENT, + SERVICE_PLAY_MEDIA_ADVANCED, + SERVICE_TRANSFER_QUEUE, +) +from homeassistant.config_entries import HomeAssistantError from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, @@ -35,6 +61,15 @@ from homeassistant.helpers import entity_registry as er from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from tests.common import AsyncMock + +MOCK_TRACK = Track( + item_id="1", + provider="library", + name="Test Track", + provider_mappings={}, +) + async def test_media_player( hass: HomeAssistant, @@ -110,11 +145,11 @@ async def test_media_player_seek_action( ) -async def test_media_player_volume_action( +async def test_media_player_volume_set_action( hass: HomeAssistant, music_assistant_client: MagicMock, ) -> None: - """Test media_player entity volume action.""" + """Test media_player entity volume_set action.""" await setup_integration_from_fixtures(hass, music_assistant_client) entity_id = "media_player.test_player_1" mass_player_id = "00:00:00:00:00:01" @@ -239,6 +274,71 @@ async def test_media_player_repeat_set_action( ) +async def test_media_player_join_players_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity join_players action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: entity_id, + ATTR_GROUP_MEMBERS: ["media_player.my_super_test_player_2"], + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/group_many", + target_player=mass_player_id, + child_player_ids=["00:00:00:00:00:02"], + ) + # test again with invalid source player + music_assistant_client.send_command.reset_mock() + with pytest.raises( + HomeAssistantError, match="Entity media_player.blah_blah not found" + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: entity_id, + ATTR_GROUP_MEMBERS: ["media_player.blah_blah"], + }, + blocking=True, + ) + + +async def test_media_player_unjoin_player_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity unjoin player action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/ungroup", player_id=mass_player_id + ) + + async def test_media_player_clear_playlist_action( hass: HomeAssistant, music_assistant_client: MagicMock, @@ -261,3 +361,249 @@ async def test_media_player_clear_playlist_action( assert music_assistant_client.send_command.call_args == call( "player_queues/clear", queue_id=mass_player_id ) + + +async def test_media_player_play_media_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player (advanced) play_media action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + + # test simple play_media call with URI as media_id and no media type + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=False, + start_item=None, + ) + + # test simple play_media call with URI and enqueue specified + music_assistant_client.send_command.reset_mock() + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + ATTR_MEDIA_ENQUEUE: "add", + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=QueueOption.ADD, + radio_mode=False, + start_item=None, + ) + + # test basic play_media call with URL and radio mode specified + music_assistant_client.send_command.reset_mock() + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + ATTR_RADIO_MODE: True, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=True, + start_item=None, + ) + + # test play_media call with media id and media type specified + music_assistant_client.send_command.reset_mock() + music_assistant_client.music.get_item = AsyncMock(return_value=MOCK_TRACK) + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "1", + ATTR_MEDIA_TYPE: "track", + }, + blocking=True, + ) + assert music_assistant_client.music.get_item.call_count == 1 + assert music_assistant_client.music.get_item.call_args == call( + MediaType.TRACK, "1", "library" + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=[MOCK_TRACK.uri], + option=None, + radio_mode=False, + start_item=None, + ) + + # test play_media call by name + music_assistant_client.send_command.reset_mock() + music_assistant_client.music.get_item_by_name = AsyncMock(return_value=MOCK_TRACK) + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "test", + ATTR_ARTIST: "artist", + ATTR_ALBUM: "album", + }, + blocking=True, + ) + assert music_assistant_client.music.get_item_by_name.call_count == 1 + assert music_assistant_client.music.get_item_by_name.call_args == call( + name="test", + artist="artist", + album="album", + media_type=None, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=[MOCK_TRACK.uri], + option=None, + radio_mode=False, + start_item=None, + ) + + +async def test_media_player_play_announcement_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player play_announcement action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_ANNOUNCEMENT, + { + ATTR_ENTITY_ID: entity_id, + ATTR_URL: "http://blah.com/announcement.mp3", + ATTR_USE_PRE_ANNOUNCE: True, + ATTR_ANNOUNCE_VOLUME: 50, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/play_announcement", + player_id=mass_player_id, + url="http://blah.com/announcement.mp3", + use_pre_announce=True, + volume_level=50, + ) + + +async def test_media_player_transfer_queue_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player transfer_queu action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_TRANSFER_QUEUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SOURCE_PLAYER: "media_player.my_super_test_player_2", + ATTR_AUTO_PLAY: True, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/transfer", + source_queue_id="00:00:00:00:00:02", + target_queue_id="00:00:00:00:00:01", + auto_play=True, + require_schema=25, + ) + # test again with invalid source player + music_assistant_client.send_command.reset_mock() + with pytest.raises(HomeAssistantError, match="Source player not available."): + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_TRANSFER_QUEUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SOURCE_PLAYER: "media_player.blah_blah", + }, + blocking=True, + ) + # test again with no source player specified (which picks first playing playerqueue) + music_assistant_client.send_command.reset_mock() + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_TRANSFER_QUEUE, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/transfer", + source_queue_id="test_group_player_1", + target_queue_id="00:00:00:00:00:01", + auto_play=None, + require_schema=25, + ) + + +async def test_media_player_get_queue_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test media_player get_queue action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_group_player_1" + response = await hass.services.async_call( + MASS_DOMAIN, + SERVICE_GET_QUEUE, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + return_response=True, + ) + # no call is made, this info comes from the cached queue data + assert music_assistant_client.send_command.call_count == 0 + assert response == snapshot(exclude=paths(f"{entity_id}.elapsed_time")) diff --git a/tests/components/mystrom/__init__.py b/tests/components/mystrom/__init__.py index 8ee62996f92..aee6657b270 100644 --- a/tests/components/mystrom/__init__.py +++ b/tests/components/mystrom/__init__.py @@ -179,4 +179,4 @@ class MyStromSwitchMock(MyStromDeviceMock): """Return the URI.""" if not self._requested_state: return None - return f"http://{self._state["ip"]}" + return f"http://{self._state['ip']}" diff --git a/tests/components/myuplink/conftest.py b/tests/components/myuplink/conftest.py index 9ede11146ef..3ab186b61a8 100644 --- a/tests/components/myuplink/conftest.py +++ b/tests/components/myuplink/conftest.py @@ -15,10 +15,11 @@ from homeassistant.components.application_credentials import ( ) from homeassistant.components.myuplink.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component from homeassistant.util.json import json_loads -from .const import CLIENT_ID, CLIENT_SECRET +from .const import CLIENT_ID, CLIENT_SECRET, UNIQUE_ID from tests.common import MockConfigEntry, load_fixture @@ -33,7 +34,7 @@ def mock_expires_at() -> float: def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry: """Return the default mocked config entry.""" config_entry = MockConfigEntry( - version=1, + minor_version=2, domain=DOMAIN, title="myUplink test", data={ @@ -48,6 +49,7 @@ def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry }, }, entry_id="myuplink_test", + unique_id=UNIQUE_ID, ) config_entry.add_to_hass(hass) return config_entry @@ -189,3 +191,21 @@ async def setup_platform( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() yield + + +@pytest.fixture +async def access_token(hass: HomeAssistant) -> str: + """Return a valid access token.""" + return config_entry_oauth2_flow._encode_jwt( + hass, + { + "sub": UNIQUE_ID, + "aud": [], + "scp": [ + "WRITESYSTEM", + "READSYSTEM", + "offline_access", + ], + "ou_code": "NA", + }, + ) diff --git a/tests/components/myuplink/const.py b/tests/components/myuplink/const.py index 6001cb151c0..4cb6db952f1 100644 --- a/tests/components/myuplink/const.py +++ b/tests/components/myuplink/const.py @@ -2,3 +2,4 @@ CLIENT_ID = "12345" CLIENT_SECRET = "67890" +UNIQUE_ID = "uid" diff --git a/tests/components/myuplink/fixtures/device_points_nibe_f730.json b/tests/components/myuplink/fixtures/device_points_nibe_f730.json index 99dd9c857e6..795a89e7e13 100644 --- a/tests/components/myuplink/fixtures/device_points_nibe_f730.json +++ b/tests/components/myuplink/fixtures/device_points_nibe_f730.json @@ -822,7 +822,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, @@ -1024,6 +1024,23 @@ "scaleValue": "1", "zoneId": null }, + { + "category": "F730 CU 3x400V", + "parameterId": "148072r", + "parameterName": "r start diff additional heat", + "parameterUnit": "DM", + "writable": false, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 700, + "strVal": "700DM", + "smartHomeCategories": [], + "minValue": 100, + "maxValue": 2000, + "stepValue": 10, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, { "category": "F730 CU 3x400V", "parameterId": "47011", @@ -1040,5 +1057,56 @@ "enumValues": [], "scaleValue": "1", "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47007", + "parameterName": "Excluded", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 1, + "strVal": "1", + "smartHomeCategories": ["sh-indoorSpOffsHeat"], + "minValue": -10, + "maxValue": 10, + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "99000", + "parameterName": "Excluded 2", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": "Hello", + "strVal": "Hello", + "smartHomeCategories": [], + "minValue": "", + "maxValue": "", + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47398", + "parameterName": "Room sensor set point value heating climate system 1", + "parameterUnit": "°C", + "writable": true, + "timestamp": "2024-12-11T13:23:12+00:00", + "value": 14.5, + "strVal": "14.5°C", + "smartHomeCategories": [], + "minValue": 50.0, + "maxValue": 350.0, + "stepValue": 5.0, + "enumValues": [], + "scaleValue": "0.1", + "zoneId": null } ] diff --git a/tests/components/myuplink/snapshots/test_binary_sensor.ambr b/tests/components/myuplink/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..755cae3c623 --- /dev/null +++ b/tests/components/myuplink/snapshots/test_binary_sensor.ambr @@ -0,0 +1,326 @@ +# serializer version: 1 +# name: test_binary_sensor_states[binary_sensor.gotham_city_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm', + 'unique_id': '123456-7890-1234-has_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Gotham City Alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-connection_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Gotham City Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_connectivity_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_connectivity_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-connection_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_connectivity_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Gotham City Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_connectivity_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_extern_adjustment_climate_system_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_extern_adjustment_climate_system_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Extern. adjust\xadment climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43161', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_extern_adjustment_climate_system_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Extern. adjust\xadment climate system 1', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_extern_adjustment_climate_system_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_extern_adjustment_climate_system_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_extern_adjustment_climate_system_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Extern. adjust\xadment climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43161', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_extern_adjustment_climate_system_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Extern. adjust\xadment climate system 1', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_extern_adjustment_climate_system_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_pump_heating_medium_gp1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_pump_heating_medium_gp1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pump: Heating medium (GP1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49995', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_pump_heating_medium_gp1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Pump: Heating medium (GP1)', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_pump_heating_medium_gp1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_pump_heating_medium_gp1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gotham_city_pump_heating_medium_gp1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pump: Heating medium (GP1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49995', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[binary_sensor.gotham_city_pump_heating_medium_gp1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Pump: Heating medium (GP1)', + }), + 'context': , + 'entity_id': 'binary_sensor.gotham_city_pump_heating_medium_gp1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/myuplink/snapshots/test_diagnostics.ambr b/tests/components/myuplink/snapshots/test_diagnostics.ambr index 1b3502c1f04..521823e282d 100644 --- a/tests/components/myuplink/snapshots/test_diagnostics.ambr +++ b/tests/components/myuplink/snapshots/test_diagnostics.ambr @@ -883,7 +883,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, @@ -1085,6 +1085,23 @@ "scaleValue": "1", "zoneId": null }, + { + "category": "F730 CU 3x400V", + "parameterId": "148072r", + "parameterName": "r start diff additional heat", + "parameterUnit": "DM", + "writable": false, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 700, + "strVal": "700DM", + "smartHomeCategories": [], + "minValue": 100, + "maxValue": 2000, + "stepValue": 10, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, { "category": "F730 CU 3x400V", "parameterId": "47011", @@ -1101,6 +1118,57 @@ "enumValues": [], "scaleValue": "1", "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47007", + "parameterName": "Excluded", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 1, + "strVal": "1", + "smartHomeCategories": ["sh-indoorSpOffsHeat"], + "minValue": -10, + "maxValue": 10, + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "99000", + "parameterName": "Excluded 2", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": "Hello", + "strVal": "Hello", + "smartHomeCategories": [], + "minValue": "", + "maxValue": "", + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47398", + "parameterName": "Room sensor set point value heating climate system 1", + "parameterUnit": "°C", + "writable": true, + "timestamp": "2024-12-11T13:23:12+00:00", + "value": 14.5, + "strVal": "14.5°C", + "smartHomeCategories": [], + "minValue": 50.0, + "maxValue": 350.0, + "stepValue": 5.0, + "enumValues": [], + "scaleValue": "0.1", + "zoneId": null } ] @@ -1977,7 +2045,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, @@ -2179,6 +2247,23 @@ "scaleValue": "1", "zoneId": null }, + { + "category": "F730 CU 3x400V", + "parameterId": "148072r", + "parameterName": "r start diff additional heat", + "parameterUnit": "DM", + "writable": false, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 700, + "strVal": "700DM", + "smartHomeCategories": [], + "minValue": 100, + "maxValue": 2000, + "stepValue": 10, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, { "category": "F730 CU 3x400V", "parameterId": "47011", @@ -2195,6 +2280,57 @@ "enumValues": [], "scaleValue": "1", "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47007", + "parameterName": "Excluded", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": 1, + "strVal": "1", + "smartHomeCategories": ["sh-indoorSpOffsHeat"], + "minValue": -10, + "maxValue": 10, + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "99000", + "parameterName": "Excluded 2", + "parameterUnit": "", + "writable": true, + "timestamp": "2024-10-18T09:51:39+00:00", + "value": "Hello", + "strVal": "Hello", + "smartHomeCategories": [], + "minValue": "", + "maxValue": "", + "stepValue": 1, + "enumValues": [], + "scaleValue": "1", + "zoneId": null + }, + { + "category": "F730 CU 3x400V", + "parameterId": "47398", + "parameterName": "Room sensor set point value heating climate system 1", + "parameterUnit": "°C", + "writable": true, + "timestamp": "2024-12-11T13:23:12+00:00", + "value": 14.5, + "strVal": "14.5°C", + "smartHomeCategories": [], + "minValue": 50.0, + "maxValue": 350.0, + "stepValue": 5.0, + "enumValues": [], + "scaleValue": "0.1", + "zoneId": null } ] diff --git a/tests/components/myuplink/snapshots/test_number.ambr b/tests/components/myuplink/snapshots/test_number.ambr new file mode 100644 index 00000000000..c47d3c60295 --- /dev/null +++ b/tests/components/myuplink/snapshots/test_number.ambr @@ -0,0 +1,445 @@ +# serializer version: 1 +# name: test_number_states[platforms0][number.gotham_city_degree_minutes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3000.0, + 'min': -3000.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_degree_minutes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Degree minutes', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'degree_minutes', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_degree_minutes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Degree minutes', + 'max': 3000.0, + 'min': -3000.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'number.gotham_city_degree_minutes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-875.0', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_degree_minutes_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3000.0, + 'min': -3000.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_degree_minutes_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Degree minutes', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'degree_minutes', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_degree_minutes_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Degree minutes', + 'max': 3000.0, + 'min': -3000.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'number.gotham_city_degree_minutes_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-875.0', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_heating_offset_climate_system_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_heating_offset_climate_system_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating offset climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47011', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_heating_offset_climate_system_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.gotham_city_heating_offset_climate_system_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_heating_offset_climate_system_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_heating_offset_climate_system_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating offset climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47011', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_heating_offset_climate_system_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.gotham_city_heating_offset_climate_system_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_room_sensor_set_point_value_heating_climate_system_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 35.0, + 'min': 5.0, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_room_sensor_set_point_value_heating_climate_system_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Room sensor set point value heating climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47398', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_room_sensor_set_point_value_heating_climate_system_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Room sensor set point value heating climate system 1', + 'max': 35.0, + 'min': 5.0, + 'mode': , + 'step': 0.5, + }), + 'context': , + 'entity_id': 'number.gotham_city_room_sensor_set_point_value_heating_climate_system_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.5', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_room_sensor_set_point_value_heating_climate_system_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 35.0, + 'min': 5.0, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_room_sensor_set_point_value_heating_climate_system_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Room sensor set point value heating climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47398', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_room_sensor_set_point_value_heating_climate_system_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Room sensor set point value heating climate system 1', + 'max': 35.0, + 'min': 5.0, + 'mode': , + 'step': 0.5, + }), + 'context': , + 'entity_id': 'number.gotham_city_room_sensor_set_point_value_heating_climate_system_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.5', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_start_diff_additional_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2000.0, + 'min': 100.0, + 'mode': , + 'step': 10.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_start_diff_additional_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'start diff additional heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'degree_minutes', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_start_diff_additional_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City start diff additional heat', + 'max': 2000.0, + 'min': 100.0, + 'mode': , + 'step': 10.0, + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'number.gotham_city_start_diff_additional_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '700.0', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_start_diff_additional_heat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2000.0, + 'min': 100.0, + 'mode': , + 'step': 10.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gotham_city_start_diff_additional_heat_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'start diff additional heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'degree_minutes', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_number_states[platforms0][number.gotham_city_start_diff_additional_heat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City start diff additional heat', + 'max': 2000.0, + 'min': 100.0, + 'mode': , + 'step': 10.0, + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'number.gotham_city_start_diff_additional_heat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '700.0', + }) +# --- diff --git a/tests/components/myuplink/snapshots/test_select.ambr b/tests/components/myuplink/snapshots/test_select.ambr new file mode 100644 index 00000000000..eff06bc7f2d --- /dev/null +++ b/tests/components/myuplink/snapshots/test_select.ambr @@ -0,0 +1,119 @@ +# serializer version: 1 +# name: test_select_states[platforms0][select.gotham_city_comfort_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Smart control', + 'Economy', + 'Normal', + 'Luxury', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.gotham_city_comfort_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'comfort mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47041', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_states[platforms0][select.gotham_city_comfort_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City comfort mode', + 'options': list([ + 'Smart control', + 'Economy', + 'Normal', + 'Luxury', + ]), + }), + 'context': , + 'entity_id': 'select.gotham_city_comfort_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Economy', + }) +# --- +# name: test_select_states[platforms0][select.gotham_city_comfort_mode_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Smart control', + 'Economy', + 'Normal', + 'Luxury', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.gotham_city_comfort_mode_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'comfort mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47041', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_states[platforms0][select.gotham_city_comfort_mode_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City comfort mode', + 'options': list([ + 'Smart control', + 'Economy', + 'Normal', + 'Luxury', + ]), + }), + 'context': , + 'entity_id': 'select.gotham_city_comfort_mode_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Economy', + }) +# --- diff --git a/tests/components/myuplink/snapshots/test_sensor.ambr b/tests/components/myuplink/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..34acbbb8785 --- /dev/null +++ b/tests/components/myuplink/snapshots/test_sensor.ambr @@ -0,0 +1,4767 @@ +# serializer version: 1 +# name: test_sensor_states[sensor.gotham_city_average_outdoor_temp_bt1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_average_outdoor_temp_bt1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average outdoor temp (BT1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40067', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_average_outdoor_temp_bt1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Average outdoor temp (BT1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_average_outdoor_temp_bt1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-12.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_average_outdoor_temp_bt1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_average_outdoor_temp_bt1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average outdoor temp (BT1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40067', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_average_outdoor_temp_bt1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Average outdoor temp (BT1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_average_outdoor_temp_bt1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-12.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_calculated_supply_climate_system_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_calculated_supply_climate_system_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Calculated supply climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43009', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_calculated_supply_climate_system_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Calculated supply climate system 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_calculated_supply_climate_system_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_calculated_supply_climate_system_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_calculated_supply_climate_system_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Calculated supply climate system 1', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43009', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_calculated_supply_climate_system_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Calculated supply climate system 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_calculated_supply_climate_system_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_condenser_bt12-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_condenser_bt12', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Condenser (BT12)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40017', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_condenser_bt12-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Condenser (BT12)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_condenser_bt12', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_condenser_bt12_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_condenser_bt12_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Condenser (BT12)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40017', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_condenser_bt12_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Condenser (BT12)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_condenser_bt12_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40079', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40079', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE2)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40081', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE2)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be2_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be2_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE2)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40081', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be2_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE2)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be2_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE3)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40083', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE3)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be3_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_be3_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current (BE3)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40083', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_be3_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Gotham City Current (BE3)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_be3_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_compressor_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_compressor_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-41778', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_compressor_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Current compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_compressor_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_compressor_frequency_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_compressor_frequency_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-41778', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_compressor_frequency_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Current compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_compressor_frequency_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_fan_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_fan_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current fan mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_mode', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43108', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_fan_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Current fan mode', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_fan_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_fan_mode_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_fan_mode_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current fan mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_mode', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43108', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_fan_mode_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Current fan mode', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_fan_mode_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_hot_water_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_hot_water_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current hot water mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43109', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_hot_water_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Current hot water mode', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_hot_water_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_hot_water_mode_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_hot_water_mode_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current hot water mode', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43109', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_hot_water_mode_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Current hot water mode', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_hot_water_mode_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_outd_temp_bt1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_outd_temp_bt1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current outd temp (BT1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40004', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_outd_temp_bt1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Current outd temp (BT1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_outd_temp_bt1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-9.3', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_outd_temp_bt1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_current_outd_temp_bt1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current outd temp (BT1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40004', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_current_outd_temp_bt1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Current outd temp (BT1)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_current_outd_temp_bt1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-9.3', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_decrease_from_reference_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_decrease_from_reference_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Decrease from reference value', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43125', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_decrease_from_reference_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Decrease from reference value', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_decrease_from_reference_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_decrease_from_reference_value_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_decrease_from_reference_value_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Decrease from reference value', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43125', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_decrease_from_reference_value_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Decrease from reference value', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_decrease_from_reference_value_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_defrosting_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_defrosting_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrosting time', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43066', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_defrosting_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Gotham City Defrosting time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_defrosting_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_defrosting_time_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_defrosting_time_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrosting time', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43066', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_defrosting_time_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Gotham City Defrosting time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_defrosting_time_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_degree_minutes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_degree_minutes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Degree minutes', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_degree_minutes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Degree minutes', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_degree_minutes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-875', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_degree_minutes_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_degree_minutes_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Degree minutes', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40940', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_degree_minutes_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Degree minutes', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_degree_minutes_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-875', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_desired_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Desired humidity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-42770', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Desired humidity', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_desired_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_desired_humidity_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Desired humidity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49633', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Desired humidity', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_desired_humidity_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_desired_humidity_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Desired humidity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-42770', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Desired humidity', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_desired_humidity_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_desired_humidity_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Desired humidity', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49633', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_desired_humidity_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Desired humidity', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_desired_humidity_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_discharge_bt14-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_discharge_bt14', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharge (BT14)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40018', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_discharge_bt14-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Discharge (BT14)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_discharge_bt14', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '89.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_discharge_bt14_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_discharge_bt14_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharge (BT14)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40018', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_discharge_bt14_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Discharge (BT14)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_discharge_bt14_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '89.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_dt_inverter_exh_air_bt20-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_dt_inverter_exh_air_bt20', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'dT Inverter - exh air (BT20)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43146', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_dt_inverter_exh_air_bt20-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City dT Inverter - exh air (BT20)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_dt_inverter_exh_air_bt20', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_dt_inverter_exh_air_bt20_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_dt_inverter_exh_air_bt20_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'dT Inverter - exh air (BT20)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43146', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_dt_inverter_exh_air_bt20_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City dT Inverter - exh air (BT20)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_dt_inverter_exh_air_bt20_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_evaporator_bt16-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_evaporator_bt16', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Evaporator (BT16)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40020', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_evaporator_bt16-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Evaporator (BT16)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_evaporator_bt16', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-14.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_evaporator_bt16_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_evaporator_bt16_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Evaporator (BT16)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40020', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_evaporator_bt16_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Evaporator (BT16)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_evaporator_bt16_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-14.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_exhaust_air_bt20-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_exhaust_air_bt20', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Exhaust air (BT20)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40025', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_exhaust_air_bt20-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Exhaust air (BT20)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_exhaust_air_bt20', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_exhaust_air_bt20_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_exhaust_air_bt20_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Exhaust air (BT20)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40025', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_exhaust_air_bt20_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Exhaust air (BT20)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_exhaust_air_bt20_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_extract_air_bt21-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_extract_air_bt21', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Extract air (BT21)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40026', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_extract_air_bt21-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Extract air (BT21)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_extract_air_bt21', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-12.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_extract_air_bt21_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_extract_air_bt21_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Extract air (BT21)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40026', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_extract_air_bt21_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Extract air (BT21)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_extract_air_bt21_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-12.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_heating_medium_pump_speed_gp1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_heating_medium_pump_speed_gp1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating medium pump speed (GP1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43437', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_heating_medium_pump_speed_gp1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Heating medium pump speed (GP1)', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_heating_medium_pump_speed_gp1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '79', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_heating_medium_pump_speed_gp1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_heating_medium_pump_speed_gp1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating medium pump speed (GP1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43437', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_heating_medium_pump_speed_gp1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Heating medium pump speed (GP1)', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_heating_medium_pump_speed_gp1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '79', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_current_value_bt12_bt63-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charge_current_value_bt12_bt63', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water: charge current value ((BT12 | BT63))', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43116', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_current_value_bt12_bt63-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water: charge current value ((BT12 | BT63))', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charge_current_value_bt12_bt63', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_current_value_bt12_bt63_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charge_current_value_bt12_bt63_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water: charge current value ((BT12 | BT63))', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43116', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_current_value_bt12_bt63_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water: charge current value ((BT12 | BT63))', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charge_current_value_bt12_bt63_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_set_point_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charge_set_point_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water: charge set point value', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43115', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_set_point_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water: charge set point value', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charge_set_point_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_set_point_value_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charge_set_point_value_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water: charge set point value', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43115', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charge_set_point_value_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water: charge set point value', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charge_set_point_value_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charging_bt6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charging_bt6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water charging (BT6)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40014', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charging_bt6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water charging (BT6)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charging_bt6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charging_bt6_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_charging_bt6_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water charging (BT6)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40014', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_charging_bt6_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water charging (BT6)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_charging_bt6_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_top_bt7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_top_bt7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water top (BT7)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40013', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_top_bt7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water top (BT7)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_top_bt7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '46', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_top_bt7_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_hot_water_top_bt7_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water top (BT7)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40013', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_hot_water_top_bt7_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Hot water top (BT7)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_hot_water_top_bt7_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '46', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Alarm', + 'Alarm', + 'Active', + 'Off', + 'Blocked', + 'Off', + 'Active', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_int_elec_add_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Int elec add heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Int elec add heat', + 'options': list([ + 'Alarm', + 'Alarm', + 'Active', + 'Off', + 'Blocked', + 'Off', + 'Active', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_int_elec_add_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Active', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Alarm', + 'Alarm', + 'Active', + 'Off', + 'Blocked', + 'Off', + 'Active', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Int elec add heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Int elec add heat', + 'options': list([ + 'Alarm', + 'Alarm', + 'Active', + 'Off', + 'Blocked', + 'Off', + 'Active', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Active', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_raw-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_raw', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Int elec add heat raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_raw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Int elec add heat raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_raw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_raw_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_raw_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Int elec add heat raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elect_add', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49993-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_int_elec_add_heat_raw_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Int elec add heat raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_int_elec_add_heat_raw_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_inverter_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_inverter_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inverter temperature', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43140', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_inverter_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Inverter temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_inverter_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_inverter_temperature_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_inverter_temperature_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inverter temperature', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43140', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_inverter_temperature_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Inverter temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_inverter_temperature_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_liquid_line_bt15-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_liquid_line_bt15', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Liquid line (BT15)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40019', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_liquid_line_bt15-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Liquid line (BT15)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_liquid_line_bt15', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_liquid_line_bt15_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_liquid_line_bt15_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Liquid line (BT15)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40019', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_liquid_line_bt15_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Liquid line (BT15)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_liquid_line_bt15_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_max_compressor_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_max_compressor_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_max_compressor_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Max compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_max_compressor_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_max_compressor_frequency_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_max_compressor_frequency_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43123', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_max_compressor_frequency_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Max compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_max_compressor_frequency_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_min_compressor_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_min_compressor_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Min compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43122', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_min_compressor_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Min compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_min_compressor_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_min_compressor_frequency_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_min_compressor_frequency_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Min compressor frequency', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43122', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_min_compressor_frequency_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Gotham City Min compressor frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_min_compressor_frequency_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_bt29-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_oil_temperature_bt29', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil temperature (BT29)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40146', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_bt29-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Oil temperature (BT29)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_oil_temperature_bt29', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_bt29_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_oil_temperature_bt29_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil temperature (BT29)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40146', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_bt29_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Oil temperature (BT29)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_oil_temperature_bt29_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_ep15_bt29-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_oil_temperature_ep15_bt29', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil temperature (EP15-BT29)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40145', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_ep15_bt29-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Oil temperature (EP15-BT29)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_oil_temperature_ep15_bt29', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_ep15_bt29_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_oil_temperature_ep15_bt29_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oil temperature (EP15-BT29)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40145', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_oil_temperature_ep15_bt29_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Oil temperature (EP15-BT29)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_oil_temperature_ep15_bt29_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Hot water', + 'Heating', + 'Pool', + 'Pool 2', + 'Trans\xadfer', + 'Cooling', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_priority', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Priority', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'priority', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Priority', + 'options': list([ + 'Off', + 'Hot water', + 'Heating', + 'Pool', + 'Pool 2', + 'Trans\xadfer', + 'Cooling', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_priority', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Hot water', + 'Heating', + 'Pool', + 'Pool 2', + 'Trans\xadfer', + 'Cooling', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_priority_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Priority', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'priority', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Priority', + 'options': list([ + 'Off', + 'Hot water', + 'Heating', + 'Pool', + 'Pool 2', + 'Trans\xadfer', + 'Cooling', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_priority_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_raw-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_priority_raw', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Prior\xadity raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'priority', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_raw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Prior\xadity raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_priority_raw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_raw_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_priority_raw_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Prior\xadity raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'priority', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-49994-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_priority_raw_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Prior\xadity raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_priority_raw_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_r_start_diff_additional_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'r start diff additional heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072r', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City r start diff additional heat', + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_r_start_diff_additional_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '700', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_r_start_diff_additional_heat_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'r start diff additional heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-148072r', + 'unit_of_measurement': 'DM', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City r start diff additional heat', + 'unit_of_measurement': 'DM', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_r_start_diff_additional_heat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '700', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_reference_air_speed_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_reference_air_speed_sensor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reference, air speed sensor', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'airflow', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43124', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_reference_air_speed_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Gotham City Reference, air speed sensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_reference_air_speed_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.6', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_reference_air_speed_sensor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_reference_air_speed_sensor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reference, air speed sensor', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'airflow', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43124', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_reference_air_speed_sensor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Gotham City Reference, air speed sensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_reference_air_speed_sensor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.6', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_return_line_bt3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return line (BT3)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40012', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Return line (BT3)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_return_line_bt3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt3_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_return_line_bt3_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return line (BT3)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40012', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt3_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Return line (BT3)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_return_line_bt3_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.4', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt62-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_return_line_bt62', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return line (BT62)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40048', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt62-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Return line (BT62)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_return_line_bt62', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt62_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_return_line_bt62_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return line (BT62)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40048', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_return_line_bt62_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Return line (BT62)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_return_line_bt62_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_room_temperature_bt50-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_room_temperature_bt50', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Room temperature (BT50)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40033', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_room_temperature_bt50-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Room temperature (BT50)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_room_temperature_bt50', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_room_temperature_bt50_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_room_temperature_bt50_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Room temperature (BT50)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40033', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_room_temperature_bt50_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Room temperature (BT50)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_room_temperature_bt50_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.2', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Starts', + 'Runs', + 'Stops', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_status_compressor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status compressor', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_compressor', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Status compressor', + 'options': list([ + 'Off', + 'Starts', + 'Runs', + 'Stops', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_status_compressor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Runs', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Starts', + 'Runs', + 'Stops', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_status_compressor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status compressor', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_compressor', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gotham City Status compressor', + 'options': list([ + 'Off', + 'Starts', + 'Runs', + 'Stops', + ]), + }), + 'context': , + 'entity_id': 'sensor.gotham_city_status_compressor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Runs', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_raw-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_status_compressor_raw', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status com\xadpressor raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_compressor', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_raw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Status com\xadpressor raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_status_compressor_raw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_raw_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_status_compressor_raw_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status com\xadpressor raw', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status_compressor', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43427-raw', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.gotham_city_status_compressor_raw_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Status com\xadpressor raw', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_status_compressor_raw_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_suction_gas_bt17-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_suction_gas_bt17', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Suction gas (BT17)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40022', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_suction_gas_bt17-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Suction gas (BT17)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_suction_gas_bt17', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_suction_gas_bt17_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_suction_gas_bt17_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Suction gas (BT17)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40022', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_suction_gas_bt17_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Suction gas (BT17)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_suction_gas_bt17_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.1', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_supply_line_bt2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply line (BT2)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40008', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Supply line (BT2)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_supply_line_bt2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt2_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_supply_line_bt2_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply line (BT2)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40008', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt2_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Supply line (BT2)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_supply_line_bt2_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39.7', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt61-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_supply_line_bt61', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply line (BT61)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40047', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt61-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Supply line (BT61)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_supply_line_bt61', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt61_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_supply_line_bt61_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply line (BT61)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40047', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.gotham_city_supply_line_bt61_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Gotham City Supply line (BT61)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gotham_city_supply_line_bt61_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_time_factor_add_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_time_factor_add_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time factor add heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43081', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_time_factor_add_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Time factor add heat', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_time_factor_add_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1686.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_time_factor_add_heat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_time_factor_add_heat_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time factor add heat', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-43081', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_time_factor_add_heat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Time factor add heat', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_time_factor_add_heat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1686.9', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_value_air_velocity_sensor_bs1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_value_air_velocity_sensor_bs1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Value, air velocity sensor (BS1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40050', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_value_air_velocity_sensor_bs1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Value, air velocity sensor (BS1)', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_value_air_velocity_sensor_bs1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.5', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_value_air_velocity_sensor_bs1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gotham_city_value_air_velocity_sensor_bs1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Value, air velocity sensor (BS1)', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-40050', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensor_states[sensor.gotham_city_value_air_velocity_sensor_bs1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Value, air velocity sensor (BS1)', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.gotham_city_value_air_velocity_sensor_bs1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.5', + }) +# --- diff --git a/tests/components/myuplink/snapshots/test_switch.ambr b/tests/components/myuplink/snapshots/test_switch.ambr new file mode 100644 index 00000000000..5d621e661ee --- /dev/null +++ b/tests/components/myuplink/snapshots/test_switch.ambr @@ -0,0 +1,185 @@ +# serializer version: 1 +# name: test_switch_states[platforms0][switch.gotham_city_increased_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gotham_city_increased_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'In\xadcreased venti\xadlation', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boost_ventilation', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50005', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_increased_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City In\xadcreased venti\xadlation', + }), + 'context': , + 'entity_id': 'switch.gotham_city_increased_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_increased_ventilation_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gotham_city_increased_ventilation_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'In\xadcreased venti\xadlation', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boost_ventilation', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50005', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_increased_ventilation_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City In\xadcreased venti\xadlation', + }), + 'context': , + 'entity_id': 'switch.gotham_city_increased_ventilation_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_temporary_lux-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gotham_city_temporary_lux', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tempo\xadrary lux', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temporary_lux', + 'unique_id': 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50004', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_temporary_lux-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Tempo\xadrary lux', + }), + 'context': , + 'entity_id': 'switch.gotham_city_temporary_lux', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_temporary_lux_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gotham_city_temporary_lux_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tempo\xadrary lux', + 'platform': 'myuplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temporary_lux', + 'unique_id': 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-50004', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.gotham_city_temporary_lux_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gotham City Tempo\xadrary lux', + }), + 'context': , + 'entity_id': 'switch.gotham_city_temporary_lux_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/myuplink/test_binary_sensor.py b/tests/components/myuplink/test_binary_sensor.py index 128a4ebdde9..160530bcdab 100644 --- a/tests/components/myuplink/test_binary_sensor.py +++ b/tests/components/myuplink/test_binary_sensor.py @@ -1,57 +1,28 @@ -"""Tests for myuplink sensor module.""" +"""Tests for myuplink binary sensor module.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch -import pytest +from syrupy import SnapshotAssertion -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -# Test one entity from each of binary_sensor classes. -@pytest.mark.parametrize( - ("entity_id", "friendly_name", "test_attributes", "expected_state"), - [ - ( - "binary_sensor.gotham_city_pump_heating_medium_gp1", - "Gotham City Pump: Heating medium (GP1)", - True, - STATE_ON, - ), - ( - "binary_sensor.gotham_city_connectivity", - "Gotham City Connectivity", - False, - STATE_ON, - ), - ( - "binary_sensor.gotham_city_alarm", - "Gotham City Pump: Alarm", - False, - STATE_OFF, - ), - ], -) -async def test_sensor_states( +async def test_binary_sensor_states( hass: HomeAssistant, mock_myuplink_client: MagicMock, mock_config_entry: MockConfigEntry, - entity_id: str, - friendly_name: str, - test_attributes: bool, - expected_state: str, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: - """Test sensor state.""" - await setup_integration(hass, mock_config_entry) + """Test binary sensor state.""" - state = hass.states.get(entity_id) - assert state is not None - assert state.state == expected_state - if test_attributes: - assert state.attributes == { - "friendly_name": friendly_name, - } + with patch("homeassistant.components.myuplink.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/myuplink/test_config_flow.py b/tests/components/myuplink/test_config_flow.py index c24d26057de..0b8d0dba17a 100644 --- a/tests/components/myuplink/test_config_flow.py +++ b/tests/components/myuplink/test_config_flow.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from .const import CLIENT_ID +from .const import CLIENT_ID, UNIQUE_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -29,6 +29,7 @@ async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, + access_token: str, setup_credentials, ) -> None: """Check full flow.""" @@ -59,7 +60,7 @@ async def test_full_flow( OAUTH2_TOKEN, json={ "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", + "access_token": access_token, "type": "Bearer", "expires_in": 60, }, @@ -68,41 +69,67 @@ async def test_full_flow( with patch( f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True ) as mock_setup: - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 + assert result["data"]["auth_implementation"] == DOMAIN + assert result["data"]["token"]["refresh_token"] == "mock-refresh-token" + assert result["result"].unique_id == UNIQUE_ID + @pytest.mark.usefixtures("current_request_with_host") -async def test_flow_reauth( +@pytest.mark.parametrize( + ("unique_id", "scope", "expected_reason"), + [ + ( + UNIQUE_ID, + CURRENT_SCOPE, + "reauth_successful", + ), + ( + "wrong_uid", + CURRENT_SCOPE, + "account_mismatch", + ), + ( + UNIQUE_ID, + "READSYSTEM offline_access", + "reauth_successful", + ), + ], + ids=["reauth_only", "account_mismatch", "wrong_scope"], +) +async def test_flow_reauth_abort( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, setup_credentials: None, mock_config_entry: MockConfigEntry, + access_token: str, expires_at: float, + unique_id: str, + scope: str, + expected_reason: str, ) -> None: - """Test reauth step.""" + """Test reauth step with correct params and mismatches.""" - OLD_SCOPE = "READSYSTEM offline_access" - OLD_SCOPE_TOKEN = { + CURRENT_TOKEN = { "auth_implementation": DOMAIN, "token": { - "access_token": "Fake_token", - "scope": OLD_SCOPE, + "access_token": access_token, + "scope": scope, "expires_in": 86399, "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", "token_type": "Bearer", "expires_at": expires_at, }, } - assert mock_config_entry.data["token"]["scope"] == CURRENT_SCOPE assert hass.config_entries.async_update_entry( - mock_config_entry, data=OLD_SCOPE_TOKEN + mock_config_entry, data=CURRENT_TOKEN, unique_id=unique_id ) - assert mock_config_entry.data["token"]["scope"] == OLD_SCOPE - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 result = await mock_config_entry.start_reauth_flow(hass) @@ -137,7 +164,7 @@ async def test_flow_reauth( OAUTH2_TOKEN, json={ "refresh_token": "updated-refresh-token", - "access_token": "updated-access-token", + "access_token": access_token, "type": "Bearer", "expires_in": "60", "scope": CURRENT_SCOPE, @@ -146,13 +173,104 @@ async def test_flow_reauth( with patch( f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True - ) as mock_setup: + ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "reauth_successful" + assert result.get("reason") == expected_reason + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("unique_id", "scope", "expected_reason"), + [ + ( + UNIQUE_ID, + CURRENT_SCOPE, + "reconfigure_successful", + ), + ( + "wrong_uid", + CURRENT_SCOPE, + "account_mismatch", + ), + ], + ids=["reauth_only", "account_mismatch"], +) +async def test_flow_reconfigure_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + mock_config_entry: MockConfigEntry, + access_token: str, + expires_at: float, + unique_id: str, + scope: str, + expected_reason: str, +) -> None: + """Test reauth step with correct params and mismatches.""" + + CURRENT_TOKEN = { + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "scope": scope, + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + } + assert hass.config_entries.async_update_entry( + mock_config_entry, data=CURRENT_TOKEN, unique_id=unique_id + ) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["step_id"] == "auth" + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + f"&scope={CURRENT_SCOPE.replace(' ', '+')}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": "60", + "scope": CURRENT_SCOPE, + }, + ) + + with patch( + f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == expected_reason assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 - assert mock_config_entry.data["token"]["scope"] == CURRENT_SCOPE diff --git a/tests/components/myuplink/test_init.py b/tests/components/myuplink/test_init.py index b474db731d1..fda0d3526f9 100644 --- a/tests/components/myuplink/test_init.py +++ b/tests/components/myuplink/test_init.py @@ -4,17 +4,21 @@ import http import time from unittest.mock import MagicMock +from aiohttp import ClientConnectionError import pytest from homeassistant.components.myuplink.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from . import setup_integration +from .const import UNIQUE_ID from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator async def test_load_unload_entry( @@ -70,6 +74,37 @@ async def test_expired_token_refresh_failure( assert mock_config_entry.state is expected_state +@pytest.mark.parametrize( + ("expires_at", "expected_state"), + [ + ( + time.time() - 3600, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=[ + "client_connection_error", + ], +) +async def test_expired_token_refresh_connection_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a ClientError.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + exc=ClientConnectionError(), + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state + + @pytest.mark.parametrize( "load_systems_file", [load_fixture("systems.json", DOMAIN)], @@ -92,7 +127,90 @@ async def test_devices_multiple_created_count( mock_myuplink_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test that multiple device are created.""" + """Test that multiple devices are created.""" await setup_integration(hass, mock_config_entry) assert len(device_registry.devices) == 2 + + +async def test_migrate_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_myuplink_client: MagicMock, + expires_at: float, + access_token: str, +) -> None: + """Test migration of config entry.""" + mock_entry_v1_1 = MockConfigEntry( + version=1, + minor_version=1, + domain=DOMAIN, + title="myUplink test", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "scope": "WRITESYSTEM READSYSTEM offline_access", + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + }, + entry_id="myuplink_test", + ) + + await setup_integration(hass, mock_entry_v1_1) + assert mock_entry_v1_1.version == 1 + assert mock_entry_v1_1.minor_version == 2 + assert mock_entry_v1_1.unique_id == UNIQUE_ID + + +async def test_oaut2_scope_failure( + hass: HomeAssistant, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an incorrect OAuth2 scope fails.""" + + mock_config_entry.data["token"]["scope"] = "wrong_scope" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_device_remove_devices( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + mock_myuplink_client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device( + identifiers={ + ( + DOMAIN, + "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ) + }, + ) + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, mock_config_entry.entry_id) + assert not response["success"] + + old_device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "OLD-DEVICE-UUID")}, + ) + response = await client.remove_device( + old_device_entry.id, mock_config_entry.entry_id + ) + assert response["success"] diff --git a/tests/components/myuplink/test_number.py b/tests/components/myuplink/test_number.py index 4106af1b5b9..ef7b1749782 100644 --- a/tests/components/myuplink/test_number.py +++ b/tests/components/myuplink/test_number.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.number import SERVICE_SET_VALUE from homeassistant.const import ATTR_ENTITY_ID, Platform @@ -11,6 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from tests.common import MockConfigEntry, snapshot_platform + TEST_PLATFORM = Platform.NUMBER pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) @@ -31,24 +34,6 @@ async def test_entity_registry( assert entry.unique_id == ENTITY_UID -async def test_attributes( - hass: HomeAssistant, - mock_myuplink_client: MagicMock, - setup_platform: None, -) -> None: - """Test the entity attributes are correct.""" - - state = hass.states.get(ENTITY_ID) - assert state.state == "1.0" - assert state.attributes == { - "friendly_name": ENTITY_FRIENDLY_NAME, - "min": -10.0, - "max": 10.0, - "mode": "auto", - "step": 1.0, - } - - async def test_set_value( hass: HomeAssistant, mock_myuplink_client: MagicMock, @@ -98,3 +83,16 @@ async def test_entity_registry_smo20( entry = entity_registry.async_get("number.gotham_city_change_in_curve") assert entry.unique_id == "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47028" + + +async def test_number_states( + hass: HomeAssistant, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test number entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/myuplink/test_select.py b/tests/components/myuplink/test_select.py index 7ad2d17cb5d..f1797ebe5ad 100644 --- a/tests/components/myuplink/test_select.py +++ b/tests/components/myuplink/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest +from syrupy import SnapshotAssertion from homeassistant.const import ( ATTR_ENTITY_ID, @@ -15,6 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from tests.common import MockConfigEntry, snapshot_platform + TEST_PLATFORM = Platform.SELECT pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) @@ -23,27 +26,6 @@ ENTITY_FRIENDLY_NAME = "Gotham City comfort mode" ENTITY_UID = "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47041" -async def test_select_entity( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_myuplink_client: MagicMock, - setup_platform: None, -) -> None: - """Test that the entities are registered in the entity registry.""" - - entry = entity_registry.async_get(ENTITY_ID) - assert entry.unique_id == ENTITY_UID - - # Test the select attributes are correct. - - state = hass.states.get(ENTITY_ID) - assert state.state == "Economy" - assert state.attributes == { - "options": ["Smart control", "Economy", "Normal", "Luxury"], - "friendly_name": ENTITY_FRIENDLY_NAME, - } - - async def test_selecting( hass: HomeAssistant, mock_myuplink_client: MagicMock, @@ -87,3 +69,16 @@ async def test_entity_registry_smo20( entry = entity_registry.async_get("select.gotham_city_all") assert entry.unique_id == "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff-47660" + + +async def test_select_states( + hass: HomeAssistant, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test select entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/myuplink/test_sensor.py b/tests/components/myuplink/test_sensor.py index 8fecb787122..98cdfc322da 100644 --- a/tests/components/myuplink/test_sensor.py +++ b/tests/components/myuplink/test_sensor.py @@ -1,28 +1,30 @@ """Tests for myuplink sensor module.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_states( hass: HomeAssistant, mock_myuplink_client: MagicMock, mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Test sensor state.""" - await setup_integration(hass, mock_config_entry) - state = hass.states.get("sensor.gotham_city_average_outdoor_temp_bt1") - assert state is not None - assert state.state == "-12.2" - assert state.attributes == { - "friendly_name": "Gotham City Average outdoor temp (BT1)", - "device_class": "temperature", - "state_class": "measurement", - "unit_of_measurement": "°C", - } + with patch("homeassistant.components.myuplink.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/myuplink/test_switch.py b/tests/components/myuplink/test_switch.py index 5e309e7152e..82d381df7fc 100644 --- a/tests/components/myuplink/test_switch.py +++ b/tests/components/myuplink/test_switch.py @@ -4,18 +4,20 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest +from syrupy import SnapshotAssertion from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from tests.common import MockConfigEntry, snapshot_platform + TEST_PLATFORM = Platform.SWITCH pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) @@ -36,20 +38,6 @@ async def test_entity_registry( assert entry.unique_id == ENTITY_UID -async def test_attributes( - hass: HomeAssistant, - mock_myuplink_client: MagicMock, - setup_platform: None, -) -> None: - """Test the switch attributes are correct.""" - - state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF - assert state.attributes == { - "friendly_name": ENTITY_FRIENDLY_NAME, - } - - @pytest.mark.parametrize( ("service"), [ @@ -109,3 +97,16 @@ async def test_entity_registry_smo20( entry = entity_registry.async_get(ENTITY_ID) assert entry.unique_id == ENTITY_UID + + +async def test_switch_states( + hass: HomeAssistant, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test switch entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 6c11399c888..80c6e86f420 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -6,16 +6,16 @@ from unittest.mock import patch from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError import pytest -from homeassistant.components import zeroconf from homeassistant.components.nam.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("10.10.2.3"), ip_addresses=[ip_address("10.10.2.3")], hostname="mock_hostname", diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 97a314b0bf4..ba89405bc97 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -9,11 +9,15 @@ from aionanoleaf import InvalidToken, Unauthorized, Unavailable import pytest from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.nanoleaf.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -248,13 +252,13 @@ async def test_discovery_link_unavailable( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery_info}", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, + properties={ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, type=type_in_discovery_info, ), ) @@ -384,13 +388,13 @@ async def test_import_discovery_integration( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery}", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, + properties={ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, type=type_in_discovery, ), ) @@ -432,7 +436,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={}, diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 8f1f0a2f074..803f6076ecc 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -5,17 +5,14 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Generator import copy from dataclasses import dataclass +import re from typing import Any -from google_nest_sdm.auth import AbstractAuth -from google_nest_sdm.device import Device -from google_nest_sdm.device_manager import DeviceManager -from google_nest_sdm.event import EventMessage -from google_nest_sdm.event_media import CachePolicy -from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber +from google_nest_sdm.streaming_manager import Message from homeassistant.components.application_credentials import ClientCredential from homeassistant.components.nest import DOMAIN +from homeassistant.components.nest.const import API_URL # Typing helpers type PlatformSetup = Callable[[], Awaitable[None]] @@ -66,68 +63,22 @@ TEST_CONFIG_NEW_SUBSCRIPTION = NestTestConfig( credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), ) - -class FakeSubscriber(GoogleNestSubscriber): - """Fake subscriber that supplies a FakeDeviceManager.""" - - stop_calls = 0 - - def __init__(self) -> None: # pylint: disable=super-init-not-called - """Initialize Fake Subscriber.""" - self._device_manager = DeviceManager() - self._subscriber_name = "fake-name" - - def set_update_callback(self, target: Callable[[EventMessage], Awaitable[None]]): - """Capture the callback set by Home Assistant.""" - self._device_manager.set_update_callback(target) - - async def create_subscription(self): - """Create the subscription.""" - return - - async def delete_subscription(self): - """Delete the subscription.""" - return - - async def start_async(self): - """Return the fake device manager.""" - return self._device_manager - - async def async_get_device_manager(self) -> DeviceManager: - """Return the fake device manager.""" - return self._device_manager - - @property - def cache_policy(self) -> CachePolicy: - """Return the cache policy.""" - return self._device_manager.cache_policy - - def stop_async(self): - """No-op to stop the subscriber.""" - self.stop_calls += 1 - - async def async_receive_event(self, event_message: EventMessage): - """Simulate a received pubsub message, invoked by tests.""" - # Update device state, then invoke HomeAssistant to refresh - await self._device_manager.async_handle_event(event_message) - - -DEVICE_ID = "enterprise/project-id/devices/device-id" +DEVICE_ID = "enterprises/project-id/devices/device-id" DEVICE_COMMAND = f"{DEVICE_ID}:executeCommand" +DEVICE_URL_MATCH = re.compile( + f"{API_URL}/enterprises/project-id/devices/[^:]+:executeCommand" +) +TEST_IMAGE_URL = "https://domain/sdm_event_snapshot/dGTZwR3o4Y1..." +TEST_CLIP_URL = "https://domain/clip/XyZ.mp4" class CreateDevice: """Fixture used for creating devices.""" - def __init__( - self, - device_manager: DeviceManager, - auth: AbstractAuth, - ) -> None: + def __init__(self) -> None: """Initialize CreateDevice.""" - self.device_manager = device_manager - self.auth = auth self.data = {"traits": {}} + self.devices = [] def create( self, @@ -138,4 +89,9 @@ class CreateDevice: data = copy.deepcopy(self.data) data.update(raw_data if raw_data else {}) data["traits"].update(raw_traits if raw_traits else {}) - self.device_manager.add_device(Device.MakeDevice(data, auth=self.auth)) + self.devices.append(data) + + +def create_nest_event(data: dict[str, Any]) -> Message: + """Create a pub/sub event message for testing.""" + return Message.from_data(data) diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 84f22e17e78..b5e3cd2b91c 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -2,7 +2,6 @@ from __future__ import annotations -from asyncio import AbstractEventLoop from collections.abc import Generator import copy import shutil @@ -13,100 +12,131 @@ import uuid import aiohttp from google_nest_sdm import diagnostics -from google_nest_sdm.auth import AbstractAuth -from google_nest_sdm.device_manager import DeviceManager +from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber +from google_nest_sdm.streaming_manager import StreamingManager import pytest +from yarl import URL from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.nest import DOMAIN -from homeassistant.components.nest.const import CONF_SUBSCRIBER_ID, SDM_SCOPES +from homeassistant.components.nest.const import API_URL, CONF_SUBSCRIBER_ID, SDM_SCOPES from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .common import ( DEVICE_ID, + DEVICE_URL_MATCH, PROJECT_ID, SUBSCRIBER_ID, + TEST_CLIP_URL, TEST_CONFIG_APP_CREDS, + TEST_IMAGE_URL, CreateDevice, - FakeSubscriber, NestTestConfig, PlatformSetup, YieldFixture, ) from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse FAKE_TOKEN = "some-token" FAKE_REFRESH_TOKEN = "some-refresh-token" -class FakeAuth(AbstractAuth): - """A fake implementation of the auth class that records requests. +class FakeAuth: + """A fixture for request handling that records requests. - This class captures the outgoing requests, and can also be used by - tests to set up fake responses. This class is registered as a response - handler for a fake aiohttp_server and can simulate successes or failures - from the API. + This class is used with AiohttpClientMocker to capture outgoing requests + and can also be used by tests to set up fake responses. """ - def __init__(self) -> None: + def __init__( + self, + aioclient_mock: AiohttpClientMocker, + device_factory: CreateDevice, + project_id: str, + ) -> None: """Initialize FakeAuth.""" - super().__init__(None, None) - # Tests can set fake responses here. - self.responses = [] + # Tests can factory fixture to create fake device responses. + self.device_factory = device_factory + # Tests can set fake structure responses here. + self.structures: list[dict[str, Any]] = [] + # Tests can set fake command responses here. + self.responses: list[aiohttp.web.Response] = [] + # The last request is recorded here. self.method = None self.url = None self.json = None self.headers = None self.captured_requests = [] - # Set up by fixture - self.client = None - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - return "" + # API makes a call to request structures to initiate pubsub feed, but the + # integration does not use this. + aioclient_mock.get( + f"{API_URL}/enterprises/{project_id}/structures", + side_effect=self.request_structures, + ) + aioclient_mock.get( + f"{API_URL}/enterprises/{project_id}/devices", + side_effect=self.request_devices, + ) + aioclient_mock.post(DEVICE_URL_MATCH, side_effect=self.request) + aioclient_mock.get(TEST_IMAGE_URL, side_effect=self.request) + aioclient_mock.get(TEST_CLIP_URL, side_effect=self.request) - async def request(self, method, url, **kwargs): + async def request_structures( + self, method: str, url: str, data: dict[str, Any] + ) -> AiohttpClientMockResponse: + """Handle requests to create devices.""" + return AiohttpClientMockResponse( + method, url, json={"structures": self.structures} + ) + + async def request_devices( + self, method: str, url: str, data: dict[str, Any] + ) -> AiohttpClientMockResponse: + """Handle requests to create devices.""" + return AiohttpClientMockResponse( + method, url, json={"devices": self.device_factory.devices} + ) + + async def request( + self, method: str, url: URL, data: dict[str, Any] + ) -> AiohttpClientMockResponse: """Capure the request arguments for tests to assert on.""" self.method = method - self.url = url - self.json = kwargs.get("json") - self.headers = kwargs.get("headers") - self.captured_requests.append((method, url, self.json, self.headers)) - return await self.client.get("/") + str_url = str(url) + self.url = str_url[len(API_URL) + 1 :] + self.json = data + self.captured_requests.append((method, url, self.json)) - async def response_handler(self, request): - """Handle fake responess for aiohttp_server.""" if len(self.responses) > 0: - return self.responses.pop(0) - return aiohttp.web.json_response() + response = self.responses.pop(0) + return AiohttpClientMockResponse( + method, url, response=response.body, status=response.status + ) + return AiohttpClientMockResponse(method, url) + + +@pytest.fixture(name="device_access_project_id") +def mock_device_access_project_id() -> str: + """Fixture to configure the device access console project id used in tests.""" + return PROJECT_ID @pytest.fixture -def aiohttp_client( - event_loop: AbstractEventLoop, - aiohttp_client: ClientSessionGenerator, - socket_enabled: None, -) -> ClientSessionGenerator: - """Return aiohttp_client and allow opening sockets.""" - return aiohttp_client - - -@pytest.fixture -async def auth(aiohttp_client: ClientSessionGenerator) -> FakeAuth: +async def auth( + aioclient_mock: AiohttpClientMocker, + create_device: CreateDevice, + device_access_project_id: str, +) -> FakeAuth: """Fixture for an AbstractAuth.""" - auth = FakeAuth() - app = aiohttp.web.Application() - app.router.add_get("/", auth.response_handler) - app.router.add_post("/", auth.response_handler) - auth.client = await aiohttp_client(app) - return auth + return FakeAuth(aioclient_mock, create_device, device_access_project_id) @pytest.fixture(autouse=True) @@ -119,20 +149,32 @@ def cleanup_media_storage(hass: HomeAssistant) -> Generator[None]: @pytest.fixture -def subscriber() -> YieldFixture[FakeSubscriber]: - """Set up the FakeSusbcriber.""" - subscriber = FakeSubscriber() +def subscriber_side_effect() -> Any | None: + """Fixture to inject failures into FakeSubscriber start.""" + return None + + +@pytest.fixture(autouse=True) +def subscriber(subscriber_side_effect: Any | None) -> Generator[AsyncMock]: + """Fixture to allow tests to emulate the pub/sub subscriber receiving messages.""" with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - yield subscriber + "google_nest_sdm.google_nest_subscriber.StreamingManager", spec=StreamingManager + ) as mock_manager: + # Use side_effect to capture the callback + def mock_init(**kwargs: Any) -> AsyncMock: + mock_manager.async_receive_event = kwargs["callback"] + if subscriber_side_effect is not None: + mock_manager.start.side_effect = subscriber_side_effect + return mock_manager + + mock_manager.side_effect = mock_init + yield mock_manager @pytest.fixture def mock_subscriber() -> YieldFixture[AsyncMock]: """Fixture for injecting errors into the subscriber.""" - mock_subscriber = AsyncMock(FakeSubscriber) + mock_subscriber = AsyncMock(GoogleNestSubscriber) with patch( "homeassistant.components.nest.api.GoogleNestSubscriber", return_value=mock_subscriber, @@ -140,12 +182,6 @@ def mock_subscriber() -> YieldFixture[AsyncMock]: yield mock_subscriber -@pytest.fixture -async def device_manager(subscriber: FakeSubscriber) -> DeviceManager: - """Set up the DeviceManager.""" - return await subscriber.async_get_device_manager() - - @pytest.fixture async def device_id() -> str: """Fixture to set default device id used when creating devices.""" @@ -166,14 +202,12 @@ async def device_traits() -> dict[str, Any]: @pytest.fixture async def create_device( - device_manager: DeviceManager, - auth: FakeAuth, device_id: str, device_type: str, device_traits: dict[str, Any], ) -> CreateDevice: """Fixture for creating devices.""" - factory = CreateDevice(device_manager, auth) + factory = CreateDevice() factory.data.update( { "name": device_id, @@ -262,6 +296,7 @@ async def setup_base_platform( hass: HomeAssistant, platforms: list[str], config_entry: MockConfigEntry | None, + auth: FakeAuth, ) -> YieldFixture[PlatformSetup]: """Fixture to setup the integration platform.""" config_entry.add_to_hass(hass) @@ -278,7 +313,8 @@ async def setup_base_platform( @pytest.fixture async def setup_platform( - setup_base_platform: PlatformSetup, subscriber: FakeSubscriber + setup_base_platform: PlatformSetup, + subscriber: AsyncMock, ) -> PlatformSetup: """Fixture to setup the integration platform and subscriber.""" return setup_base_platform diff --git a/tests/components/nest/snapshots/test_diagnostics.ambr b/tests/components/nest/snapshots/test_diagnostics.ambr index aa679b8821c..5e17156fd6a 100644 --- a/tests/components/nest/snapshots/test_diagnostics.ambr +++ b/tests/components/nest/snapshots/test_diagnostics.ambr @@ -31,6 +31,9 @@ }), }), ]), + 'subscriber': dict({ + 'start': 1, + }), }) # --- # name: test_device_diagnostics @@ -85,5 +88,8 @@ }), }), ]), + 'subscriber': dict({ + 'start': 1, + }), }) # --- diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index a13d4d3a337..98c3e06cfb8 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -9,16 +9,16 @@ The tests below exercise both cases during integration setup. """ import time -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch -from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber +from google.oauth2.credentials import Credentials import pytest from homeassistant.components.nest.const import API_URL, OAUTH2_TOKEN, SDM_SCOPES from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from .common import CLIENT_ID, CLIENT_SECRET, PROJECT_ID, FakeSubscriber, PlatformSetup +from .common import CLIENT_ID, CLIENT_SECRET, PROJECT_ID, PlatformSetup from .conftest import FAKE_REFRESH_TOKEN, FAKE_TOKEN from tests.test_util.aiohttp import AiohttpClientMocker @@ -27,7 +27,7 @@ FAKE_UPDATED_TOKEN = "fake-updated-token" @pytest.fixture -def subscriber() -> FakeSubscriber | None: +def subscriber() -> Mock | None: """Disable default subscriber since tests use their own patch.""" return None @@ -54,16 +54,16 @@ async def test_auth( # Prepare to capture credentials for Subscriber captured_creds = None - async def async_new_subscriber( - creds, subscription_name, event_loop, async_callback - ) -> GoogleNestSubscriber | None: + def async_new_subscriber( + credentials: Credentials, + ) -> Mock: """Capture credentials for tests.""" nonlocal captured_creds - captured_creds = creds - return None # GoogleNestSubscriber + captured_creds = credentials + return AsyncMock() with patch( - "google_nest_sdm.google_nest_subscriber.DefaultSubscriberFactory.async_new_subscriber", + "google_nest_sdm.subscriber_client.pubsub_v1.SubscriberAsyncClient", side_effect=async_new_subscriber, ) as new_subscriber_mock: await setup_platform() @@ -122,16 +122,16 @@ async def test_auth_expired_token( # Prepare to capture credentials for Subscriber captured_creds = None - async def async_new_subscriber( - creds, subscription_name, event_loop, async_callback - ) -> GoogleNestSubscriber | None: + def async_new_subscriber( + credentials: Credentials, + ) -> Mock: """Capture credentials for tests.""" nonlocal captured_creds - captured_creds = creds - return None # GoogleNestSubscriber + captured_creds = credentials + return AsyncMock() with patch( - "google_nest_sdm.google_nest_subscriber.DefaultSubscriberFactory.async_new_subscriber", + "google_nest_sdm.subscriber_client.pubsub_v1.SubscriberAsyncClient", side_effect=async_new_subscriber, ) as new_subscriber_mock: await setup_platform() diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 698e9b7a274..3e7dbd3f223 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .common import DEVICE_ID, CreateDevice, FakeSubscriber, PlatformSetup +from .common import DEVICE_ID, CreateDevice, PlatformSetup from .conftest import FakeAuth from tests.common import async_fire_time_changed @@ -520,13 +520,10 @@ async def test_camera_removed( hass: HomeAssistant, auth: FakeAuth, camera_device: None, - subscriber: FakeSubscriber, setup_platform: PlatformSetup, ) -> None: """Test case where entities are removed and stream tokens revoked.""" await setup_platform() - # Simplify test setup - subscriber.cache_policy.fetch = False assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") @@ -780,7 +777,7 @@ async def test_camera_web_rtc_offer_failure( assert response["event"] == { "type": "error", "code": "webrtc_offer_failed", - "message": "Nest API error: Bad Request response from API (400)", + "message": "Nest API error: response from API (400)", } diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index 88847759a16..fe148c2529d 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -7,13 +7,13 @@ pubsub subscriber. from collections.abc import Awaitable, Callable from http import HTTPStatus from typing import Any +from unittest.mock import AsyncMock import aiohttp -from google_nest_sdm.auth import AbstractAuth -from google_nest_sdm.event import EventMessage import pytest from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, ATTR_FAN_MODES, @@ -45,8 +45,8 @@ from .common import ( DEVICE_COMMAND, DEVICE_ID, CreateDevice, - FakeSubscriber, PlatformSetup, + create_nest_event, ) from .conftest import FakeAuth @@ -72,14 +72,13 @@ def device_traits() -> dict[str, Any]: @pytest.fixture async def create_event( hass: HomeAssistant, - auth: AbstractAuth, - subscriber: FakeSubscriber, + subscriber: AsyncMock, ) -> CreateEvent: """Fixture to send a pub/sub event.""" async def create_event(traits: dict[str, Any]) -> None: await subscriber.async_receive_event( - EventMessage.create_event( + create_nest_event( { "eventId": EVENT_ID, "timestamp": "2019-01-01T00:00:01Z", @@ -88,7 +87,6 @@ async def create_event( "traits": traits, }, }, - auth=auth, ) ) await hass.async_block_till_done() @@ -125,6 +123,9 @@ async def test_thermostat_off( "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 40.6, + }, }, ) await setup_platform() @@ -134,6 +135,7 @@ async def test_thermostat_off( assert thermostat is not None assert thermostat.state == HVACMode.OFF assert thermostat.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF + assert thermostat.attributes[ATTR_CURRENT_HUMIDITY] == 40.6 assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVACMode.HEAT, @@ -166,6 +168,9 @@ async def test_thermostat_heat( "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 40.6, + }, "sdm.devices.traits.ThermostatTemperatureSetpoint": { "heatCelsius": 22.0, }, @@ -178,6 +183,7 @@ async def test_thermostat_heat( assert thermostat is not None assert thermostat.state == HVACMode.HEAT assert thermostat.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert thermostat.attributes[ATTR_CURRENT_HUMIDITY] == 40.6 assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVACMode.HEAT, @@ -886,6 +892,9 @@ async def test_thermostat_fan_off( "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 40.6, + }, } ) await setup_platform() @@ -895,6 +904,7 @@ async def test_thermostat_fan_off( assert thermostat is not None assert thermostat.state == HVACMode.COOL assert thermostat.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert thermostat.attributes[ATTR_CURRENT_HUMIDITY] == 40.6 assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVACMode.HEAT, @@ -935,6 +945,9 @@ async def test_thermostat_fan_on( "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 40.6, + }, } ) await setup_platform() @@ -944,6 +957,7 @@ async def test_thermostat_fan_on( assert thermostat is not None assert thermostat.state == HVACMode.COOL assert thermostat.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert thermostat.attributes[ATTR_CURRENT_HUMIDITY] == 40.6 assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVACMode.HEAT, @@ -1131,6 +1145,9 @@ async def test_thermostat_fan_empty( "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 40.6, + }, } ) await setup_platform() @@ -1140,6 +1157,7 @@ async def test_thermostat_fan_empty( assert thermostat is not None assert thermostat.state == HVACMode.OFF assert thermostat.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF + assert thermostat.attributes[ATTR_CURRENT_HUMIDITY] == 40.6 assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVACMode.HEAT, @@ -1184,6 +1202,9 @@ async def test_thermostat_invalid_fan_mode( "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 40.6, + }, } ) await setup_platform() @@ -1193,6 +1214,7 @@ async def test_thermostat_invalid_fan_mode( assert thermostat is not None assert thermostat.state == HVACMode.COOL assert thermostat.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert thermostat.attributes[ATTR_CURRENT_HUMIDITY] == 40.6 assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVACMode.HEAT, diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 807e299b79c..f08eeb82a1d 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -7,16 +7,15 @@ from typing import Any from unittest.mock import patch from google_nest_sdm.exceptions import AuthException -from google_nest_sdm.structure import Structure import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .common import ( CLIENT_ID, @@ -25,9 +24,9 @@ from .common import ( SUBSCRIBER_ID, TEST_CONFIG_APP_CREDS, TEST_CONFIGFLOW_APP_CREDS, - FakeSubscriber, NestTestConfig, ) +from .conftest import FakeAuth, PlatformSetup from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -37,7 +36,7 @@ WEB_REDIRECT_URL = "https://example.com/auth/external/callback" APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" RAND_SUBSCRIBER_SUFFIX = "ABCDEF" -FAKE_DHCP_DATA = dhcp.DhcpServiceInfo( +FAKE_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="fake_hostname" ) @@ -58,6 +57,11 @@ def mock_rand_topic_name_fixture() -> None: yield +@pytest.fixture(autouse=True) +def mock_request_setup(auth: FakeAuth) -> None: + """Fixture to ensure fake requests are setup.""" + + class OAuthFixture: """Simulate the oauth flow used by the config flow.""" @@ -257,12 +261,6 @@ def mock_subscriptions() -> list[tuple[str, str]]: return [] -@pytest.fixture(name="device_access_project_id") -def mock_device_access_project_id() -> str: - """Fixture to configure the device access console project id used in tests.""" - return PROJECT_ID - - @pytest.fixture(name="cloud_project_id") def mock_cloud_project_id() -> str: """Fixture to configure the cloud console project id used in tests.""" @@ -350,8 +348,7 @@ def mock_pubsub_api_responses( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_app_credentials( hass: HomeAssistant, - oauth, - subscriber, + oauth: OAuthFixture, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -388,7 +385,7 @@ async def test_app_credentials( ("sdm_managed_topic", "device_access_project_id", "cloud_project_id"), [(True, "new-project-id", "new-cloud-project-id")], ) -async def test_config_flow_restart(hass: HomeAssistant, oauth, subscriber) -> None: +async def test_config_flow_restart(hass: HomeAssistant, oauth: OAuthFixture) -> None: """Check with auth implementation is re-initialized when aborting the flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -443,8 +440,7 @@ async def test_config_flow_restart(hass: HomeAssistant, oauth, subscriber) -> No @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_config_flow_wrong_project_id( hass: HomeAssistant, - oauth, - subscriber, + oauth: OAuthFixture, ) -> None: """Check the case where the wrong project ids are entered.""" result = await hass.config_entries.flow.async_init( @@ -500,8 +496,7 @@ async def test_config_flow_wrong_project_id( ) async def test_config_flow_pubsub_configuration_error( hass: HomeAssistant, - oauth, - mock_subscriber, + oauth: OAuthFixture, ) -> None: """Check full flow fails with configuration error.""" result = await hass.config_entries.flow.async_init( @@ -546,7 +541,8 @@ async def test_config_flow_pubsub_configuration_error( [(True, HTTPStatus.INTERNAL_SERVER_ERROR)], ) async def test_config_flow_pubsub_subscriber_error( - hass: HomeAssistant, oauth, mock_subscriber + hass: HomeAssistant, + oauth: OAuthFixture, ) -> None: """Check full flow with a subscriber error.""" result = await hass.config_entries.flow.async_init( @@ -697,7 +693,8 @@ async def test_reauth_multiple_config_entries( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_pubsub_subscription_strip_whitespace( - hass: HomeAssistant, oauth, subscriber + hass: HomeAssistant, + oauth: OAuthFixture, ) -> None: """Check that project id has whitespace stripped on entry.""" result = await hass.config_entries.flow.async_init( @@ -776,10 +773,9 @@ async def test_pubsub_subscription_auth_failure( ) async def test_pubsub_subscriber_config_entry_reauth( hass: HomeAssistant, - oauth, - setup_platform, - subscriber, - config_entry, + oauth: OAuthFixture, + setup_platform: PlatformSetup, + config_entry: MockConfigEntry, ) -> None: """Test the pubsub subscriber id is preserved during reauth.""" await setup_platform() @@ -805,22 +801,21 @@ async def test_pubsub_subscriber_config_entry_reauth( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_config_entry_title_from_home( - hass: HomeAssistant, oauth, subscriber + hass: HomeAssistant, + oauth: OAuthFixture, + auth: FakeAuth, ) -> None: """Test that the Google Home name is used for the config entry title.""" - device_manager = await subscriber.async_get_device_manager() - device_manager.add_structure( - Structure.MakeStructure( - { - "name": f"enterprise/{PROJECT_ID}/structures/some-structure-id", - "traits": { - "sdm.structures.traits.Info": { - "customName": "Example Home", - }, + auth.structures.append( + { + "name": f"enterprise/{PROJECT_ID}/structures/some-structure-id", + "traits": { + "sdm.structures.traits.Info": { + "customName": "Example Home", }, - } - ) + }, + } ) result = await hass.config_entries.flow.async_init( @@ -848,13 +843,13 @@ async def test_config_entry_title_from_home( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_config_entry_title_multiple_homes( - hass: HomeAssistant, oauth, subscriber + hass: HomeAssistant, + oauth: OAuthFixture, + auth: FakeAuth, ) -> None: """Test handling of multiple Google Homes authorized.""" - - device_manager = await subscriber.async_get_device_manager() - device_manager.add_structure( - Structure.MakeStructure( + auth.structures.extend( + [ { "name": f"enterprise/{PROJECT_ID}/structures/id-1", "traits": { @@ -862,11 +857,7 @@ async def test_config_entry_title_multiple_homes( "customName": "Example Home #1", }, }, - } - ) - ) - device_manager.add_structure( - Structure.MakeStructure( + }, { "name": f"enterprise/{PROJECT_ID}/structures/id-2", "traits": { @@ -874,8 +865,8 @@ async def test_config_entry_title_multiple_homes( "customName": "Example Home #2", }, }, - } - ) + }, + ] ) result = await hass.config_entries.flow.async_init( @@ -923,18 +914,17 @@ async def test_title_failure_fallback( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) -async def test_structure_missing_trait(hass: HomeAssistant, oauth, subscriber) -> None: +async def test_structure_missing_trait( + hass: HomeAssistant, oauth: OAuthFixture, auth: FakeAuth +) -> None: """Test handling the case where a structure has no name set.""" - device_manager = await subscriber.async_get_device_manager() - device_manager.add_structure( - Structure.MakeStructure( - { - "name": f"enterprise/{PROJECT_ID}/structures/id-1", - # Missing Info trait - "traits": {}, - } - ) + auth.structures.append( + { + "name": f"enterprise/{PROJECT_ID}/structures/id-1", + # Missing Info trait + "traits": {}, + } ) result = await hass.config_entries.flow.async_init( @@ -973,8 +963,7 @@ async def test_dhcp_discovery( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_dhcp_discovery_with_creds( hass: HomeAssistant, - oauth, - subscriber, + oauth: OAuthFixture, ) -> None: """Exercise discovery dhcp with no config present (can't run).""" result = await hass.config_entries.flow.async_init( @@ -1029,7 +1018,6 @@ async def test_dhcp_discovery_with_creds( async def test_token_error( hass: HomeAssistant, oauth: OAuthFixture, - subscriber: FakeSubscriber, status_code: HTTPStatus, error_reason: str, ) -> None: @@ -1064,8 +1052,7 @@ async def test_token_error( ) async def test_existing_topic_and_subscription( hass: HomeAssistant, - oauth, - subscriber, + oauth: OAuthFixture, ) -> None: """Test selecting existing user managed topic and subscription.""" result = await hass.config_entries.flow.async_init( @@ -1103,8 +1090,7 @@ async def test_existing_topic_and_subscription( async def test_no_eligible_topics( hass: HomeAssistant, - oauth, - subscriber, + oauth: OAuthFixture, ) -> None: """Test the case where there are no eligible pub/sub topics.""" result = await hass.config_entries.flow.async_init( @@ -1127,8 +1113,7 @@ async def test_no_eligible_topics( ) async def test_list_topics_failure( hass: HomeAssistant, - oauth, - subscriber, + oauth: OAuthFixture, ) -> None: """Test selecting existing user managed topic and subscription.""" result = await hass.config_entries.flow.async_init( @@ -1151,8 +1136,7 @@ async def test_list_topics_failure( ) async def test_list_subscriptions_failure( hass: HomeAssistant, - oauth, - subscriber, + oauth: OAuthFixture, ) -> None: """Test selecting existing user managed topic and subscription.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index cf0e1c5ecce..33f5c1637fa 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -1,8 +1,8 @@ """The tests for Nest device triggers.""" from typing import Any +from unittest.mock import AsyncMock -from google_nest_sdm.event import EventMessage import pytest from pytest_unordered import unordered @@ -18,7 +18,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .common import DEVICE_ID, CreateDevice, FakeSubscriber, PlatformSetup +from .common import DEVICE_ID, CreateDevice, PlatformSetup, create_nest_event from tests.common import async_get_device_automations @@ -447,7 +447,7 @@ async def test_subscriber_automation( service_calls: list[ServiceCall], create_device: CreateDevice, setup_platform: PlatformSetup, - subscriber: FakeSubscriber, + subscriber: AsyncMock, ) -> None: """Test end to end subscriber triggers automation.""" create_device.create( @@ -465,7 +465,7 @@ async def test_subscriber_automation( assert await setup_automation(hass, device_entry.id, "camera_motion") # Simulate a pubsub message received by the subscriber with a motion event - event = EventMessage.create_event( + event = create_nest_event( { "eventId": "some-event-id", "timestamp": "2019-01-01T00:00:01Z", @@ -479,7 +479,6 @@ async def test_subscriber_automation( }, }, }, - auth=None, ) await subscriber.async_receive_event(event) await hass.async_block_till_done() diff --git a/tests/components/nest/test_event.py b/tests/components/nest/test_event.py index f45e6c1c6e6..942f19d6626 100644 --- a/tests/components/nest/test_event.py +++ b/tests/components/nest/test_event.py @@ -2,7 +2,7 @@ import datetime from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from google_nest_sdm.event import EventMessage, EventType @@ -13,7 +13,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow -from .common import DEVICE_ID, CreateDevice, FakeSubscriber +from .common import DEVICE_ID, TEST_CLIP_URL, CreateDevice, create_nest_event from .conftest import PlatformSetup EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." @@ -31,14 +31,6 @@ def platforms() -> list[Platform]: return [Platform.EVENT] -@pytest.fixture(autouse=True) -def enable_prefetch(subscriber: FakeSubscriber) -> None: - """Fixture to enable media fetching for tests to exercise.""" - subscriber.cache_policy.fetch = True - with patch("homeassistant.components.nest.EVENT_MEDIA_CACHE_SIZE", new=5): - yield - - @pytest.fixture def device_type() -> str: """Fixture for the type of device under test.""" @@ -80,7 +72,7 @@ def create_event_messages( events: dict[str, Any], parameters: dict[str, Any] | None = None ) -> EventMessage: """Create an EventMessage for events.""" - return EventMessage.create_event( + return create_nest_event( { "eventId": "some-event-id", "timestamp": utcnow().isoformat(timespec="seconds"), @@ -90,7 +82,6 @@ def create_event_messages( }, **(parameters if parameters else {}), }, - auth=None, ) @@ -152,7 +143,7 @@ def create_event_messages( ) async def test_receive_events( hass: HomeAssistant, - subscriber: FakeSubscriber, + subscriber: AsyncMock, setup_platform: PlatformSetup, create_device: CreateDevice, trait_types: list[TraitType], @@ -192,7 +183,7 @@ async def test_receive_events( @pytest.mark.parametrize(("trait_type"), [(TraitType.DOORBELL_CHIME)]) async def test_ignore_unrelated_event( hass: HomeAssistant, - subscriber: FakeSubscriber, + subscriber: AsyncMock, setup_platform: PlatformSetup, create_device: CreateDevice, trait_type: TraitType, @@ -222,7 +213,7 @@ async def test_ignore_unrelated_event( @pytest.mark.freeze_time("2024-08-24T12:00:00Z") async def test_event_threads( hass: HomeAssistant, - subscriber: FakeSubscriber, + subscriber: AsyncMock, setup_platform: PlatformSetup, create_device: CreateDevice, freezer: FrozenDateTimeFactory, @@ -275,7 +266,7 @@ async def test_event_threads( }, EventType.CAMERA_CLIP_PREVIEW: { "eventSessionId": EVENT_SESSION_ID, - "previewUrl": "http://example", + "previewUrl": TEST_CLIP_URL, }, }, parameters={"eventThreadState": "ENDED"}, @@ -306,7 +297,7 @@ async def test_event_threads( }, EventType.CAMERA_CLIP_PREVIEW: { "eventSessionId": EVENT_SESSION_ID2, - "previewUrl": "http://example", + "previewUrl": TEST_CLIP_URL, }, }, parameters={"eventThreadState": "ENDED"}, diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index e746e5f263f..d4ad81bd4e8 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -9,26 +9,38 @@ from __future__ import annotations from collections.abc import Mapping import datetime from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock -from google_nest_sdm.device import Device -from google_nest_sdm.event import EventMessage +import aiohttp import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow -from .common import CreateDevice +from .common import ( + DEVICE_ID, + TEST_CLIP_URL, + TEST_IMAGE_URL, + CreateDevice, + PlatformSetup, + create_nest_event, +) from tests.common import async_capture_events DOMAIN = "nest" -DEVICE_ID = "some-device-id" PLATFORM = "camera" NEST_EVENT = "nest_event" EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." +GENERATE_IMAGE_URL_RESPONSE = { + "results": { + "url": TEST_IMAGE_URL, + "token": "g.0.eventToken", + }, +} +IMAGE_BYTES_FROM_EVENT = b"test url image bytes" EVENT_KEYS = {"device_id", "type", "timestamp", "zones"} @@ -104,7 +116,7 @@ def create_events(events, device_id=DEVICE_ID, timestamp=None): """Create an EventMessage for events.""" if not timestamp: timestamp = utcnow() - return EventMessage.create_event( + return create_nest_event( { "eventId": "some-event-id", "timestamp": timestamp.isoformat(timespec="seconds"), @@ -113,7 +125,6 @@ def create_events(events, device_id=DEVICE_ID, timestamp=None): "events": events, }, }, - auth=None, ) @@ -167,7 +178,7 @@ async def test_event( entry = entity_registry.async_get("camera.front") assert entry is not None - assert entry.unique_id == "some-device-id-camera" + assert entry.unique_id == f"{DEVICE_ID}-camera" assert entry.domain == "camera" device = device_registry.async_get(entry.device_id) @@ -175,6 +186,11 @@ async def test_event( assert device.model == expected_model assert device.identifiers == {("nest", DEVICE_ID)} + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + timestamp = utcnow() await subscriber.async_receive_event(create_event(event_trait, timestamp=timestamp)) await hass.async_block_till_done() @@ -300,12 +316,11 @@ async def test_event_message_without_device_event( events = async_capture_events(hass, NEST_EVENT) await setup_platform() timestamp = utcnow() - event = EventMessage.create_event( + event = create_nest_event( { "eventId": "some-event-id", "timestamp": timestamp.isoformat(timespec="seconds"), }, - auth=None, ) await subscriber.async_receive_event(event) await hass.async_block_till_done() @@ -339,7 +354,7 @@ async def test_doorbell_event_thread( }, "sdm.devices.events.CameraClipPreview.ClipPreview": { "eventSessionId": EVENT_SESSION_ID, - "previewUrl": "image-url-1", + "previewUrl": TEST_CLIP_URL, }, }, }, @@ -356,9 +371,7 @@ async def test_doorbell_event_thread( "eventThreadState": "STARTED", } ) - await subscriber.async_receive_event( - EventMessage.create_event(message_data_1, auth=None) - ) + await subscriber.async_receive_event(create_nest_event(message_data_1)) # Publish message #2 that sends a no-op update to end the event thread timestamp2 = timestamp1 + datetime.timedelta(seconds=1) @@ -369,9 +382,7 @@ async def test_doorbell_event_thread( "eventThreadState": "ENDED", } ) - await subscriber.async_receive_event( - EventMessage.create_event(message_data_2, auth=None) - ) + await subscriber.async_receive_event(create_nest_event(message_data_2)) await hass.async_block_till_done() # The event is only published once @@ -415,7 +426,7 @@ async def test_doorbell_event_session_update( }, "sdm.devices.events.CameraClipPreview.ClipPreview": { "eventSessionId": EVENT_SESSION_ID, - "previewUrl": "image-url-1", + "previewUrl": TEST_CLIP_URL, }, }, timestamp=timestamp1, @@ -437,7 +448,7 @@ async def test_doorbell_event_session_update( }, "sdm.devices.events.CameraClipPreview.ClipPreview": { "eventSessionId": EVENT_SESSION_ID, - "previewUrl": "image-url-1", + "previewUrl": TEST_CLIP_URL, }, }, timestamp=timestamp2, @@ -459,7 +470,11 @@ async def test_doorbell_event_session_update( async def test_structure_update_event( - hass: HomeAssistant, entity_registry: er.EntityRegistry, subscriber, setup_platform + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + subscriber: AsyncMock, + setup_platform: PlatformSetup, + create_device: CreateDevice, ) -> None: """Test a pubsub message for a new device being added.""" events = async_capture_events(hass, NEST_EVENT) @@ -468,8 +483,8 @@ async def test_structure_update_event( # Entity for first device is registered assert entity_registry.async_get("camera.front") - new_device = Device.MakeDevice( - { + create_device.create( + raw_data={ "name": "device-id-2", "type": "sdm.devices.types.CAMERA", "traits": { @@ -479,16 +494,13 @@ async def test_structure_update_event( "sdm.devices.traits.CameraLiveStream": {}, }, }, - auth=None, ) - device_manager = await subscriber.async_get_device_manager() - device_manager.add_device(new_device) # Entity for new devie has not yet been loaded assert not entity_registry.async_get("camera.back") # Send a message that triggers the device to be loaded - message = EventMessage.create_event( + message = create_nest_event( { "eventId": "some-event-id", "timestamp": utcnow().isoformat(timespec="seconds"), @@ -498,17 +510,10 @@ async def test_structure_update_event( "object": "enterprise/example/devices/some-device-id2", }, }, - auth=None, ) - with ( - patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), - patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ), - ): - await subscriber.async_receive_event(message) - await hass.async_block_till_done() + + await subscriber.async_receive_event(message) + await hass.async_block_till_done() # No home assistant events published assert not events diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index 17ddc485e85..7d04624dcc8 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -9,9 +9,9 @@ relevant modes. """ from collections.abc import Generator +from http import HTTPStatus import logging -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from google_nest_sdm.exceptions import ( ApiException, @@ -29,11 +29,11 @@ from .common import ( PROJECT_ID, SUBSCRIBER_ID, TEST_CONFIG_NEW_SUBSCRIPTION, - FakeSubscriber, PlatformSetup, - YieldFixture, ) +from tests.test_util.aiohttp import AiohttpClientMocker + PLATFORM = "sensor" @@ -61,25 +61,6 @@ def warning_caplog( yield caplog -@pytest.fixture -def subscriber_side_effect() -> Any | None: - """Fixture to inject failures into FakeSubscriber start.""" - return None - - -@pytest.fixture -def failing_subscriber( - subscriber_side_effect: Any | None, -) -> YieldFixture[FakeSubscriber]: - """Fixture overriding default subscriber behavior to allow failure injection.""" - subscriber = FakeSubscriber() - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", - side_effect=subscriber_side_effect, - ): - yield subscriber - - async def test_setup_success( hass: HomeAssistant, error_caplog: pytest.LogCaptureFixture, setup_platform ) -> None: @@ -125,10 +106,9 @@ async def test_setup_configuration_failure( @pytest.mark.parametrize("subscriber_side_effect", [SubscriberException()]) -async def test_setup_susbcriber_failure( +async def test_setup_subscriber_failure( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - failing_subscriber, setup_base_platform, ) -> None: """Test configuration error.""" @@ -145,7 +125,6 @@ async def test_setup_device_manager_failure( ) -> None: """Test device manager api failure.""" with ( - patch("homeassistant.components.nest.api.GoogleNestSubscriber.start_async"), patch( "homeassistant.components.nest.api.GoogleNestSubscriber.async_get_device_manager", side_effect=ApiException(), @@ -165,7 +144,6 @@ async def test_subscriber_auth_failure( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, setup_base_platform, - failing_subscriber, ) -> None: """Test subscriber throws an authentication error.""" await setup_base_platform() @@ -184,7 +162,6 @@ async def test_subscriber_configuration_failure( hass: HomeAssistant, error_caplog: pytest.LogCaptureFixture, setup_base_platform, - failing_subscriber, ) -> None: """Test configuration error.""" await setup_base_platform() @@ -210,14 +187,12 @@ async def test_unload_entry(hass: HomeAssistant, setup_platform) -> None: async def test_remove_entry( hass: HomeAssistant, - setup_base_platform, + setup_base_platform: PlatformSetup, + aioclient_mock: AiohttpClientMocker, + subscriber: AsyncMock, ) -> None: """Test successful unload of a ConfigEntry.""" - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=FakeSubscriber(), - ): - await setup_base_platform() + await setup_base_platform() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -227,14 +202,18 @@ async def test_remove_entry( assert entry.data.get("subscriber_id") == SUBSCRIBER_ID assert entry.data.get("project_id") == PROJECT_ID - with ( - patch("homeassistant.components.nest.api.GoogleNestSubscriber.subscriber_id"), - patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.delete_subscription", - ) as delete, - ): - assert await hass.config_entries.async_remove(entry.entry_id) - assert delete.called + aioclient_mock.clear_requests() + aioclient_mock.delete( + f"https://pubsub.googleapis.com/v1/{SUBSCRIBER_ID}", + json={}, + ) + + assert not subscriber.stop.called + + assert await hass.config_entries.async_remove(entry.entry_id) + + assert aioclient_mock.call_count == 1 + assert subscriber.stop.called entries = hass.config_entries.async_entries(DOMAIN) assert not entries @@ -243,7 +222,7 @@ async def test_remove_entry( async def test_home_assistant_stop( hass: HomeAssistant, setup_platform: PlatformSetup, - subscriber: FakeSubscriber, + subscriber: AsyncMock, ) -> None: """Test successful subscriber shutdown when HomeAssistant stops.""" await setup_platform() @@ -253,31 +232,37 @@ async def test_home_assistant_stop( entry = entries[0] assert entry.state is ConfigEntryState.LOADED + assert not subscriber.stop.called await hass.async_stop() - assert subscriber.stop_calls == 1 + assert subscriber.stop.called async def test_remove_entry_delete_subscriber_failure( - hass: HomeAssistant, setup_base_platform + hass: HomeAssistant, + setup_base_platform: PlatformSetup, + aioclient_mock: AiohttpClientMocker, + subscriber: AsyncMock, ) -> None: """Test a failure when deleting the subscription.""" - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=FakeSubscriber(), - ): - await setup_base_platform() + await setup_base_platform() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 entry = entries[0] assert entry.state is ConfigEntryState.LOADED - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.delete_subscription", - side_effect=SubscriberException(), - ) as delete: - assert await hass.config_entries.async_remove(entry.entry_id) - assert delete.called + aioclient_mock.clear_requests() + aioclient_mock.delete( + f"https://pubsub.googleapis.com/v1/{SUBSCRIBER_ID}", + status=HTTPStatus.NOT_FOUND, + ) + + assert not subscriber.stop.called + + assert await hass.config_entries.async_remove(entry.entry_id) + + assert aioclient_mock.call_count == 1 + assert subscriber.stop.called entries = hass.config_entries.async_entries(DOMAIN) assert not entries diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 2526bfdf975..051f7bb87e4 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -13,7 +13,6 @@ from unittest.mock import patch import aiohttp import av -from google_nest_sdm.event import EventMessage import numpy as np import pytest @@ -31,7 +30,14 @@ from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .common import DEVICE_ID, CreateDevice, FakeSubscriber +from .common import ( + DEVICE_ID, + TEST_CLIP_URL, + TEST_IMAGE_URL, + CreateDevice, + create_nest_event, +) +from .conftest import FakeAuth from tests.common import MockUser, async_capture_events from tests.typing import ClientSessionGenerator @@ -70,7 +76,6 @@ BATTERY_CAMERA_TRAITS = { PERSON_EVENT = "sdm.devices.events.CameraPerson.Person" MOTION_EVENT = "sdm.devices.events.CameraMotion.Motion" -TEST_IMAGE_URL = "https://domain/sdm_event_snapshot/dGTZwR3o4Y1..." GENERATE_IMAGE_URL_RESPONSE = { "results": { "url": TEST_IMAGE_URL, @@ -162,12 +167,6 @@ def mp4() -> io.BytesIO: return output -@pytest.fixture(autouse=True) -def enable_prefetch(subscriber: FakeSubscriber) -> None: - """Fixture to enable media fetching for tests to exercise.""" - subscriber.cache_policy.fetch = True - - @pytest.fixture def cache_size() -> int: """Fixture for overrideing cache size.""" @@ -200,7 +199,7 @@ def create_event_message(event_data, timestamp, device_id=None): """Create an EventMessage for a single event type.""" if device_id is None: device_id = DEVICE_ID - return EventMessage.create_event( + return create_nest_event( { "eventId": f"{EVENT_ID}-{timestamp}", "timestamp": timestamp.isoformat(timespec="seconds"), @@ -209,7 +208,6 @@ def create_event_message(event_data, timestamp, device_id=None): "events": event_data, }, }, - auth=None, ) @@ -224,7 +222,7 @@ def create_battery_event_data( }, "sdm.devices.events.CameraClipPreview.ClipPreview": { "eventSessionId": event_session_id, - "previewUrl": "https://127.0.0.1/example", + "previewUrl": TEST_CLIP_URL, }, } @@ -284,7 +282,9 @@ async def test_supported_device( assert len(browse.children) == 0 -async def test_integration_unloaded(hass: HomeAssistant, auth, setup_platform) -> None: +async def test_integration_unloaded( + hass: HomeAssistant, auth: FakeAuth, setup_platform +) -> None: """Test the media player loads, but has no devices, when config unloaded.""" await setup_platform() @@ -1012,9 +1012,9 @@ async def test_media_permission_unauthorized( client = await hass_client() response = await client.get(media_url) - assert ( - response.status == HTTPStatus.UNAUTHORIZED - ), f"Response not matched: {response}" + assert response.status == HTTPStatus.UNAUTHORIZED, ( + f"Response not matched: {response}" + ) async def test_multiple_devices( @@ -1257,6 +1257,7 @@ async def test_media_store_save_filesystem_error( assert response.status == HTTPStatus.NOT_FOUND, f"Response not matched: {response}" +@pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) async def test_media_store_load_filesystem_error( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -1305,9 +1306,9 @@ async def test_media_store_load_filesystem_error( response = await client.get( f"/api/nest/event_media/{device.id}/{event_identifier}" ) - assert ( - response.status == HTTPStatus.NOT_FOUND - ), f"Response not matched: {response}" + assert response.status == HTTPStatus.NOT_FOUND, ( + f"Response not matched: {response}" + ) @pytest.mark.parametrize(("device_traits", "cache_size"), [(BATTERY_CAMERA_TRAITS, 5)]) diff --git a/tests/components/nest/test_sensor.py b/tests/components/nest/test_sensor.py index 2339d72ebc7..8bda1d387f8 100644 --- a/tests/components/nest/test_sensor.py +++ b/tests/components/nest/test_sensor.py @@ -5,8 +5,8 @@ pubsub subscriber. """ from typing import Any +from unittest.mock import AsyncMock -from google_nest_sdm.event import EventMessage import pytest from homeassistant.components.sensor import ( @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import DEVICE_ID, CreateDevice, FakeSubscriber, PlatformSetup +from .common import DEVICE_ID, CreateDevice, PlatformSetup, create_nest_event @pytest.fixture @@ -198,7 +198,7 @@ async def test_device_name_from_structure( async def test_event_updates_sensor( hass: HomeAssistant, - subscriber: FakeSubscriber, + subscriber: AsyncMock, create_device: CreateDevice, setup_platform: PlatformSetup, ) -> None: @@ -217,7 +217,7 @@ async def test_event_updates_sensor( assert temperature.state == "25.1" # Simulate a pubsub message received by the subscriber with a trait update - event = EventMessage.create_event( + event = create_nest_event( { "eventId": "some-event-id", "timestamp": "2019-01-01T00:00:01Z", @@ -230,7 +230,6 @@ async def test_event_updates_sensor( }, }, }, - auth=None, ) await subscriber.async_receive_event(event) await hass.async_block_till_done() # Process dispatch/update signal diff --git a/tests/components/netatmo/snapshots/test_button.ambr b/tests/components/netatmo/snapshots/test_button.ambr new file mode 100644 index 00000000000..6ad1b9e78ba --- /dev/null +++ b/tests/components/netatmo/snapshots/test_button.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_entity[button.bubendorff_blind_preferred_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.bubendorff_blind_preferred_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preferred position', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preferred_position', + 'unique_id': '0009999993-DeviceType.NBO-preferred_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[button.bubendorff_blind_preferred_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Bubendorff blind Preferred position', + }), + 'context': , + 'entity_id': 'button.bubendorff_blind_preferred_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity[button.entrance_blinds_preferred_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.entrance_blinds_preferred_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preferred position', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preferred_position', + 'unique_id': '0009999992-DeviceType.NBR-preferred_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[button.entrance_blinds_preferred_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Entrance Blinds Preferred position', + }), + 'context': , + 'entity_id': 'button.entrance_blinds_preferred_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/netatmo/test_button.py b/tests/components/netatmo/test_button.py new file mode 100644 index 00000000000..681e42af051 --- /dev/null +++ b/tests/components/netatmo/test_button.py @@ -0,0 +1,72 @@ +"""The tests for Netatmo button.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from .common import selected_platforms, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.BUTTON, + entity_registry, + snapshot, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_setup_and_services( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: + """Test setup and services.""" + with selected_platforms([Platform.BUTTON]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + button_entity = "button.entrance_blinds_preferred_position" + + assert hass.states.get(button_entity).state == STATE_UNKNOWN + + # Test button press + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: button_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "0009999992", + "target_position": -2, + "bridge": "12:34:56:30:d5:d4", + } + ] + } + ) + + assert (state := hass.states.get(button_entity)) + assert state.state != STATE_UNKNOWN diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 436f75b12ec..f5714d69a98 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -7,7 +7,6 @@ from pyatmo.const import ALL_SCOPES import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.netatmo import config_flow from homeassistant.components.netatmo.const import ( CONF_NEW_AREA, @@ -20,6 +19,10 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .conftest import CLIENT_ID @@ -46,13 +49,13 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( "netatmo", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py index 724a0568580..3c83bb57c43 100644 --- a/tests/components/netgear/test_config_flow.py +++ b/tests/components/netgear/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import Mock, patch from pynetgear import DEFAULT_USER import pytest -from homeassistant.components import ssdp from homeassistant.components.netgear.const import ( CONF_CONSIDER_HOME, DOMAIN, @@ -23,6 +22,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -208,14 +213,14 @@ async def test_ssdp_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL_SLL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -228,13 +233,13 @@ async def test_ssdp_no_serial(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, }, ), ) @@ -253,14 +258,14 @@ async def test_ssdp_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URLipv6, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -273,14 +278,14 @@ async def test_ssdp(hass: HomeAssistant, service) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -305,14 +310,14 @@ async def test_ssdp_port_5555(hass: HomeAssistant, service) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL_SLL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: MODELS_PORT_5555[0], - ssdp.ATTR_UPNP_PRESENTATION_URL: URL_SSL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: MODELS_PORT_5555[0], + ATTR_UPNP_PRESENTATION_URL: URL_SSL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) diff --git a/tests/components/network/__init__.py b/tests/components/network/__init__.py index f3ccacbd064..bbac7ca2f7c 100644 --- a/tests/components/network/__init__.py +++ b/tests/components/network/__init__.py @@ -1 +1,4 @@ """Tests for the Network Configuration integration.""" + +NO_LOOPBACK_IPADDR = "192.168.1.5" +LOOPBACK_IPADDR = "127.0.0.1" diff --git a/tests/components/network/conftest.py b/tests/components/network/conftest.py index d5fbb95a814..db2f268e968 100644 --- a/tests/components/network/conftest.py +++ b/tests/components/network/conftest.py @@ -1,14 +1,51 @@ """Tests for the Network Configuration integration.""" from collections.abc import Generator -from unittest.mock import _patch +from unittest.mock import MagicMock, Mock, _patch, patch +import ifaddr import pytest +from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR + + +def _generate_mock_adapters(): + mock_lo0 = Mock(spec=ifaddr.Adapter) + mock_lo0.nice_name = "lo0" + mock_lo0.ips = [ifaddr.IP(LOOPBACK_IPADDR, 8, "lo0")] + mock_lo0.index = 0 + mock_eth0 = Mock(spec=ifaddr.Adapter) + mock_eth0.nice_name = "eth0" + mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")] + mock_eth0.index = 1 + mock_eth1 = Mock(spec=ifaddr.Adapter) + mock_eth1.nice_name = "eth1" + mock_eth1.ips = [ifaddr.IP(NO_LOOPBACK_IPADDR, 23, "eth1")] + mock_eth1.index = 2 + mock_vtun0 = Mock(spec=ifaddr.Adapter) + mock_vtun0.nice_name = "vtun0" + mock_vtun0.ips = [ifaddr.IP("169.254.3.2", 16, "vtun0")] + mock_vtun0.index = 3 + return [mock_eth0, mock_lo0, mock_eth1, mock_vtun0] + + +def _mock_socket(sockname: list[str]) -> Generator[None]: + """Mock the network socket.""" + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=MagicMock(getsockname=Mock(return_value=sockname)), + ): + yield + @pytest.fixture(autouse=True) -def mock_network(): +def mock_network() -> Generator[None]: """Override mock of network util's async_get_adapters.""" + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + yield @pytest.fixture(autouse=True) @@ -19,3 +56,21 @@ def override_mock_get_source_ip( mock_get_source_ip.stop() yield mock_get_source_ip.start() + + +@pytest.fixture +def mock_socket(request: pytest.FixtureRequest) -> Generator[None]: + """Mock the network socket.""" + yield from _mock_socket(request.param) + + +@pytest.fixture +def mock_socket_loopback() -> Generator[None]: + """Mock the network socket with loopback address.""" + yield from _mock_socket([LOOPBACK_IPADDR]) + + +@pytest.fixture +def mock_socket_no_loopback() -> Generator[None]: + """Mock the network socket with loopback address.""" + yield from _mock_socket([NO_LOOPBACK_IPADDR]) diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index dca31106dba..a2352e6af9e 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -4,7 +4,6 @@ from ipaddress import IPv4Address from typing import Any from unittest.mock import MagicMock, Mock, patch -import ifaddr import pytest from homeassistant.components import network @@ -20,17 +19,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR + from tests.typing import WebSocketGenerator -_NO_LOOPBACK_IPADDR = "192.168.1.5" -_LOOPBACK_IPADDR = "127.0.0.1" - - -def _mock_socket(sockname): - mock_socket = MagicMock() - mock_socket.getsockname = Mock(return_value=sockname) - return mock_socket - def _mock_cond_socket(sockname): class CondMockSock(MagicMock): @@ -54,42 +46,13 @@ def _mock_socket_exception(exc): return mock_socket -def _generate_mock_adapters(): - mock_lo0 = Mock(spec=ifaddr.Adapter) - mock_lo0.nice_name = "lo0" - mock_lo0.ips = [ifaddr.IP("127.0.0.1", 8, "lo0")] - mock_lo0.index = 0 - mock_eth0 = Mock(spec=ifaddr.Adapter) - mock_eth0.nice_name = "eth0" - mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")] - mock_eth0.index = 1 - mock_eth1 = Mock(spec=ifaddr.Adapter) - mock_eth1.nice_name = "eth1" - mock_eth1.ips = [ifaddr.IP("192.168.1.5", 23, "eth1")] - mock_eth1.index = 2 - mock_vtun0 = Mock(spec=ifaddr.Adapter) - mock_vtun0.nice_name = "vtun0" - mock_vtun0.ips = [ifaddr.IP("169.254.3.2", 16, "vtun0")] - mock_vtun0.index = 3 - return [mock_eth0, mock_lo0, mock_eth1, mock_vtun0] - - +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_detect_interfaces_setting_non_loopback_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns a non-loopback address.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -141,22 +104,13 @@ async def test_async_detect_interfaces_setting_non_loopback_route( ] +@pytest.mark.usefixtures("mock_socket_loopback") async def test_async_detect_interfaces_setting_loopback_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns a loopback address.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -207,22 +161,14 @@ async def test_async_detect_interfaces_setting_loopback_route( ] +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_detect_interfaces_setting_empty_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns nothing.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -277,15 +223,9 @@ async def test_async_detect_interfaces_setting_exception( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route throws an exception.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket_exception(AttributeError), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket_exception(AttributeError), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -339,6 +279,7 @@ async def test_async_detect_interfaces_setting_exception( ] +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_interfaces_configured_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -348,18 +289,9 @@ async def test_interfaces_configured_from_storage( "key": STORAGE_KEY, "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == ["eth0", "eth1", "vtun0"] @@ -422,15 +354,9 @@ async def test_interfaces_configured_from_storage_websocket_update( "key": STORAGE_KEY, "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=MagicMock(getsockname=Mock(return_value=[NO_LOOPBACK_IPADDR])), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -546,6 +472,7 @@ async def test_interfaces_configured_from_storage_websocket_update( ] +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_matching_interface( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -556,22 +483,13 @@ async def test_async_get_source_ip_matching_interface( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == NO_LOOPBACK_IPADDR +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_interface_not_match( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -582,22 +500,14 @@ async def test_async_get_source_ip_interface_not_match( "data": {ATTR_CONFIGURED_ADAPTERS: ["vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "169.254.3.2" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "169.254.3.2" +@pytest.mark.parametrize("mock_socket", [[None]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_get_source_ip_cannot_determine_target( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -608,22 +518,13 @@ async def test_async_get_source_ip_cannot_determine_target( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([None]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == NO_LOOPBACK_IPADDR +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_ipv4_broadcast_addresses_default( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -634,24 +535,15 @@ async def test_async_get_ipv4_broadcast_addresses_default( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() assert await network.async_get_ipv4_broadcast_addresses(hass) == { IPv4Address("255.255.255.255") } +@pytest.mark.usefixtures("mock_socket_loopback") async def test_async_get_ipv4_broadcast_addresses_multiple( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -662,18 +554,8 @@ async def test_async_get_ipv4_broadcast_addresses_multiple( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() assert await network.async_get_ipv4_broadcast_addresses(hass) == { IPv4Address("255.255.255.255"), @@ -682,6 +564,7 @@ async def test_async_get_ipv4_broadcast_addresses_multiple( } +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_no_enabled_addresses( hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: @@ -692,24 +575,23 @@ async def test_async_get_source_ip_no_enabled_addresses( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[], - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[], ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert ( + await network.async_get_source_ip(hass, MDNS_TARGET_IP) + == NO_LOOPBACK_IPADDR + ) assert "source address detection may be inaccurate" in caplog.text +@pytest.mark.parametrize("mock_socket", [[None]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses( hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: @@ -720,15 +602,9 @@ async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[], - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([None]), - ), + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[], ): assert not await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -753,7 +629,7 @@ async def test_async_get_source_ip_no_ip_loopback( ), patch( "homeassistant.components.network.util.socket.socket", - return_value=_mock_cond_socket(_LOOPBACK_IPADDR), + return_value=_mock_cond_socket(LOOPBACK_IPADDR), ), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/network/test_system_health.py b/tests/components/network/test_system_health.py new file mode 100644 index 00000000000..eb383aafde7 --- /dev/null +++ b/tests/components/network/test_system_health.py @@ -0,0 +1,32 @@ +"""Test network system health.""" + +import asyncio + +import pytest + +from homeassistant.components.network.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import get_system_health_info + + +@pytest.mark.usefixtures("mock_socket_no_loopback") +async def test_network_system_health(hass: HomeAssistant) -> None: + """Test network system health.""" + + assert await async_setup_component(hass, "system_health", {}) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + info = await get_system_health_info(hass, "network") + + for key, val in info.items(): + if asyncio.iscoroutine(val): + info[key] = await val + + assert info == { + "adapters": "eth0 (disabled), lo0 (disabled), eth1 (enabled, default, auto), vtun0 (disabled)", + "announce_addresses": "192.168.1.5", + "ipv4_addresses": "eth0 (), lo0 (127.0.0.1/8), eth1 (192.168.1.5/23), vtun0 (169.254.3.2/16)", + "ipv6_addresses": "eth0 (2001:db8::/8), lo0 (), eth1 (), vtun0 ()", + } diff --git a/tests/components/nextdns/snapshots/test_sensor.ambr b/tests/components/nextdns/snapshots/test_sensor.ambr index a1ff0941e3f..14bebea53f8 100644 --- a/tests/components/nextdns/snapshots/test_sensor.ambr +++ b/tests/components/nextdns/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'supported_features': 0, 'translation_key': 'doh3_queries', 'unique_id': 'xyz12_doh3_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_over_http_3_queries-state] @@ -39,6 +39,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-HTTP/3 queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_http_3_queries', @@ -130,7 +131,7 @@ 'supported_features': 0, 'translation_key': 'doh_queries', 'unique_id': 'xyz12_doh_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_over_https_queries-state] @@ -138,6 +139,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-HTTPS queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_https_queries', @@ -229,7 +231,7 @@ 'supported_features': 0, 'translation_key': 'doq_queries', 'unique_id': 'xyz12_doq_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_over_quic_queries-state] @@ -237,6 +239,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-QUIC queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_quic_queries', @@ -328,7 +331,7 @@ 'supported_features': 0, 'translation_key': 'dot_queries', 'unique_id': 'xyz12_dot_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_over_tls_queries-state] @@ -336,6 +339,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS-over-TLS queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_over_tls_queries', @@ -427,7 +431,7 @@ 'supported_features': 0, 'translation_key': 'all_queries', 'unique_id': 'xyz12_all_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_queries-state] @@ -435,6 +439,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_queries', @@ -476,7 +481,7 @@ 'supported_features': 0, 'translation_key': 'blocked_queries', 'unique_id': 'xyz12_blocked_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_queries_blocked-state] @@ -484,6 +489,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS queries blocked', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_queries_blocked', @@ -575,7 +581,7 @@ 'supported_features': 0, 'translation_key': 'relayed_queries', 'unique_id': 'xyz12_relayed_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dns_queries_relayed-state] @@ -583,6 +589,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNS queries relayed', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dns_queries_relayed', @@ -624,7 +631,7 @@ 'supported_features': 0, 'translation_key': 'not_validated_queries', 'unique_id': 'xyz12_not_validated_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dnssec_not_validated_queries-state] @@ -632,6 +639,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNSSEC not validated queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dnssec_not_validated_queries', @@ -673,7 +681,7 @@ 'supported_features': 0, 'translation_key': 'validated_queries', 'unique_id': 'xyz12_validated_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_dnssec_validated_queries-state] @@ -681,6 +689,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile DNSSEC validated queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_dnssec_validated_queries', @@ -772,7 +781,7 @@ 'supported_features': 0, 'translation_key': 'encrypted_queries', 'unique_id': 'xyz12_encrypted_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_encrypted_queries-state] @@ -780,6 +789,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile Encrypted queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_encrypted_queries', @@ -871,7 +881,7 @@ 'supported_features': 0, 'translation_key': 'ipv4_queries', 'unique_id': 'xyz12_ipv4_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_ipv4_queries-state] @@ -879,6 +889,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile IPv4 queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_ipv4_queries', @@ -920,7 +931,7 @@ 'supported_features': 0, 'translation_key': 'ipv6_queries', 'unique_id': 'xyz12_ipv6_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_ipv6_queries-state] @@ -928,6 +939,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile IPv6 queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_ipv6_queries', @@ -1019,7 +1031,7 @@ 'supported_features': 0, 'translation_key': 'tcp_queries', 'unique_id': 'xyz12_tcp_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_tcp_queries-state] @@ -1027,6 +1039,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile TCP queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_tcp_queries', @@ -1118,7 +1131,7 @@ 'supported_features': 0, 'translation_key': 'udp_queries', 'unique_id': 'xyz12_udp_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_udp_queries-state] @@ -1126,6 +1139,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile UDP queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_udp_queries', @@ -1217,7 +1231,7 @@ 'supported_features': 0, 'translation_key': 'unencrypted_queries', 'unique_id': 'xyz12_unencrypted_queries', - 'unit_of_measurement': None, + 'unit_of_measurement': 'queries', }) # --- # name: test_sensor[sensor.fake_profile_unencrypted_queries-state] @@ -1225,6 +1239,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Fake Profile Unencrypted queries', 'state_class': , + 'unit_of_measurement': 'queries', }), 'context': , 'entity_id': 'sensor.fake_profile_unencrypted_queries', diff --git a/tests/components/nice_go/fixtures/get_all_barriers.json b/tests/components/nice_go/fixtures/get_all_barriers.json index 84799e0dd32..5a7607612c1 100644 --- a/tests/components/nice_go/fixtures/get_all_barriers.json +++ b/tests/components/nice_go/fixtures/get_all_barriers.json @@ -11,7 +11,6 @@ ], "state": { "deviceId": "1", - "desired": { "key": "value" }, "reported": { "displayName": "Test Garage 1", "autoDisabled": false, @@ -42,7 +41,6 @@ ], "state": { "deviceId": "2", - "desired": { "key": "value" }, "reported": { "displayName": "Test Garage 2", "autoDisabled": false, @@ -73,7 +71,6 @@ ], "state": { "deviceId": "3", - "desired": { "key": "value" }, "reported": { "displayName": "Test Garage 3", "autoDisabled": false, @@ -101,7 +98,6 @@ ], "state": { "deviceId": "4", - "desired": { "key": "value" }, "reported": { "displayName": "Test Garage 4", "autoDisabled": false, diff --git a/tests/components/nice_go/test_init.py b/tests/components/nice_go/test_init.py index 4eb3851516e..051c6623b23 100644 --- a/tests/components/nice_go/test_init.py +++ b/tests/components/nice_go/test_init.py @@ -81,7 +81,6 @@ async def test_firmware_update_required( "displayName": "test-display-name", "migrationStatus": "NOT_STARTED", }, - desired=None, connectionState=None, version=None, timestamp=None, diff --git a/tests/components/niko_home_control/__init__.py b/tests/components/niko_home_control/__init__.py new file mode 100644 index 00000000000..0182a24ba7c --- /dev/null +++ b/tests/components/niko_home_control/__init__.py @@ -0,0 +1,28 @@ +"""Tests for the niko_home_control integration.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def find_update_callback( + mock: AsyncMock, identifier: int +) -> Callable[[int], Awaitable[None]]: + """Find the update callback for a specific identifier.""" + for call in mock.register_callback.call_args_list: + if call[0][0] == identifier: + return call[0][1] + pytest.fail(f"Callback for identifier {identifier} not found") diff --git a/tests/components/niko_home_control/conftest.py b/tests/components/niko_home_control/conftest.py new file mode 100644 index 00000000000..130baf72228 --- /dev/null +++ b/tests/components/niko_home_control/conftest.py @@ -0,0 +1,93 @@ +"""niko_home_control integration tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from nhc.cover import NHCCover +from nhc.light import NHCLight +import pytest + +from homeassistant.components.niko_home_control.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override integration setup.""" + with patch( + "homeassistant.components.niko_home_control.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def light() -> NHCLight: + """Return a light mock.""" + mock = AsyncMock(spec=NHCLight) + mock.id = 1 + mock.type = 1 + mock.is_dimmable = False + mock.name = "light" + mock.suggested_area = "room" + mock.state = 100 + return mock + + +@pytest.fixture +def dimmable_light() -> NHCLight: + """Return a dimmable light mock.""" + mock = AsyncMock(spec=NHCLight) + mock.id = 2 + mock.type = 2 + mock.is_dimmable = True + mock.name = "dimmable light" + mock.suggested_area = "room" + mock.state = 100 + return mock + + +@pytest.fixture +def cover() -> NHCCover: + """Return a cover mock.""" + mock = AsyncMock(spec=NHCCover) + mock.id = 3 + mock.type = 4 + mock.name = "cover" + mock.suggested_area = "room" + mock.state = 100 + return mock + + +@pytest.fixture +def mock_niko_home_control_connection( + light: NHCLight, dimmable_light: NHCLight, cover: NHCCover +) -> Generator[AsyncMock]: + """Mock a NHC client.""" + with ( + patch( + "homeassistant.components.niko_home_control.NHCController", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.niko_home_control.config_flow.NHCController", + new=mock_client, + ), + ): + client = mock_client.return_value + client.lights = [light, dimmable_light] + client.covers = [cover] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Niko Home Control", + data={CONF_HOST: "192.168.0.123"}, + entry_id="01JFN93M7KRA38V5AMPCJ2JYYV", + ) diff --git a/tests/components/niko_home_control/snapshots/test_cover.ambr b/tests/components/niko_home_control/snapshots/test_cover.ambr new file mode 100644 index 00000000000..6f99c1adb8c --- /dev/null +++ b/tests/components/niko_home_control/snapshots/test_cover.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_cover[cover.cover-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.cover', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'niko_home_control', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JFN93M7KRA38V5AMPCJ2JYYV-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.cover-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'cover', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/niko_home_control/snapshots/test_light.ambr b/tests/components/niko_home_control/snapshots/test_light.ambr new file mode 100644 index 00000000000..702b7326ee2 --- /dev/null +++ b/tests/components/niko_home_control/snapshots/test_light.ambr @@ -0,0 +1,112 @@ +# serializer version: 1 +# name: test_entities[light.dimmable_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dimmable_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'niko_home_control', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JFN93M7KRA38V5AMPCJ2JYYV-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.dimmable_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'friendly_name': 'dimmable light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmable_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[light.light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'niko_home_control', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JFN93M7KRA38V5AMPCJ2JYYV-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/niko_home_control/test_config_flow.py b/tests/components/niko_home_control/test_config_flow.py new file mode 100644 index 00000000000..f911f4ebb1a --- /dev/null +++ b/tests/components/niko_home_control/test_config_flow.py @@ -0,0 +1,140 @@ +"""Test niko_home_control config flow.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.components.niko_home_control.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.123"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Niko Home Control" + assert result["data"] == {CONF_HOST: "192.168.0.123"} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_cannot_connect(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the cannot connect error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.niko_home_control.config_flow.NHCController.connect", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.123"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.niko_home_control.config_flow.NHCController.connect", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.123"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test uniqueness.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.123"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_flow( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the import flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: "192.168.0.123"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Niko Home Control" + assert result["data"] == {CONF_HOST: "192.168.0.123"} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the cannot connect error.""" + + with patch( + "homeassistant.components.niko_home_control.config_flow.NHCController.connect", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: "192.168.0.123"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_duplicate_import_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test uniqueness.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: "192.168.0.123"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/niko_home_control/test_cover.py b/tests/components/niko_home_control/test_cover.py new file mode 100644 index 00000000000..5e9a17c3324 --- /dev/null +++ b/tests/components/niko_home_control/test_cover.py @@ -0,0 +1,138 @@ +"""Tests for the Niko Home Control cover platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_OPEN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import find_update_callback, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_cover( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.niko_home_control.PLATFORMS", [Platform.COVER] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("cover_id", "entity_id"), + [ + (0, "cover.cover"), + ], +) +async def test_open_cover( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + cover_id: int, + entity_id: int, +) -> None: + """Test opening the cover.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_niko_home_control_connection.covers[cover_id].open.assert_called_once_with() + + +@pytest.mark.parametrize( + ("cover_id", "entity_id"), + [ + (0, "cover.cover"), + ], +) +async def test_close_cover( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + cover_id: int, + entity_id: str, +) -> None: + """Test closing the cover.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_niko_home_control_connection.covers[cover_id].close.assert_called_once_with() + + +@pytest.mark.parametrize( + ("cover_id", "entity_id"), + [ + (0, "cover.cover"), + ], +) +async def test_stop_cover( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + cover_id: int, + entity_id: str, +) -> None: + """Test closing the cover.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_niko_home_control_connection.covers[cover_id].stop.assert_called_once_with() + + +async def test_updating( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + cover: AsyncMock, +) -> None: + """Test closing the cover.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.cover").state == STATE_OPEN + + cover.state = 0 + await find_update_callback(mock_niko_home_control_connection, 3)(0) + await hass.async_block_till_done() + + assert hass.states.get("cover.cover").state == STATE_CLOSED + + cover.state = 100 + await find_update_callback(mock_niko_home_control_connection, 3)(100) + await hass.async_block_till_done() + + assert hass.states.get("cover.cover").state == STATE_OPEN diff --git a/tests/components/niko_home_control/test_init.py b/tests/components/niko_home_control/test_init.py new file mode 100644 index 00000000000..422b7d7c30c --- /dev/null +++ b/tests/components/niko_home_control/test_init.py @@ -0,0 +1,36 @@ +"""Test init.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.niko_home_control.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_migrate_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_setup_entry: AsyncMock +) -> None: + """Validate that the unique_id is migrated to the new unique_id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + minor_version=1, + data={CONF_HOST: "192.168.0.123"}, + ) + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + LIGHT_DOMAIN, DOMAIN, "light-1", config_entry=config_entry + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get(entity_entry.entity_id) + + assert config_entry.minor_version == 2 + assert ( + entity_registry.async_get(entity_entry.entity_id).unique_id + == f"{config_entry.entry_id}-1" + ) diff --git a/tests/components/niko_home_control/test_light.py b/tests/components/niko_home_control/test_light.py new file mode 100644 index 00000000000..865e1303cb0 --- /dev/null +++ b/tests/components/niko_home_control/test_light.py @@ -0,0 +1,136 @@ +"""Tests for the Niko Home Control Light platform.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import find_update_callback, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.niko_home_control.PLATFORMS", [Platform.LIGHT] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("light_id", "data", "set_brightness"), + [ + (0, {ATTR_ENTITY_ID: "light.light"}, 100), + ( + 1, + {ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50}, + 20, + ), + ], +) +async def test_turning_on( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + light_id: int, + data: dict[str, Any], + set_brightness: int, +) -> None: + """Test turning on the light.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + data, + blocking=True, + ) + mock_niko_home_control_connection.lights[light_id].turn_on.assert_called_once_with( + set_brightness + ) + + +@pytest.mark.parametrize( + ("light_id", "entity_id"), + [ + (0, "light.light"), + (1, "light.dimmable_light"), + ], +) +async def test_turning_off( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + light_id: int, + entity_id: str, +) -> None: + """Test turning on the light.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_niko_home_control_connection.lights[ + light_id + ].turn_off.assert_called_once_with() + + +async def test_updating( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + light: AsyncMock, + dimmable_light: AsyncMock, +) -> None: + """Test turning on the light.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.light").state == STATE_ON + + light.state = 0 + await find_update_callback(mock_niko_home_control_connection, 1)(0) + await hass.async_block_till_done() + + assert hass.states.get("light.light").state == STATE_OFF + + assert hass.states.get("light.dimmable_light").state == STATE_ON + assert hass.states.get("light.dimmable_light").attributes[ATTR_BRIGHTNESS] == 255 + + dimmable_light.state = 80 + await find_update_callback(mock_niko_home_control_connection, 2)(80) + await hass.async_block_till_done() + + assert hass.states.get("light.dimmable_light").state == STATE_ON + assert hass.states.get("light.dimmable_light").attributes[ATTR_BRIGHTNESS] == 204 + + dimmable_light.state = 0 + await find_update_callback(mock_niko_home_control_connection, 2)(0) + await hass.async_block_till_done() + + assert hass.states.get("light.dimmable_light").state == STATE_OFF + assert hass.states.get("light.dimmable_light").attributes[ATTR_BRIGHTNESS] is None diff --git a/tests/components/nmbs/__init__.py b/tests/components/nmbs/__init__.py new file mode 100644 index 00000000000..91226950aba --- /dev/null +++ b/tests/components/nmbs/__init__.py @@ -0,0 +1,20 @@ +"""Tests for the NMBS integration.""" + +import json +from typing import Any + +from tests.common import load_fixture + + +def mock_api_unavailable() -> dict[str, Any]: + """Mock for unavailable api.""" + return -1 + + +def mock_station_response() -> dict[str, Any]: + """Mock for valid station response.""" + dummy_stations_response: dict[str, Any] = json.loads( + load_fixture("stations.json", "nmbs") + ) + + return dummy_stations_response diff --git a/tests/components/nmbs/conftest.py b/tests/components/nmbs/conftest.py new file mode 100644 index 00000000000..69200fc4c98 --- /dev/null +++ b/tests/components/nmbs/conftest.py @@ -0,0 +1,58 @@ +"""NMBS tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.nmbs.const import ( + CONF_STATION_FROM, + CONF_STATION_TO, + DOMAIN, +) + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nmbs.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_nmbs_client() -> Generator[AsyncMock]: + """Mock a NMBS client.""" + with ( + patch( + "homeassistant.components.nmbs.iRail", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.nmbs.config_flow.iRail", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_stations.return_value = load_json_object_fixture( + "stations.json", DOMAIN + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi", + data={ + CONF_STATION_FROM: "BE.NMBS.008812005", + CONF_STATION_TO: "BE.NMBS.008814001", + }, + unique_id="BE.NMBS.008812005_BE.NMBS.008814001", + ) diff --git a/tests/components/nmbs/fixtures/stations.json b/tests/components/nmbs/fixtures/stations.json new file mode 100644 index 00000000000..b774e064f78 --- /dev/null +++ b/tests/components/nmbs/fixtures/stations.json @@ -0,0 +1,30 @@ +{ + "version": "1.3", + "timestamp": "1720252400", + "station": [ + { + "@id": "http://irail.be/stations/NMBS/008812005", + "id": "BE.NMBS.008812005", + "name": "Brussels-North", + "locationX": "4.360846", + "locationY": "50.859663", + "standardname": "Brussel-Noord/Bruxelles-Nord" + }, + { + "@id": "http://irail.be/stations/NMBS/008813003", + "id": "BE.NMBS.008813003", + "name": "Brussels-Central", + "locationX": "4.356801", + "locationY": "50.845658", + "standardname": "Brussel-Centraal/Bruxelles-Central" + }, + { + "@id": "http://irail.be/stations/NMBS/008814001", + "id": "BE.NMBS.008814001", + "name": "Brussels-South/Brussels-Midi", + "locationX": "4.336531", + "locationY": "50.835707", + "standardname": "Brussel-Zuid/Bruxelles-Midi" + } + ] +} diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py new file mode 100644 index 00000000000..6e55f89e54a --- /dev/null +++ b/tests/components/nmbs/test_config_flow.py @@ -0,0 +1,320 @@ +"""Test the NMBS config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.nmbs.const import ( + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +DUMMY_DATA_IMPORT: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "Brussel-Noord/Bruxelles-Nord", + "STAT_BRUSSELS_CENTRAL": "Brussel-Centraal/Bruxelles-Central", + "STAT_BRUSSELS_SOUTH": "Brussel-Zuid/Bruxelles-Midi", +} + +DUMMY_DATA_ALTERNATIVE_IMPORT: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "Brussels-North", + "STAT_BRUSSELS_CENTRAL": "Brussels-Central", + "STAT_BRUSSELS_SOUTH": "Brussels-South/Brussels-Midi", +} + +DUMMY_DATA: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "BE.NMBS.008812005", + "STAT_BRUSSELS_CENTRAL": "BE.NMBS.008813003", + "STAT_BRUSSELS_SOUTH": "BE.NMBS.008814001", +} + + +async def test_full_flow( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == "Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi" + ) + assert result["data"] == { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + } + assert ( + result["result"].unique_id + == f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) + + +async def test_same_station( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test selecting the same station.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "same_station"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_abort_if_exists( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test aborting the flow if the entry already exists.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_unavailable_api( + hass: HomeAssistant, mock_nmbs_client: AsyncMock +) -> None: + """Test starting a flow by user and api is unavailable.""" + mock_nmbs_client.get_stations.return_value = -1 + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_unavailable" + + +async def test_import( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test starting a flow by user which filled in data for connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_CENTRAL"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == "Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi" + ) + assert result["data"] == { + CONF_STATION_FROM: "BE.NMBS.008812005", + CONF_STATION_LIVE: "BE.NMBS.008813003", + CONF_STATION_TO: "BE.NMBS.008814001", + } + assert result["result"].unique_id == "BE.NMBS.008812005_BE.NMBS.008814001" + + +async def test_step_import_abort_if_already_setup( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by user which filled in data for connection for already existing connection.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_unavailable_api_import( + hass: HomeAssistant, mock_nmbs_client: AsyncMock +) -> None: + """Test starting a flow by import and api is unavailable.""" + mock_nmbs_client.get_stations.return_value = -1 + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_CENTRAL"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_unavailable" + + +@pytest.mark.parametrize( + ("config", "reason"), + [ + ( + { + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: "Utrecht Centraal", + }, + "invalid_station", + ), + ( + { + CONF_STATION_FROM: "Utrecht Centraal", + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + "invalid_station", + ), + ( + { + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + }, + "same_station", + ), + ], +) +async def test_invalid_station_name( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + config: dict[str, Any], + reason: str, +) -> None: + """Test importing invalid YAML.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_sensor_id_migration_standardname( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating unique id.""" + old_unique_id = ( + f"live_{DUMMY_DATA_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_IMPORT['STAT_BRUSSELS_SOUTH']}" + ) + new_unique_id = ( + f"nmbs_live_{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) + old_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, old_unique_id + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry_id = result["result"].entry_id + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry(entity_registry, config_entry_id) + assert len(entities) == 3 + entities_map = {entity.unique_id: entity for entity in entities} + assert old_unique_id not in entities_map + assert new_unique_id in entities_map + assert entities_map[new_unique_id].id == old_entry.id + + +async def test_sensor_id_migration_localized_name( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating unique id.""" + old_unique_id = ( + f"live_{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_SOUTH']}" + ) + new_unique_id = ( + f"nmbs_live_{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) + old_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, old_unique_id + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_LIVE: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_FROM: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry_id = result["result"].entry_id + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry(entity_registry, config_entry_id) + assert len(entities) == 3 + entities_map = {entity.unique_id: entity for entity in entities} + assert old_unique_id not in entities_map + assert new_unique_id in entities_map + assert entities_map[new_unique_id].id == old_entry.id diff --git a/tests/components/nordpool/conftest.py b/tests/components/nordpool/conftest.py index d1c1972c568..ca1e2a05a0b 100644 --- a/tests/components/nordpool/conftest.py +++ b/tests/components/nordpool/conftest.py @@ -2,20 +2,16 @@ from __future__ import annotations -from datetime import datetime +from collections.abc import AsyncGenerator import json from typing import Any -from unittest.mock import patch -from pynordpool import NordPoolClient -from pynordpool.const import Currency -from pynordpool.model import DeliveryPeriodData +from pynordpool import API, NordPoolClient import pytest from homeassistant.components.nordpool.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG @@ -24,9 +20,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture -async def load_int( - hass: HomeAssistant, get_data: DeliveryPeriodData -) -> MockConfigEntry: +async def load_int(hass: HomeAssistant, get_client: NordPoolClient) -> MockConfigEntry: """Set up the Nord Pool integration in Home Assistant.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -36,40 +30,83 @@ async def load_int( config_entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", - return_value=get_data, - ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return config_entry -@pytest.fixture(name="get_data") +@pytest.fixture(name="get_client") async def get_data_from_library( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, load_json: dict[str, Any] -) -> DeliveryPeriodData: + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + load_json: list[dict[str, Any]], +) -> AsyncGenerator[NordPoolClient]: """Retrieve data from Nord Pool library.""" - + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-05", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + json=load_json[0], + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-05", + "market": "DayAhead", + "deliveryArea": "SE3", + "currency": "EUR", + }, + json=load_json[0], + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-04", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + json=load_json[1], + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-06", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + json=load_json[2], + ) client = NordPoolClient(aioclient_mock.create_session(hass.loop)) - with patch("pynordpool.NordPoolClient._get", return_value=load_json): - output = await client.async_get_delivery_period( - datetime(2024, 11, 5, 13, tzinfo=dt_util.UTC), Currency.SEK, ["SE3", "SE4"] - ) + yield client await client._session.close() - return output @pytest.fixture(name="load_json") -def load_json_from_fixture(load_data: str) -> dict[str, Any]: +def load_json_from_fixture(load_data: list[str, str, str]) -> list[dict[str, Any]]: """Load fixture with json data and return.""" - return json.loads(load_data) + return [ + json.loads(load_data[0]), + json.loads(load_data[1]), + json.loads(load_data[2]), + ] @pytest.fixture(name="load_data", scope="package") -def load_data_from_fixture() -> str: +def load_data_from_fixture() -> list[str, str, str]: """Load fixture with fixture data and return.""" - return load_fixture("delivery_period.json", DOMAIN) + return [ + load_fixture("delivery_period_today.json", DOMAIN), + load_fixture("delivery_period_yesterday.json", DOMAIN), + load_fixture("delivery_period_tomorrow.json", DOMAIN), + ] diff --git a/tests/components/nordpool/fixtures/delivery_period_nl.json b/tests/components/nordpool/fixtures/delivery_period_nl.json new file mode 100644 index 00000000000..cd326e05d01 --- /dev/null +++ b/tests/components/nordpool/fixtures/delivery_period_nl.json @@ -0,0 +1,229 @@ +{ + "deliveryDateCET": "2024-11-05", + "version": 2, + "updatedAt": "2024-11-04T11:58:10.7711584Z", + "deliveryAreas": ["NL"], + "market": "DayAhead", + "multiAreaEntries": [ + { + "deliveryStart": "2024-11-04T23:00:00Z", + "deliveryEnd": "2024-11-05T00:00:00Z", + "entryPerArea": { + "NL": 83.63 + } + }, + { + "deliveryStart": "2024-11-05T00:00:00Z", + "deliveryEnd": "2024-11-05T01:00:00Z", + "entryPerArea": { + "NL": 94.0 + } + }, + { + "deliveryStart": "2024-11-05T01:00:00Z", + "deliveryEnd": "2024-11-05T02:00:00Z", + "entryPerArea": { + "NL": 90.68 + } + }, + { + "deliveryStart": "2024-11-05T02:00:00Z", + "deliveryEnd": "2024-11-05T03:00:00Z", + "entryPerArea": { + "NL": 91.3 + } + }, + { + "deliveryStart": "2024-11-05T03:00:00Z", + "deliveryEnd": "2024-11-05T04:00:00Z", + "entryPerArea": { + "NL": 94.0 + } + }, + { + "deliveryStart": "2024-11-05T04:00:00Z", + "deliveryEnd": "2024-11-05T05:00:00Z", + "entryPerArea": { + "NL": 96.09 + } + }, + { + "deliveryStart": "2024-11-05T05:00:00Z", + "deliveryEnd": "2024-11-05T06:00:00Z", + "entryPerArea": { + "NL": 106.0 + } + }, + { + "deliveryStart": "2024-11-05T06:00:00Z", + "deliveryEnd": "2024-11-05T07:00:00Z", + "entryPerArea": { + "NL": 135.99 + } + }, + { + "deliveryStart": "2024-11-05T07:00:00Z", + "deliveryEnd": "2024-11-05T08:00:00Z", + "entryPerArea": { + "NL": 136.21 + } + }, + { + "deliveryStart": "2024-11-05T08:00:00Z", + "deliveryEnd": "2024-11-05T09:00:00Z", + "entryPerArea": { + "NL": 118.23 + } + }, + { + "deliveryStart": "2024-11-05T09:00:00Z", + "deliveryEnd": "2024-11-05T10:00:00Z", + "entryPerArea": { + "NL": 105.87 + } + }, + { + "deliveryStart": "2024-11-05T10:00:00Z", + "deliveryEnd": "2024-11-05T11:00:00Z", + "entryPerArea": { + "NL": 95.28 + } + }, + { + "deliveryStart": "2024-11-05T11:00:00Z", + "deliveryEnd": "2024-11-05T12:00:00Z", + "entryPerArea": { + "NL": 94.92 + } + }, + { + "deliveryStart": "2024-11-05T12:00:00Z", + "deliveryEnd": "2024-11-05T13:00:00Z", + "entryPerArea": { + "NL": 99.25 + } + }, + { + "deliveryStart": "2024-11-05T13:00:00Z", + "deliveryEnd": "2024-11-05T14:00:00Z", + "entryPerArea": { + "NL": 107.98 + } + }, + { + "deliveryStart": "2024-11-05T14:00:00Z", + "deliveryEnd": "2024-11-05T15:00:00Z", + "entryPerArea": { + "NL": 149.86 + } + }, + { + "deliveryStart": "2024-11-05T15:00:00Z", + "deliveryEnd": "2024-11-05T16:00:00Z", + "entryPerArea": { + "NL": 303.24 + } + }, + { + "deliveryStart": "2024-11-05T16:00:00Z", + "deliveryEnd": "2024-11-05T17:00:00Z", + "entryPerArea": { + "NL": 472.99 + } + }, + { + "deliveryStart": "2024-11-05T17:00:00Z", + "deliveryEnd": "2024-11-05T18:00:00Z", + "entryPerArea": { + "NL": 431.02 + } + }, + { + "deliveryStart": "2024-11-05T18:00:00Z", + "deliveryEnd": "2024-11-05T19:00:00Z", + "entryPerArea": { + "NL": 320.33 + } + }, + { + "deliveryStart": "2024-11-05T19:00:00Z", + "deliveryEnd": "2024-11-05T20:00:00Z", + "entryPerArea": { + "NL": 169.7 + } + }, + { + "deliveryStart": "2024-11-05T20:00:00Z", + "deliveryEnd": "2024-11-05T21:00:00Z", + "entryPerArea": { + "NL": 129.9 + } + }, + { + "deliveryStart": "2024-11-05T21:00:00Z", + "deliveryEnd": "2024-11-05T22:00:00Z", + "entryPerArea": { + "NL": 117.77 + } + }, + { + "deliveryStart": "2024-11-05T22:00:00Z", + "deliveryEnd": "2024-11-05T23:00:00Z", + "entryPerArea": { + "NL": 110.03 + } + } + ], + "blockPriceAggregates": [ + { + "blockName": "Off-peak 1", + "deliveryStart": "2024-11-04T23:00:00Z", + "deliveryEnd": "2024-11-05T07:00:00Z", + "averagePricePerArea": { + "NL": { + "average": 98.96, + "min": 83.63, + "max": 135.99 + } + } + }, + { + "blockName": "Peak", + "deliveryStart": "2024-11-05T07:00:00Z", + "deliveryEnd": "2024-11-05T19:00:00Z", + "averagePricePerArea": { + "NL": { + "average": 202.93, + "min": 94.92, + "max": 472.99 + } + } + }, + { + "blockName": "Off-peak 2", + "deliveryStart": "2024-11-05T19:00:00Z", + "deliveryEnd": "2024-11-05T23:00:00Z", + "averagePricePerArea": { + "NL": { + "average": 131.85, + "min": 110.03, + "max": 169.7 + } + } + } + ], + "currency": "EUR", + "exchangeRate": 1, + "areaStates": [ + { + "state": "Final", + "areas": ["NL"] + } + ], + "areaAverages": [ + { + "areaCode": "NL", + "price": 156.43 + } + ] +} diff --git a/tests/components/nordpool/fixtures/delivery_period.json b/tests/components/nordpool/fixtures/delivery_period_today.json similarity index 100% rename from tests/components/nordpool/fixtures/delivery_period.json rename to tests/components/nordpool/fixtures/delivery_period_today.json diff --git a/tests/components/nordpool/fixtures/delivery_period_tomorrow.json b/tests/components/nordpool/fixtures/delivery_period_tomorrow.json new file mode 100644 index 00000000000..abaa24e93ed --- /dev/null +++ b/tests/components/nordpool/fixtures/delivery_period_tomorrow.json @@ -0,0 +1,272 @@ +{ + "deliveryDateCET": "2024-11-06", + "version": 3, + "updatedAt": "2024-11-05T12:12:51.9853434Z", + "deliveryAreas": ["SE3", "SE4"], + "market": "DayAhead", + "multiAreaEntries": [ + { + "deliveryStart": "2024-11-05T23:00:00Z", + "deliveryEnd": "2024-11-06T00:00:00Z", + "entryPerArea": { + "SE3": 126.66, + "SE4": 275.6 + } + }, + { + "deliveryStart": "2024-11-06T00:00:00Z", + "deliveryEnd": "2024-11-06T01:00:00Z", + "entryPerArea": { + "SE3": 74.06, + "SE4": 157.34 + } + }, + { + "deliveryStart": "2024-11-06T01:00:00Z", + "deliveryEnd": "2024-11-06T02:00:00Z", + "entryPerArea": { + "SE3": 78.38, + "SE4": 165.62 + } + }, + { + "deliveryStart": "2024-11-06T02:00:00Z", + "deliveryEnd": "2024-11-06T03:00:00Z", + "entryPerArea": { + "SE3": 92.37, + "SE4": 196.17 + } + }, + { + "deliveryStart": "2024-11-06T03:00:00Z", + "deliveryEnd": "2024-11-06T04:00:00Z", + "entryPerArea": { + "SE3": 99.14, + "SE4": 190.58 + } + }, + { + "deliveryStart": "2024-11-06T04:00:00Z", + "deliveryEnd": "2024-11-06T05:00:00Z", + "entryPerArea": { + "SE3": 447.51, + "SE4": 932.93 + } + }, + { + "deliveryStart": "2024-11-06T05:00:00Z", + "deliveryEnd": "2024-11-06T06:00:00Z", + "entryPerArea": { + "SE3": 641.47, + "SE4": 1284.69 + } + }, + { + "deliveryStart": "2024-11-06T06:00:00Z", + "deliveryEnd": "2024-11-06T07:00:00Z", + "entryPerArea": { + "SE3": 1820.5, + "SE4": 2449.96 + } + }, + { + "deliveryStart": "2024-11-06T07:00:00Z", + "deliveryEnd": "2024-11-06T08:00:00Z", + "entryPerArea": { + "SE3": 1723.0, + "SE4": 2244.22 + } + }, + { + "deliveryStart": "2024-11-06T08:00:00Z", + "deliveryEnd": "2024-11-06T09:00:00Z", + "entryPerArea": { + "SE3": 1298.57, + "SE4": 1643.45 + } + }, + { + "deliveryStart": "2024-11-06T09:00:00Z", + "deliveryEnd": "2024-11-06T10:00:00Z", + "entryPerArea": { + "SE3": 1099.25, + "SE4": 1507.23 + } + }, + { + "deliveryStart": "2024-11-06T10:00:00Z", + "deliveryEnd": "2024-11-06T11:00:00Z", + "entryPerArea": { + "SE3": 903.31, + "SE4": 1362.84 + } + }, + { + "deliveryStart": "2024-11-06T11:00:00Z", + "deliveryEnd": "2024-11-06T12:00:00Z", + "entryPerArea": { + "SE3": 959.99, + "SE4": 1376.13 + } + }, + { + "deliveryStart": "2024-11-06T12:00:00Z", + "deliveryEnd": "2024-11-06T13:00:00Z", + "entryPerArea": { + "SE3": 1186.61, + "SE4": 1449.96 + } + }, + { + "deliveryStart": "2024-11-06T13:00:00Z", + "deliveryEnd": "2024-11-06T14:00:00Z", + "entryPerArea": { + "SE3": 1307.67, + "SE4": 1608.35 + } + }, + { + "deliveryStart": "2024-11-06T14:00:00Z", + "deliveryEnd": "2024-11-06T15:00:00Z", + "entryPerArea": { + "SE3": 1385.46, + "SE4": 2110.8 + } + }, + { + "deliveryStart": "2024-11-06T15:00:00Z", + "deliveryEnd": "2024-11-06T16:00:00Z", + "entryPerArea": { + "SE3": 1366.8, + "SE4": 3031.25 + } + }, + { + "deliveryStart": "2024-11-06T16:00:00Z", + "deliveryEnd": "2024-11-06T17:00:00Z", + "entryPerArea": { + "SE3": 2366.57, + "SE4": 5511.77 + } + }, + { + "deliveryStart": "2024-11-06T17:00:00Z", + "deliveryEnd": "2024-11-06T18:00:00Z", + "entryPerArea": { + "SE3": 1481.92, + "SE4": 3351.64 + } + }, + { + "deliveryStart": "2024-11-06T18:00:00Z", + "deliveryEnd": "2024-11-06T19:00:00Z", + "entryPerArea": { + "SE3": 1082.69, + "SE4": 2484.95 + } + }, + { + "deliveryStart": "2024-11-06T19:00:00Z", + "deliveryEnd": "2024-11-06T20:00:00Z", + "entryPerArea": { + "SE3": 716.82, + "SE4": 1624.33 + } + }, + { + "deliveryStart": "2024-11-06T20:00:00Z", + "deliveryEnd": "2024-11-06T21:00:00Z", + "entryPerArea": { + "SE3": 583.16, + "SE4": 1306.27 + } + }, + { + "deliveryStart": "2024-11-06T21:00:00Z", + "deliveryEnd": "2024-11-06T22:00:00Z", + "entryPerArea": { + "SE3": 523.09, + "SE4": 1142.99 + } + }, + { + "deliveryStart": "2024-11-06T22:00:00Z", + "deliveryEnd": "2024-11-06T23:00:00Z", + "entryPerArea": { + "SE3": 250.64, + "SE4": 539.42 + } + } + ], + "blockPriceAggregates": [ + { + "blockName": "Off-peak 1", + "deliveryStart": "2024-11-05T23:00:00Z", + "deliveryEnd": "2024-11-06T07:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 422.51, + "min": 74.06, + "max": 1820.5 + }, + "SE4": { + "average": 706.61, + "min": 157.34, + "max": 2449.96 + } + } + }, + { + "blockName": "Peak", + "deliveryStart": "2024-11-06T07:00:00Z", + "deliveryEnd": "2024-11-06T19:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 1346.82, + "min": 903.31, + "max": 2366.57 + }, + "SE4": { + "average": 2306.88, + "min": 1362.84, + "max": 5511.77 + } + } + }, + { + "blockName": "Off-peak 2", + "deliveryStart": "2024-11-06T19:00:00Z", + "deliveryEnd": "2024-11-06T23:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 518.43, + "min": 250.64, + "max": 716.82 + }, + "SE4": { + "average": 1153.25, + "min": 539.42, + "max": 1624.33 + } + } + } + ], + "currency": "SEK", + "exchangeRate": 11.66314, + "areaStates": [ + { + "state": "Final", + "areas": ["SE3", "SE4"] + } + ], + "areaAverages": [ + { + "areaCode": "SE3", + "price": 900.65 + }, + { + "areaCode": "SE4", + "price": 1581.19 + } + ] +} diff --git a/tests/components/nordpool/fixtures/delivery_period_yesterday.json b/tests/components/nordpool/fixtures/delivery_period_yesterday.json new file mode 100644 index 00000000000..bc79aeb99f0 --- /dev/null +++ b/tests/components/nordpool/fixtures/delivery_period_yesterday.json @@ -0,0 +1,272 @@ +{ + "deliveryDateCET": "2024-11-04", + "version": 3, + "updatedAt": "2024-11-04T08:09:11.1931991Z", + "deliveryAreas": ["SE3", "SE4"], + "market": "DayAhead", + "multiAreaEntries": [ + { + "deliveryStart": "2024-11-03T23:00:00Z", + "deliveryEnd": "2024-11-04T00:00:00Z", + "entryPerArea": { + "SE3": 66.13, + "SE4": 78.59 + } + }, + { + "deliveryStart": "2024-11-04T00:00:00Z", + "deliveryEnd": "2024-11-04T01:00:00Z", + "entryPerArea": { + "SE3": 72.54, + "SE4": 86.51 + } + }, + { + "deliveryStart": "2024-11-04T01:00:00Z", + "deliveryEnd": "2024-11-04T02:00:00Z", + "entryPerArea": { + "SE3": 73.12, + "SE4": 84.88 + } + }, + { + "deliveryStart": "2024-11-04T02:00:00Z", + "deliveryEnd": "2024-11-04T03:00:00Z", + "entryPerArea": { + "SE3": 171.97, + "SE4": 217.26 + } + }, + { + "deliveryStart": "2024-11-04T03:00:00Z", + "deliveryEnd": "2024-11-04T04:00:00Z", + "entryPerArea": { + "SE3": 181.05, + "SE4": 227.74 + } + }, + { + "deliveryStart": "2024-11-04T04:00:00Z", + "deliveryEnd": "2024-11-04T05:00:00Z", + "entryPerArea": { + "SE3": 360.71, + "SE4": 414.61 + } + }, + { + "deliveryStart": "2024-11-04T05:00:00Z", + "deliveryEnd": "2024-11-04T06:00:00Z", + "entryPerArea": { + "SE3": 917.83, + "SE4": 1439.33 + } + }, + { + "deliveryStart": "2024-11-04T06:00:00Z", + "deliveryEnd": "2024-11-04T07:00:00Z", + "entryPerArea": { + "SE3": 1426.17, + "SE4": 1695.95 + } + }, + { + "deliveryStart": "2024-11-04T07:00:00Z", + "deliveryEnd": "2024-11-04T08:00:00Z", + "entryPerArea": { + "SE3": 1350.96, + "SE4": 1605.13 + } + }, + { + "deliveryStart": "2024-11-04T08:00:00Z", + "deliveryEnd": "2024-11-04T09:00:00Z", + "entryPerArea": { + "SE3": 1195.06, + "SE4": 1393.46 + } + }, + { + "deliveryStart": "2024-11-04T09:00:00Z", + "deliveryEnd": "2024-11-04T10:00:00Z", + "entryPerArea": { + "SE3": 992.35, + "SE4": 1126.71 + } + }, + { + "deliveryStart": "2024-11-04T10:00:00Z", + "deliveryEnd": "2024-11-04T11:00:00Z", + "entryPerArea": { + "SE3": 976.63, + "SE4": 1107.97 + } + }, + { + "deliveryStart": "2024-11-04T11:00:00Z", + "deliveryEnd": "2024-11-04T12:00:00Z", + "entryPerArea": { + "SE3": 952.76, + "SE4": 1085.73 + } + }, + { + "deliveryStart": "2024-11-04T12:00:00Z", + "deliveryEnd": "2024-11-04T13:00:00Z", + "entryPerArea": { + "SE3": 1029.37, + "SE4": 1177.71 + } + }, + { + "deliveryStart": "2024-11-04T13:00:00Z", + "deliveryEnd": "2024-11-04T14:00:00Z", + "entryPerArea": { + "SE3": 1043.35, + "SE4": 1194.59 + } + }, + { + "deliveryStart": "2024-11-04T14:00:00Z", + "deliveryEnd": "2024-11-04T15:00:00Z", + "entryPerArea": { + "SE3": 1359.57, + "SE4": 1561.12 + } + }, + { + "deliveryStart": "2024-11-04T15:00:00Z", + "deliveryEnd": "2024-11-04T16:00:00Z", + "entryPerArea": { + "SE3": 1848.35, + "SE4": 2145.84 + } + }, + { + "deliveryStart": "2024-11-04T16:00:00Z", + "deliveryEnd": "2024-11-04T17:00:00Z", + "entryPerArea": { + "SE3": 2812.53, + "SE4": 3313.53 + } + }, + { + "deliveryStart": "2024-11-04T17:00:00Z", + "deliveryEnd": "2024-11-04T18:00:00Z", + "entryPerArea": { + "SE3": 2351.69, + "SE4": 2751.87 + } + }, + { + "deliveryStart": "2024-11-04T18:00:00Z", + "deliveryEnd": "2024-11-04T19:00:00Z", + "entryPerArea": { + "SE3": 1553.08, + "SE4": 1842.77 + } + }, + { + "deliveryStart": "2024-11-04T19:00:00Z", + "deliveryEnd": "2024-11-04T20:00:00Z", + "entryPerArea": { + "SE3": 1165.02, + "SE4": 1398.35 + } + }, + { + "deliveryStart": "2024-11-04T20:00:00Z", + "deliveryEnd": "2024-11-04T21:00:00Z", + "entryPerArea": { + "SE3": 1007.48, + "SE4": 1172.35 + } + }, + { + "deliveryStart": "2024-11-04T21:00:00Z", + "deliveryEnd": "2024-11-04T22:00:00Z", + "entryPerArea": { + "SE3": 792.09, + "SE4": 920.28 + } + }, + { + "deliveryStart": "2024-11-04T22:00:00Z", + "deliveryEnd": "2024-11-04T23:00:00Z", + "entryPerArea": { + "SE3": 465.38, + "SE4": 528.83 + } + } + ], + "blockPriceAggregates": [ + { + "blockName": "Off-peak 1", + "deliveryStart": "2024-11-03T23:00:00Z", + "deliveryEnd": "2024-11-04T07:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 408.69, + "min": 66.13, + "max": 1426.17 + }, + "SE4": { + "average": 530.61, + "min": 78.59, + "max": 1695.95 + } + } + }, + { + "blockName": "Peak", + "deliveryStart": "2024-11-04T07:00:00Z", + "deliveryEnd": "2024-11-04T19:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 1455.48, + "min": 952.76, + "max": 2812.53 + }, + "SE4": { + "average": 1692.2, + "min": 1085.73, + "max": 3313.53 + } + } + }, + { + "blockName": "Off-peak 2", + "deliveryStart": "2024-11-04T19:00:00Z", + "deliveryEnd": "2024-11-04T23:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 857.49, + "min": 465.38, + "max": 1165.02 + }, + "SE4": { + "average": 1004.95, + "min": 528.83, + "max": 1398.35 + } + } + } + ], + "currency": "SEK", + "exchangeRate": 11.64318, + "areaStates": [ + { + "state": "Final", + "areas": ["SE3", "SE4"] + } + ], + "areaAverages": [ + { + "areaCode": "SE3", + "price": 1006.88 + }, + { + "areaCode": "SE4", + "price": 1190.46 + } + ] +} diff --git a/tests/components/nordpool/snapshots/test_diagnostics.ambr b/tests/components/nordpool/snapshots/test_diagnostics.ambr index dde2eca0022..76a3dd96405 100644 --- a/tests/components/nordpool/snapshots/test_diagnostics.ambr +++ b/tests/components/nordpool/snapshots/test_diagnostics.ambr @@ -2,282 +2,840 @@ # name: test_diagnostics dict({ 'raw': dict({ - 'areaAverages': list([ - dict({ - 'areaCode': 'SE3', - 'price': 900.74, - }), - dict({ - 'areaCode': 'SE4', - 'price': 1166.12, - }), - ]), - 'areaStates': list([ - dict({ - 'areas': list([ - 'SE3', - 'SE4', - ]), - 'state': 'Final', - }), - ]), - 'blockPriceAggregates': list([ - dict({ - 'averagePricePerArea': dict({ - 'SE3': dict({ - 'average': 422.87, - 'max': 1406.14, - 'min': 61.69, + '2024-11-04': dict({ + 'areaAverages': list([ + dict({ + 'areaCode': 'SE3', + 'price': 1006.88, + }), + dict({ + 'areaCode': 'SE4', + 'price': 1190.46, + }), + ]), + 'areaStates': list([ + dict({ + 'areas': list([ + 'SE3', + 'SE4', + ]), + 'state': 'Final', + }), + ]), + 'blockPriceAggregates': list([ + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 408.69, + 'max': 1426.17, + 'min': 66.13, + }), + 'SE4': dict({ + 'average': 530.61, + 'max': 1695.95, + 'min': 78.59, + }), }), - 'SE4': dict({ - 'average': 497.97, - 'max': 1648.25, - 'min': 65.19, + 'blockName': 'Off-peak 1', + 'deliveryEnd': '2024-11-04T07:00:00Z', + 'deliveryStart': '2024-11-03T23:00:00Z', + }), + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 1455.48, + 'max': 2812.53, + 'min': 952.76, + }), + 'SE4': dict({ + 'average': 1692.2, + 'max': 3313.53, + 'min': 1085.73, + }), + }), + 'blockName': 'Peak', + 'deliveryEnd': '2024-11-04T19:00:00Z', + 'deliveryStart': '2024-11-04T07:00:00Z', + }), + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 857.49, + 'max': 1165.02, + 'min': 465.38, + }), + 'SE4': dict({ + 'average': 1004.95, + 'max': 1398.35, + 'min': 528.83, + }), + }), + 'blockName': 'Off-peak 2', + 'deliveryEnd': '2024-11-04T23:00:00Z', + 'deliveryStart': '2024-11-04T19:00:00Z', + }), + ]), + 'currency': 'SEK', + 'deliveryAreas': list([ + 'SE3', + 'SE4', + ]), + 'deliveryDateCET': '2024-11-04', + 'exchangeRate': 11.64318, + 'market': 'DayAhead', + 'multiAreaEntries': list([ + dict({ + 'deliveryEnd': '2024-11-04T00:00:00Z', + 'deliveryStart': '2024-11-03T23:00:00Z', + 'entryPerArea': dict({ + 'SE3': 66.13, + 'SE4': 78.59, }), }), - 'blockName': 'Off-peak 1', - 'deliveryEnd': '2024-11-05T07:00:00Z', - 'deliveryStart': '2024-11-04T23:00:00Z', - }), - dict({ - 'averagePricePerArea': dict({ - 'SE3': dict({ - 'average': 1315.97, - 'max': 2512.65, - 'min': 925.05, - }), - 'SE4': dict({ - 'average': 1735.59, - 'max': 3533.03, - 'min': 1081.72, + dict({ + 'deliveryEnd': '2024-11-04T01:00:00Z', + 'deliveryStart': '2024-11-04T00:00:00Z', + 'entryPerArea': dict({ + 'SE3': 72.54, + 'SE4': 86.51, }), }), - 'blockName': 'Peak', - 'deliveryEnd': '2024-11-05T19:00:00Z', - 'deliveryStart': '2024-11-05T07:00:00Z', - }), - dict({ - 'averagePricePerArea': dict({ - 'SE3': dict({ - 'average': 610.79, - 'max': 835.53, - 'min': 289.14, - }), - 'SE4': dict({ - 'average': 793.98, - 'max': 1112.57, - 'min': 349.21, + dict({ + 'deliveryEnd': '2024-11-04T02:00:00Z', + 'deliveryStart': '2024-11-04T01:00:00Z', + 'entryPerArea': dict({ + 'SE3': 73.12, + 'SE4': 84.88, }), }), - 'blockName': 'Off-peak 2', - 'deliveryEnd': '2024-11-05T23:00:00Z', - 'deliveryStart': '2024-11-05T19:00:00Z', - }), - ]), - 'currency': 'SEK', - 'deliveryAreas': list([ - 'SE3', - 'SE4', - ]), - 'deliveryDateCET': '2024-11-05', - 'exchangeRate': 11.6402, - 'market': 'DayAhead', - 'multiAreaEntries': list([ - dict({ - 'deliveryEnd': '2024-11-05T00:00:00Z', - 'deliveryStart': '2024-11-04T23:00:00Z', - 'entryPerArea': dict({ - 'SE3': 250.73, - 'SE4': 283.79, + dict({ + 'deliveryEnd': '2024-11-04T03:00:00Z', + 'deliveryStart': '2024-11-04T02:00:00Z', + 'entryPerArea': dict({ + 'SE3': 171.97, + 'SE4': 217.26, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T01:00:00Z', - 'deliveryStart': '2024-11-05T00:00:00Z', - 'entryPerArea': dict({ - 'SE3': 76.36, - 'SE4': 81.36, + dict({ + 'deliveryEnd': '2024-11-04T04:00:00Z', + 'deliveryStart': '2024-11-04T03:00:00Z', + 'entryPerArea': dict({ + 'SE3': 181.05, + 'SE4': 227.74, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T02:00:00Z', - 'deliveryStart': '2024-11-05T01:00:00Z', - 'entryPerArea': dict({ - 'SE3': 73.92, - 'SE4': 79.15, + dict({ + 'deliveryEnd': '2024-11-04T05:00:00Z', + 'deliveryStart': '2024-11-04T04:00:00Z', + 'entryPerArea': dict({ + 'SE3': 360.71, + 'SE4': 414.61, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T03:00:00Z', - 'deliveryStart': '2024-11-05T02:00:00Z', - 'entryPerArea': dict({ - 'SE3': 61.69, - 'SE4': 65.19, + dict({ + 'deliveryEnd': '2024-11-04T06:00:00Z', + 'deliveryStart': '2024-11-04T05:00:00Z', + 'entryPerArea': dict({ + 'SE3': 917.83, + 'SE4': 1439.33, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T04:00:00Z', - 'deliveryStart': '2024-11-05T03:00:00Z', - 'entryPerArea': dict({ - 'SE3': 64.6, - 'SE4': 68.44, + dict({ + 'deliveryEnd': '2024-11-04T07:00:00Z', + 'deliveryStart': '2024-11-04T06:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1426.17, + 'SE4': 1695.95, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T05:00:00Z', - 'deliveryStart': '2024-11-05T04:00:00Z', - 'entryPerArea': dict({ - 'SE3': 453.27, - 'SE4': 516.71, + dict({ + 'deliveryEnd': '2024-11-04T08:00:00Z', + 'deliveryStart': '2024-11-04T07:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1350.96, + 'SE4': 1605.13, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T06:00:00Z', - 'deliveryStart': '2024-11-05T05:00:00Z', - 'entryPerArea': dict({ - 'SE3': 996.28, - 'SE4': 1240.85, + dict({ + 'deliveryEnd': '2024-11-04T09:00:00Z', + 'deliveryStart': '2024-11-04T08:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1195.06, + 'SE4': 1393.46, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T07:00:00Z', - 'deliveryStart': '2024-11-05T06:00:00Z', - 'entryPerArea': dict({ - 'SE3': 1406.14, - 'SE4': 1648.25, + dict({ + 'deliveryEnd': '2024-11-04T10:00:00Z', + 'deliveryStart': '2024-11-04T09:00:00Z', + 'entryPerArea': dict({ + 'SE3': 992.35, + 'SE4': 1126.71, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T08:00:00Z', - 'deliveryStart': '2024-11-05T07:00:00Z', - 'entryPerArea': dict({ - 'SE3': 1346.54, - 'SE4': 1570.5, + dict({ + 'deliveryEnd': '2024-11-04T11:00:00Z', + 'deliveryStart': '2024-11-04T10:00:00Z', + 'entryPerArea': dict({ + 'SE3': 976.63, + 'SE4': 1107.97, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T09:00:00Z', - 'deliveryStart': '2024-11-05T08:00:00Z', - 'entryPerArea': dict({ - 'SE3': 1150.28, - 'SE4': 1345.37, + dict({ + 'deliveryEnd': '2024-11-04T12:00:00Z', + 'deliveryStart': '2024-11-04T11:00:00Z', + 'entryPerArea': dict({ + 'SE3': 952.76, + 'SE4': 1085.73, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T10:00:00Z', - 'deliveryStart': '2024-11-05T09:00:00Z', - 'entryPerArea': dict({ - 'SE3': 1031.32, - 'SE4': 1206.51, + dict({ + 'deliveryEnd': '2024-11-04T13:00:00Z', + 'deliveryStart': '2024-11-04T12:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1029.37, + 'SE4': 1177.71, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T11:00:00Z', - 'deliveryStart': '2024-11-05T10:00:00Z', - 'entryPerArea': dict({ - 'SE3': 927.37, - 'SE4': 1085.8, + dict({ + 'deliveryEnd': '2024-11-04T14:00:00Z', + 'deliveryStart': '2024-11-04T13:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1043.35, + 'SE4': 1194.59, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T12:00:00Z', - 'deliveryStart': '2024-11-05T11:00:00Z', - 'entryPerArea': dict({ - 'SE3': 925.05, - 'SE4': 1081.72, + dict({ + 'deliveryEnd': '2024-11-04T15:00:00Z', + 'deliveryStart': '2024-11-04T14:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1359.57, + 'SE4': 1561.12, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T13:00:00Z', - 'deliveryStart': '2024-11-05T12:00:00Z', - 'entryPerArea': dict({ - 'SE3': 949.49, - 'SE4': 1130.38, + dict({ + 'deliveryEnd': '2024-11-04T16:00:00Z', + 'deliveryStart': '2024-11-04T15:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1848.35, + 'SE4': 2145.84, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T14:00:00Z', - 'deliveryStart': '2024-11-05T13:00:00Z', - 'entryPerArea': dict({ - 'SE3': 1042.03, - 'SE4': 1256.91, + dict({ + 'deliveryEnd': '2024-11-04T17:00:00Z', + 'deliveryStart': '2024-11-04T16:00:00Z', + 'entryPerArea': dict({ + 'SE3': 2812.53, + 'SE4': 3313.53, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T15:00:00Z', - 'deliveryStart': '2024-11-05T14:00:00Z', - 'entryPerArea': dict({ - 'SE3': 1258.89, - 'SE4': 1765.82, + dict({ + 'deliveryEnd': '2024-11-04T18:00:00Z', + 'deliveryStart': '2024-11-04T17:00:00Z', + 'entryPerArea': dict({ + 'SE3': 2351.69, + 'SE4': 2751.87, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T16:00:00Z', - 'deliveryStart': '2024-11-05T15:00:00Z', - 'entryPerArea': dict({ - 'SE3': 1816.45, - 'SE4': 2522.55, + dict({ + 'deliveryEnd': '2024-11-04T19:00:00Z', + 'deliveryStart': '2024-11-04T18:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1553.08, + 'SE4': 1842.77, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T17:00:00Z', - 'deliveryStart': '2024-11-05T16:00:00Z', - 'entryPerArea': dict({ - 'SE3': 2512.65, - 'SE4': 3533.03, + dict({ + 'deliveryEnd': '2024-11-04T20:00:00Z', + 'deliveryStart': '2024-11-04T19:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1165.02, + 'SE4': 1398.35, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T18:00:00Z', - 'deliveryStart': '2024-11-05T17:00:00Z', - 'entryPerArea': dict({ - 'SE3': 1819.83, - 'SE4': 2524.06, + dict({ + 'deliveryEnd': '2024-11-04T21:00:00Z', + 'deliveryStart': '2024-11-04T20:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1007.48, + 'SE4': 1172.35, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T19:00:00Z', - 'deliveryStart': '2024-11-05T18:00:00Z', - 'entryPerArea': dict({ - 'SE3': 1011.77, - 'SE4': 1804.46, + dict({ + 'deliveryEnd': '2024-11-04T22:00:00Z', + 'deliveryStart': '2024-11-04T21:00:00Z', + 'entryPerArea': dict({ + 'SE3': 792.09, + 'SE4': 920.28, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T20:00:00Z', - 'deliveryStart': '2024-11-05T19:00:00Z', - 'entryPerArea': dict({ - 'SE3': 835.53, - 'SE4': 1112.57, + dict({ + 'deliveryEnd': '2024-11-04T23:00:00Z', + 'deliveryStart': '2024-11-04T22:00:00Z', + 'entryPerArea': dict({ + 'SE3': 465.38, + 'SE4': 528.83, + }), }), - }), - dict({ - 'deliveryEnd': '2024-11-05T21:00:00Z', - 'deliveryStart': '2024-11-05T20:00:00Z', - 'entryPerArea': dict({ - 'SE3': 796.19, - 'SE4': 1051.69, + ]), + 'updatedAt': '2024-11-04T08:09:11.1931991Z', + 'version': 3, + }), + '2024-11-05': dict({ + 'areaAverages': list([ + dict({ + 'areaCode': 'SE3', + 'price': 900.74, }), - }), - dict({ - 'deliveryEnd': '2024-11-05T22:00:00Z', - 'deliveryStart': '2024-11-05T21:00:00Z', - 'entryPerArea': dict({ - 'SE3': 522.3, - 'SE4': 662.44, + dict({ + 'areaCode': 'SE4', + 'price': 1166.12, }), - }), - dict({ - 'deliveryEnd': '2024-11-05T23:00:00Z', - 'deliveryStart': '2024-11-05T22:00:00Z', - 'entryPerArea': dict({ - 'SE3': 289.14, - 'SE4': 349.21, + ]), + 'areaStates': list([ + dict({ + 'areas': list([ + 'SE3', + 'SE4', + ]), + 'state': 'Final', }), - }), - ]), - 'updatedAt': '2024-11-04T12:15:03.9456464Z', - 'version': 3, + ]), + 'blockPriceAggregates': list([ + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 422.87, + 'max': 1406.14, + 'min': 61.69, + }), + 'SE4': dict({ + 'average': 497.97, + 'max': 1648.25, + 'min': 65.19, + }), + }), + 'blockName': 'Off-peak 1', + 'deliveryEnd': '2024-11-05T07:00:00Z', + 'deliveryStart': '2024-11-04T23:00:00Z', + }), + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 1315.97, + 'max': 2512.65, + 'min': 925.05, + }), + 'SE4': dict({ + 'average': 1735.59, + 'max': 3533.03, + 'min': 1081.72, + }), + }), + 'blockName': 'Peak', + 'deliveryEnd': '2024-11-05T19:00:00Z', + 'deliveryStart': '2024-11-05T07:00:00Z', + }), + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 610.79, + 'max': 835.53, + 'min': 289.14, + }), + 'SE4': dict({ + 'average': 793.98, + 'max': 1112.57, + 'min': 349.21, + }), + }), + 'blockName': 'Off-peak 2', + 'deliveryEnd': '2024-11-05T23:00:00Z', + 'deliveryStart': '2024-11-05T19:00:00Z', + }), + ]), + 'currency': 'SEK', + 'deliveryAreas': list([ + 'SE3', + 'SE4', + ]), + 'deliveryDateCET': '2024-11-05', + 'exchangeRate': 11.6402, + 'market': 'DayAhead', + 'multiAreaEntries': list([ + dict({ + 'deliveryEnd': '2024-11-05T00:00:00Z', + 'deliveryStart': '2024-11-04T23:00:00Z', + 'entryPerArea': dict({ + 'SE3': 250.73, + 'SE4': 283.79, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T01:00:00Z', + 'deliveryStart': '2024-11-05T00:00:00Z', + 'entryPerArea': dict({ + 'SE3': 76.36, + 'SE4': 81.36, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T02:00:00Z', + 'deliveryStart': '2024-11-05T01:00:00Z', + 'entryPerArea': dict({ + 'SE3': 73.92, + 'SE4': 79.15, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T03:00:00Z', + 'deliveryStart': '2024-11-05T02:00:00Z', + 'entryPerArea': dict({ + 'SE3': 61.69, + 'SE4': 65.19, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T04:00:00Z', + 'deliveryStart': '2024-11-05T03:00:00Z', + 'entryPerArea': dict({ + 'SE3': 64.6, + 'SE4': 68.44, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T05:00:00Z', + 'deliveryStart': '2024-11-05T04:00:00Z', + 'entryPerArea': dict({ + 'SE3': 453.27, + 'SE4': 516.71, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T06:00:00Z', + 'deliveryStart': '2024-11-05T05:00:00Z', + 'entryPerArea': dict({ + 'SE3': 996.28, + 'SE4': 1240.85, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T07:00:00Z', + 'deliveryStart': '2024-11-05T06:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1406.14, + 'SE4': 1648.25, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T08:00:00Z', + 'deliveryStart': '2024-11-05T07:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1346.54, + 'SE4': 1570.5, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T09:00:00Z', + 'deliveryStart': '2024-11-05T08:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1150.28, + 'SE4': 1345.37, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T10:00:00Z', + 'deliveryStart': '2024-11-05T09:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1031.32, + 'SE4': 1206.51, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T11:00:00Z', + 'deliveryStart': '2024-11-05T10:00:00Z', + 'entryPerArea': dict({ + 'SE3': 927.37, + 'SE4': 1085.8, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T12:00:00Z', + 'deliveryStart': '2024-11-05T11:00:00Z', + 'entryPerArea': dict({ + 'SE3': 925.05, + 'SE4': 1081.72, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T13:00:00Z', + 'deliveryStart': '2024-11-05T12:00:00Z', + 'entryPerArea': dict({ + 'SE3': 949.49, + 'SE4': 1130.38, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T14:00:00Z', + 'deliveryStart': '2024-11-05T13:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1042.03, + 'SE4': 1256.91, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T15:00:00Z', + 'deliveryStart': '2024-11-05T14:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1258.89, + 'SE4': 1765.82, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T16:00:00Z', + 'deliveryStart': '2024-11-05T15:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1816.45, + 'SE4': 2522.55, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T17:00:00Z', + 'deliveryStart': '2024-11-05T16:00:00Z', + 'entryPerArea': dict({ + 'SE3': 2512.65, + 'SE4': 3533.03, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T18:00:00Z', + 'deliveryStart': '2024-11-05T17:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1819.83, + 'SE4': 2524.06, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T19:00:00Z', + 'deliveryStart': '2024-11-05T18:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1011.77, + 'SE4': 1804.46, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T20:00:00Z', + 'deliveryStart': '2024-11-05T19:00:00Z', + 'entryPerArea': dict({ + 'SE3': 835.53, + 'SE4': 1112.57, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T21:00:00Z', + 'deliveryStart': '2024-11-05T20:00:00Z', + 'entryPerArea': dict({ + 'SE3': 796.19, + 'SE4': 1051.69, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T22:00:00Z', + 'deliveryStart': '2024-11-05T21:00:00Z', + 'entryPerArea': dict({ + 'SE3': 522.3, + 'SE4': 662.44, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T23:00:00Z', + 'deliveryStart': '2024-11-05T22:00:00Z', + 'entryPerArea': dict({ + 'SE3': 289.14, + 'SE4': 349.21, + }), + }), + ]), + 'updatedAt': '2024-11-04T12:15:03.9456464Z', + 'version': 3, + }), + '2024-11-06': dict({ + 'areaAverages': list([ + dict({ + 'areaCode': 'SE3', + 'price': 900.65, + }), + dict({ + 'areaCode': 'SE4', + 'price': 1581.19, + }), + ]), + 'areaStates': list([ + dict({ + 'areas': list([ + 'SE3', + 'SE4', + ]), + 'state': 'Final', + }), + ]), + 'blockPriceAggregates': list([ + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 422.51, + 'max': 1820.5, + 'min': 74.06, + }), + 'SE4': dict({ + 'average': 706.61, + 'max': 2449.96, + 'min': 157.34, + }), + }), + 'blockName': 'Off-peak 1', + 'deliveryEnd': '2024-11-06T07:00:00Z', + 'deliveryStart': '2024-11-05T23:00:00Z', + }), + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 1346.82, + 'max': 2366.57, + 'min': 903.31, + }), + 'SE4': dict({ + 'average': 2306.88, + 'max': 5511.77, + 'min': 1362.84, + }), + }), + 'blockName': 'Peak', + 'deliveryEnd': '2024-11-06T19:00:00Z', + 'deliveryStart': '2024-11-06T07:00:00Z', + }), + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 518.43, + 'max': 716.82, + 'min': 250.64, + }), + 'SE4': dict({ + 'average': 1153.25, + 'max': 1624.33, + 'min': 539.42, + }), + }), + 'blockName': 'Off-peak 2', + 'deliveryEnd': '2024-11-06T23:00:00Z', + 'deliveryStart': '2024-11-06T19:00:00Z', + }), + ]), + 'currency': 'SEK', + 'deliveryAreas': list([ + 'SE3', + 'SE4', + ]), + 'deliveryDateCET': '2024-11-06', + 'exchangeRate': 11.66314, + 'market': 'DayAhead', + 'multiAreaEntries': list([ + dict({ + 'deliveryEnd': '2024-11-06T00:00:00Z', + 'deliveryStart': '2024-11-05T23:00:00Z', + 'entryPerArea': dict({ + 'SE3': 126.66, + 'SE4': 275.6, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T01:00:00Z', + 'deliveryStart': '2024-11-06T00:00:00Z', + 'entryPerArea': dict({ + 'SE3': 74.06, + 'SE4': 157.34, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T02:00:00Z', + 'deliveryStart': '2024-11-06T01:00:00Z', + 'entryPerArea': dict({ + 'SE3': 78.38, + 'SE4': 165.62, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T03:00:00Z', + 'deliveryStart': '2024-11-06T02:00:00Z', + 'entryPerArea': dict({ + 'SE3': 92.37, + 'SE4': 196.17, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T04:00:00Z', + 'deliveryStart': '2024-11-06T03:00:00Z', + 'entryPerArea': dict({ + 'SE3': 99.14, + 'SE4': 190.58, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T05:00:00Z', + 'deliveryStart': '2024-11-06T04:00:00Z', + 'entryPerArea': dict({ + 'SE3': 447.51, + 'SE4': 932.93, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T06:00:00Z', + 'deliveryStart': '2024-11-06T05:00:00Z', + 'entryPerArea': dict({ + 'SE3': 641.47, + 'SE4': 1284.69, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T07:00:00Z', + 'deliveryStart': '2024-11-06T06:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1820.5, + 'SE4': 2449.96, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T08:00:00Z', + 'deliveryStart': '2024-11-06T07:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1723.0, + 'SE4': 2244.22, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T09:00:00Z', + 'deliveryStart': '2024-11-06T08:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1298.57, + 'SE4': 1643.45, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T10:00:00Z', + 'deliveryStart': '2024-11-06T09:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1099.25, + 'SE4': 1507.23, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T11:00:00Z', + 'deliveryStart': '2024-11-06T10:00:00Z', + 'entryPerArea': dict({ + 'SE3': 903.31, + 'SE4': 1362.84, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T12:00:00Z', + 'deliveryStart': '2024-11-06T11:00:00Z', + 'entryPerArea': dict({ + 'SE3': 959.99, + 'SE4': 1376.13, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T13:00:00Z', + 'deliveryStart': '2024-11-06T12:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1186.61, + 'SE4': 1449.96, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T14:00:00Z', + 'deliveryStart': '2024-11-06T13:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1307.67, + 'SE4': 1608.35, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T15:00:00Z', + 'deliveryStart': '2024-11-06T14:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1385.46, + 'SE4': 2110.8, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T16:00:00Z', + 'deliveryStart': '2024-11-06T15:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1366.8, + 'SE4': 3031.25, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T17:00:00Z', + 'deliveryStart': '2024-11-06T16:00:00Z', + 'entryPerArea': dict({ + 'SE3': 2366.57, + 'SE4': 5511.77, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T18:00:00Z', + 'deliveryStart': '2024-11-06T17:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1481.92, + 'SE4': 3351.64, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T19:00:00Z', + 'deliveryStart': '2024-11-06T18:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1082.69, + 'SE4': 2484.95, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T20:00:00Z', + 'deliveryStart': '2024-11-06T19:00:00Z', + 'entryPerArea': dict({ + 'SE3': 716.82, + 'SE4': 1624.33, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T21:00:00Z', + 'deliveryStart': '2024-11-06T20:00:00Z', + 'entryPerArea': dict({ + 'SE3': 583.16, + 'SE4': 1306.27, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T22:00:00Z', + 'deliveryStart': '2024-11-06T21:00:00Z', + 'entryPerArea': dict({ + 'SE3': 523.09, + 'SE4': 1142.99, + }), + }), + dict({ + 'deliveryEnd': '2024-11-06T23:00:00Z', + 'deliveryStart': '2024-11-06T22:00:00Z', + 'entryPerArea': dict({ + 'SE3': 250.64, + 'SE4': 539.42, + }), + }), + ]), + 'updatedAt': '2024-11-05T12:12:51.9853434Z', + 'version': 3, + }), }), }) # --- diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr index 01600352861..9b328c3a71d 100644 --- a/tests/components/nordpool/snapshots/test_sensor.ambr +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -200,6 +200,58 @@ 'state': '11.6402', }) # --- +# name: test_sensor[sensor.nord_pool_se3_highest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'highest_price', + 'unique_id': 'SE3-highest_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'end': '2024-11-05T17:00:00+00:00', + 'friendly_name': 'Nord Pool SE3 Highest price', + 'start': '2024-11-05T16:00:00+00:00', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.51265', + }) +# --- # name: test_sensor[sensor.nord_pool_se3_last_updated-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -247,6 +299,58 @@ 'state': '2024-11-04T12:15:03+00:00', }) # --- +# name: test_sensor[sensor.nord_pool_se3_lowest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se3_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lowest_price', + 'unique_id': 'SE3-lowest_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'end': '2024-11-05T03:00:00+00:00', + 'friendly_name': 'Nord Pool SE3 Lowest price', + 'start': '2024-11-05T02:00:00+00:00', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.06169', + }) +# --- # name: test_sensor[sensor.nord_pool_se3_next_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1307,6 +1411,58 @@ 'state': '11.6402', }) # --- +# name: test_sensor[sensor.nord_pool_se4_highest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'highest_price', + 'unique_id': 'SE4-highest_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'end': '2024-11-05T17:00:00+00:00', + 'friendly_name': 'Nord Pool SE4 Highest price', + 'start': '2024-11-05T16:00:00+00:00', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.53303', + }) +# --- # name: test_sensor[sensor.nord_pool_se4_last_updated-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1354,6 +1510,58 @@ 'state': '2024-11-04T12:15:03+00:00', }) # --- +# name: test_sensor[sensor.nord_pool_se4_lowest_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nord_pool_se4_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lowest_price', + 'unique_id': 'SE4-lowest_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'end': '2024-11-05T03:00:00+00:00', + 'friendly_name': 'Nord Pool SE4 Lowest price', + 'start': '2024-11-05T02:00:00+00:00', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.06519', + }) +# --- # name: test_sensor[sensor.nord_pool_se4_next_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/nordpool/snapshots/test_services.ambr b/tests/components/nordpool/snapshots/test_services.ambr new file mode 100644 index 00000000000..6a57d7ecce9 --- /dev/null +++ b/tests/components/nordpool/snapshots/test_services.ambr @@ -0,0 +1,127 @@ +# serializer version: 1 +# name: test_service_call + dict({ + 'SE3': list([ + dict({ + 'end': '2024-11-05T00:00:00+00:00', + 'price': 250.73, + 'start': '2024-11-04T23:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T01:00:00+00:00', + 'price': 76.36, + 'start': '2024-11-05T00:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T02:00:00+00:00', + 'price': 73.92, + 'start': '2024-11-05T01:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T03:00:00+00:00', + 'price': 61.69, + 'start': '2024-11-05T02:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T04:00:00+00:00', + 'price': 64.6, + 'start': '2024-11-05T03:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T05:00:00+00:00', + 'price': 453.27, + 'start': '2024-11-05T04:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T06:00:00+00:00', + 'price': 996.28, + 'start': '2024-11-05T05:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T07:00:00+00:00', + 'price': 1406.14, + 'start': '2024-11-05T06:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T08:00:00+00:00', + 'price': 1346.54, + 'start': '2024-11-05T07:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T09:00:00+00:00', + 'price': 1150.28, + 'start': '2024-11-05T08:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T10:00:00+00:00', + 'price': 1031.32, + 'start': '2024-11-05T09:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T11:00:00+00:00', + 'price': 927.37, + 'start': '2024-11-05T10:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T12:00:00+00:00', + 'price': 925.05, + 'start': '2024-11-05T11:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T13:00:00+00:00', + 'price': 949.49, + 'start': '2024-11-05T12:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T14:00:00+00:00', + 'price': 1042.03, + 'start': '2024-11-05T13:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T15:00:00+00:00', + 'price': 1258.89, + 'start': '2024-11-05T14:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T16:00:00+00:00', + 'price': 1816.45, + 'start': '2024-11-05T15:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T17:00:00+00:00', + 'price': 2512.65, + 'start': '2024-11-05T16:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T18:00:00+00:00', + 'price': 1819.83, + 'start': '2024-11-05T17:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T19:00:00+00:00', + 'price': 1011.77, + 'start': '2024-11-05T18:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T20:00:00+00:00', + 'price': 835.53, + 'start': '2024-11-05T19:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T21:00:00+00:00', + 'price': 796.19, + 'start': '2024-11-05T20:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T22:00:00+00:00', + 'price': 522.3, + 'start': '2024-11-05T21:00:00+00:00', + }), + dict({ + 'end': '2024-11-05T23:00:00+00:00', + 'price': 289.14, + 'start': '2024-11-05T22:00:00+00:00', + }), + ]), + }) +# --- diff --git a/tests/components/nordpool/test_config_flow.py b/tests/components/nordpool/test_config_flow.py index cfdfc63aca7..1f0e99b65ff 100644 --- a/tests/components/nordpool/test_config_flow.py +++ b/tests/components/nordpool/test_config_flow.py @@ -2,10 +2,11 @@ from __future__ import annotations +from typing import Any from unittest.mock import patch from pynordpool import ( - DeliveryPeriodData, + NordPoolClient, NordPoolConnectionError, NordPoolEmptyResponseError, NordPoolError, @@ -22,10 +23,11 @@ from homeassistant.data_entry_flow import FlowResultType from . import ENTRY_CONFIG from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") -async def test_form(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: +async def test_form(hass: HomeAssistant, get_client: NordPoolClient) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -34,17 +36,11 @@ async def test_form(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: assert result["step_id"] == "user" assert result["type"] is FlowResultType.FORM - with ( - patch( - "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", - return_value=get_data, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - ENTRY_CONFIG, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + ENTRY_CONFIG, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["version"] == 1 @@ -54,7 +50,7 @@ async def test_form(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: @pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") async def test_single_config_entry( - hass: HomeAssistant, load_int: None, get_data: DeliveryPeriodData + hass: HomeAssistant, load_int: None, get_client: NordPoolClient ) -> None: """Test abort for single config entry.""" @@ -77,7 +73,7 @@ async def test_single_config_entry( ) async def test_cannot_connect( hass: HomeAssistant, - get_data: DeliveryPeriodData, + get_client: NordPoolClient, error_message: Exception, p_error: str, ) -> None: @@ -101,14 +97,10 @@ async def test_cannot_connect( assert result["errors"] == {"base": p_error} - with patch( - "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", - return_value=get_data, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=ENTRY_CONFIG, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Nord Pool" @@ -119,25 +111,18 @@ async def test_cannot_connect( async def test_reconfigure( hass: HomeAssistant, load_int: MockConfigEntry, - get_data: DeliveryPeriodData, ) -> None: """Test reconfiguration.""" result = await load_int.start_reconfigure_flow(hass) - with ( - patch( - "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", - return_value=get_data, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_AREAS: ["SE3"], - CONF_CURRENCY: "EUR", - }, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_AREAS: ["SE3"], + CONF_CURRENCY: "EUR", + }, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" @@ -162,7 +147,8 @@ async def test_reconfigure( async def test_reconfigure_cannot_connect( hass: HomeAssistant, load_int: MockConfigEntry, - get_data: DeliveryPeriodData, + aioclient_mock: AiohttpClientMocker, + load_json: list[dict[str, Any]], error_message: Exception, p_error: str, ) -> None: @@ -184,17 +170,13 @@ async def test_reconfigure_cannot_connect( assert result["errors"] == {"base": p_error} - with patch( - "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", - return_value=get_data, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_AREAS: ["SE3"], - CONF_CURRENCY: "EUR", - }, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_AREAS: ["SE3"], + CONF_CURRENCY: "EUR", + }, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index d2d912b1b99..71c4644ea95 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -7,8 +7,8 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory from pynordpool import ( - DeliveryPeriodData, NordPoolAuthenticationError, + NordPoolClient, NordPoolEmptyResponseError, NordPoolError, NordPoolResponseError, @@ -28,7 +28,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") async def test_coordinator( hass: HomeAssistant, - get_data: DeliveryPeriodData, + get_client: NordPoolClient, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, ) -> None: @@ -41,66 +41,72 @@ async def test_coordinator( config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "0.92737" + with ( patch( "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=NordPoolError("error"), ) as mock_data, ): - mock_data.return_value = get_data - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - mock_data.assert_called_once() - state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.92737" - mock_data.reset_mock() - - mock_data.side_effect = NordPoolError("error") freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - mock_data.assert_called_once() + assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE - mock_data.reset_mock() + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=NordPoolAuthenticationError("Authentication error"), + ) as mock_data, + ): assert "Authentication error" not in caplog.text - mock_data.side_effect = NordPoolAuthenticationError("Authentication error") freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - mock_data.assert_called_once() + assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE assert "Authentication error" in caplog.text - mock_data.reset_mock() + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=NordPoolEmptyResponseError("Empty response"), + ) as mock_data, + ): assert "Empty response" not in caplog.text - mock_data.side_effect = NordPoolEmptyResponseError("Empty response") freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - mock_data.assert_called_once() + # Empty responses does not raise + assert mock_data.call_count == 3 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE assert "Empty response" in caplog.text - mock_data.reset_mock() + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=NordPoolResponseError("Response error"), + ) as mock_data, + ): assert "Response error" not in caplog.text - mock_data.side_effect = NordPoolResponseError("Response error") freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - mock_data.assert_called_once() + assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") assert state.state == STATE_UNAVAILABLE assert "Response error" in caplog.text - mock_data.reset_mock() - mock_data.return_value = get_data - mock_data.side_effect = None - freezer.tick(timedelta(hours=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - mock_data.assert_called_once() - state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.81645" + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "1.81645" diff --git a/tests/components/nordpool/test_diagnostics.py b/tests/components/nordpool/test_diagnostics.py index 4639186ecf1..a9dfdd5eca5 100644 --- a/tests/components/nordpool/test_diagnostics.py +++ b/tests/components/nordpool/test_diagnostics.py @@ -2,19 +2,21 @@ from __future__ import annotations +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - load_int: ConfigEntry, + load_int: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" diff --git a/tests/components/nordpool/test_init.py b/tests/components/nordpool/test_init.py index ebebb8b60c1..c9b6167ff3c 100644 --- a/tests/components/nordpool/test_init.py +++ b/tests/components/nordpool/test_init.py @@ -2,10 +2,12 @@ from __future__ import annotations +import json from unittest.mock import patch from pynordpool import ( - DeliveryPeriodData, + API, + NordPoolClient, NordPoolConnectionError, NordPoolEmptyResponseError, NordPoolError, @@ -13,16 +15,21 @@ from pynordpool import ( ) import pytest -from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.components.nordpool.const import CONF_AREAS, DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_CURRENCY from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import ENTRY_CONFIG -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker -async def test_unload_entry(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: +@pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") +async def test_unload_entry(hass: HomeAssistant, get_client: NordPoolClient) -> None: """Test load and unload an entry.""" entry = MockConfigEntry( domain=DOMAIN, @@ -31,13 +38,7 @@ async def test_unload_entry(hass: HomeAssistant, get_data: DeliveryPeriodData) - ) entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", - return_value=get_data, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED @@ -56,7 +57,7 @@ async def test_unload_entry(hass: HomeAssistant, get_data: DeliveryPeriodData) - ], ) async def test_initial_startup_fails( - hass: HomeAssistant, get_data: DeliveryPeriodData, error: Exception + hass: HomeAssistant, get_client: NordPoolClient, error: Exception ) -> None: """Test load and unload an entry.""" entry = MockConfigEntry( @@ -76,3 +77,100 @@ async def test_initial_startup_fails( await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") +async def test_reconfigure_cleans_up_device( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + get_client: NordPoolClient, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test clean up devices due to reconfiguration.""" + nl_json_file = load_fixture("delivery_period_nl.json", DOMAIN) + load_nl_json = json.loads(nl_json_file) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.state is ConfigEntryState.LOADED + + assert device_registry.async_get_device(identifiers={(DOMAIN, "SE3")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "SE4")}) + assert entity_registry.async_get("sensor.nord_pool_se3_current_price") + assert entity_registry.async_get("sensor.nord_pool_se4_current_price") + assert hass.states.get("sensor.nord_pool_se3_current_price") + assert hass.states.get("sensor.nord_pool_se4_current_price") + + aioclient_mock.clear_requests() + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-04", + "market": "DayAhead", + "deliveryArea": "NL", + "currency": "EUR", + }, + json=load_nl_json, + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-05", + "market": "DayAhead", + "deliveryArea": "NL", + "currency": "EUR", + }, + json=load_nl_json, + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-06", + "market": "DayAhead", + "deliveryArea": "NL", + "currency": "EUR", + }, + json=load_nl_json, + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_AREAS: ["NL"], + CONF_CURRENCY: "EUR", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + "areas": [ + "NL", + ], + "currency": "EUR", + } + await hass.async_block_till_done(wait_background_tasks=True) + + assert device_registry.async_get_device(identifiers={(DOMAIN, "NL")}) + assert entity_registry.async_get("sensor.nord_pool_nl_current_price") + assert hass.states.get("sensor.nord_pool_nl_current_price") + + assert not device_registry.async_get_device(identifiers={(DOMAIN, "SE3")}) + assert not entity_registry.async_get("sensor.nord_pool_se3_current_price") + assert not hass.states.get("sensor.nord_pool_se3_current_price") + assert not device_registry.async_get_device(identifiers={(DOMAIN, "SE4")}) + assert not entity_registry.async_get("sensor.nord_pool_se4_current_price") + assert not hass.states.get("sensor.nord_pool_se4_current_price") diff --git a/tests/components/nordpool/test_sensor.py b/tests/components/nordpool/test_sensor.py index c7a305c8a40..60be1ee3258 100644 --- a/tests/components/nordpool/test_sensor.py +++ b/tests/components/nordpool/test_sensor.py @@ -2,14 +2,22 @@ from __future__ import annotations +from datetime import timedelta +from http import HTTPStatus +from typing import Any + +from freezegun.api import FrozenDateTimeFactory +from pynordpool import API import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import snapshot_platform +from tests.common import async_fire_time_changed, snapshot_platform +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") @@ -23,3 +31,168 @@ async def test_sensor( """Test the Nord Pool sensor.""" await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) + + +@pytest.mark.freeze_time("2024-11-05T23:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_no_next_price(hass: HomeAssistant, load_int: ConfigEntry) -> None: + """Test the Nord Pool sensor.""" + + current_price = hass.states.get("sensor.nord_pool_se3_current_price") + last_price = hass.states.get("sensor.nord_pool_se3_previous_price") + next_price = hass.states.get("sensor.nord_pool_se3_next_price") + + assert current_price is not None + assert last_price is not None + assert next_price is not None + assert current_price.state == "0.12666" # SE3 2024-11-05T23:00:00Z + assert last_price.state == "0.28914" # SE3 2024-11-05T22:00:00Z + assert next_price.state == "0.07406" # SE3 2024-11-06T00:00:00Z" + + +@pytest.mark.freeze_time("2024-11-06T00:00:00+01:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_no_previous_price( + hass: HomeAssistant, load_int: ConfigEntry +) -> None: + """Test the Nord Pool sensor.""" + + current_price = hass.states.get("sensor.nord_pool_se3_current_price") + last_price = hass.states.get("sensor.nord_pool_se3_previous_price") + next_price = hass.states.get("sensor.nord_pool_se3_next_price") + + assert current_price is not None + assert last_price is not None + assert next_price is not None + assert current_price.state == "0.12666" # SE3 2024-11-05T23:00:00Z + assert last_price.state == "0.28914" # SE3 2024-11-05T22:00:00Z + assert next_price.state == "0.07406" # SE3 2024-11-06T00:00:00Z + + +@pytest.mark.freeze_time("2024-11-05T11:00:01+01:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_empty_response( + hass: HomeAssistant, + load_int: ConfigEntry, + load_json: list[dict[str, Any]], + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Nord Pool sensor with empty response.""" + + responses = list(load_json) + + current_price = hass.states.get("sensor.nord_pool_se3_current_price") + last_price = hass.states.get("sensor.nord_pool_se3_previous_price") + next_price = hass.states.get("sensor.nord_pool_se3_next_price") + assert current_price is not None + assert last_price is not None + assert next_price is not None + assert current_price.state == "0.92737" + assert last_price.state == "1.03132" + assert next_price.state == "0.92505" + + aioclient_mock.clear_requests() + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-04", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + json=responses[1], + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-05", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + json=responses[0], + ) + # Future date without data should return 204 + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-06", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + status=HTTPStatus.NO_CONTENT, + ) + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # All prices should be known as tomorrow is not loaded by sensors + + current_price = hass.states.get("sensor.nord_pool_se3_current_price") + last_price = hass.states.get("sensor.nord_pool_se3_previous_price") + next_price = hass.states.get("sensor.nord_pool_se3_next_price") + assert current_price is not None + assert last_price is not None + assert next_price is not None + assert current_price.state == "0.92505" + assert last_price.state == "0.92737" + assert next_price.state == "0.94949" + + aioclient_mock.clear_requests() + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-04", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + json=responses[1], + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-05", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + json=responses[0], + ) + # Future date without data should return 204 + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2024-11-06", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + status=HTTPStatus.NO_CONTENT, + ) + + freezer.move_to("2024-11-05T22:00:01+00:00") + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Current and last price should be known, next price should be unknown + # as api responds with empty data (204) + + current_price = hass.states.get("sensor.nord_pool_se3_current_price") + last_price = hass.states.get("sensor.nord_pool_se3_previous_price") + next_price = hass.states.get("sensor.nord_pool_se3_next_price") + assert current_price is not None + assert last_price is not None + assert next_price is not None + assert current_price.state == "0.28914" + assert last_price.state == "0.5223" + assert next_price.state == STATE_UNKNOWN diff --git a/tests/components/nordpool/test_services.py b/tests/components/nordpool/test_services.py new file mode 100644 index 00000000000..6d6af685d28 --- /dev/null +++ b/tests/components/nordpool/test_services.py @@ -0,0 +1,139 @@ +"""Test services in Nord Pool.""" + +from unittest.mock import patch + +from pynordpool import ( + NordPoolAuthenticationError, + NordPoolEmptyResponseError, + NordPoolError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.components.nordpool.services import ( + ATTR_AREAS, + ATTR_CONFIG_ENTRY, + ATTR_CURRENCY, + SERVICE_GET_PRICES_FOR_DATE, +) +from homeassistant.const import ATTR_DATE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from tests.common import MockConfigEntry + +TEST_SERVICE_DATA = { + ATTR_CONFIG_ENTRY: "to_replace", + ATTR_DATE: "2024-11-05", + ATTR_AREAS: "SE3", + ATTR_CURRENCY: "EUR", +} +TEST_SERVICE_DATA_USE_DEFAULTS = { + ATTR_CONFIG_ENTRY: "to_replace", + ATTR_DATE: "2024-11-05", +} + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_service_call( + hass: HomeAssistant, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test get_prices_for_date service call.""" + + service_data = TEST_SERVICE_DATA.copy() + service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + service_data, + blocking=True, + return_response=True, + ) + + assert response == snapshot + price_value = response["SE3"][0]["price"] + + service_data = TEST_SERVICE_DATA_USE_DEFAULTS.copy() + service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + service_data, + blocking=True, + return_response=True, + ) + + assert "SE3" in response + assert response["SE3"][0]["price"] == price_value + + +@pytest.mark.parametrize( + ("error", "key"), + [ + (NordPoolAuthenticationError, "authentication_error"), + (NordPoolEmptyResponseError, "empty_response"), + (NordPoolError, "connection_error"), + ], +) +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_service_call_failures( + hass: HomeAssistant, + load_int: MockConfigEntry, + error: Exception, + key: str, +) -> None: + """Test get_prices_for_date service call when it fails.""" + service_data = TEST_SERVICE_DATA.copy() + service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=error, + ), + pytest.raises(ServiceValidationError) as err, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + service_data, + blocking=True, + return_response=True, + ) + assert err.value.translation_key == key + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_service_call_config_entry_bad_state( + hass: HomeAssistant, + load_int: MockConfigEntry, +) -> None: + """Test get_prices_for_date service call when config entry bad state.""" + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + TEST_SERVICE_DATA, + blocking=True, + return_response=True, + ) + assert err.value.translation_key == "entry_not_found" + + service_data = TEST_SERVICE_DATA.copy() + service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id + await hass.config_entries.async_unload(load_int.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + service_data, + blocking=True, + return_response=True, + ) + assert err.value.translation_key == "entry_not_loaded" diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py index d4ddc261f1e..efc6c6c5abc 100644 --- a/tests/components/nuki/test_config_flow.py +++ b/tests/components/nuki/test_config_flow.py @@ -6,11 +6,11 @@ from pynuki.bridge import InvalidCredentialsException from requests.exceptions import RequestException from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.nuki.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .mock import DHCP_FORMATTED_MAC, HOST, MOCK_INFO, NAME, setup_nuki_integration @@ -151,9 +151,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( - hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC - ), + data=DhcpServiceInfo(hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC), context={"source": config_entries.SOURCE_DHCP}, ) @@ -196,9 +194,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: await setup_nuki_integration(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( - hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC - ), + data=DhcpServiceInfo(hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC), context={"source": config_entries.SOURCE_DHCP}, ) diff --git a/tests/components/number/test_const.py b/tests/components/number/test_const.py deleted file mode 100644 index 13d94e2eeaf..00000000000 --- a/tests/components/number/test_const.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Test the number const module.""" - -import pytest - -from homeassistant.components.number import const - -from tests.common import help_test_all, import_and_test_deprecated_constant_enum - - -def test_all() -> None: - """Test module.__all__ is correctly set.""" - help_test_all(const) - - -@pytest.mark.parametrize(("enum"), list(const.NumberMode)) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: const.NumberMode, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum(caplog, const, enum, "MODE_", "2025.1") diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index ffebd62fcbf..6b24c15f18a 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -243,18 +243,26 @@ async def test_action_legacy( async def test_capabilities( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test getting capabilities.""" - entry = entity_registry.async_get_or_create( - DOMAIN, "test", "5678", device_id="abcdefgh" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id ) capabilities = await device_action.async_get_action_capabilities( hass, { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": entry.id, + "entity_id": entity_entry.id, "type": "set_value", }, ) @@ -267,18 +275,26 @@ async def test_capabilities( async def test_capabilities_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test getting capabilities.""" - entry = entity_registry.async_get_or_create( - DOMAIN, "test", "5678", device_id="abcdefgh" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id ) capabilities = await device_action.async_get_action_capabilities( hass, { "domain": DOMAIN, "device_id": "abcdefgh", - "entity_id": entry.entity_id, + "entity_id": entity_entry.entity_id, "type": "set_value", }, ) diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 721b531e8cd..31d99dc55d7 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -2,7 +2,7 @@ from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -836,6 +836,69 @@ async def test_custom_unit_change( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == default_unit +async def test_translated_unit( + hass: HomeAssistant, +) -> None: + """Test translated unit.""" + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={ + "component.test.entity.number.test_translation_key.unit_of_measurement": "Tests" + }, + ): + entity0 = common.MockNumberEntity( + name="Test", + native_value=123, + unique_id="very_unique", + ) + entity0.entity_description = NumberEntityDescription( + "test", + translation_key="test_translation_key", + ) + setup_test_component_platform(hass, DOMAIN, [entity0]) + + assert await async_setup_component( + hass, "number", {"number": {"platform": "test"}} + ) + await hass.async_block_till_done() + + entity_id = entity0.entity_id + state = hass.states.get(entity_id) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "Tests" + + +async def test_translated_unit_with_native_unit_raises( + hass: HomeAssistant, +) -> None: + """Test that translated unit.""" + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={ + "component.test.entity.number.test_translation_key.unit_of_measurement": "Tests" + }, + ): + entity0 = common.MockNumberEntity( + name="Test", + native_value=123, + unique_id="very_unique", + ) + entity0.entity_description = NumberEntityDescription( + "test", + translation_key="test_translation_key", + native_unit_of_measurement="bad_unit", + ) + setup_test_component_platform(hass, DOMAIN, [entity0]) + + assert await async_setup_component( + hass, "number", {"number": {"platform": "test"}} + ) + await hass.async_block_till_done() + # Setup fails so entity_id is None + assert entity0.entity_id is None + + def test_device_classes_aligned() -> None: """Make sure all sensor device classes are also available in NumberDeviceClass.""" diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 537b6aba5ac..ed9c87f2f90 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch from aionut import NUTError, NUTLoginError from homeassistant import config_entries, setup -from homeassistant.components import zeroconf from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_ALIAS, @@ -20,6 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .util import _get_mock_nutclient @@ -38,7 +38,7 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", diff --git a/tests/components/obihai/__init__.py b/tests/components/obihai/__init__.py index b88f0a5c874..7b483514dcf 100644 --- a/tests/components/obihai/__init__.py +++ b/tests/components/obihai/__init__.py @@ -1,7 +1,7 @@ """Tests for the Obihai Integration.""" -from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo USER_INPUT = { CONF_HOST: "10.10.10.30", @@ -9,7 +9,7 @@ USER_INPUT = { CONF_USERNAME: "admin", } -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="obi200", ip="192.168.1.100", macaddress="9cadef000000", diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index e0696486718..d7d7e43e99c 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -6,10 +6,11 @@ from unittest.mock import patch from pyoctoprintapi import ApiError, DiscoverySettings from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.octoprint.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -183,7 +184,7 @@ async def test_show_zerconf_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -252,7 +253,7 @@ async def test_show_ssdp_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -525,7 +526,7 @@ async def test_duplicate_zerconf_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -551,7 +552,7 @@ async def test_duplicate_ssdp_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ diff --git a/tests/components/ohme/__init__.py b/tests/components/ohme/__init__.py new file mode 100644 index 00000000000..7c00bedbd1e --- /dev/null +++ b/tests/components/ohme/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Ohme integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Ohme integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py new file mode 100644 index 00000000000..3d3db730d08 --- /dev/null +++ b/tests/components/ohme/conftest.py @@ -0,0 +1,68 @@ +"""Provide common fixtures.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from ohme import ChargerPower, ChargerStatus +import pytest + +from homeassistant.components.ohme.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ohme.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="test@example.com", + domain=DOMAIN, + version=1, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter2", + }, + ) + + +@pytest.fixture +def mock_client(): + """Fixture to mock the OhmeApiClient.""" + with ( + patch( + "homeassistant.components.ohme.config_flow.OhmeApiClient", + autospec=True, + ) as client, + patch( + "homeassistant.components.ohme.OhmeApiClient", + new=client, + ), + ): + client = client.return_value + client.async_login.return_value = True + client.status = ChargerStatus.CHARGING + client.power = ChargerPower(0, 0, 0, 0) + + client.target_soc = 50 + client.target_time = (8, 0) + client.battery = 80 + client.serial = "chargerid" + client.ct_connected = True + client.energy = 1000 + client.device_info = { + "name": "Ohme Home Pro", + "model": "Home Pro", + "sw_version": "v2.65", + } + yield client diff --git a/tests/components/ohme/snapshots/test_button.ambr b/tests/components/ohme/snapshots/test_button.ambr new file mode 100644 index 00000000000..32de16208f4 --- /dev/null +++ b/tests/components/ohme/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_buttons[button.ohme_home_pro_approve_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ohme_home_pro_approve_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Approve charge', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'approve', + 'unique_id': 'chargerid_approve', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.ohme_home_pro_approve_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Approve charge', + }), + 'context': , + 'entity_id': 'button.ohme_home_pro_approve_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/ohme/snapshots/test_init.ambr b/tests/components/ohme/snapshots/test_init.ambr new file mode 100644 index 00000000000..e3ed339b78a --- /dev/null +++ b/tests/components/ohme/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ohme', + 'chargerid', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Ohme', + 'model': 'Home Pro', + 'model_id': None, + 'name': 'Ohme Home Pro', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'chargerid', + 'suggested_area': None, + 'sw_version': 'v2.65', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/ohme/snapshots/test_number.ambr b/tests/components/ohme/snapshots/test_number.ambr new file mode 100644 index 00000000000..580082635df --- /dev/null +++ b/tests/components/ohme/snapshots/test_number.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_numbers[number.ohme_home_pro_target_percentage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ohme_home_pro_target_percentage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target percentage', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'target_percentage', + 'unique_id': 'chargerid_target_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[number.ohme_home_pro_target_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Target percentage', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.ohme_home_pro_target_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6415d720419 --- /dev/null +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -0,0 +1,321 @@ +# serializer version: 1 +# name: test_sensors[sensor.ohme_home_pro_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CT current', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ct_current', + 'unique_id': 'chargerid_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Ohme Home Pro CT current', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'chargerid_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Ohme Home Pro Current', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'chargerid_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Ohme Home Pro Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'chargerid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Ohme Home Pro Power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'pending_approval', + 'charging', + 'plugged_in', + 'paused', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'chargerid_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Ohme Home Pro Status', + 'options': list([ + 'unplugged', + 'pending_approval', + 'charging', + 'plugged_in', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_vehicle_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_vehicle_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vehicle battery', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_battery', + 'unique_id': 'chargerid_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_vehicle_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Ohme Home Pro Vehicle battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_vehicle_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- diff --git a/tests/components/ohme/snapshots/test_services.ambr b/tests/components/ohme/snapshots/test_services.ambr new file mode 100644 index 00000000000..91917ed6925 --- /dev/null +++ b/tests/components/ohme/snapshots/test_services.ambr @@ -0,0 +1,12 @@ +# serializer version: 1 +# name: test_list_charge_slots + dict({ + 'slots': list([ + dict({ + 'end': '2024-12-30T04:30:39+00:00', + 'energy': 2.042, + 'start': '2024-12-30T04:00:00+00:00', + }), + ]), + }) +# --- diff --git a/tests/components/ohme/snapshots/test_switch.ambr b/tests/components/ohme/snapshots/test_switch.ambr new file mode 100644 index 00000000000..76066b6e658 --- /dev/null +++ b/tests/components/ohme/snapshots/test_switch.ambr @@ -0,0 +1,139 @@ +# serializer version: 1 +# name: test_switches[switch.ohme_home_pro_lock_buttons-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ohme_home_pro_lock_buttons', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock buttons', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock_buttons', + 'unique_id': 'chargerid_lock_buttons', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ohme_home_pro_lock_buttons-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Lock buttons', + }), + 'context': , + 'entity_id': 'switch.ohme_home_pro_lock_buttons', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ohme_home_pro_require_approval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ohme_home_pro_require_approval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Require approval', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'require_approval', + 'unique_id': 'chargerid_require_approval', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ohme_home_pro_require_approval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Require approval', + }), + 'context': , + 'entity_id': 'switch.ohme_home_pro_require_approval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ohme_home_pro_sleep_when_inactive-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ohme_home_pro_sleep_when_inactive', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sleep when inactive', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sleep_when_inactive', + 'unique_id': 'chargerid_sleep_when_inactive', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ohme_home_pro_sleep_when_inactive-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Sleep when inactive', + }), + 'context': , + 'entity_id': 'switch.ohme_home_pro_sleep_when_inactive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ohme/snapshots/test_time.ambr b/tests/components/ohme/snapshots/test_time.ambr new file mode 100644 index 00000000000..4d9fab20e0b --- /dev/null +++ b/tests/components/ohme/snapshots/test_time.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_time[time.ohme_home_pro_target_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': None, + 'entity_id': 'time.ohme_home_pro_target_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target time', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'target_time', + 'unique_id': 'chargerid_target_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_time[time.ohme_home_pro_target_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Target time', + }), + 'context': , + 'entity_id': 'time.ohme_home_pro_target_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '08:00:00', + }) +# --- diff --git a/tests/components/ohme/test_button.py b/tests/components/ohme/test_button.py new file mode 100644 index 00000000000..1728563b2e9 --- /dev/null +++ b/tests/components/ohme/test_button.py @@ -0,0 +1,79 @@ +"""Tests for sensors.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from ohme import ChargerStatus +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_buttons( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme buttons.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_button_available( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test that button shows as unavailable when a charge is not pending approval.""" + mock_client.status = ChargerStatus.PENDING_APPROVAL + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("button.ohme_home_pro_approve_charge") + assert state.state == STATE_UNKNOWN + + mock_client.status = ChargerStatus.PLUGGED_IN + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("button.ohme_home_pro_approve_charge") + assert state.state == STATE_UNAVAILABLE + + +async def test_button_press( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the button press action.""" + mock_client.status = ChargerStatus.PENDING_APPROVAL + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.ohme_home_pro_approve_charge", + }, + blocking=True, + ) + + assert len(mock_client.async_approve_charge.mock_calls) == 1 diff --git a/tests/components/ohme/test_config_flow.py b/tests/components/ohme/test_config_flow.py new file mode 100644 index 00000000000..bb7ecc00bdc --- /dev/null +++ b/tests/components/ohme/test_config_flow.py @@ -0,0 +1,184 @@ +"""Tests for the config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from ohme import ApiException, AuthException +import pytest + +from homeassistant.components.ohme.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_config_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_client: MagicMock +) -> None: + """Test config flow.""" + + # Initial form load + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + # Successful login + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "hunter2"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter2", + } + + +@pytest.mark.parametrize( + ("test_exception", "expected_error"), + [(AuthException, "invalid_auth"), (ApiException, "unknown")], +) +async def test_config_flow_fail( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + test_exception: Exception, + expected_error: str, +) -> None: + """Test config flow errors.""" + + # Initial form load + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + # Failed login + mock_client.async_login.side_effect = test_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "hunter1"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # End with CREATE_ENTRY + mock_client.async_login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "hunter1"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + } + + +async def test_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Ensure we can't add the same account twice.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter3", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_form(hass: HomeAssistant, mock_client: MagicMock) -> None: + """Test reauth form.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + }, + ) + entry.add_to_hass(hass) + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + assert not result["errors"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter2"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("test_exception", "expected_error"), + [(AuthException, "invalid_auth"), (ApiException, "unknown")], +) +async def test_reauth_fail( + hass: HomeAssistant, + mock_client: MagicMock, + test_exception: Exception, + expected_error: str, +) -> None: + """Test reauth errors.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + }, + ) + entry.add_to_hass(hass) + + # Initial form load + result = await entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + # Failed login + mock_client.async_login.side_effect = test_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter1"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # End with success + mock_client.async_login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter2"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/ohme/test_init.py b/tests/components/ohme/test_init.py new file mode 100644 index 00000000000..0f4c7cd64ee --- /dev/null +++ b/tests/components/ohme/test_init.py @@ -0,0 +1,47 @@ +"""Test init of Ohme integration.""" + +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.ohme.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test loading and unloading the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device( + mock_client: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Snapshot the device from registry.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device({(DOMAIN, mock_client.serial)}) + assert device + assert device == snapshot diff --git a/tests/components/ohme/test_number.py b/tests/components/ohme/test_number.py new file mode 100644 index 00000000000..9cfce2a850f --- /dev/null +++ b/tests/components/ohme/test_number.py @@ -0,0 +1,55 @@ +"""Tests for numbers.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_numbers( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme sensors.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_number( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the number set.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: 100, + }, + target={ + ATTR_ENTITY_ID: "number.ohme_home_pro_target_percentage", + }, + blocking=True, + ) + + assert len(mock_client.async_set_target.mock_calls) == 1 diff --git a/tests/components/ohme/test_sensor.py b/tests/components/ohme/test_sensor.py new file mode 100644 index 00000000000..21f9f06f963 --- /dev/null +++ b/tests/components/ohme/test_sensor.py @@ -0,0 +1,59 @@ +"""Tests for sensors.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from ohme import ApiException +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme sensors.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test that sensors show as unavailable after a coordinator failure.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.ohme_home_pro_energy") + assert state.state == "1.0" + + mock_client.async_get_charge_session.side_effect = ApiException + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.ohme_home_pro_energy") + assert state.state == STATE_UNAVAILABLE + + mock_client.async_get_charge_session.side_effect = None + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.ohme_home_pro_energy") + assert state.state == "1.0" diff --git a/tests/components/ohme/test_services.py b/tests/components/ohme/test_services.py new file mode 100644 index 00000000000..76c7ce94b57 --- /dev/null +++ b/tests/components/ohme/test_services.py @@ -0,0 +1,70 @@ +"""Tests for services.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ohme.const import DOMAIN +from homeassistant.components.ohme.services import ( + ATTR_CONFIG_ENTRY, + SERVICE_LIST_CHARGE_SLOTS, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_list_charge_slots( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test list charge slots service.""" + + await setup_integration(hass, mock_config_entry) + + mock_client.slots = [ + { + "start": "2024-12-30T04:00:00+00:00", + "end": "2024-12-30T04:30:39+00:00", + "energy": 2.042, + } + ] + + assert snapshot == await hass.services.async_call( + DOMAIN, + "list_charge_slots", + { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + }, + blocking=True, + return_response=True, + ) + + +async def test_list_charge_slots_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test list charge slots service.""" + + await setup_integration(hass, mock_config_entry) + + # Test error + with pytest.raises( + ServiceValidationError, match="Invalid config entry provided. Got invalid" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_LIST_CHARGE_SLOTS, + {ATTR_CONFIG_ENTRY: "invalid"}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/ohme/test_switch.py b/tests/components/ohme/test_switch.py new file mode 100644 index 00000000000..b16b70d67f8 --- /dev/null +++ b/tests/components/ohme/test_switch.py @@ -0,0 +1,72 @@ +"""Tests for switches.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_switches( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme switches.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_switch_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the switch turn_on action.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.ohme_home_pro_lock_buttons", + }, + blocking=True, + ) + + assert len(mock_client.async_set_configuration_value.mock_calls) == 1 + + +async def test_switch_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the switch turn_off action.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "switch.ohme_home_pro_lock_buttons", + }, + blocking=True, + ) + + assert len(mock_client.async_set_configuration_value.mock_calls) == 1 diff --git a/tests/components/ohme/test_time.py b/tests/components/ohme/test_time.py new file mode 100644 index 00000000000..0562dfa124c --- /dev/null +++ b/tests/components/ohme/test_time.py @@ -0,0 +1,55 @@ +"""Tests for time.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.time import ( + ATTR_TIME, + DOMAIN as TIME_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_time( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme sensors.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.TIME]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_time( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the time set.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_TIME: "00:00:00", + }, + target={ + ATTR_ENTITY_ID: "time.ohme_home_pro_target_time", + }, + blocking=True, + ) + + assert len(mock_client.async_set_target.mock_calls) == 1 diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 66dc8a0c603..202f7385697 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -51,13 +51,13 @@ async def test_chat( assert args["model"] == "test model" assert args["messages"] == [ - Message({"role": "system", "content": prompt}), - Message({"role": "user", "content": "test message"}), + Message(role="system", content=prompt), + Message(role="user", content="test message"), ] - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert result.response.speech["plain"]["speech"] == "test response" # Test Conversation tracing @@ -106,9 +106,9 @@ async def test_template_variables( hass, "hello", None, context, agent_id=mock_config_entry.entry_id ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] @@ -341,7 +341,7 @@ async def test_message_history_trimming( for i in range(5): result = await conversation.async_converse( hass, - f"message {i+1}", + f"message {i + 1}", conversation_id="1234", context=Context(), agent_id=mock_config_entry.entry_id, @@ -432,7 +432,7 @@ async def test_message_history_pruning( for i in range(3): result = await conversation.async_converse( hass, - f"message {i+1}", + f"message {i + 1}", conversation_id=None, context=Context(), agent_id=mock_config_entry.entry_id, @@ -463,9 +463,9 @@ async def test_message_history_pruning( context=Context(), agent_id=mock_config_entry.entry_id, ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) # Only the most recent histories should remain assert len(agent._history) == 2 @@ -490,7 +490,7 @@ async def test_message_history_unlimited( for i in range(100): result = await conversation.async_converse( hass, - f"message {i+1}", + f"message {i + 1}", conversation_id=conversation_id, context=Context(), agent_id=mock_config_entry.entry_id, diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index ed15cac94be..9c025fe33af 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -7,57 +7,58 @@ from unittest.mock import MagicMock from pyownet.protocol import ProtocolError -from homeassistant.const import Platform - from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES -def setup_owproxy_mock_devices( - owproxy: MagicMock, platform: Platform, device_ids: list[str] -) -> None: +def setup_owproxy_mock_devices(owproxy: MagicMock, device_ids: list[str]) -> None: """Set up mock for owproxy.""" - main_dir_return_value = [] - sub_dir_side_effect = [] - main_read_side_effect = [] - sub_read_side_effect = [] + dir_side_effect: dict[str, list] = {} + read_side_effect: dict[str, list] = {} + + # Setup directory listing + dir_side_effect["/"] = [[f"/{device_id}/" for device_id in device_ids]] for device_id in device_ids: - _setup_owproxy_mock_device( - main_dir_return_value, - sub_dir_side_effect, - main_read_side_effect, - sub_read_side_effect, - device_id, - platform, - ) + _setup_owproxy_mock_device(dir_side_effect, read_side_effect, device_id) - # Ensure enough read side effect - dir_side_effect = [main_dir_return_value, *sub_dir_side_effect] - read_side_effect = ( - main_read_side_effect - + sub_read_side_effect - + [ProtocolError("Missing injected value")] * 20 - ) - owproxy.return_value.dir.side_effect = dir_side_effect - owproxy.return_value.read.side_effect = read_side_effect + def _dir(path: str) -> Any: + if (side_effect := dir_side_effect.get(path)) is None: + raise NotImplementedError(f"Unexpected _dir call: {path}") + result = side_effect.pop(0) + if isinstance(result, Exception) or ( + isinstance(result, type) and issubclass(result, Exception) + ): + raise result + return result + + def _read(path: str) -> Any: + if (side_effect := read_side_effect.get(path)) is None: + raise NotImplementedError(f"Unexpected _read call: {path}") + if len(side_effect) == 0: + raise ProtocolError(f"Missing injected value for: {path}") + result = side_effect.pop(0) + if isinstance(result, Exception) or ( + isinstance(result, type) and issubclass(result, Exception) + ): + raise result + return result + + owproxy.return_value.dir.side_effect = _dir + owproxy.return_value.read.side_effect = _read def _setup_owproxy_mock_device( - main_dir_return_value: list, - sub_dir_side_effect: list, - main_read_side_effect: list, - sub_read_side_effect: list, - device_id: str, - platform: Platform, + dir_side_effect: dict[str, list], read_side_effect: dict[str, list], device_id: str ) -> None: """Set up mock for owproxy.""" mock_device = MOCK_OWPROXY_DEVICES[device_id] - # Setup directory listing - main_dir_return_value += [f"/{device_id}/"] if "branches" in mock_device: # Setup branch directory listing for branch, branch_details in mock_device["branches"].items(): + sub_dir_side_effect = dir_side_effect.setdefault( + f"/{device_id}/{branch}", [] + ) sub_dir_side_effect.append( [ # dir on branch f"/{device_id}/{branch}/{sub_device_id}/" @@ -65,46 +66,31 @@ def _setup_owproxy_mock_device( ] ) - _setup_owproxy_mock_device_reads( - main_read_side_effect, - sub_read_side_effect, - mock_device, - device_id, - platform, - ) + _setup_owproxy_mock_device_reads(read_side_effect, mock_device, "/", device_id) if "branches" in mock_device: - for branch_details in mock_device["branches"].values(): + for branch, branch_details in mock_device["branches"].items(): for sub_device_id, sub_device in branch_details.items(): _setup_owproxy_mock_device_reads( - main_read_side_effect, - sub_read_side_effect, + read_side_effect, sub_device, + f"/{device_id}/{branch}/", sub_device_id, - platform, ) def _setup_owproxy_mock_device_reads( - main_read_side_effect: list, - sub_read_side_effect: list, - mock_device: Any, - device_id: str, - platform: Platform, + read_side_effect: dict[str, list], mock_device: Any, root_path: str, device_id: str ) -> None: """Set up mock for owproxy.""" # Setup device reads - main_read_side_effect += [device_id[0:2].encode()] - if ATTR_INJECT_READS in mock_device: - main_read_side_effect += mock_device[ATTR_INJECT_READS] - - # Setup sub-device reads - device_sensors = mock_device.get(platform, []) - if platform is Platform.SENSOR and device_id.startswith("12"): - # We need to check if there is TAI8570 plugged in - sub_read_side_effect.extend( - expected_sensor[ATTR_INJECT_READS] for expected_sensor in device_sensors - ) - sub_read_side_effect.extend( - expected_sensor[ATTR_INJECT_READS] for expected_sensor in device_sensors + family_read_side_effect = read_side_effect.setdefault( + f"{root_path}{device_id}/family", [] ) + family_read_side_effect += [device_id[0:2].encode()] + if ATTR_INJECT_READS in mock_device: + for k, v in mock_device[ATTR_INJECT_READS].items(): + device_read_side_effect = read_side_effect.setdefault( + f"{root_path}{device_id}{k}", [] + ) + device_read_side_effect += v diff --git a/tests/components/onewire/conftest.py b/tests/components/onewire/conftest.py index 65a86b58f2f..9d4303eaa1c 100644 --- a/tests/components/onewire/conftest.py +++ b/tests/components/onewire/conftest.py @@ -7,7 +7,7 @@ from pyownet.protocol import ConnError import pytest from homeassistant.components.onewire.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -32,7 +32,7 @@ def get_device_id(request: pytest.FixtureRequest) -> str: @pytest.fixture(name="config_entry") -def get_config_entry(hass: HomeAssistant) -> ConfigEntry: +def get_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create and register mock config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -54,14 +54,14 @@ def get_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture(name="owproxy") -def get_owproxy() -> MagicMock: +def get_owproxy() -> Generator[MagicMock]: """Mock owproxy.""" with patch("homeassistant.components.onewire.onewirehub.protocol.proxy") as owproxy: yield owproxy @pytest.fixture(name="owproxy_with_connerror") -def get_owproxy_with_connerror() -> MagicMock: +def get_owproxy_with_connerror() -> Generator[MagicMock]: """Mock owproxy.""" with patch( "homeassistant.components.onewire.onewirehub.protocol.proxy", diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index a1bab9807d5..4c05442eadc 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -2,333 +2,243 @@ from pyownet.protocol import ProtocolError -from homeassistant.components.onewire.const import Platform - -ATTR_DEVICE_FILE = "device_file" ATTR_INJECT_READS = "inject_reads" MOCK_OWPROXY_DEVICES = { "00.111111111111": { - ATTR_INJECT_READS: [ - b"", # read device type - ], + ATTR_INJECT_READS: { + "/type": [b""], + }, }, "05.111111111111": { - ATTR_INJECT_READS: [ - b"DS2405", # read device type - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2405"], + "/PIO": [b" 1"], + }, }, "10.111111111111": { - ATTR_INJECT_READS: [ - b"DS18S20", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18S20"], + "/temperature": [b" 25.123"], + }, }, "12.111111111111": { - ATTR_INJECT_READS: [ - b"DS2406", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 1025.123"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2406"], + # TAI8570 values are read twice: + # - once during init to make sure TAI8570 is accessible + # - once during first update to get the actual values + "/TAI8570/temperature": [b" 25.123", b" 25.123"], + "/TAI8570/pressure": [b" 1025.123", b" 1025.123"], + "/PIO.A": [b" 1"], + "/PIO.B": [b" 0"], + "/latch.A": [b" 1"], + "/latch.B": [b" 0"], + "/sensed.A": [b" 1"], + "/sensed.B": [b" 0"], + }, }, "1D.111111111111": { - ATTR_INJECT_READS: [ - b"DS2423", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 251123"}, - {ATTR_INJECT_READS: b" 248125"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2423"], + "/counter.A": [b" 251123"], + "/counter.B": [b" 248125"], + } }, "16.111111111111": { # Test case for issue #115984, where the device type cannot be read - ATTR_INJECT_READS: [ - ProtocolError(), # read device type - ], + ATTR_INJECT_READS: {"/type": [ProtocolError()]}, }, "1F.111111111111": { - ATTR_INJECT_READS: [ - b"DS2409", # read device type - ], + ATTR_INJECT_READS: {"/type": [b"DS2409"]}, "branches": { "aux": {}, "main": { "1D.111111111111": { - ATTR_INJECT_READS: [ - b"DS2423", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.A", - ATTR_INJECT_READS: b" 251123", - }, - { - ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.B", - ATTR_INJECT_READS: b" 248125", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2423"], + "/counter.A": [b" 251123"], + "/counter.B": [b" 248125"], + }, }, }, }, }, "22.111111111111": { - ATTR_INJECT_READS: [ - b"DS1822", # read device type - ], - Platform.SENSOR: [ - { - ATTR_INJECT_READS: ProtocolError, - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS1822"], + "/temperature": [ProtocolError], + }, }, "26.111111111111": { - ATTR_INJECT_READS: [ - b"DS2438", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 72.7563"}, - {ATTR_INJECT_READS: b" 73.7563"}, - {ATTR_INJECT_READS: b" 74.7563"}, - {ATTR_INJECT_READS: b" 75.7563"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 969.265"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 4.74"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2438"], + "/temperature": [b" 25.123"], + "/humidity": [b" 72.7563"], + "/HIH3600/humidity": [b" 73.7563"], + "/HIH4000/humidity": [b" 74.7563"], + "/HIH5030/humidity": [b" 75.7563"], + "/HTM1735/humidity": [ProtocolError], + "/B1-R1-A/pressure": [b" 969.265"], + "/S3-R1-A/illuminance": [b" 65.8839"], + "/VAD": [b" 2.97"], + "/VDD": [b" 4.74"], + "/vis": [b" 0.12"], + "/IAD": [b" 1"], + }, }, "28.111111111111": { - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 26.984"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature": [b" 26.984"], + "/tempres": [b" 12"], + }, }, "28.222222222222": { # This device has precision options in the config entry - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/28.222222222222/temperature9", - ATTR_INJECT_READS: b" 26.984", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature9": [b" 26.984"], + }, }, "28.222222222223": { # This device has an illegal precision option in the config entry - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/28.222222222223/temperature", - ATTR_INJECT_READS: b" 26.984", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature": [b" 26.984"], + }, }, "29.111111111111": { - ATTR_INJECT_READS: [ - b"DS2408", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2408"], + "/PIO.0": [b" 1"], + "/PIO.1": [b" 0"], + "/PIO.2": [b" 1"], + "/PIO.3": [ProtocolError], + "/PIO.4": [b" 1"], + "/PIO.5": [b" 0"], + "/PIO.6": [b" 1"], + "/PIO.7": [b" 0"], + "/latch.0": [b" 1"], + "/latch.1": [b" 0"], + "/latch.2": [b" 1"], + "/latch.3": [b" 0"], + "/latch.4": [b" 1"], + "/latch.5": [b" 0"], + "/latch.6": [b" 1"], + "/latch.7": [b" 0"], + "/sensed.0": [b" 1"], + "/sensed.1": [b" 0"], + "/sensed.2": [b" 0"], + "/sensed.3": [ProtocolError], + "/sensed.4": [b" 0"], + "/sensed.5": [b" 0"], + "/sensed.6": [b" 0"], + "/sensed.7": [b" 0"], + }, }, "30.111111111111": { - ATTR_INJECT_READS: [ - b"DS2760", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 26.984"}, - { - ATTR_DEVICE_FILE: "/30.111111111111/typeK/temperature", - ATTR_INJECT_READS: b" 173.7563", - }, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2760"], + "/temperature": [b" 26.984"], + "/typeK/temperature": [b" 173.7563"], + "/volt": [b" 2.97"], + "/vis": [b" 0.12"], + }, }, "3A.111111111111": { - ATTR_INJECT_READS: [ - b"DS2413", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2413"], + "/PIO.A": [b" 1"], + "/PIO.B": [b" 0"], + "/sensed.A": [b" 1"], + "/sensed.B": [b" 0"], + }, }, "3B.111111111111": { - ATTR_INJECT_READS: [ - b"DS1825", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 28.243"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS1825"], + "/temperature": [b" 28.243"], + }, }, "42.111111111111": { - ATTR_INJECT_READS: [ - b"DS28EA00", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 29.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS28EA00"], + "/temperature": [b" 29.123"], + }, }, "A6.111111111111": { - ATTR_INJECT_READS: [ - b"DS2438", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 72.7563"}, - {ATTR_INJECT_READS: b" 73.7563"}, - {ATTR_INJECT_READS: b" 74.7563"}, - {ATTR_INJECT_READS: b" 75.7563"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 969.265"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 4.74"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2438"], + "/temperature": [b" 25.123"], + "/humidity": [b" 72.7563"], + "/HIH3600/humidity": [b" 73.7563"], + "/HIH4000/humidity": [b" 74.7563"], + "/HIH5030/humidity": [b" 75.7563"], + "/HTM1735/humidity": [ProtocolError], + "/B1-R1-A/pressure": [b" 969.265"], + "/S3-R1-A/illuminance": [b" 65.8839"], + "/VAD": [b" 2.97"], + "/VDD": [b" 4.74"], + "/vis": [b" 0.12"], + "/IAD": [b" 1"], + }, }, "EF.111111111111": { - ATTR_INJECT_READS: [ - b"HobbyBoards_EF", # read type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 67.745"}, - {ATTR_INJECT_READS: b" 65.541"}, - {ATTR_INJECT_READS: b" 25.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HobbyBoards_EF"], + "/humidity/humidity_corrected": [b" 67.745"], + "/humidity/humidity_raw": [b" 65.541"], + "/humidity/temperature": [b" 25.123"], + }, }, "EF.111111111112": { - ATTR_INJECT_READS: [ - b"HB_MOISTURE_METER", # read type - b" 1", # read is_leaf_0 - b" 1", # read is_leaf_1 - b" 0", # read is_leaf_2 - b" 0", # read is_leaf_3 - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 41.745"}, - {ATTR_INJECT_READS: b" 42.541"}, - {ATTR_INJECT_READS: b" 43.123"}, - {ATTR_INJECT_READS: b" 44.123"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HB_MOISTURE_METER"], + "/moisture/is_leaf.0": [b" 1"], + "/moisture/is_leaf.1": [b" 1"], + "/moisture/is_leaf.2": [b" 0"], + "/moisture/is_leaf.3": [b" 0"], + "/moisture/sensor.0": [b" 41.745"], + "/moisture/sensor.1": [b" 42.541"], + "/moisture/sensor.2": [b" 43.123"], + "/moisture/sensor.3": [b" 44.123"], + "/moisture/is_moisture.0": [b" 1"], + "/moisture/is_moisture.1": [b" 1"], + "/moisture/is_moisture.2": [b" 0"], + "/moisture/is_moisture.3": [b" 0"], + }, }, "EF.111111111113": { - ATTR_INJECT_READS: [ - b"HB_HUB", # read type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HB_HUB"], + "/hub/branch.0": [b" 1"], + "/hub/branch.1": [b" 0"], + "/hub/branch.2": [b" 1"], + "/hub/branch.3": [b" 0"], + "/hub/short.0": [b" 1"], + "/hub/short.1": [b" 0"], + "/hub/short.2": [b" 1"], + "/hub/short.3": [b" 0"], + }, }, "7E.111111111111": { - ATTR_INJECT_READS: [ - b"EDS", # read type - b"EDS0068", # read device_type - note EDS specific - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 13.9375"}, - {ATTR_INJECT_READS: b" 1012.21"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 41.375"}, - ], + ATTR_INJECT_READS: { + "/type": [b"EDS"], + "/device_type": [b"EDS0068"], + "/EDS0068/temperature": [b" 13.9375"], + "/EDS0068/pressure": [b" 1012.21"], + "/EDS0068/light": [b" 65.8839"], + "/EDS0068/humidity": [b" 41.375"], + }, }, "7E.222222222222": { - ATTR_INJECT_READS: [ - b"EDS", # read type - b"EDS0066", # read device_type - note EDS specific - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 13.9375"}, - {ATTR_INJECT_READS: b" 1012.21"}, - ], + ATTR_INJECT_READS: { + "/type": [b"EDS"], + "/device_type": [b"EDS0066"], + "/EDS0066/temperature": [b" 13.9375"], + "/EDS0066/pressure": [b" 1012.21"], + }, }, } diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 450cc4c7486..d94eda5b7c3 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -1,1645 +1,773 @@ # serializer version: 1 -# name: test_binary_sensors[00.111111111111] - list([ - ]) -# --- -# name: test_binary_sensors[00.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[00.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[05.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[05.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.12_111111111111_sensed_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[10.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[10.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/12.111111111111/sensed.A', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.12_111111111111_sensed_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/12.111111111111/sensed.A', - 'unit_of_measurement': None, +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/sensed.A', + 'friendly_name': '12.111111111111 Sensed A', + 'raw_value': 1.0, }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.12_111111111111_sensed_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/12.111111111111/sensed.B', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'binary_sensor.12_111111111111_sensed_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/sensed.A', - 'friendly_name': '12.111111111111 Sensed A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.12_111111111111_sensed_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.12_111111111111_sensed_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/sensed.B', - 'friendly_name': '12.111111111111 Sensed B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.12_111111111111_sensed_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/12.111111111111/sensed.B', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[16.111111111111] - list([ - ]) -# --- -# name: test_binary_sensors[16.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[16.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/sensed.B', + 'friendly_name': '12.111111111111 Sensed B', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.12_111111111111_sensed_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[1D.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[1D.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[1F.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[1F.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.0', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[22.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[22.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.0', + 'friendly_name': '29.111111111111 Sensed 0', + 'raw_value': 1.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[26.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[26.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[28.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[28.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.1', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[28.222222222222].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.1', + 'friendly_name': '29.111111111111 Sensed 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[28.222222222222].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.2', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.2', + 'friendly_name': '29.111111111111 Sensed 2', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[28.222222222223].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.3', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[28.222222222223].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.3', + 'friendly_name': '29.111111111111 Sensed 3', + 'raw_value': None, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_binary_sensors[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.4', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[29.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.0', - 'unit_of_measurement': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.4', + 'friendly_name': '29.111111111111 Sensed 4', + 'raw_value': 0.0, }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_6', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_7', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.7', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[29.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.0', - 'friendly_name': '29.111111111111 Sensed 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.1', - 'friendly_name': '29.111111111111 Sensed 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.2', - 'friendly_name': '29.111111111111 Sensed 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.3', - 'friendly_name': '29.111111111111 Sensed 3', - 'raw_value': None, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.5', + 'friendly_name': '29.111111111111 Sensed 5', + 'raw_value': 0.0, }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.4', - 'friendly_name': '29.111111111111 Sensed 4', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.5', - 'friendly_name': '29.111111111111 Sensed 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.6', - 'friendly_name': '29.111111111111 Sensed 6', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.7', - 'friendly_name': '29.111111111111 Sensed 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.6', + 'friendly_name': '29.111111111111 Sensed 6', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[30.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[30.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[3A.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/3A.111111111111/sensed.A', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/3A.111111111111/sensed.B', - 'unit_of_measurement': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.7', + 'friendly_name': '29.111111111111 Sensed 7', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[3A.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/sensed.A', - 'friendly_name': '3A.111111111111 Sensed A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/sensed.B', - 'friendly_name': '3A.111111111111 Sensed B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/3A.111111111111/sensed.A', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[3B.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[3B.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/sensed.A', + 'friendly_name': '3A.111111111111 Sensed A', + 'raw_value': 1.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[42.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[42.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[7E.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[7E.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[7E.222222222222].1 - list([ - ]) -# --- -# name: test_binary_sensors[7E.222222222222].2 - list([ - ]) -# --- -# name: test_binary_sensors[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/3A.111111111111/sensed.B', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[A6.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[A6.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/sensed.B', + 'friendly_name': '3A.111111111111 Sensed B', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[EF.111111111111].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.0', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111111].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.0', + 'friendly_name': 'EF.111111111113 Hub short on branch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.1', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111112].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.1', + 'friendly_name': 'EF.111111111113 Hub short on branch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[EF.111111111112].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.2', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.2', + 'friendly_name': 'EF.111111111113 Hub short on branch 2', + 'raw_value': 1.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[EF.111111111113].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.0', - 'unit_of_measurement': None, +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.1', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.2', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.3', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.3', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111113].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.0', - 'friendly_name': 'EF.111111111113 Hub short on branch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.3', + 'friendly_name': 'EF.111111111113 Hub short on branch 3', + 'raw_value': 0.0, }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.1', - 'friendly_name': 'EF.111111111113 Hub short on branch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.2', - 'friendly_name': 'EF.111111111113 Hub short on branch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.3', - 'friendly_name': 'EF.111111111113 Hub short on branch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/onewire/snapshots/test_diagnostics.ambr b/tests/components/onewire/snapshots/test_diagnostics.ambr index f51fca7e988..6c5631331ca 100644 --- a/tests/components/onewire/snapshots/test_diagnostics.ambr +++ b/tests/components/onewire/snapshots/test_diagnostics.ambr @@ -12,7 +12,9 @@ ]), 'manufacturer': 'Hobby Boards', 'model': 'HB_HUB', + 'model_id': 'HB_HUB', 'name': 'EF.111111111113', + 'serial_number': '111111111113', }), 'family': 'EF', 'id': 'EF.111111111113', diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr new file mode 100644 index 00000000000..159f3acea42 --- /dev/null +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -0,0 +1,673 @@ +# serializer version: 1 +# name: test_registry[05.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '05.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2405', + 'model_id': 'DS2405', + 'name': '05.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[10.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '10.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18S20', + 'model_id': 'DS18S20', + 'name': '10.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[12.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '12.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2406', + 'model_id': 'DS2406', + 'name': '12.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[1D.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '1D.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2423', + 'model_id': 'DS2423', + 'name': '1D.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_registry[1F.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '1F.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2409', + 'model_id': 'DS2409', + 'name': '1F.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[22.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '22.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1822', + 'model_id': 'DS1822', + 'name': '22.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[26.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '26.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2438', + 'model_id': 'DS2438', + 'name': '26.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[28.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': 'DS18B20', + 'name': '28.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[28.222222222222-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.222222222222', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': 'DS18B20', + 'name': '28.222222222222', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '222222222222', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[28.222222222223-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.222222222223', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': 'DS18B20', + 'name': '28.222222222223', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '222222222223', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[29.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '29.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2408', + 'model_id': 'DS2408', + 'name': '29.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[30.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '30.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2760', + 'model_id': 'DS2760', + 'name': '30.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[3A.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '3A.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2413', + 'model_id': 'DS2413', + 'name': '3A.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[3B.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '3B.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1825', + 'model_id': 'DS1825', + 'name': '3B.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[42.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '42.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS28EA00', + 'model_id': 'DS28EA00', + 'name': '42.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[7E.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '7E.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0068', + 'model_id': 'EDS0068', + 'name': '7E.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[7E.222222222222-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '7E.222222222222', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0066', + 'model_id': 'EDS0066', + 'name': '7E.222222222222', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '222222222222', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[A6.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'A6.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2438', + 'model_id': 'DS2438', + 'name': 'A6.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HobbyBoards_EF', + 'model_id': 'HobbyBoards_EF', + 'name': 'EF.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111112-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111112', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HB_MOISTURE_METER', + 'model_id': 'HB_MOISTURE_METER', + 'name': 'EF.111111111112', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111112', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111113-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111113', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HB_HUB', + 'model_id': 'HB_HUB', + 'name': 'EF.111111111113', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111113', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/onewire/snapshots/test_select.ambr b/tests/components/onewire/snapshots/test_select.ambr new file mode 100644 index 00000000000..7c4027cd046 --- /dev/null +++ b/tests/components/onewire/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_selects[select.28_111111111111_temperature_resolution-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '9', + '10', + '11', + '12', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.28_111111111111_temperature_resolution', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature resolution', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tempres', + 'unique_id': '/28.111111111111/tempres', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.28_111111111111_temperature_resolution-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/28.111111111111/tempres', + 'friendly_name': '28.111111111111 Temperature resolution', + 'options': list([ + '9', + '10', + '11', + '12', + ]), + 'raw_value': 12.0, + }), + 'context': , + 'entity_id': 'select.28_111111111111_temperature_resolution', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 5ad4cf2ef4b..1b8484b27a4 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -1,3481 +1,2647 @@ # serializer version: 1 -# name: test_sensors[00.111111111111] - list([ - ]) -# --- -# name: test_sensors[00.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[00.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.10_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_sensors[05.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[05.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.10_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/10.111111111111/temperature', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[10.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.10_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/10.111111111111/temperature', +# name: test_sensors[sensor.10_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/10.111111111111/temperature', + 'friendly_name': '10.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.10_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) # --- -# name: test_sensors[10.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/10.111111111111/temperature', - 'friendly_name': '10.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.10_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', +# name: test_sensors[sensor.12_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.12_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/12.111111111111/TAI8570/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.12_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/12.111111111111/TAI8570/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.12_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/12.111111111111/TAI8570/pressure', +# name: test_sensors[sensor.12_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/12.111111111111/TAI8570/pressure', + 'friendly_name': '12.111111111111 Pressure', + 'raw_value': 1025.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.12_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1025.123', + }) # --- -# name: test_sensors[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/12.111111111111/TAI8570/temperature', - 'friendly_name': '12.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.12_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', +# name: test_sensors[sensor.12_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/12.111111111111/TAI8570/pressure', - 'friendly_name': '12.111111111111 Pressure', - 'raw_value': 1025.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.12_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1025.1', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.12_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/12.111111111111/TAI8570/temperature', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[16.111111111111] - list([ - ]) -# --- -# name: test_sensors[16.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[16.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[1D.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Counter A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.A', - 'unit_of_measurement': 'count', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Counter B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.B', - 'unit_of_measurement': 'count', - }), - ]) -# --- -# name: test_sensors[1D.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1D.111111111111/counter.A', - 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, - 'state_class': , - 'unit_of_measurement': 'count', - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '251123', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1D.111111111111/counter.B', - 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, - 'state_class': , - 'unit_of_measurement': 'count', - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '248125', - }), - ]) -# --- -# name: test_sensors[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }), - ]) -# --- -# name: test_sensors[1F.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Counter A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.A', - 'unit_of_measurement': 'count', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Counter B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.B', - 'unit_of_measurement': 'count', - }), - ]) -# --- -# name: test_sensors[1F.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1F.111111111111/main/1D.111111111111/counter.A', - 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, - 'state_class': , - 'unit_of_measurement': 'count', - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '251123', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1F.111111111111/main/1D.111111111111/counter.B', - 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, - 'state_class': , - 'unit_of_measurement': 'count', - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '248125', - }), - ]) -# --- -# name: test_sensors[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[22.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.22_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/22.111111111111/temperature', +# name: test_sensors[sensor.12_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/12.111111111111/TAI8570/temperature', + 'friendly_name': '12.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.12_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) # --- -# name: test_sensors[22.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/22.111111111111/temperature', - 'friendly_name': '22.111111111111 Temperature', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.22_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_sensors[sensor.1d_111111111111_counter_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_sensors[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1d_111111111111_counter_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Counter A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'counter_id', + 'unique_id': '/1D.111111111111/counter.A', + 'unit_of_measurement': None, + }) # --- -# name: test_sensors[26.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/temperature', +# name: test_sensors[sensor.1d_111111111111_counter_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1D.111111111111/counter.A', + 'friendly_name': '1D.111111111111 Counter A', + 'raw_value': 251123.0, + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '251123', + }) +# --- +# name: test_sensors[sensor.1d_111111111111_counter_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1d_111111111111_counter_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Counter B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'counter_id', + 'unique_id': '/1D.111111111111/counter.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.1d_111111111111_counter_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1D.111111111111/counter.B', + 'friendly_name': '1D.111111111111 Counter B', + 'raw_value': 248125.0, + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '248125', + }) +# --- +# name: test_sensors[sensor.22_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.22_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/22.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.22_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/22.111111111111/temperature', + 'friendly_name': '22.111111111111 Temperature', + 'raw_value': None, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/humidity', + 'context': , + 'entity_id': 'sensor.22_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih3600_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih3600_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH3600 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih3600', + 'unique_id': '/26.111111111111/HIH3600/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih3600_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH3600/humidity', + 'friendly_name': '26.111111111111 HIH3600 humidity', + 'raw_value': 73.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih3600_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH3600 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih3600', - 'unique_id': '/26.111111111111/HIH3600/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih3600_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '73.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih4000_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih4000_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH4000 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih4000', + 'unique_id': '/26.111111111111/HIH4000/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih4000_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH4000/humidity', + 'friendly_name': '26.111111111111 HIH4000 humidity', + 'raw_value': 74.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih4000_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH4000 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih4000', - 'unique_id': '/26.111111111111/HIH4000/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih4000_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih5030_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih5030_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH5030 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih5030', + 'unique_id': '/26.111111111111/HIH5030/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih5030_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH5030/humidity', + 'friendly_name': '26.111111111111 HIH5030 humidity', + 'raw_value': 75.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih5030_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH5030 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih5030', - 'unique_id': '/26.111111111111/HIH5030/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih5030_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_htm1735_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_htm1735_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HTM1735 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_htm1735', + 'unique_id': '/26.111111111111/HTM1735/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_htm1735_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HTM1735/humidity', + 'friendly_name': '26.111111111111 HTM1735 humidity', + 'raw_value': None, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_htm1735_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HTM1735 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_htm1735', - 'unique_id': '/26.111111111111/HTM1735/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_htm1735_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.26_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/humidity', + 'friendly_name': '26.111111111111 Humidity', + 'raw_value': 72.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/B1-R1-A/pressure', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.26_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_illuminance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Illuminance', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/S3-R1-A/illuminance', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/S3-R1-A/illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.26_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/26.111111111111/S3-R1-A/illuminance', + 'friendly_name': '26.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , 'unit_of_measurement': 'lx', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vad_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VAD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vad', - 'unique_id': '/26.111111111111/VAD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vdd_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VDD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vdd', - 'unique_id': '/26.111111111111/VDD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage difference', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis', - 'unique_id': '/26.111111111111/vis', - 'unit_of_measurement': , - }), - ]) + 'context': , + 'entity_id': 'sensor.26_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) # --- -# name: test_sensors[26.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/26.111111111111/temperature', - 'friendly_name': '26.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', +# name: test_sensors[sensor.26_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/humidity', - 'friendly_name': '26.111111111111 Humidity', - 'raw_value': 72.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '72.8', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH3600/humidity', - 'friendly_name': '26.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih3600_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '73.8', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH4000/humidity', - 'friendly_name': '26.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih4000_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '74.8', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH5030/humidity', - 'friendly_name': '26.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih5030_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '75.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HTM1735/humidity', - 'friendly_name': '26.111111111111 HTM1735 humidity', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_htm1735_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/26.111111111111/B1-R1-A/pressure', - 'friendly_name': '26.111111111111 Pressure', - 'raw_value': 969.265, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '969.3', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/26.111111111111/S3-R1-A/illuminance', - 'friendly_name': '26.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.9', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/VAD', - 'friendly_name': '26.111111111111 VAD voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vad_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/VDD', - 'friendly_name': '26.111111111111 VDD voltage', - 'raw_value': 4.74, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vdd_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4.7', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/vis', - 'friendly_name': '26.111111111111 VIS voltage difference', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/B1-R1-A/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.111111111111/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[28.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.111111111111/temperature', - 'friendly_name': '28.111111111111 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - ]) -# --- -# name: test_sensors[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.222222222222].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_222222222222_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.222222222222/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[28.222222222222].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.222222222222/temperature9', - 'friendly_name': '28.222222222222 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_222222222222_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - ]) -# --- -# name: test_sensors[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.222222222223].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_222222222223_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.222222222223/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[28.222222222223].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.222222222223/temperature', - 'friendly_name': '28.222222222223 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_222222222223_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - ]) -# --- -# name: test_sensors[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[29.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[29.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[30.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/30.111111111111/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Thermocouple K temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'thermocouple_temperature_k', - 'unique_id': '/30.111111111111/typeX/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/30.111111111111/volt', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage gradient', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis_gradient', - 'unique_id': '/30.111111111111/vis', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[30.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/30.111111111111/temperature', - 'friendly_name': '30.111111111111 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/30.111111111111/typeK/temperature', - 'friendly_name': '30.111111111111 Thermocouple K temperature', - 'raw_value': 173.7563, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '173.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/30.111111111111/volt', - 'friendly_name': '30.111111111111 Voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/30.111111111111/vis', - 'friendly_name': '30.111111111111 VIS voltage gradient', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }), - ]) -# --- -# name: test_sensors[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[3A.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[3A.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[3B.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.3b_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/3B.111111111111/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[3B.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/3B.111111111111/temperature', - 'friendly_name': '3B.111111111111 Temperature', - 'raw_value': 28.243, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.3b_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '28.2', - }), - ]) -# --- -# name: test_sensors[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[42.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.42_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/42.111111111111/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[42.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/42.111111111111/temperature', - 'friendly_name': '42.111111111111 Temperature', - 'raw_value': 29.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.42_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '29.1', - }), - ]) -# --- -# name: test_sensors[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[7E.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/pressure', +# name: test_sensors[sensor.26_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/26.111111111111/B1-R1-A/pressure', + 'friendly_name': '26.111111111111 Pressure', + 'raw_value': 969.265, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_illuminance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Illuminance', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/light', + 'context': , + 'entity_id': 'sensor.26_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '969.265', + }) +# --- +# name: test_sensors[sensor.26_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/26.111111111111/temperature', + 'friendly_name': '26.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vad_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vad_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VAD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vad', + 'unique_id': '/26.111111111111/VAD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vad_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/VAD', + 'friendly_name': '26.111111111111 VAD voltage', + 'raw_value': 2.97, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vad_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vdd_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vdd_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VDD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vdd', + 'unique_id': '/26.111111111111/VDD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vdd_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/VDD', + 'friendly_name': '26.111111111111 VDD voltage', + 'raw_value': 4.74, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vdd_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.74', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vis_voltage_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage difference', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis', + 'unique_id': '/26.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vis_voltage_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/vis', + 'friendly_name': '26.111111111111 VIS voltage difference', + 'raw_value': 0.12, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.28_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.111111111111/temperature', + 'friendly_name': '28.111111111111 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.28_222222222222_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_222222222222_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.222222222222/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_222222222222_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.222222222222/temperature9', + 'friendly_name': '28.222222222222 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_222222222222_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.28_222222222223_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_222222222223_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.222222222223/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_222222222223_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.222222222223/temperature', + 'friendly_name': '28.222222222223 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_222222222223_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.30_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/30.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/30.111111111111/temperature', + 'friendly_name': '30.111111111111 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.30_111111111111_thermocouple_k_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermocouple K temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermocouple_temperature_k', + 'unique_id': '/30.111111111111/typeX/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_thermocouple_k_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/30.111111111111/typeK/temperature', + 'friendly_name': '30.111111111111 Thermocouple K temperature', + 'raw_value': 173.7563, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '173.7563', + }) +# --- +# name: test_sensors[sensor.30_111111111111_vis_voltage_gradient-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage gradient', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis_gradient', + 'unique_id': '/30.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_vis_voltage_gradient-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/30.111111111111/vis', + 'friendly_name': '30.111111111111 VIS voltage gradient', + 'raw_value': 0.12, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.30_111111111111_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/30.111111111111/volt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/30.111111111111/volt', + 'friendly_name': '30.111111111111 Voltage', + 'raw_value': 2.97, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) +# --- +# name: test_sensors[sensor.3b_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.3b_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/3B.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.3b_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/3B.111111111111/temperature', + 'friendly_name': '3B.111111111111 Temperature', + 'raw_value': 28.243, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.3b_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.243', + }) +# --- +# name: test_sensors[sensor.42_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.42_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/42.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.42_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/42.111111111111/temperature', + 'friendly_name': '42.111111111111 Temperature', + 'raw_value': 29.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.42_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.123', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/7E.111111111111/EDS0068/humidity', + 'friendly_name': '7E.111111111111 Humidity', + 'raw_value': 41.375, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.7e_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.375', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/light', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/7E.111111111111/EDS0068/light', + 'friendly_name': '7E.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , 'unit_of_measurement': 'lx', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/humidity', - 'unit_of_measurement': '%', - }), - ]) + 'context': , + 'entity_id': 'sensor.7e_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) # --- -# name: test_sensors[7E.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/7E.111111111111/EDS0068/temperature', - 'friendly_name': '7E.111111111111 Temperature', - 'raw_value': 13.9375, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13.9', +# name: test_sensors[sensor.7e_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/7E.111111111111/EDS0068/pressure', - 'friendly_name': '7E.111111111111 Pressure', - 'raw_value': 1012.21, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1012.2', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/7E.111111111111/EDS0068/light', - 'friendly_name': '7E.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.9', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/7E.111111111111/EDS0068/humidity', - 'friendly_name': '7E.111111111111 Humidity', - 'raw_value': 41.375, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '41.4', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[7E.222222222222].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_222222222222_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.222222222222/EDS0066/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_222222222222_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.222222222222/EDS0066/pressure', +# name: test_sensors[sensor.7e_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/7E.111111111111/EDS0068/pressure', + 'friendly_name': '7E.111111111111 Pressure', + 'raw_value': 1012.21, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.7e_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1012.21', + }) # --- -# name: test_sensors[7E.222222222222].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/7E.222222222222/EDS0066/temperature', - 'friendly_name': '7E.222222222222 Temperature', - 'raw_value': 13.9375, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_222222222222_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13.9', +# name: test_sensors[sensor.7e_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/7E.222222222222/EDS0066/pressure', - 'friendly_name': '7E.222222222222 Pressure', - 'raw_value': 1012.21, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_222222222222_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1012.2', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/temperature', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[A6.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/temperature', +# name: test_sensors[sensor.7e_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/7E.111111111111/EDS0068/temperature', + 'friendly_name': '7E.111111111111 Temperature', + 'raw_value': 13.9375, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/humidity', - 'unit_of_measurement': '%', + 'context': , + 'entity_id': 'sensor.7e_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.9375', + }) +# --- +# name: test_sensors[sensor.7e_222222222222_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH3600 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih3600', - 'unique_id': '/A6.111111111111/HIH3600/humidity', - 'unit_of_measurement': '%', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH4000 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih4000', - 'unique_id': '/A6.111111111111/HIH4000/humidity', - 'unit_of_measurement': '%', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_222222222222_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH5030 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih5030', - 'unique_id': '/A6.111111111111/HIH5030/humidity', - 'unit_of_measurement': '%', + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HTM1735 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_htm1735', - 'unique_id': '/A6.111111111111/HTM1735/humidity', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/B1-R1-A/pressure', + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.222222222222/EDS0066/pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.7e_222222222222_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/7E.222222222222/EDS0066/pressure', + 'friendly_name': '7E.222222222222 Pressure', + 'raw_value': 1012.21, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_illuminance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Illuminance', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/S3-R1-A/illuminance', + 'context': , + 'entity_id': 'sensor.7e_222222222222_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1012.21', + }) +# --- +# name: test_sensors[sensor.7e_222222222222_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_222222222222_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.222222222222/EDS0066/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.7e_222222222222_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/7E.222222222222/EDS0066/temperature', + 'friendly_name': '7E.222222222222 Temperature', + 'raw_value': 13.9375, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.7e_222222222222_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.9375', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih3600_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH3600 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih3600', + 'unique_id': '/A6.111111111111/HIH3600/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih3600_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH3600/humidity', + 'friendly_name': 'A6.111111111111 HIH3600 humidity', + 'raw_value': 73.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '73.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih4000_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH4000 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih4000', + 'unique_id': '/A6.111111111111/HIH4000/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih4000_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH4000/humidity', + 'friendly_name': 'A6.111111111111 HIH4000 humidity', + 'raw_value': 74.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih5030_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH5030 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih5030', + 'unique_id': '/A6.111111111111/HIH5030/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih5030_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH5030/humidity', + 'friendly_name': 'A6.111111111111 HIH5030 humidity', + 'raw_value': 75.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_htm1735_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HTM1735 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_htm1735', + 'unique_id': '/A6.111111111111/HTM1735/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_htm1735_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HTM1735/humidity', + 'friendly_name': 'A6.111111111111 HTM1735 humidity', + 'raw_value': None, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/humidity', + 'friendly_name': 'A6.111111111111 Humidity', + 'raw_value': 72.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/S3-R1-A/illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/A6.111111111111/S3-R1-A/illuminance', + 'friendly_name': 'A6.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , 'unit_of_measurement': 'lx', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vad_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VAD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vad', - 'unique_id': '/A6.111111111111/VAD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vdd_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VDD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vdd', - 'unique_id': '/A6.111111111111/VDD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage difference', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis', - 'unique_id': '/A6.111111111111/vis', - 'unit_of_measurement': , - }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) # --- -# name: test_sensors[A6.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/A6.111111111111/temperature', - 'friendly_name': 'A6.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', +# name: test_sensors[sensor.a6_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/humidity', - 'friendly_name': 'A6.111111111111 Humidity', - 'raw_value': 72.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '72.8', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH3600/humidity', - 'friendly_name': 'A6.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '73.8', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH4000/humidity', - 'friendly_name': 'A6.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '74.8', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH5030/humidity', - 'friendly_name': 'A6.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '75.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HTM1735/humidity', - 'friendly_name': 'A6.111111111111 HTM1735 humidity', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/A6.111111111111/B1-R1-A/pressure', - 'friendly_name': 'A6.111111111111 Pressure', - 'raw_value': 969.265, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '969.3', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/A6.111111111111/S3-R1-A/illuminance', - 'friendly_name': 'A6.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.9', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/VAD', - 'friendly_name': 'A6.111111111111 VAD voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vad_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/VDD', - 'friendly_name': 'A6.111111111111 VDD voltage', - 'raw_value': 4.74, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vdd_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4.7', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/vis', - 'friendly_name': 'A6.111111111111 VIS voltage difference', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/B1-R1-A/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.a6_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/A6.111111111111/B1-R1-A/pressure', + 'friendly_name': 'A6.111111111111 Pressure', + 'raw_value': 969.265, + 'state_class': , + 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '969.265', + }) # --- -# name: test_sensors[EF.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/EF.111111111111/humidity/humidity_corrected', - 'unit_of_measurement': '%', +# name: test_sensors[sensor.a6_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_raw_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Raw humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_raw', - 'unique_id': '/EF.111111111111/humidity/humidity_raw', - 'unit_of_measurement': '%', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/EF.111111111111/humidity/temperature', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/A6.111111111111/temperature', + 'friendly_name': 'A6.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) # --- -# name: test_sensors[EF.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111111/humidity/humidity_corrected', - 'friendly_name': 'EF.111111111111 Humidity', - 'raw_value': 67.745, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '67.7', +# name: test_sensors[sensor.a6_111111111111_vad_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111111/humidity/humidity_raw', - 'friendly_name': 'EF.111111111111 Raw humidity', - 'raw_value': 65.541, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_raw_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.5', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/EF.111111111111/humidity/temperature', - 'friendly_name': 'EF.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vad_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VAD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vad', + 'unique_id': '/A6.111111111111/VAD', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.a6_111111111111_vad_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/VAD', + 'friendly_name': 'A6.111111111111 VAD voltage', + 'raw_value': 2.97, + 'state_class': , + 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_vad_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) # --- -# name: test_sensors[EF.111111111112].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_wetness_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wetness 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wetness_id', - 'unique_id': '/EF.111111111112/moisture/sensor.0', +# name: test_sensors[sensor.a6_111111111111_vdd_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vdd_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VDD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vdd', + 'unique_id': '/A6.111111111111/VDD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vdd_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/VDD', + 'friendly_name': 'A6.111111111111 VDD voltage', + 'raw_value': 4.74, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_vdd_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.74', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vis_voltage_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage difference', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis', + 'unique_id': '/A6.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vis_voltage_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/vis', + 'friendly_name': 'A6.111111111111 VIS voltage difference', + 'raw_value': 0.12, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/EF.111111111111/humidity/humidity_corrected', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111111/humidity/humidity_corrected', + 'friendly_name': 'EF.111111111111 Humidity', + 'raw_value': 67.745, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_wetness_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wetness 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wetness_id', - 'unique_id': '/EF.111111111112/moisture/sensor.1', + 'context': , + 'entity_id': 'sensor.ef_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67.745', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_raw_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_raw_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Raw humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_raw', + 'unique_id': '/EF.111111111111/humidity/humidity_raw', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_raw_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111111/humidity/humidity_raw', + 'friendly_name': 'EF.111111111111 Raw humidity', + 'raw_value': 65.541, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_moisture_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Moisture 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_id', - 'unique_id': '/EF.111111111112/moisture/sensor.2', + 'context': , + 'entity_id': 'sensor.ef_111111111111_raw_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.541', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/EF.111111111111/humidity/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/EF.111111111111/humidity/temperature', + 'friendly_name': 'EF.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ef_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_moisture_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_id', + 'unique_id': '/EF.111111111112/moisture/sensor.2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/EF.111111111112/moisture/sensor.2', + 'friendly_name': 'EF.111111111112 Moisture 2', + 'raw_value': 43.123, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_moisture_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Moisture 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_id', - 'unique_id': '/EF.111111111112/moisture/sensor.3', + 'context': , + 'entity_id': 'sensor.ef_111111111112_moisture_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.123', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_moisture_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_id', + 'unique_id': '/EF.111111111112/moisture/sensor.3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/EF.111111111112/moisture/sensor.3', + 'friendly_name': 'EF.111111111112 Moisture 3', + 'raw_value': 44.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.ef_111111111112_moisture_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.123', + }) # --- -# name: test_sensors[EF.111111111112].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111112/moisture/sensor.0', - 'friendly_name': 'EF.111111111112 Wetness 0', - 'raw_value': 41.745, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_wetness_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '41.7', +# name: test_sensors[sensor.ef_111111111112_wetness_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111112/moisture/sensor.1', - 'friendly_name': 'EF.111111111112 Wetness 1', - 'raw_value': 42.541, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_wetness_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '42.5', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/EF.111111111112/moisture/sensor.2', - 'friendly_name': 'EF.111111111112 Moisture 2', - 'raw_value': 43.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_moisture_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '43.1', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_wetness_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/EF.111111111112/moisture/sensor.3', - 'friendly_name': 'EF.111111111112 Moisture 3', - 'raw_value': 44.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_moisture_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '44.1', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wetness 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wetness_id', + 'unique_id': '/EF.111111111112/moisture/sensor.0', + 'unit_of_measurement': '%', + }) # --- -# name: test_sensors[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.ef_111111111112_wetness_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111112/moisture/sensor.0', + 'friendly_name': 'EF.111111111112 Wetness 0', + 'raw_value': 41.745, + 'state_class': , + 'unit_of_measurement': '%', }), - ]) + 'context': , + 'entity_id': 'sensor.ef_111111111112_wetness_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.745', + }) # --- -# name: test_sensors[EF.111111111113].1 - list([ - ]) +# name: test_sensors[sensor.ef_111111111112_wetness_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_wetness_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wetness 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wetness_id', + 'unique_id': '/EF.111111111112/moisture/sensor.1', + 'unit_of_measurement': '%', + }) # --- -# name: test_sensors[EF.111111111113].2 - list([ - ]) +# name: test_sensors[sensor.ef_111111111112_wetness_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111112/moisture/sensor.1', + 'friendly_name': 'EF.111111111112 Wetness 1', + 'raw_value': 42.541, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ef_111111111112_wetness_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.541', + }) # --- diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index 3bc7a2d3def..cb752982bec 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -1,2565 +1,1777 @@ # serializer version: 1 -# name: test_switches[00.111111111111] - list([ - ]) -# --- -# name: test_switches[00.111111111111].1 - list([ - ]) -# --- -# name: test_switches[00.111111111111].2 - list([ - ]) -# --- -# name: test_switches[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[05.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.05_111111111111_programmed_input_output', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio', - 'unique_id': '/05.111111111111/PIO', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[05.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/05.111111111111/PIO', - 'friendly_name': '05.111111111111 Programmed input-output', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.05_111111111111_programmed_input_output', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[10.111111111111].1 - list([ - ]) -# --- -# name: test_switches[10.111111111111].2 - list([ - ]) -# --- -# name: test_switches[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_programmed_input_output_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/12.111111111111/PIO.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_programmed_input_output_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/12.111111111111/PIO.B', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_latch_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/12.111111111111/latch.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_latch_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/12.111111111111/latch.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/PIO.A', - 'friendly_name': '12.111111111111 Programmed input-output A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_programmed_input_output_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/PIO.B', - 'friendly_name': '12.111111111111 Programmed input-output B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_programmed_input_output_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/latch.A', - 'friendly_name': '12.111111111111 Latch A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_latch_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/latch.B', - 'friendly_name': '12.111111111111 Latch B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_latch_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[16.111111111111] - list([ - ]) -# --- -# name: test_switches[16.111111111111].1 - list([ - ]) -# --- -# name: test_switches[16.111111111111].2 - list([ - ]) -# --- -# name: test_switches[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[1D.111111111111].1 - list([ - ]) -# --- -# name: test_switches[1D.111111111111].2 - list([ - ]) -# --- -# name: test_switches[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }), - ]) -# --- -# name: test_switches[1F.111111111111].1 - list([ - ]) -# --- -# name: test_switches[1F.111111111111].2 - list([ - ]) -# --- -# name: test_switches[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[22.111111111111].1 - list([ - ]) -# --- -# name: test_switches[22.111111111111].2 - list([ - ]) -# --- -# name: test_switches[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[26.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.26_111111111111_current_a_d_control', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Current A/D control', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'iad', - 'unique_id': '/26.111111111111/IAD', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[26.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/26.111111111111/IAD', - 'friendly_name': '26.111111111111 Current A/D control', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.26_111111111111_current_a_d_control', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.111111111111].1 - list([ - ]) -# --- -# name: test_switches[28.111111111111].2 - list([ - ]) -# --- -# name: test_switches[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.222222222222].1 - list([ - ]) -# --- -# name: test_switches[28.222222222222].2 - list([ - ]) -# --- -# name: test_switches[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.222222222223].1 - list([ - ]) -# --- -# name: test_switches[28.222222222223].2 - list([ - ]) -# --- -# name: test_switches[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[29.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_6', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_7', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.7', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_6', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_7', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.7', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[29.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.0', - 'friendly_name': '29.111111111111 Programmed input-output 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.1', - 'friendly_name': '29.111111111111 Programmed input-output 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.2', - 'friendly_name': '29.111111111111 Programmed input-output 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.3', - 'friendly_name': '29.111111111111 Programmed input-output 3', - 'raw_value': None, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.4', - 'friendly_name': '29.111111111111 Programmed input-output 4', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.5', - 'friendly_name': '29.111111111111 Programmed input-output 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.6', - 'friendly_name': '29.111111111111 Programmed input-output 6', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.7', - 'friendly_name': '29.111111111111 Programmed input-output 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.0', - 'friendly_name': '29.111111111111 Latch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.1', - 'friendly_name': '29.111111111111 Latch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.2', - 'friendly_name': '29.111111111111 Latch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.3', - 'friendly_name': '29.111111111111 Latch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.4', - 'friendly_name': '29.111111111111 Latch 4', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.5', - 'friendly_name': '29.111111111111 Latch 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.6', - 'friendly_name': '29.111111111111 Latch 6', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.7', - 'friendly_name': '29.111111111111 Latch 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[30.111111111111].1 - list([ - ]) -# --- -# name: test_switches[30.111111111111].2 - list([ - ]) -# --- -# name: test_switches[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[3A.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/3A.111111111111/PIO.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/3A.111111111111/PIO.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[3A.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/PIO.A', - 'friendly_name': '3A.111111111111 Programmed input-output A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/PIO.B', - 'friendly_name': '3A.111111111111 Programmed input-output B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[3B.111111111111].1 - list([ - ]) -# --- -# name: test_switches[3B.111111111111].2 - list([ - ]) -# --- -# name: test_switches[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[42.111111111111].1 - list([ - ]) -# --- -# name: test_switches[42.111111111111].2 - list([ - ]) -# --- -# name: test_switches[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[7E.111111111111].1 - list([ - ]) -# --- -# name: test_switches[7E.111111111111].2 - list([ - ]) -# --- -# name: test_switches[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[7E.222222222222].1 - list([ - ]) -# --- -# name: test_switches[7E.222222222222].2 - list([ - ]) -# --- -# name: test_switches[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[A6.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.a6_111111111111_current_a_d_control', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Current A/D control', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'iad', - 'unique_id': '/A6.111111111111/IAD', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[A6.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/A6.111111111111/IAD', - 'friendly_name': 'A6.111111111111 Current A/D control', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.a6_111111111111_current_a_d_control', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111111].1 - list([ - ]) -# --- -# name: test_switches[EF.111111111111].2 - list([ - ]) -# --- -# name: test_switches[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111112].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaf sensor 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaf sensor 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaf sensor 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaf sensor 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Moisture sensor 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Moisture sensor 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Moisture sensor 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Moisture sensor 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.3', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[EF.111111111112].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.0', - 'friendly_name': 'EF.111111111112 Leaf sensor 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.1', - 'friendly_name': 'EF.111111111112 Leaf sensor 1', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.2', - 'friendly_name': 'EF.111111111112 Leaf sensor 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.3', - 'friendly_name': 'EF.111111111112 Leaf sensor 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.0', - 'friendly_name': 'EF.111111111112 Moisture sensor 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.1', - 'friendly_name': 'EF.111111111112 Moisture sensor 1', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.2', - 'friendly_name': 'EF.111111111112 Moisture sensor 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.3', - 'friendly_name': 'EF.111111111112 Moisture sensor 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111113].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hub branch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hub branch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hub branch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hub branch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.3', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[EF.111111111113].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.0', - 'friendly_name': 'EF.111111111113 Hub branch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.1', - 'friendly_name': 'EF.111111111113 Hub branch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.2', - 'friendly_name': 'EF.111111111113 Hub branch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.3', - 'friendly_name': 'EF.111111111113 Hub branch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) +# name: test_switches[switch.05_111111111111_programmed_input_output-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.05_111111111111_programmed_input_output', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio', + 'unique_id': '/05.111111111111/PIO', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.05_111111111111_programmed_input_output-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/05.111111111111/PIO', + 'friendly_name': '05.111111111111 Programmed input-output', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.05_111111111111_programmed_input_output', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_latch_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_latch_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/12.111111111111/latch.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_latch_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/latch.A', + 'friendly_name': '12.111111111111 Latch A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_latch_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_latch_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_latch_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/12.111111111111/latch.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_latch_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/latch.B', + 'friendly_name': '12.111111111111 Latch B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_latch_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_programmed_input_output_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/12.111111111111/PIO.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/PIO.A', + 'friendly_name': '12.111111111111 Programmed input-output A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_programmed_input_output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_programmed_input_output_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/12.111111111111/PIO.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/PIO.B', + 'friendly_name': '12.111111111111 Programmed input-output B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_programmed_input_output_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.26_111111111111_current_a_d_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.26_111111111111_current_a_d_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current A/D control', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'iad', + 'unique_id': '/26.111111111111/IAD', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.26_111111111111_current_a_d_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/26.111111111111/IAD', + 'friendly_name': '26.111111111111 Current A/D control', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.26_111111111111_current_a_d_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.0', + 'friendly_name': '29.111111111111 Latch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.1', + 'friendly_name': '29.111111111111 Latch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.2', + 'friendly_name': '29.111111111111 Latch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.3', + 'friendly_name': '29.111111111111 Latch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.4', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.4', + 'friendly_name': '29.111111111111 Latch 4', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.5', + 'friendly_name': '29.111111111111 Latch 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.6', + 'friendly_name': '29.111111111111 Latch 6', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.7', + 'friendly_name': '29.111111111111 Latch 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.0', + 'friendly_name': '29.111111111111 Programmed input-output 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.1', + 'friendly_name': '29.111111111111 Programmed input-output 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.2', + 'friendly_name': '29.111111111111 Programmed input-output 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.3', + 'friendly_name': '29.111111111111 Programmed input-output 3', + 'raw_value': None, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.4', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.4', + 'friendly_name': '29.111111111111 Programmed input-output 4', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.5', + 'friendly_name': '29.111111111111 Programmed input-output 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.6', + 'friendly_name': '29.111111111111 Programmed input-output 6', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.7', + 'friendly_name': '29.111111111111 Programmed input-output 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/3A.111111111111/PIO.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/PIO.A', + 'friendly_name': '3A.111111111111 Programmed input-output A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/3A.111111111111/PIO.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/PIO.B', + 'friendly_name': '3A.111111111111 Programmed input-output B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.a6_111111111111_current_a_d_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.a6_111111111111_current_a_d_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current A/D control', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'iad', + 'unique_id': '/A6.111111111111/IAD', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.a6_111111111111_current_a_d_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/A6.111111111111/IAD', + 'friendly_name': 'A6.111111111111 Current A/D control', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.a6_111111111111_current_a_d_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.0', + 'friendly_name': 'EF.111111111112 Leaf sensor 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.1', + 'friendly_name': 'EF.111111111112 Leaf sensor 1', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.2', + 'friendly_name': 'EF.111111111112 Leaf sensor 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.3', + 'friendly_name': 'EF.111111111112 Leaf sensor 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.0', + 'friendly_name': 'EF.111111111112 Moisture sensor 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.1', + 'friendly_name': 'EF.111111111112 Moisture sensor 1', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.2', + 'friendly_name': 'EF.111111111112 Moisture sensor 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.3', + 'friendly_name': 'EF.111111111112 Moisture sensor 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.0', + 'friendly_name': 'EF.111111111113 Hub branch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.1', + 'friendly_name': 'EF.111111111113 Hub branch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.2', + 'friendly_name': 'EF.111111111113 Hub branch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.3', + 'friendly_name': 'EF.111111111113 Hub branch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 31895f705ff..dd2f3874e36 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -3,57 +3,65 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.BINARY_SENSOR]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.BINARY_SENSOR]): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire binary sensors.""" - setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) + """Test for 1-Wire binary sensor entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["29.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire binary sensor entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 8 ) - assert device_entries == snapshot - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index 029e1278c86..65bdaafc131 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for 1-Wire config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, patch from pyownet import protocol @@ -11,18 +12,39 @@ from homeassistant.components.onewire.const import ( INPUT_ENTRY_DEVICE_SELECTION, MANUFACTURER_MAXIM, ) -from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from tests.common import MockConfigEntry + +_HASSIO_DISCOVERY = HassioServiceInfo( + config={"host": "1302b8e0-owserver", "port": 4304, "addon": "owserver (1-wire)"}, + name="owserver (1-wire)", + slug="1302b8e0_owserver", + uuid="e3fa56560d93458b96a594cbcea3017e", +) +_ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("5.6.7.8"), + ip_addresses=[ip_address("5.6.7.8")], + hostname="ubuntu.local.", + name="OWFS (1-wire) Server", + port=4304, + type="_owserver._tcp.local.", + properties={}, +) pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.fixture async def filled_device_registry( - hass: HomeAssistant, config_entry: ConfigEntry, device_registry: dr.DeviceRegistry + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, ) -> dr.DeviceRegistry: """Fill device registry with mock devices.""" for key in ("28.111111111111", "28.222222222222", "28.222222222223"): @@ -36,13 +58,31 @@ async def filled_device_registry( return device_registry -async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_user_flow(hass: HomeAssistant) -> None: """Test user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert not result["errors"] + + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "1.2.3.4" + assert new_entry.data == {CONF_HOST: "1.2.3.4", CONF_PORT: 1234} + + +async def test_user_flow_recovery(hass: HomeAssistant) -> None: + """Test user flow recovery after invalid server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) # Invalid server with patch( @@ -54,9 +94,9 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} # Valid server with patch( @@ -67,19 +107,14 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "1.2.3.4" - assert result["data"] == { - CONF_HOST: "1.2.3.4", - CONF_PORT: 1234, - } - await hass.async_block_till_done() - - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "1.2.3.4" + assert new_entry.data == {CONF_HOST: "1.2.3.4", CONF_PORT: 1234} async def test_user_duplicate( - hass: HomeAssistant, config_entry: ConfigEntry, mock_setup_entry: AsyncMock + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test user duplicate flow.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -102,9 +137,185 @@ async def test_user_duplicate( assert result["reason"] == "already_configured" +async def test_reconfigure_flow( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_setup_entry: AsyncMock +) -> None: + """Test reconfigure flow.""" + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert not result["errors"] + + # Invalid server + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + side_effect=protocol.ConnError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "2.3.4.5", CONF_PORT: 2345}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + + # Valid server + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "2.3.4.5", CONF_PORT: 2345}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == {CONF_HOST: "2.3.4.5", CONF_PORT: 2345} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_duplicate( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_setup_entry: AsyncMock +) -> None: + """Test reconfigure duplicate flow.""" + other_config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOST: "2.3.4.5", + CONF_PORT: 2345, + }, + entry_id="other", + ) + other_config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert not result["errors"] + + # Duplicate server + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "2.3.4.5", CONF_PORT: 2345}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(mock_setup_entry.mock_calls) == 0 + assert config_entry.data == {CONF_HOST: "1.2.3.4", CONF_PORT: 1234} + assert other_config_entry.data == {CONF_HOST: "2.3.4.5", CONF_PORT: 2345} + + +async def test_hassio_flow(hass: HomeAssistant) -> None: + """Test HassIO discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_HASSIO}, + data=_HASSIO_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert not result["errors"] + + # Cannot connect to server => retry + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + side_effect=protocol.ConnError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Connect OK + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "owserver (1-wire)" + assert new_entry.data == {CONF_HOST: "1302b8e0-owserver", CONF_PORT: 4304} + + +@pytest.mark.usefixtures("config_entry") +async def test_hassio_duplicate(hass: HomeAssistant) -> None: + """Test HassIO discovery duplicate flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_HASSIO}, + data=_HASSIO_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow(hass: HomeAssistant) -> None: + """Test zeroconf discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert not result["errors"] + + # Cannot connect to server => retry + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + side_effect=protocol.ConnError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Connect OK + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "OWFS (1-wire) Server" + assert new_entry.data == {CONF_HOST: "ubuntu.local.", CONF_PORT: 4304} + + +@pytest.mark.usefixtures("config_entry") +async def test_zeroconf_duplicate(hass: HomeAssistant) -> None: + """Test zeroconf discovery duplicate flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_clear( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test clearing the options.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -127,8 +338,8 @@ async def test_user_options_clear( @pytest.mark.usefixtures("filled_device_registry") -async def test_user_options_empty_selection( - hass: HomeAssistant, config_entry: ConfigEntry +async def test_user_options_empty_selection_recovery( + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test leaving the selection of devices empty.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -141,7 +352,7 @@ async def test_user_options_empty_selection( "28.222222222223": False, } - # Verify that an empty selection does not modify the options + # Verify that an empty selection shows the form again result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={INPUT_ENTRY_DEVICE_SELECTION: []}, @@ -150,10 +361,29 @@ async def test_user_options_empty_selection( assert result["step_id"] == "device_selection" assert result["errors"] == {"base": "device_not_selected"} + # Verify that a single selected device to configure comes back as a form with the device to configure + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={INPUT_ENTRY_DEVICE_SELECTION: ["28.111111111111"]}, + ) + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"]["sensor_id"] == "28.111111111111" + + # Verify that the setting for the device comes back as default when no input is given + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["data"]["device_options"]["28.111111111111"]["precision"] + == "temperature" + ) + @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_set_single( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test configuring a single device.""" # Clear config options to certify functionality when starting from scratch @@ -191,7 +421,7 @@ async def test_user_options_set_single( async def test_user_options_set_multiple( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, filled_device_registry: dr.DeviceRegistry, ) -> None: """Test configuring multiple consecutive devices in a row.""" @@ -254,7 +484,7 @@ async def test_user_options_set_multiple( async def test_user_options_no_devices( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test that options does not change when no devices are available.""" assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/onewire/test_diagnostics.py b/tests/components/onewire/test_diagnostics.py index ecdae859597..60b57bd14f7 100644 --- a/tests/components/onewire/test_diagnostics.py +++ b/tests/components/onewire/test_diagnostics.py @@ -6,12 +6,12 @@ from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from . import setup_owproxy_mock_devices +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -19,35 +19,21 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SWITCH]): yield -DEVICE_DETAILS = { - "device_info": { - "identifiers": [["onewire", "EF.111111111113"]], - "manufacturer": "Hobby Boards", - "model": "HB_HUB", - "name": "EF.111111111113", - }, - "family": "EF", - "id": "EF.111111111113", - "path": "/EF.111111111113/", - "type": "HB_HUB", -} - - @pytest.mark.parametrize("device_id", ["EF.111111111113"], indirect=True) async def test_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, owproxy: MagicMock, device_id: str, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 82ff75628c2..0748481c40b 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -3,23 +3,30 @@ from copy import deepcopy from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from pyownet import protocol import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.onewire.const import DOMAIN -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @pytest.mark.usefixtures("owproxy_with_connerror") -async def test_connect_failure(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def test_connect_failure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test connection failure raises ConfigEntryNotReady.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -29,7 +36,7 @@ async def test_connect_failure(hass: HomeAssistant, config_entry: ConfigEntry) - async def test_listing_failure( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock ) -> None: """Test listing failure raises ConfigEntryNotReady.""" owproxy.return_value.dir.side_effect = protocol.OwnetError() @@ -42,7 +49,7 @@ async def test_listing_failure( @pytest.mark.usefixtures("owproxy") -async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test being able to unload an entry.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -57,7 +64,7 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N async def test_update_options( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock ) -> None: """Test update options triggers reload.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -77,11 +84,56 @@ async def test_update_options( assert owproxy.call_count == 2 -@patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_registry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device are correctly registered.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) + await hass.config_entries.async_setup(config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries + for device_entry in device_entries: + assert device_entry == snapshot(name=f"{device_entry.name}-entry") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_registry_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device are correctly registered.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, ["1F.111111111111"]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 2 + ) + + +@patch("homeassistant.components.onewire._PLATFORMS", [Platform.SENSOR]) async def test_registry_cleanup( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, hass_ws_client: WebSocketGenerator, ) -> None: @@ -93,12 +145,12 @@ async def test_registry_cleanup( dead_id = "28.111111111111" # Initialise with two components - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id, dead_id]) + setup_owproxy_mock_devices(owproxy, [live_id, dead_id]) await hass.config_entries.async_setup(entry_id) await hass.async_block_till_done() # Reload with a device no longer on bus - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id]) + setup_owproxy_mock_devices(owproxy, [live_id]) await hass.config_entries.async_reload(entry_id) await hass.async_block_till_done() assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 diff --git a/tests/components/onewire/test_select.py b/tests/components/onewire/test_select.py new file mode 100644 index 00000000000..6e1c3277c73 --- /dev/null +++ b/tests/components/onewire/test_select.py @@ -0,0 +1,96 @@ +"""Tests for 1-Wire selects.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SELECT]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_selects( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for 1-Wire select entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) + await hass.config_entries.async_setup(config_entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["28.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_selects_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire select entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) + + +@pytest.mark.parametrize("device_id", ["28.111111111111"]) +async def test_selection_option_service( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, +) -> None: + """Test for 1-Wire select option service.""" + setup_owproxy_mock_devices(owproxy, [device_id]) + await hass.config_entries.async_setup(config_entry.entry_id) + + entity_id = "select.28_111111111111_temperature_resolution" + assert hass.states.get(entity_id).state == "12" + + # Test SELECT_OPTION service + owproxy.return_value.read.side_effect = [b" 9"] + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "9"}, + blocking=True, + ) + assert hass.states.get(entity_id).state == "9" diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index ba0e21701f8..f1ef2dfa11b 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -5,68 +5,75 @@ from copy import deepcopy import logging from unittest.mock import MagicMock, _patch_dict, patch +from freezegun.api import FrozenDateTimeFactory from pyownet.protocol import OwnetError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SENSOR]): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire sensors.""" - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + """Test for 1-Wire sensor entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["12.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire sensor entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 2 ) - assert device_entries == snapshot - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot @pytest.mark.parametrize("device_id", ["12.111111111111"]) async def test_tai8570_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, device_id: str, entity_registry: er.EntityRegistry, @@ -78,11 +85,11 @@ async def test_tai8570_sensors( """ mock_devices = deepcopy(MOCK_OWPROXY_DEVICES) mock_device = mock_devices[device_id] - mock_device[ATTR_INJECT_READS].append(OwnetError) - mock_device[ATTR_INJECT_READS].append(OwnetError) + mock_device[ATTR_INJECT_READS]["/TAI8570/temperature"] = [OwnetError] + mock_device[ATTR_INJECT_READS]["/TAI8570/pressure"] = [OwnetError] with _patch_dict(MOCK_OWPROXY_DEVICES, mock_devices): - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) with caplog.at_level(logging.DEBUG): await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index 936e83f66ec..ca13a69e2da 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -3,11 +3,12 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TOGGLE, @@ -16,66 +17,73 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SWITCH]): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switches( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire switches.""" - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + """Test for 1-Wire switch entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["05.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switches_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire switch entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 ) - assert device_entries == snapshot - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot @pytest.mark.parametrize("device_id", ["05.111111111111"]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switch_toggle( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, device_id: str, ) -> None: """Test for 1-Wire switch TOGGLE service.""" - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index 8900f189aea..064075d109e 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -19,6 +19,16 @@ def create_receiver_info(id: int) -> ReceiverInfo: ) +def create_connection(id: int) -> Mock: + """Create an mock connection object for testing.""" + connection = Mock() + connection.host = f"host {id}" + connection.port = 0 + connection.name = f"type {id}" + connection.identifier = f"id{id}" + return connection + + def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry: """Create a config entry from receiver info.""" data = {CONF_HOST: info.host} diff --git a/tests/components/onkyo/conftest.py b/tests/components/onkyo/conftest.py index c37966e3bae..abbe39dd966 100644 --- a/tests/components/onkyo/conftest.py +++ b/tests/components/onkyo/conftest.py @@ -1,25 +1,16 @@ """Configure tests for the Onkyo integration.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest from homeassistant.components.onkyo.const import DOMAIN +from . import create_connection + from tests.common import MockConfigEntry -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.onkyo.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - yield mock_setup_entry - - @pytest.fixture(name="config_entry") def mock_config_entry() -> MockConfigEntry: """Create Onkyo entry in Home Assistant.""" @@ -28,3 +19,56 @@ def mock_config_entry() -> MockConfigEntry: title="Onkyo", data={}, ) + + +@pytest.fixture(autouse=True) +def patch_timeouts(): + """Patch timeouts to avoid tests waiting.""" + with patch.multiple( + "homeassistant.components.onkyo.receiver", + DEVICE_INTERVIEW_TIMEOUT=0, + DEVICE_DISCOVERY_TIMEOUT=0, + ): + yield + + +@pytest.fixture +async def default_mock_discovery(): + """Mock discovery with a single device.""" + + async def mock_discover(host=None, discovery_callback=None, timeout=0): + await discovery_callback(create_connection(1)) + + with patch( + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + new=mock_discover, + ): + yield + + +@pytest.fixture +async def stub_mock_discovery(): + """Mock discovery with no devices.""" + + async def mock_discover(host=None, discovery_callback=None, timeout=0): + pass + + with patch( + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + new=mock_discover, + ): + yield + + +@pytest.fixture +async def empty_mock_discovery(): + """Mock discovery with an empty connection.""" + + async def mock_discover(host=None, discovery_callback=None, timeout=0): + await discovery_callback(None) + + with patch( + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + new=mock_discover, + ): + yield diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index f230ab124bd..203cc22cf95 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -10,6 +10,7 @@ from homeassistant.components.onkyo import InputSource from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, + OPTION_INPUT_SOURCES, OPTION_MAX_VOLUME, OPTION_VOLUME_RESOLUTION, ) @@ -17,15 +18,20 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType, InvalidData +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) from . import ( create_config_entry_from_info, + create_connection, create_empty_config_entry, create_receiver_info, setup_integration, ) -from tests.common import Mock, MockConfigEntry +from tests.common import MockConfigEntry async def test_user_initial_menu(hass: HomeAssistant) -> None: @@ -40,9 +46,8 @@ async def test_user_initial_menu(hass: HomeAssistant) -> None: assert not set(init_result["menu_options"]) ^ {"manual", "eiscp_discovery"} -async def test_manual_valid_host(hass: HomeAssistant) -> None: +async def test_manual_valid_host(hass: HomeAssistant, default_mock_discovery) -> None: """Test valid host entered.""" - init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -53,30 +58,17 @@ async def test_manual_valid_host(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - mock_info = Mock() - mock_info.identifier = "mock_id" - mock_info.host = "mock_host" - mock_info.model_name = "mock_model" + select_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "host 1"}, + ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=mock_info, - ): - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - assert select_result["step_id"] == "configure_receiver" - assert ( - select_result["description_placeholders"]["name"] - == "mock_model (mock_host)" - ) + assert select_result["step_id"] == "configure_receiver" + assert select_result["description_placeholders"]["name"] == "type 1 (host 1)" -async def test_manual_invalid_host(hass: HomeAssistant) -> None: +async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> None: """Test invalid host entered.""" - init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -87,19 +79,18 @@ async def test_manual_invalid_host(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", return_value=None - ): - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + host_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) assert host_result["step_id"] == "manual" assert host_result["errors"]["base"] == "cannot_connect" -async def test_manual_valid_host_unexpected_error(hass: HomeAssistant) -> None: +async def test_manual_valid_host_unexpected_error( + hass: HomeAssistant, empty_mock_discovery +) -> None: """Test valid host entered.""" init_result = await hass.config_entries.flow.async_init( @@ -112,55 +103,49 @@ async def test_manual_valid_host_unexpected_error(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - side_effect=Exception(), - ): - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + host_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) assert host_result["step_id"] == "manual" assert host_result["errors"]["base"] == "unknown" -async def test_discovery_and_no_devices_discovered(hass: HomeAssistant) -> None: +async def test_discovery_and_no_devices_discovered( + hass: HomeAssistant, stub_mock_discovery +) -> None: """Test initial menu.""" init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - with patch( - "homeassistant.components.onkyo.config_flow.async_discover", return_value=[] - ): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) + form_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + {"next_step_id": "eiscp_discovery"}, + ) - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "no_devices_found" + assert form_result["type"] is FlowResultType.ABORT + assert form_result["reason"] == "no_devices_found" -async def test_discovery_with_exception(hass: HomeAssistant) -> None: +async def test_discovery_with_exception( + hass: HomeAssistant, empty_mock_discovery +) -> None: """Test discovery which throws an unexpected exception.""" init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - with patch( - "homeassistant.components.onkyo.config_flow.async_discover", - side_effect=Exception(), - ): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "unknown" + form_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + {"next_step_id": "eiscp_discovery"}, + ) + + assert form_result["type"] is FlowResultType.ABORT + assert form_result["reason"] == "unknown" async def test_discovery_with_new_and_existing_found(hass: HomeAssistant) -> None: @@ -170,13 +155,12 @@ async def test_discovery_with_new_and_existing_found(hass: HomeAssistant) -> Non context={"source": SOURCE_USER}, ) - infos = [create_receiver_info(1), create_receiver_info(2)] + async def mock_discover(discovery_callback, timeout): + await discovery_callback(create_connection(1)) + await discovery_callback(create_connection(2)) with ( - patch( - "homeassistant.components.onkyo.config_flow.async_discover", - return_value=infos, - ), + patch("pyeiscp.Connection.discover", new=mock_discover), # Fake it like the first entry was already added patch.object(OnkyoConfigFlow, "_async_current_ids", return_value=["id1"]), ): @@ -185,12 +169,12 @@ async def test_discovery_with_new_and_existing_found(hass: HomeAssistant) -> Non {"next_step_id": "eiscp_discovery"}, ) - assert form_result["type"] is FlowResultType.FORM + assert form_result["type"] is FlowResultType.FORM - assert form_result["data_schema"] is not None - schema = form_result["data_schema"].schema - container = schema["device"].container - assert container == {"id2": "type 2 (host 2)"} + assert form_result["data_schema"] is not None + schema = form_result["data_schema"].schema + container = schema["device"].container + assert container == {"id2": "type 2 (host 2)"} async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: @@ -200,14 +184,11 @@ async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: context={"source": SOURCE_USER}, ) - infos = [create_receiver_info(42), create_receiver_info(0)] + async def mock_discover(discovery_callback, timeout): + await discovery_callback(create_connection(42)) + await discovery_callback(create_connection(0)) - with ( - patch( - "homeassistant.components.onkyo.config_flow.async_discover", - return_value=infos, - ), - ): + with patch("pyeiscp.Connection.discover", new=mock_discover): form_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], {"next_step_id": "eiscp_discovery"}, @@ -218,11 +199,159 @@ async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: user_input={"device": "id42"}, ) - assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == "type 42 (host 42)" + assert select_result["step_id"] == "configure_receiver" + assert select_result["description_placeholders"]["name"] == "type 42 (host 42)" -async def test_configure_empty_source_list(hass: HomeAssistant) -> None: +async def test_ssdp_discovery_success( + hass: HomeAssistant, default_mock_discovery +) -> None: + """Test SSDP discovery with valid host.""" + discovery_info = SsdpServiceInfo( + ssdp_location="http://192.168.1.100:8080", + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + select_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"volume_resolution": 200, "input_sources": ["TV"]}, + ) + + assert select_result["type"] is FlowResultType.CREATE_ENTRY + assert select_result["data"]["host"] == "192.168.1.100" + assert select_result["result"].unique_id == "id1" + + +async def test_ssdp_discovery_already_configured( + hass: HomeAssistant, default_mock_discovery +) -> None: + """Test SSDP discovery with already configured device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + unique_id="id1", + ) + config_entry.add_to_hass(hass) + + discovery_info = SsdpServiceInfo( + ssdp_location="http://192.168.1.100:8080", + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: + """Test SSDP discovery with host info error.""" + discovery_info = SsdpServiceInfo( + ssdp_location="http://192.168.1.100:8080", + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_st="mock_st", + ) + + with patch( + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_ssdp_discovery_host_none_info( + hass: HomeAssistant, stub_mock_discovery +) -> None: + """Test SSDP discovery with host info error.""" + discovery_info = SsdpServiceInfo( + ssdp_location="http://192.168.1.100:8080", + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_ssdp_discovery_no_location( + hass: HomeAssistant, default_mock_discovery +) -> None: + """Test SSDP discovery with no location.""" + discovery_info = SsdpServiceInfo( + ssdp_location=None, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_ssdp_discovery_no_host( + hass: HomeAssistant, default_mock_discovery +) -> None: + """Test SSDP discovery with no host.""" + discovery_info = SsdpServiceInfo( + ssdp_location="http://", + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_configure_empty_source_list( + hass: HomeAssistant, default_mock_discovery +) -> None: """Test receiver configuration with no sources set.""" init_result = await hass.config_entries.flow.async_init( @@ -235,29 +364,22 @@ async def test_configure_empty_source_list(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - mock_info = Mock() - mock_info.identifier = "mock_id" + select_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=mock_info, - ): - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + configure_result = await hass.config_entries.flow.async_configure( + select_result["flow_id"], + user_input={"volume_resolution": 200, "input_sources": []}, + ) - configure_result = await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": []}, - ) - - assert configure_result["errors"] == { - "input_sources": "empty_input_source_list" - } + assert configure_result["errors"] == {"input_sources": "empty_input_source_list"} -async def test_configure_no_resolution(hass: HomeAssistant) -> None: +async def test_configure_no_resolution( + hass: HomeAssistant, default_mock_discovery +) -> None: """Test receiver configure with no resolution set.""" init_result = await hass.config_entries.flow.async_init( @@ -270,26 +392,21 @@ async def test_configure_no_resolution(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - mock_info = Mock() - mock_info.identifier = "mock_id" + select_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=mock_info, - ): - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, + with pytest.raises(InvalidData): + await hass.config_entries.flow.async_configure( + select_result["flow_id"], + user_input={"input_sources": ["TV"]}, ) - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"input_sources": ["TV"]}, - ) - -async def test_configure_resolution_set(hass: HomeAssistant) -> None: +async def test_configure_resolution_set( + hass: HomeAssistant, default_mock_discovery +) -> None: """Test receiver configure with specified resolution.""" init_result = await hass.config_entries.flow.async_init( @@ -302,16 +419,10 @@ async def test_configure_resolution_set(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - receiver_info = create_receiver_info(1) - - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=receiver_info, - ): - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + select_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) configure_result = await hass.config_entries.flow.async_configure( select_result["flow_id"], @@ -322,7 +433,9 @@ async def test_configure_resolution_set(hass: HomeAssistant) -> None: assert configure_result["options"]["volume_resolution"] == 200 -async def test_configure_invalid_resolution_set(hass: HomeAssistant) -> None: +async def test_configure_invalid_resolution_set( + hass: HomeAssistant, default_mock_discovery +) -> None: """Test receiver configure with invalid resolution.""" init_result = await hass.config_entries.flow.async_init( @@ -335,54 +448,43 @@ async def test_configure_invalid_resolution_set(hass: HomeAssistant) -> None: {"next_step_id": "manual"}, ) - mock_info = Mock() - mock_info.identifier = "mock_id" + select_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=mock_info, - ): - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, + with pytest.raises(InvalidData): + await hass.config_entries.flow.async_configure( + select_result["flow_id"], + user_input={"volume_resolution": 42, "input_sources": ["TV"]}, ) - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 42, "input_sources": ["TV"]}, - ) - -async def test_reconfigure(hass: HomeAssistant) -> None: +async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: """Test the reconfigure config flow.""" receiver_info = create_receiver_info(1) config_entry = create_config_entry_from_info(receiver_info) await setup_integration(hass, config_entry, receiver_info) old_host = config_entry.data[CONF_HOST] - old_max_volume = config_entry.options[OPTION_MAX_VOLUME] + old_options = config_entry.options result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" - with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=receiver_info, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": receiver_info.host} - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"host": receiver_info.host} + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "configure_receiver" result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TUNER"]}, + user_input={OPTION_VOLUME_RESOLUTION: 200}, ) assert result3["type"] is FlowResultType.ABORT @@ -390,7 +492,10 @@ async def test_reconfigure(hass: HomeAssistant) -> None: assert config_entry.data[CONF_HOST] == old_host assert config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 - assert config_entry.options[OPTION_MAX_VOLUME] == old_max_volume + for option, option_value in old_options.items(): + if option == OPTION_VOLUME_RESOLUTION: + continue + assert config_entry.options[option] == option_value async def test_reconfigure_new_device(hass: HomeAssistant) -> None: @@ -403,14 +508,18 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None: result = await config_entry.start_reconfigure_flow(hass) - receiver_info_2 = create_receiver_info(2) + mock_connection = create_connection(2) + + # Create mock discover that calls callback immediately + async def mock_discover(host, discovery_callback, timeout): + await discovery_callback(mock_connection) with patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=receiver_info_2, + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + new=mock_discover, ): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": receiver_info_2.host} + result["flow_id"], user_input={"host": mock_connection.host} ) await hass.async_block_till_done() @@ -455,12 +564,10 @@ async def test_import_fail( error: str, ) -> None: """Test import flow failed.""" - with ( - patch( - "homeassistant.components.onkyo.config_flow.async_interview", - return_value=None, - side_effect=exception, - ), + + with patch( + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + side_effect=exception, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=user_input @@ -503,6 +610,15 @@ async def test_import_success( } +@pytest.mark.parametrize( + "ignore_translations", + [ + [ # The schema is dynamically created from input sources + "component.onkyo.options.step.names.sections.input_sources.data.TV", + "component.onkyo.options.step.names.sections.input_sources.data_description.TV", + ] + ], +) async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test options flow.""" @@ -510,23 +626,43 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) config_entry = create_empty_config_entry() await setup_integration(hass, config_entry, receiver_info) + old_volume_resolution = config_entry.options[OPTION_VOLUME_RESOLUTION] + result = await hass.config_entries.options.async_init(config_entry.entry_id) - await hass.async_block_till_done() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "max_volume": 42, - "TV": "television", + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: [], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: ["TV"], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "names" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_INPUT_SOURCES: {"TV": "television"}, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "volume_resolution": 80, - "max_volume": 42.0, - "input_sources": { - "12": "television", - }, + OPTION_VOLUME_RESOLUTION: old_volume_resolution, + OPTION_MAX_VOLUME: 42.0, + OPTION_INPUT_SOURCES: {"12": "television"}, } diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 5c01fb2d200..0bad7050fd9 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -6,13 +6,13 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.onvif import DOMAIN, config_flow from homeassistant.config_entries import SOURCE_DHCP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( HOST, @@ -44,10 +44,10 @@ DISCOVERY = [ "MAC": "ff:ee:dd:cc:bb:aa", }, ] -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname="any", ip="5.6.7.8", macaddress=MAC.lower().replace(":", "") ) -DHCP_DISCOVERY_SAME_IP = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_SAME_IP = DhcpServiceInfo( hostname="any", ip="1.2.3.4", macaddress=MAC.lower().replace(":", "") ) diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py new file mode 100644 index 00000000000..4f7e10abae6 --- /dev/null +++ b/tests/components/onvif/test_parsers.py @@ -0,0 +1,791 @@ +"""Test ONVIF parsers.""" + +import datetime +import os + +import onvif +import onvif.settings +from zeep import Client +from zeep.transports import Transport + +from homeassistant.components.onvif import models, parsers +from homeassistant.core import HomeAssistant + +TEST_UID = "test-unique-id" + + +async def get_event(notification_data: dict) -> models.Event: + """Take in a zeep dict, run it through the parser, and return an Event. + + When the parser encounters an unknown topic that it doesn't know how to parse, + it outputs a message 'No registered handler for event from ...' along with a + print out of the serialized xml message from zeep. If it tries to parse and + can't, it prints out 'Unable to parse event from ...' along with the same + serialized message. This method can take the output directly from these log + messages and run them through the parser, which makes it easy to add new unit + tests that verify the message can now be parsed. + """ + zeep_client = Client( + f"{os.path.dirname(onvif.__file__)}/wsdl/events.wsdl", + wsse=None, + transport=Transport(), + ) + + notif_msg_type = zeep_client.get_type("ns5:NotificationMessageHolderType") + assert notif_msg_type is not None + notif_msg = notif_msg_type(**notification_data) + assert notif_msg is not None + + # The xsd:any type embedded inside the message doesn't parse, so parse it manually. + msg_elem = zeep_client.get_element("ns8:Message") + assert msg_elem is not None + msg_data = msg_elem(**notification_data["Message"]["_value_1"]) + assert msg_data is not None + notif_msg.Message._value_1 = msg_data + + parser = parsers.PARSERS.get(notif_msg.Topic._value_1) + assert parser is not None + + return await parser(TEST_UID, notif_msg) + + +async def test_line_detector_crossed(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/LineDetector/Crossed.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": {"_value_1": None, "_attr_1": None}, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/LineDetector/Crossed", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "xx.xx.xx.xx/onvif/event/alarm", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "video_source_config1", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "analytics_video_source", + }, + {"Name": "Rule", "Value": "MyLineDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "ObjectId", "Value": "0"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime(2020, 5, 24, 7, 24, 47), + "PropertyOperation": "Initialized", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Line Detector Crossed" + assert event.platform == "sensor" + assert event.value == "0" + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/LineDetector/" + "Crossed_video_source_config1_analytics_video_source_MyLineDetectorRule" + ) + + +async def test_tapo_line_crossed(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/LineCross.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/LineCross", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyLineCrossDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsLineCross", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 3, 21, 5, 14, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Line Detector Crossed" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "LineCross_VideoSourceToken_VideoAnalyticsToken_MyLineCrossDetectorRule" + ) + + +async def test_tapo_tpsmartevent_vehicle(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - vehicle.""" + event = await get_event( + { + "Message": { + "_value_1": { + "Data": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [{"Name": "IsVehicle", "Value": "true"}], + "_attr_1": None, + }, + "Extension": None, + "Key": None, + "PropertyOperation": "Changed", + "Source": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + { + "Name": "Rule", + "Value": "MyTPSmartEventDetectorRule", + }, + ], + "_attr_1": None, + }, + "UtcTime": datetime.datetime( + 2024, 11, 2, 0, 33, 11, tzinfo=datetime.UTC + ), + "_attr_1": {}, + } + }, + "ProducerReference": { + "Address": { + "_attr_1": None, + "_value_1": "http://192.168.56.127:5656/event", + }, + "Metadata": None, + "ReferenceParameters": None, + "_attr_1": None, + "_value_1": None, + }, + "SubscriptionReference": { + "Address": { + "_attr_1": None, + "_value_1": "http://192.168.56.127:2020/event-0_2020", + }, + "Metadata": None, + "ReferenceParameters": None, + "_attr_1": None, + "_value_1": None, + }, + "Topic": { + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + "_value_1": "tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent", + }, + } + ) + + assert event is not None + assert event.name == "Vehicle Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/TPSmartEventDetector/" + "TPSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule" + ) + + +async def test_tapo_cellmotiondetector_vehicle(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/TpSmartEvent - vehicle.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/TpSmartEvent", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTPSmartEventDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsVehicle", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 5, 14, 2, 9, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Vehicle Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "TpSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule" + ) + + +async def test_tapo_tpsmartevent_person(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - person.""" + event = await get_event( + { + "Message": { + "_value_1": { + "Data": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], + "_attr_1": None, + }, + "Extension": None, + "Key": None, + "PropertyOperation": "Changed", + "Source": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyPeopleDetectorRule"}, + ], + "_attr_1": None, + }, + "UtcTime": datetime.datetime( + 2024, 11, 3, 18, 40, 43, tzinfo=datetime.UTC + ), + "_attr_1": {}, + } + }, + "ProducerReference": { + "Address": { + "_attr_1": None, + "_value_1": "http://192.168.56.127:5656/event", + }, + "Metadata": None, + "ReferenceParameters": None, + "_attr_1": None, + "_value_1": None, + }, + "SubscriptionReference": { + "Address": { + "_attr_1": None, + "_value_1": "http://192.168.56.127:2020/event-0_2020", + }, + "Metadata": None, + "ReferenceParameters": None, + "_attr_1": None, + "_value_1": None, + }, + "Topic": { + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + "_value_1": "tns1:RuleEngine/PeopleDetector/People", + }, + } + ) + + assert event is not None + assert event.name == "Person Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/PeopleDetector/" + "People_VideoSourceToken_VideoAnalyticsToken_MyPeopleDetectorRule" + ) + + +async def test_tapo_tpsmartevent_pet(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - pet.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.56.63:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.56.63:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTPSmartEventDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsPet", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 22, 13, 24, 57, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Pet Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/TPSmartEventDetector/" + "TPSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule" + ) + + +async def test_tapo_cellmotiondetector_person(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/People - person.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.56.63:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/People", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.56.63:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyPeopleDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 3, 20, 9, 22, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Person Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "People_VideoSourceToken_VideoAnalyticsToken_MyPeopleDetectorRule" + ) + + +async def test_tapo_tamper(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/Tamper - tamper.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/Tamper", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTamperDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsTamper", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 5, 21, 1, 5, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Tamper Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "tamper" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "Tamper_VideoSourceToken_VideoAnalyticsToken_MyTamperDetectorRule" + ) + + +async def test_tapo_intrusion(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/Intrusion - intrusion.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.100.155:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/Intrusion", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.100.155:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyIntrusionDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsIntrusion", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 11, 10, 40, 45, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Intrusion Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "safety" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "Intrusion_VideoSourceToken_VideoAnalyticsToken_MyIntrusionDetectorRule" + ) + + +async def test_tapo_missing_attributes(hass: HomeAssistant) -> None: + """Tests async_parse_tplink_detector with missing fields.""" + event = await get_event( + { + "Message": { + "_value_1": { + "Data": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], + "_attr_1": None, + }, + } + }, + "Topic": { + "_value_1": "tns1:RuleEngine/PeopleDetector/People", + }, + } + ) + + assert event is None + + +async def test_tapo_unknown_type(hass: HomeAssistant) -> None: + """Tests async_parse_tplink_detector with unknown event type.""" + event = await get_event( + { + "Message": { + "_value_1": { + "Data": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [{"Name": "IsNotPerson", "Value": "true"}], + "_attr_1": None, + }, + "Source": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyPeopleDetectorRule"}, + ], + }, + } + }, + "Topic": { + "_value_1": "tns1:RuleEngine/PeopleDetector/People", + }, + } + ) + + assert event is None diff --git a/tests/components/open_meteo/conftest.py b/tests/components/open_meteo/conftest.py index 22138846915..66670782265 100644 --- a/tests/components/open_meteo/conftest.py +++ b/tests/components/open_meteo/conftest.py @@ -43,7 +43,7 @@ def mock_open_meteo(request: pytest.FixtureRequest) -> Generator[MagicMock]: forecast = Forecast.from_json(load_fixture(fixture, DOMAIN)) with patch( - "homeassistant.components.open_meteo.OpenMeteo", autospec=True + "homeassistant.components.open_meteo.coordinator.OpenMeteo", autospec=True ) as open_meteo_mock: open_meteo = open_meteo_mock.return_value open_meteo.forecast.return_value = forecast diff --git a/tests/components/open_meteo/snapshots/test_weather.ambr b/tests/components/open_meteo/snapshots/test_weather.ambr new file mode 100644 index 00000000000..dd5beb56d77 --- /dev/null +++ b/tests/components/open_meteo/snapshots/test_weather.ambr @@ -0,0 +1,1070 @@ +# serializer version: 1 +# name: test_forecast_service[forecast_daily] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-24T00:00:00+00:00', + 'precipitation': 0.19, + 'temperature': 7.6, + 'templow': 5.5, + 'wind_bearing': 251, + 'wind_speed': 10.9, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-25T00:00:00+00:00', + 'precipitation': 0.29, + 'temperature': 5.4, + 'templow': 0.2, + 'wind_bearing': 210, + 'wind_speed': 12.9, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-26T00:00:00+00:00', + 'precipitation': 0.76, + 'temperature': 4.8, + 'templow': 1.8, + 'wind_bearing': 230, + 'wind_speed': 14.8, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-27T00:00:00+00:00', + 'precipitation': 0.12, + 'temperature': 4.5, + 'templow': -0.1, + 'wind_bearing': 143, + 'wind_speed': 10.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-28T00:00:00+00:00', + 'precipitation': 0.15, + 'temperature': 3.4, + 'templow': -0.2, + 'wind_bearing': 143, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-11-29T00:00:00+00:00', + 'precipitation': 0.64, + 'temperature': 2.2, + 'templow': -0.5, + 'wind_bearing': 248, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T00:00:00+00:00', + 'precipitation': 1.74, + 'temperature': 3.0, + 'templow': -0.3, + 'wind_bearing': 256, + 'wind_speed': 16.1, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[forecast_hourly] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-24T04:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 6.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T05:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 6.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T12:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 7.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T13:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 7.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T14:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 7.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-24T15:00:00+00:00', + 'precipitation': 0.06, + 'temperature': 7.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-24T16:00:00+00:00', + 'precipitation': 0.06, + 'temperature': 7.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T17:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T18:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T19:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T20:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T21:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-24T23:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T00:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T01:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T02:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.9, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-25T04:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.2, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-25T05:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.2, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-25T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.2, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-25T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.5, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-25T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T12:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T13:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T14:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 4.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-25T15:00:00+00:00', + 'precipitation': 0.07, + 'temperature': 4.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T16:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T17:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T18:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.9, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-25T19:00:00+00:00', + 'precipitation': 0.09, + 'temperature': 3.9, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-25T20:00:00+00:00', + 'precipitation': 0.09, + 'temperature': 4.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T21:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 3.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-25T23:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T00:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T01:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T02:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T04:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T05:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T12:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T13:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T14:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T15:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 4.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-26T16:00:00+00:00', + 'precipitation': 0.1, + 'temperature': 4.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-26T17:00:00+00:00', + 'precipitation': 0.3, + 'temperature': 3.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-26T18:00:00+00:00', + 'precipitation': 0.2, + 'temperature': 3.3, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-26T19:00:00+00:00', + 'precipitation': 0.15, + 'temperature': 3.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T20:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T21:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-26T23:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T00:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 1.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T01:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T02:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T04:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T05:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.2, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-27T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.6, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-27T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-27T12:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.9, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-27T13:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-27T14:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.5, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-27T15:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 4.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T16:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-27T17:00:00+00:00', + 'precipitation': 0.1, + 'temperature': 3.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T18:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 2.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T19:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T20:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T21:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-27T23:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-28T00:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.6, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-28T01:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T02:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T04:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T05:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T12:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T13:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T14:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T15:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T16:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 3.2, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-28T17:00:00+00:00', + 'precipitation': 0.05, + 'temperature': 3.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-28T18:00:00+00:00', + 'precipitation': 0.05, + 'temperature': 2.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-28T19:00:00+00:00', + 'precipitation': 0.05, + 'temperature': 2.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T20:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T21:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.5, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-28T23:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T00:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T01:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T02:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T03:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T04:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T05:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T06:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T07:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-11-29T08:00:00+00:00', + 'precipitation': 0.01, + 'temperature': -0.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-11-29T09:00:00+00:00', + 'precipitation': 0.01, + 'temperature': -0.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-11-29T10:00:00+00:00', + 'precipitation': 0.01, + 'temperature': -0.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T11:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 0.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T12:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 1.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T13:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 2.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T14:00:00+00:00', + 'precipitation': 0.02, + 'temperature': 2.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T15:00:00+00:00', + 'precipitation': 0.02, + 'temperature': 2.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T16:00:00+00:00', + 'precipitation': 0.02, + 'temperature': 1.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-29T17:00:00+00:00', + 'precipitation': 0.13, + 'temperature': 1.4, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-29T18:00:00+00:00', + 'precipitation': 0.13, + 'temperature': 1.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-29T19:00:00+00:00', + 'precipitation': 0.13, + 'temperature': 0.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T20:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T21:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T22:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-29T23:00:00+00:00', + 'precipitation': 0.07, + 'temperature': 1.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T00:00:00+00:00', + 'precipitation': 0.07, + 'temperature': 1.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T01:00:00+00:00', + 'precipitation': 0.07, + 'temperature': 1.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T02:00:00+00:00', + 'precipitation': 0.16, + 'temperature': 1.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T03:00:00+00:00', + 'precipitation': 0.16, + 'temperature': 1.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T04:00:00+00:00', + 'precipitation': 0.16, + 'temperature': 1.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T05:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 1.1, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T06:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 0.8, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T07:00:00+00:00', + 'precipitation': 0.01, + 'temperature': 0.5, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-30T08:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.1, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-30T09:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.2, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-11-30T10:00:00+00:00', + 'precipitation': 0.0, + 'temperature': -0.3, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-30T11:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 0.2, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-30T12:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.0, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-11-30T13:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 1.9, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T14:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T15:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.4, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-11-30T16:00:00+00:00', + 'precipitation': 0.0, + 'temperature': 2.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T17:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 2.6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T18:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 2.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T19:00:00+00:00', + 'precipitation': 0.03, + 'temperature': 2.4, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T20:00:00+00:00', + 'precipitation': 0.04, + 'temperature': 2.5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T21:00:00+00:00', + 'precipitation': 0.04, + 'temperature': 2.8, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T22:00:00+00:00', + 'precipitation': 0.04, + 'temperature': 3.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-11-30T23:00:00+00:00', + 'precipitation': 0.88, + 'temperature': 3.0, + }), + ]), + }), + }) +# --- diff --git a/tests/components/open_meteo/test_init.py b/tests/components/open_meteo/test_init.py index 19efcbec35c..39e3666ef7f 100644 --- a/tests/components/open_meteo/test_init.py +++ b/tests/components/open_meteo/test_init.py @@ -33,7 +33,7 @@ async def test_load_unload_config_entry( @patch( - "homeassistant.components.open_meteo.OpenMeteo.forecast", + "homeassistant.components.open_meteo.coordinator.OpenMeteo.forecast", side_effect=OpenMeteoConnectionError, ) async def test_config_entry_not_ready( diff --git a/tests/components/open_meteo/test_weather.py b/tests/components/open_meteo/test_weather.py new file mode 100644 index 00000000000..b43385c924a --- /dev/null +++ b/tests/components/open_meteo/test_weather.py @@ -0,0 +1,46 @@ +"""Test for the open meteo weather entity.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.weather import ( + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.freeze_time("2021-11-24T03:00:00+00:00") +async def test_forecast_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_open_meteo: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test forecast service.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_ENTITY_ID: "weather.home", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == snapshot(name="forecast_daily") + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_ENTITY_ID: "weather.home", "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert response == snapshot(name="forecast_hourly") diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index eaa3a9de64c..4ef8b8655ee 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ - 'conversation_id': None, + 'conversation_id': 'my-conversation-id', 'response': IntentResponse( card=dict({ }), diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index e0665bc449f..9ee19cd330c 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,6 +1,6 @@ """Tests for the OpenAI integration.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, patch from freezegun import freeze_time from httpx import Response @@ -12,7 +12,6 @@ from openai.types.chat.chat_completion_message_tool_call import ( Function, ) from openai.types.completion_usage import CompletionUsage -from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation @@ -22,7 +21,6 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm from homeassistant.setup import async_setup_component -from homeassistant.util import ulid from tests.common import MockConfigEntry @@ -57,7 +55,7 @@ async def test_entity( async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component ) -> None: - """Test that the default prompt works.""" + """Test that we handle errors when calling completion API.""" with patch( "openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock, @@ -73,82 +71,6 @@ async def test_error_handling( assert result.response.error_code == "unknown", result -async def test_template_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template error handling works.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", - }, - ) - with ( - patch( - "openai.resources.models.AsyncModels.list", - ), - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result - - -async def test_template_variables( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template variables work.""" - context = Context(user_id="12345") - mock_user = Mock() - mock_user.id = "12345" - mock_user.name = "Test User" - - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": ( - "The user name is {{ user_name }}. " - "The user id is {{ llm_context.context.user_id }}." - ), - }, - ) - with ( - patch( - "openai.resources.models.AsyncModels.list", - ), - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create, - patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, "hello", None, context, agent_id=mock_config_entry.entry_id - ) - - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert ( - "The user name is Test User." - in mock_create.mock_calls[0][2]["messages"][0]["content"] - ) - assert ( - "The user id is 12345." - in mock_create.mock_calls[0][2]["messages"][0]["content"] - ) - - async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -504,65 +426,3 @@ async def test_assist_api_tools_conversion( tools = mock_create.mock_calls[0][2]["tools"] assert tools - - -async def test_unknown_hass_api( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, - mock_init_component, -) -> None: - """Test when we reference an API that no longer exists.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - **mock_config_entry.options, - CONF_LLM_HASS_API: "non-existing", - }, - ) - - await hass.async_block_till_done() - - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result == snapshot - - -@patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, -) -async def test_conversation_id( - mock_create, - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, -) -> None: - """Test conversation ID is honored.""" - result = await conversation.async_converse( - hass, "hello", None, None, agent_id=mock_config_entry.entry_id - ) - - conversation_id = result.conversation_id - - result = await conversation.async_converse( - hass, "hello", conversation_id, None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id == conversation_id - - unknown_id = ulid.ulid() - - result = await conversation.async_converse( - hass, "hello", unknown_id, None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id != unknown_id - - result = await conversation.async_converse( - hass, "hello", "koala", None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id == "koala" diff --git a/tests/components/openhome/test_config_flow.py b/tests/components/openhome/test_config_flow.py index 7ab1e69106c..6430b8610e9 100644 --- a/tests/components/openhome/test_config_flow.py +++ b/tests/components/openhome/test_config_flow.py @@ -1,12 +1,15 @@ """Tests for the Openhome config flow module.""" -from homeassistant.components import ssdp from homeassistant.components.openhome.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -14,7 +17,7 @@ MOCK_UDN = "uuid:4c494e4e-1234-ab12-abcd-01234567819f" MOCK_FRIENDLY_NAME = "Test Client" MOCK_SSDP_LOCATION = "http://device:12345/description.xml" -MOCK_DISCOVER = ssdp.SsdpServiceInfo( +MOCK_DISCOVER = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location=MOCK_SSDP_LOCATION, @@ -60,7 +63,7 @@ async def test_device_exists(hass: HomeAssistant) -> None: async def test_missing_udn(hass: HomeAssistant) -> None: """Test a ssdp import where discovery is missing udn.""" - broken_discovery = ssdp.SsdpServiceInfo( + broken_discovery = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location=MOCK_SSDP_LOCATION, @@ -79,7 +82,7 @@ async def test_missing_udn(hass: HomeAssistant) -> None: async def test_missing_ssdp_location(hass: HomeAssistant) -> None: """Test a ssdp import where discovery is missing udn.""" - broken_discovery = ssdp.SsdpServiceInfo( + broken_discovery = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location="", diff --git a/tests/components/opentherm_gw/test_button.py b/tests/components/opentherm_gw/test_button.py index b02a9d9fef0..d8de52559e7 100644 --- a/tests/components/opentherm_gw/test_button.py +++ b/tests/components/opentherm_gw/test_button.py @@ -16,6 +16,40 @@ from .conftest import MINIMAL_STATUS from tests.common import MockConfigEntry +async def test_cancel_room_setpoint_override_button( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_pyotgw: MagicMock, +) -> None: + """Test cancel room setpoint override button.""" + + mock_pyotgw.return_value.set_target_temp = AsyncMock(return_value=0) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + button_entity_id := entity_registry.async_get_entity_id( + BUTTON_DOMAIN, + OPENTHERM_DOMAIN, + f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-cancel_room_setpoint_override", + ) + ) is not None + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: button_entity_id, + }, + blocking=True, + ) + + mock_pyotgw.return_value.set_target_temp.assert_awaited_once_with(0, True) + + async def test_restart_button( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index cd02c14e4eb..d14fbc5cbd1 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -830,7 +830,9 @@ async def test_hassio_discovery_flow_new_port_missing_unique_id( # Setup the config entry config_entry = MockConfigEntry( data={ - "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + "url": ( + f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port'] + 1}" + ) }, domain=otbr.DOMAIN, options={}, @@ -861,7 +863,9 @@ async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None: # Setup the config entry config_entry = MockConfigEntry( data={ - "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + "url": ( + f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port'] + 1}" + ) }, domain=otbr.DOMAIN, options={}, @@ -897,7 +901,9 @@ async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) - # Setup the config entry config_entry = MockConfigEntry( - data={"url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}"}, + data={ + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port'] + 1}" + }, domain=otbr.DOMAIN, options={}, source="hassio", @@ -914,7 +920,7 @@ async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) - # Make sure the data of the existing entry was not updated expected_data = { - "url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}", + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port'] + 1}", } config_entry = hass.config_entries.async_get_entry(config_entry.entry_id) assert config_entry.data == expected_data diff --git a/tests/components/otp/conftest.py b/tests/components/otp/conftest.py index 7926be1e48e..d2a9e5c2b62 100644 --- a/tests/components/otp/conftest.py +++ b/tests/components/otp/conftest.py @@ -6,9 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.otp.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_NAME, CONF_PLATFORM, CONF_TOKEN -from homeassistant.helpers.typing import ConfigType +from homeassistant.const import CONF_NAME, CONF_TOKEN from tests.common import MockConfigEntry @@ -51,15 +49,3 @@ def mock_otp_config_entry() -> MockConfigEntry: }, unique_id="2FX5FBSYRE6VEC2FSHBQCRKO2GNDVZ52", ) - - -@pytest.fixture(name="otp_yaml_config") -def mock_otp_yaml_config() -> ConfigType: - """Mock otp configuration entry.""" - return { - SENSOR_DOMAIN: { - CONF_PLATFORM: "otp", - CONF_TOKEN: "2FX5FBSYRE6VEC2FSHBQCRKO2GNDVZ52", - CONF_NAME: "OTP Sensor", - } - } diff --git a/tests/components/otp/test_config_flow.py b/tests/components/otp/test_config_flow.py index f9fac433ff9..f1e8428be44 100644 --- a/tests/components/otp/test_config_flow.py +++ b/tests/components/otp/test_config_flow.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest from homeassistant.components.otp.const import CONF_NEW_TOKEN, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_CODE, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -97,22 +97,6 @@ async def test_errors_and_recover( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.usefixtures("mock_pyotp", "mock_setup_entry") -async def test_flow_import(hass: HomeAssistant) -> None: - """Test that we can import a YAML config.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=TEST_DATA_RESULT, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "OTP Sensor" - assert result["data"] == TEST_DATA_RESULT - - @pytest.mark.usefixtures("mock_pyotp") async def test_generate_new_token( hass: HomeAssistant, mock_setup_entry: AsyncMock diff --git a/tests/components/otp/test_sensor.py b/tests/components/otp/test_sensor.py index e75ce6707d4..67894166e93 100644 --- a/tests/components/otp/test_sensor.py +++ b/tests/components/otp/test_sensor.py @@ -3,12 +3,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.otp.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -26,16 +21,3 @@ async def test_setup( await hass.async_block_till_done() assert hass.states.get("sensor.otp_sensor") == snapshot - - -async def test_deprecated_yaml_issue( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, otp_yaml_config: ConfigType -) -> None: - """Test an issue is created when attempting setup from yaml config.""" - - assert await async_setup_component(hass, SENSOR_DOMAIN, otp_yaml_config) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue( - domain=HOMEASSISTANT_DOMAIN, issue_id=f"deprecated_yaml_{DOMAIN}" - ) diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index cef5ef350a9..711cc6c1d86 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -17,11 +17,11 @@ from pyoverkiz.exceptions import ( import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.overkiz.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -742,7 +742,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="gateway-1234-5678-9123", ip="192.168.1.4", macaddress="f8811a000000", @@ -801,7 +801,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="gateway-1234-5678-9123", ip="192.168.1.4", macaddress="f8811a000000", diff --git a/tests/components/overseerr/__init__.py b/tests/components/overseerr/__init__.py new file mode 100644 index 00000000000..b53b9d4f57a --- /dev/null +++ b/tests/components/overseerr/__init__.py @@ -0,0 +1,39 @@ +"""Tests for the Overseerr integration.""" + +from typing import Any +from urllib.parse import urlparse + +from aiohttp.test_utils import TestClient + +from homeassistant.components.webhook import async_generate_url +from homeassistant.core import HomeAssistant + +from .const import WEBHOOK_ID + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def call_webhook( + hass: HomeAssistant, data: dict[str, Any], client: TestClient +) -> None: + """Call the webhook.""" + webhook_url = async_generate_url(hass, WEBHOOK_ID) + + resp = await client.post( + urlparse(webhook_url).path, + json=data, + ) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + data = await resp.json() + resp.close() diff --git a/tests/components/overseerr/conftest.py b/tests/components/overseerr/conftest.py new file mode 100644 index 00000000000..9ae6be407ec --- /dev/null +++ b/tests/components/overseerr/conftest.py @@ -0,0 +1,120 @@ +"""Overseerr tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from python_overseerr import MovieDetails, RequestCount, RequestResponse +from python_overseerr.models import TVDetails, WebhookNotificationConfig + +from homeassistant.components.overseerr import CONF_CLOUDHOOK_URL +from homeassistant.components.overseerr.const import DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_WEBHOOK_ID, +) + +from .const import WEBHOOK_ID + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.overseerr.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_overseerr_client() -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + with ( + patch( + "homeassistant.components.overseerr.coordinator.OverseerrClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.overseerr.config_flow.OverseerrClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_request_count.return_value = RequestCount.from_json( + load_fixture("request_count.json", DOMAIN) + ) + client.get_webhook_notification_config.return_value = ( + WebhookNotificationConfig.from_json( + load_fixture("webhook_config.json", DOMAIN) + ) + ) + client.test_webhook_notification_config.return_value = True + client.get_requests.return_value = RequestResponse.from_json( + load_fixture("requests.json", DOMAIN) + ).results + client.get_movie_details.return_value = MovieDetails.from_json( + load_fixture("movie.json", DOMAIN) + ) + client.get_tv_details.return_value = TVDetails.from_json( + load_fixture("tv.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_overseerr_client_needs_change( + mock_overseerr_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + mock_overseerr_client.get_webhook_notification_config.return_value.types = 0 + return mock_overseerr_client + + +@pytest.fixture +def mock_overseerr_client_cloudhook( + mock_overseerr_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + mock_overseerr_client.get_webhook_notification_config.return_value.options.webhook_url = "https://hooks.nabu.casa/ABCD" + return mock_overseerr_client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Overseerr", + data={ + CONF_HOST: "overseerr.test", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "test-key", + CONF_WEBHOOK_ID: WEBHOOK_ID, + }, + entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", + ) + + +@pytest.fixture +def mock_cloudhook_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Overseerr", + data={ + CONF_HOST: "overseerr.test", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "test-key", + CONF_WEBHOOK_ID: WEBHOOK_ID, + CONF_CLOUDHOOK_URL: "https://hooks.nabu.casa/ABCD", + }, + entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", + ) diff --git a/tests/components/overseerr/const.py b/tests/components/overseerr/const.py new file mode 100644 index 00000000000..8c5c86e3ee4 --- /dev/null +++ b/tests/components/overseerr/const.py @@ -0,0 +1,3 @@ +"""Constants for the Overseerr tests.""" + +WEBHOOK_ID = "test-webhook-id" diff --git a/tests/components/overseerr/fixtures/movie.json b/tests/components/overseerr/fixtures/movie.json new file mode 100644 index 00000000000..2f5e86b3261 --- /dev/null +++ b/tests/components/overseerr/fixtures/movie.json @@ -0,0 +1,2644 @@ +{ + "id": 1156593, + "adult": false, + "budget": 0, + "genres": [ + { + "id": 10749, + "name": "Romance" + }, + { + "id": 18, + "name": "Drama" + } + ], + "relatedVideos": [ + { + "site": "YouTube", + "key": "IoyJOL5WnvQ", + "name": "Nick and Noah Meet Briar and Sofia [Subtitled]", + "size": 1080, + "type": "Clip", + "url": "https://www.youtube.com/watch?v=IoyJOL5WnvQ" + }, + { + "site": "YouTube", + "key": "nuPjieeswM4", + "name": "Sneak Peek: Nick and Noah are Back [Subtitled]", + "size": 1080, + "type": "Clip", + "url": "https://www.youtube.com/watch?v=nuPjieeswM4" + }, + { + "site": "YouTube", + "key": "QT5j0Uf-rdM", + "name": "Official Trailer 2 [Subtitled]", + "size": 1080, + "type": "Trailer", + "url": "https://www.youtube.com/watch?v=QT5j0Uf-rdM" + }, + { + "site": "YouTube", + "key": "m_TWESxP_DE", + "name": "Official Trailer [Subtitled]", + "size": 1080, + "type": "Trailer", + "url": "https://www.youtube.com/watch?v=m_TWESxP_DE" + }, + { + "site": "YouTube", + "key": "Wp4S0-fWgI0", + "name": "Official Teaser [Subtitled]", + "size": 1080, + "type": "Teaser", + "url": "https://www.youtube.com/watch?v=Wp4S0-fWgI0" + }, + { + "site": "YouTube", + "key": "3KTZekgTyFU", + "name": "Date Announce", + "size": 1080, + "type": "Teaser", + "url": "https://www.youtube.com/watch?v=3KTZekgTyFU" + } + ], + "originalLanguage": "es", + "originalTitle": "Culpa tuya", + "popularity": 3958.479, + "productionCompanies": [ + { + "id": 32485, + "name": "Pokeepsie Films", + "originCountry": "ES", + "logoPath": "/fVXreZkiJTOq1cCi6htSQlPSy7R.png" + }, + { + "id": 210099, + "name": "Amazon MGM Studios", + "originCountry": "US", + "logoPath": "/d6HwljzlOzxJ4tXlrpRkNZaZMWL.png" + } + ], + "productionCountries": [ + { + "iso_3166_1": "ES", + "name": "Spain" + }, + { + "iso_3166_1": "US", + "name": "United States of America" + } + ], + "releaseDate": "2024-12-26", + "releases": { + "results": [ + { + "iso_3166_1": "AR", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "AS", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-26T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "AT", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "BE", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "BG", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "BO", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "BR", + "release_dates": [ + { + "certification": "18", + "descriptors": ["Sexual Content", "Illegal Drugs"], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-26T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "CA", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "CH", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "CL", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "CO", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "CR", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "CZ", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "DE", + "release_dates": [ + { + "certification": "16", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "DK", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "EC", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "EG", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "ES", + "release_dates": [ + { + "certification": "16", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "FI", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "FR", + "release_dates": [ + { + "certification": "16", + "descriptors": [], + "iso_639_1": "", + "note": "Prime vidéo", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "GB", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "GR", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "GT", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "GU", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "HK", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "HR", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "HU", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "ID", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "IE", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "IN", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "IT", + "release_dates": [ + { + "certification": "14+", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "JP", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "KR", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "MP", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "MX", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "MY", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "NL", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "NO", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "NZ", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "PE", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "PH", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "PK", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "PL", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "PR", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-26T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "PT", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "RO", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "SA", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "SE", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "SG", + "release_dates": [ + { + "certification": "M18", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "SK", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "TH", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "TR", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "TW", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "US", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-26T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "VE", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "VI", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-26T00:00:00.000Z", + "type": 4 + } + ] + }, + { + "iso_3166_1": "ZA", + "release_dates": [ + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "Prime Video", + "release_date": "2024-12-27T00:00:00.000Z", + "type": 4 + } + ] + } + ] + }, + "revenue": 0, + "spokenLanguages": [ + { + "english_name": "Spanish", + "iso_639_1": "es", + "name": "Español" + } + ], + "status": "Released", + "title": "Your Fault", + "video": false, + "voteAverage": 7.7, + "voteCount": 190, + "backdropPath": "/6qld2YxAO9gdEblo0rsEb8BcYKO.jpg", + "homepage": "https://www.amazon.com/dp/B0DJ1L7ZN3", + "imdbId": "tt28510079", + "overview": "The love between Noah and Nick seems unwavering despite their parents' attempts to separate them. But his job and her entry into college open up their lives to new relationships that will shake the foundations of both their relationship and the Leister family itself.", + "posterPath": "/1sQA7lfcF9yUyoLYC0e6Zo3jmxE.jpg", + "runtime": 120, + "tagline": "Divided by family. Driven by love.", + "credits": { + "cast": [ + { + "castId": 1, + "character": "Noah Morgan", + "creditId": "64c12ecefdc1460139f9e120", + "id": 2527414, + "name": "Nicole Wallace", + "order": 0, + "gender": 1, + "profilePath": "/gSGCoJVKAMUTmyA3GzCAX22WNBA.jpg" + }, + { + "castId": 2, + "character": "Nick Leister", + "creditId": "64c12ed6df86a80125827557", + "id": 2786960, + "name": "Gabriel Guevara", + "order": 1, + "gender": 2, + "profilePath": "/pviRYKEEmoPUfLYwP1VHJ6LQcRg.jpg" + }, + { + "castId": 12, + "character": "William Leister", + "creditId": "65e0ed456a300b017d1dc9fc", + "id": 1251336, + "name": "Iván Sánchez", + "order": 2, + "gender": 2, + "profilePath": "/woVz8D7t1VUKjFJnsTAdc8tyz5C.jpg" + }, + { + "castId": 13, + "character": "Rafaela", + "creditId": "65e0ed9851f99a018557897f", + "id": 970027, + "name": "Marta Hazas", + "order": 3, + "gender": 1, + "profilePath": "/1dbeTFRCbWBt70dIGjYHKVLnpaG.jpg" + }, + { + "castId": 14, + "character": "Jenna", + "creditId": "65e0edda41a561016382560b", + "id": 4105602, + "name": "Eva Ruiz", + "order": 4, + "gender": 1, + "profilePath": "/bcu0nmQvhxwTzh4csc4kuxJsQee.jpg" + }, + { + "castId": 15, + "character": "Lion", + "creditId": "65e0ef982d531a0162bf8423", + "id": 2126335, + "name": "Víctor Varona", + "order": 5, + "gender": 0, + "profilePath": "/lcwFAjHjhJXkxf59TXGSjGGOlLj.jpg" + }, + { + "castId": 16, + "character": "Sofía", + "creditId": "65e0f001724de10187de9bf1", + "id": 3513082, + "name": "Gabriela Andrada", + "order": 6, + "gender": 1, + "profilePath": "/h8cAdDqbDK2ayGIuxQQdNCzyCsb.jpg" + }, + { + "castId": 17, + "character": "Briar", + "creditId": "65e0f09541a5610186825d32", + "id": 3904252, + "name": "Alex Bejar", + "order": 7, + "gender": 1, + "profilePath": "/kPeFa6xX57IyhAfoT8dtgfPMgmX.jpg" + }, + { + "castId": 18, + "character": "Michael", + "creditId": "65e0f0b46a300b017d1dcbde", + "id": 2785763, + "name": "Javier Morgade", + "order": 8, + "gender": 2, + "profilePath": "/hcDV10bTZlm1rTi0NRhoGIpdicw.jpg" + }, + { + "castId": 19, + "character": "Luca", + "creditId": "65e0f35ea39d0b018608bda5", + "id": 3117422, + "name": "Felipe Londoño", + "order": 9, + "gender": 0, + "profilePath": "/gUKu2bla3ZKtwuzfymMbyzUsobC.jpg" + }, + { + "castId": 20, + "character": "Anabel", + "creditId": "65e0f37c9dee58017c7cdb1f", + "id": 261, + "name": "Goya Toledo", + "order": 10, + "gender": 1, + "profilePath": "/dihdQxVr2sFA1XznPK4orzC2m7i.jpg" + }, + { + "castId": 21, + "character": "Simón", + "creditId": "65e0f3b107e2810163dc6d82", + "id": 1983424, + "name": "Fran Morcillo", + "order": 11, + "gender": 2, + "profilePath": "/ovPzGuRvMQhB4OrC6lfxlEGLewD.jpg" + }, + { + "castId": 22, + "character": "Dioni", + "creditId": "65e0f420724de10187de9dbf", + "id": 2508286, + "name": "José Manuel Palacios", + "order": 12, + "gender": 2, + "profilePath": "/1oXz8Hht5pBluq700BmHBQwWbVQ.jpg" + } + ], + "crew": [ + { + "creditId": "64c54c10cadb6b01440d5d47", + "department": "Writing", + "id": 4106905, + "job": "Novel", + "name": "Mercedes Ron", + "gender": 1, + "profilePath": null + }, + { + "creditId": "64c12f562f1be000ae4ba431", + "department": "Production", + "id": 57865, + "job": "Producer", + "name": "Álex de la Iglesia", + "gender": 2, + "profilePath": "/vyZFSSNtY2jL06v4nJHLIT5fWa2.jpg" + }, + { + "creditId": "64c12f63097c49011d82bc77", + "department": "Production", + "id": 512958, + "job": "Producer", + "name": "Carolina Bang", + "gender": 1, + "profilePath": "/zwY5dMMeRAxUGU2Q50u7mWk3DA0.jpg" + }, + { + "creditId": "64c17c3d871b34011e697f24", + "department": "Directing", + "id": 1739102, + "job": "Director", + "name": "Domingo González", + "gender": 0, + "profilePath": null + }, + { + "creditId": "674ddb345c9ffdbae7692b9a", + "department": "Writing", + "id": 1739102, + "job": "Screenplay", + "name": "Domingo González", + "gender": 0, + "profilePath": null + }, + { + "creditId": "674ddb3f7473778bbd9afc75", + "department": "Writing", + "id": 1495797, + "job": "Screenplay", + "name": "Sofía Cuenca", + "gender": 0, + "profilePath": "/d5JbOSgQFcLd0GlBSotuNXK9Yia.jpg" + } + ] + }, + "collection": { + "id": 1156666, + "name": "Fault Collection", + "posterPath": "/cAimhhErEnKdvaZoU6Wtbf38sTS.jpg", + "backdropPath": "/7N9p8D39MXJDfm14zlBwW7mBh7g.jpg" + }, + "externalIds": { + "facebookId": null, + "imdbId": "tt28510079", + "instagramId": null, + "twitterId": null + }, + "mediaInfo": { + "downloadStatus": [], + "downloadStatus4k": [], + "id": 537, + "mediaType": "movie", + "tmdbId": 1156593, + "tvdbId": null, + "imdbId": null, + "status": 3, + "status4k": 1, + "createdAt": "2024-12-29T10:04:16.000Z", + "updatedAt": "2024-12-29T10:04:17.000Z", + "lastSeasonChange": "2024-12-29T10:04:16.000Z", + "mediaAddedAt": null, + "serviceId": 0, + "serviceId4k": null, + "externalServiceId": 423, + "externalServiceId4k": null, + "externalServiceSlug": "1156593", + "externalServiceSlug4k": null, + "ratingKey": null, + "ratingKey4k": null, + "requests": [ + { + "id": 16, + "status": 2, + "createdAt": "2024-12-29T10:04:16.000Z", + "updatedAt": "2024-12-29T10:04:16.000Z", + "type": "movie", + "is4k": false, + "serverId": 0, + "profileId": 7, + "rootFolder": "/media/movies", + "languageProfileId": null, + "tags": [], + "isAutoRequest": false, + "media": { + "downloadStatus": [], + "downloadStatus4k": [], + "id": 537, + "mediaType": "movie", + "tmdbId": 1156593, + "tvdbId": null, + "imdbId": null, + "status": 3, + "status4k": 1, + "createdAt": "2024-12-29T10:04:16.000Z", + "updatedAt": "2024-12-29T10:04:17.000Z", + "lastSeasonChange": "2024-12-29T10:04:16.000Z", + "mediaAddedAt": null, + "serviceId": 0, + "serviceId4k": null, + "externalServiceId": 423, + "externalServiceId4k": null, + "externalServiceSlug": "1156593", + "externalServiceSlug4k": null, + "ratingKey": null, + "ratingKey4k": null, + "serviceUrl": "http://192.168.0.1:7878/movie/1156593" + }, + "requestedBy": { + "permissions": 2, + "id": 1, + "email": "me@email.com", + "plexUsername": "somebody", + "username": null, + "recoveryLinkExpirationDate": null, + "userType": 1, + "plexId": 43972689, + "avatar": "https://plex.tv/users/aaaaaaa/avatar?c=aaaaaaa", + "movieQuotaLimit": null, + "movieQuotaDays": null, + "tvQuotaLimit": null, + "tvQuotaDays": null, + "createdAt": "2024-12-16T21:13:58.000Z", + "updatedAt": "2024-12-16T23:59:03.000Z", + "requestCount": 11, + "displayName": "somebody" + }, + "modifiedBy": { + "permissions": 2, + "id": 1, + "email": "me@email.com", + "plexUsername": "somebody", + "username": null, + "recoveryLinkExpirationDate": null, + "userType": 1, + "plexId": 43972689, + "avatar": "https://plex.tv/users/aaaaaaa/avatar?c=aaaaaaa", + "movieQuotaLimit": null, + "movieQuotaDays": null, + "tvQuotaLimit": null, + "tvQuotaDays": null, + "createdAt": "2024-12-16T21:13:58.000Z", + "updatedAt": "2024-12-16T23:59:03.000Z", + "requestCount": 11, + "displayName": "somebody" + }, + "seasons": [], + "seasonCount": 0 + } + ], + "issues": [], + "seasons": [], + "serviceUrl": "http://192.168.0.1:7878/movie/1156593" + }, + "watchProviders": [ + { + "iso_3166_1": "AD", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=AD", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "AE", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=AE", + "buy": [], + "flatrate": [ + { + "displayPriority": 9, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "AG", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=AG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "AL", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=AL", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "AR", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=AR", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "AT", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=AT", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 9, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 65, + "logoPath": "/8aBqoNeGGr0oSA85iopgNZUOTOc.jpg", + "id": 2100, + "name": "Amazon Prime Video with Ads" + } + ] + }, + { + "iso_3166_1": "AU", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=AU", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BA", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=BA", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BB", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=BB", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BE", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=BE", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BG", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=BG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BH", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=BH", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BM", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=BM", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BO", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=BO", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BR", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=BR", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BS", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=BS", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CA", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=CA", + "buy": [], + "flatrate": [ + { + "displayPriority": 3, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 152, + "logoPath": "/8aBqoNeGGr0oSA85iopgNZUOTOc.jpg", + "id": 2100, + "name": "Amazon Prime Video with Ads" + } + ] + }, + { + "iso_3166_1": "CH", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=CH", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CI", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=CI", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CL", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=CL", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CO", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=CO", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CR", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=CR", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CZ", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=CZ", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "DE", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=DE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 9, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 157, + "logoPath": "/8aBqoNeGGr0oSA85iopgNZUOTOc.jpg", + "id": 2100, + "name": "Amazon Prime Video with Ads" + } + ] + }, + { + "iso_3166_1": "DK", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=DK", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "DO", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=DO", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "DZ", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=DZ", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "EC", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=EC", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "EE", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=EE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "EG", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=EG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "ES", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=ES", + "buy": [], + "flatrate": [ + { + "displayPriority": 3, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "FI", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=FI", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "FR", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=FR", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GB", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=GB", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 9, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 136, + "logoPath": "/8aBqoNeGGr0oSA85iopgNZUOTOc.jpg", + "id": 2100, + "name": "Amazon Prime Video with Ads" + } + ] + }, + { + "iso_3166_1": "GF", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=GF", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GG", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=GG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GH", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=GH", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GI", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=GI", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GR", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=GR", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GT", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=GT", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "HK", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=HK", + "buy": [], + "flatrate": [ + { + "displayPriority": 5, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "HN", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=HN", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "HR", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=HR", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "HU", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=HU", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "ID", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=ID", + "buy": [], + "flatrate": [ + { + "displayPriority": 7, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IE", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=IE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IL", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=IL", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IN", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=IN", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IQ", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=IQ", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IS", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=IS", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IT", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=IT", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 15, + "logoPath": "/6FDKQWcR6JfmRKLqezSsvGgRuUY.jpg", + "id": 109, + "name": "Timvision" + } + ] + }, + { + "iso_3166_1": "JM", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=JM", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "JO", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=JO", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "JP", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=JP", + "buy": [], + "flatrate": [ + { + "displayPriority": 4, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 9, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "KE", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=KE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "KR", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=KR", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "KW", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=KW", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LB", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=LB", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LC", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=LC", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LI", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=LI", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LT", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=LT", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LV", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=LV", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LY", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=LY", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MA", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=MA", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MC", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=MC", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MD", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=MD", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MK", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=MK", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MT", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=MT", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MX", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=MX", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MY", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=MY", + "buy": [], + "flatrate": [ + { + "displayPriority": 9, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MZ", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=MZ", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "NE", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=NE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "NG", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=NG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "NL", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=NL", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "NO", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=NO", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "NZ", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=NZ", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "OM", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=OM", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PA", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=PA", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PE", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=PE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PF", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=PF", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PH", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=PH", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PK", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=PK", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PL", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=PL", + "buy": [], + "flatrate": [ + { + "displayPriority": 5, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PS", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=PS", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PT", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=PT", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PY", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=PY", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "QA", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=QA", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "RO", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=RO", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "RS", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=RS", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SA", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=SA", + "buy": [], + "flatrate": [ + { + "displayPriority": 23, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SE", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=SE", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SG", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=SG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SI", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=SI", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SK", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=SK", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SM", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=SM", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SV", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=SV", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TC", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=TC", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TH", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=TH", + "buy": [], + "flatrate": [ + { + "displayPriority": 3, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TN", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=TN", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TR", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=TR", + "buy": [], + "flatrate": [ + { + "displayPriority": 6, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TT", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=TT", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TW", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=TW", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "UG", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=UG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "US", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=US", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 9, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 258, + "logoPath": "/8aBqoNeGGr0oSA85iopgNZUOTOc.jpg", + "id": 2100, + "name": "Amazon Prime Video with Ads" + } + ] + }, + { + "iso_3166_1": "UY", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=UY", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "VA", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=VA", + "buy": [], + "flatrate": [ + { + "displayPriority": 0, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "VE", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=VE", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "YE", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=YE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "ZA", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=ZA", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "ZM", + "link": "https://www.themoviedb.org/movie/1156593-culpa-tuya/watch?locale=ZM", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + } + ], + "keywords": [ + { + "id": 818, + "name": "based on novel or book" + }, + { + "id": 9663, + "name": "sequel" + } + ] +} diff --git a/tests/components/overseerr/fixtures/request_count.json b/tests/components/overseerr/fixtures/request_count.json new file mode 100644 index 00000000000..441429cfa00 --- /dev/null +++ b/tests/components/overseerr/fixtures/request_count.json @@ -0,0 +1,10 @@ +{ + "total": 11, + "movie": 9, + "tv": 2, + "pending": 0, + "approved": 11, + "declined": 0, + "processing": 3, + "available": 8 +} diff --git a/tests/components/overseerr/fixtures/requests.json b/tests/components/overseerr/fixtures/requests.json new file mode 100644 index 00000000000..ff422420893 --- /dev/null +++ b/tests/components/overseerr/fixtures/requests.json @@ -0,0 +1,176 @@ +{ + "pageInfo": { + "pages": 2, + "pageSize": 10, + "results": 14, + "page": 1 + }, + "results": [ + { + "id": 16, + "status": 2, + "createdAt": "2024-12-29T10:04:16.000Z", + "updatedAt": "2024-12-29T10:04:16.000Z", + "type": "movie", + "is4k": false, + "serverId": 0, + "profileId": 7, + "rootFolder": "/media/movies", + "languageProfileId": null, + "tags": [], + "isAutoRequest": false, + "media": { + "downloadStatus": [], + "downloadStatus4k": [], + "id": 537, + "mediaType": "movie", + "tmdbId": 1156593, + "tvdbId": null, + "imdbId": null, + "status": 3, + "status4k": 1, + "createdAt": "2024-12-29T10:04:16.000Z", + "updatedAt": "2024-12-29T10:04:17.000Z", + "lastSeasonChange": "2024-12-29T10:04:16.000Z", + "mediaAddedAt": null, + "serviceId": 0, + "serviceId4k": null, + "externalServiceId": 423, + "externalServiceId4k": null, + "externalServiceSlug": "1156593", + "externalServiceSlug4k": null, + "ratingKey": null, + "ratingKey4k": null, + "serviceUrl": "http://192.168.0.1:7878/movie/1156593" + }, + "seasons": [], + "modifiedBy": { + "permissions": 2, + "id": 1, + "email": "one@email.com", + "plexUsername": "somebody", + "username": null, + "recoveryLinkExpirationDate": null, + "userType": 1, + "plexId": 321321321, + "avatar": "https://plex.tv/users/aaaaa/avatar?c=aaaaa", + "movieQuotaLimit": null, + "movieQuotaDays": null, + "tvQuotaLimit": null, + "tvQuotaDays": null, + "createdAt": "2024-12-16T21:13:58.000Z", + "updatedAt": "2024-12-16T23:59:03.000Z", + "requestCount": 11, + "displayName": "somebody" + }, + "requestedBy": { + "permissions": 2, + "id": 1, + "email": "one@email.com", + "plexUsername": "somebody", + "username": null, + "recoveryLinkExpirationDate": null, + "userType": 1, + "plexId": 321321321, + "avatar": "https://plex.tv/users/aaaaa/avatar?c=aaaaa", + "movieQuotaLimit": null, + "movieQuotaDays": null, + "tvQuotaLimit": null, + "tvQuotaDays": null, + "createdAt": "2024-12-16T21:13:58.000Z", + "updatedAt": "2024-12-16T23:59:03.000Z", + "requestCount": 11, + "displayName": "somebody" + }, + "seasonCount": 0 + }, + { + "id": 14, + "status": 2, + "createdAt": "2024-12-26T14:37:30.000Z", + "updatedAt": "2024-12-26T14:37:30.000Z", + "type": "tv", + "is4k": false, + "serverId": 0, + "profileId": 7, + "rootFolder": "/media/tv", + "languageProfileId": 1, + "tags": [], + "isAutoRequest": false, + "media": { + "downloadStatus": [], + "downloadStatus4k": [], + "id": 535, + "mediaType": "tv", + "tmdbId": 249522, + "tvdbId": 447806, + "imdbId": null, + "status": 4, + "status4k": 1, + "createdAt": "2024-12-26T14:37:30.000Z", + "updatedAt": "2024-12-26T14:45:00.000Z", + "lastSeasonChange": "2024-12-26T14:37:30.000Z", + "mediaAddedAt": "2024-12-26T14:39:56.000Z", + "serviceId": 0, + "serviceId4k": null, + "externalServiceId": 144, + "externalServiceId4k": null, + "externalServiceSlug": "beast-games", + "externalServiceSlug4k": null, + "ratingKey": "10189", + "ratingKey4k": null, + "plexUrl": "https://app.plex.tv/desktop#!/server/aaaa/details?key=%2Flibrary%2Fmetadata%2F10189", + "iOSPlexUrl": "plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F10189&server=aaaa", + "serviceUrl": "http://192.168.0.2:8989/series/beast-games" + }, + "seasons": [ + { + "id": 4, + "seasonNumber": 1, + "status": 2, + "createdAt": "2024-12-26T14:37:30.000Z", + "updatedAt": "2024-12-26T14:37:30.000Z" + } + ], + "modifiedBy": { + "permissions": 2, + "id": 1, + "email": "one@email.com", + "plexUsername": "somebody", + "username": null, + "recoveryLinkExpirationDate": null, + "userType": 1, + "plexId": 321321321, + "avatar": "https://plex.tv/users/aaaaa/avatar?c=aaaaa", + "movieQuotaLimit": null, + "movieQuotaDays": null, + "tvQuotaLimit": null, + "tvQuotaDays": null, + "createdAt": "2024-12-16T21:13:58.000Z", + "updatedAt": "2024-12-16T23:59:03.000Z", + "requestCount": 11, + "displayName": "somebody" + }, + "requestedBy": { + "permissions": 2, + "id": 1, + "email": "one@email.com", + "plexUsername": "somebody", + "username": null, + "recoveryLinkExpirationDate": null, + "userType": 1, + "plexId": 321321321, + "avatar": "https://plex.tv/users/aaaaa/avatar?c=aaaaa", + "movieQuotaLimit": null, + "movieQuotaDays": null, + "tvQuotaLimit": null, + "tvQuotaDays": null, + "createdAt": "2024-12-16T21:13:58.000Z", + "updatedAt": "2024-12-16T23:59:03.000Z", + "requestCount": 11, + "displayName": "somebody" + }, + "seasonCount": 1 + } + ] +} diff --git a/tests/components/overseerr/fixtures/tv.json b/tests/components/overseerr/fixtures/tv.json new file mode 100644 index 00000000000..445197de0ad --- /dev/null +++ b/tests/components/overseerr/fixtures/tv.json @@ -0,0 +1,2052 @@ +{ + "createdBy": [ + { + "id": 2282154, + "credit_id": "673fc8e2d7bee5585c559789", + "name": "Jimmy Donaldson", + "original_name": "Jimmy Donaldson", + "gender": 2, + "profile_path": "/dE6VIMrvZsY5a9gIaWoX40tzYkV.jpg" + }, + { + "id": 4886515, + "credit_id": "673fc8ecd7bee5585c55979f", + "name": "Tyler Conklin", + "original_name": "Tyler Conklin", + "gender": 2, + "profile_path": null + }, + { + "id": 572350, + "credit_id": "673fc8f4d7bee5585c5597a7", + "name": "Sean Klitzner", + "original_name": "Sean Klitzner", + "gender": 2, + "profile_path": "/rTVGa5hK5N9mhJwrd2oGOWzbeh7.jpg" + }, + { + "id": 5073789, + "credit_id": "673fc8f88337acae076dca7d", + "name": "Mack Hopkins", + "original_name": "Mack Hopkins", + "gender": 2, + "profile_path": "/5yaA2crWsdzTf2BFnlZ3ZuvFaL8.jpg" + } + ], + "episodeRunTime": [], + "firstAirDate": "2024-12-19", + "genres": [ + { + "id": 10764, + "name": "Reality" + } + ], + "relatedVideos": [ + { + "site": "YouTube", + "key": "37ZG9lddxto", + "name": "Official Trailer", + "size": 1080, + "type": "Trailer", + "url": "https://www.youtube.com/watch?v=37ZG9lddxto" + }, + { + "site": "YouTube", + "key": "2lyCZnx71ps", + "name": "Teaser Trailer", + "size": 1080, + "type": "Teaser", + "url": "https://www.youtube.com/watch?v=2lyCZnx71ps" + } + ], + "homepage": "https://www.amazon.com/dp/B0CZ7S3N9Y", + "id": 249522, + "inProduction": true, + "languages": ["da", "en"], + "lastAirDate": "2024-12-26", + "name": "Beast Games", + "networks": [ + { + "id": 1024, + "name": "Prime Video", + "originCountry": "", + "logoPath": "/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png" + } + ], + "numberOfEpisodes": 10, + "numberOfSeasons": 1, + "originCountry": ["US"], + "originalLanguage": "en", + "originalName": "Beast Games", + "tagline": "1,000 players. 5 million dollars. 1 winner.", + "overview": "I gathered 1,000 people to fight for $5,000,000, the LARGEST cash prize in TV history! We're also giving away a private island, Lamborghinis, and millions more in cash throughout the competition! Go watch to see the greatest show ever made!", + "popularity": 769.189, + "productionCompanies": [ + { + "id": 210099, + "name": "Amazon MGM Studios", + "originCountry": "US", + "logoPath": "/d6HwljzlOzxJ4tXlrpRkNZaZMWL.png" + }, + { + "id": 247865, + "name": "MrBeast", + "originCountry": "US", + "logoPath": "/9jchzJF1IonYRakcyVIu2fKb8Mz.png" + } + ], + "productionCountries": [ + { + "iso_3166_1": "US", + "name": "United States of America" + } + ], + "contentRatings": { + "results": [ + { + "descriptors": [], + "iso_3166_1": "SG", + "rating": "G" + }, + { + "descriptors": [], + "iso_3166_1": "BR", + "rating": "L" + }, + { + "descriptors": [], + "iso_3166_1": "MX", + "rating": "A" + }, + { + "descriptors": [], + "iso_3166_1": "US", + "rating": "TV-G" + }, + { + "descriptors": [], + "iso_3166_1": "ES", + "rating": "7" + } + ] + }, + "spokenLanguages": [ + { + "englishName": "Danish", + "iso_639_1": "da", + "name": "Dansk" + }, + { + "englishName": "English", + "iso_639_1": "en", + "name": "English" + } + ], + "seasons": [ + { + "airDate": "2024-12-19", + "episodeCount": 10, + "id": 384427, + "name": "Season 1", + "overview": "", + "seasonNumber": 1, + "posterPath": "/3itZlypnOcVcqI5xxyO6nvJ52yM.jpg" + } + ], + "status": "Returning Series", + "type": "Reality", + "voteAverage": 7.523, + "voteCount": 44, + "backdropPath": "/bXJII8e2iIQ7EzDx4KRhSghn2me.jpg", + "lastEpisodeToAir": { + "id": 5802152, + "airDate": "2024-12-26", + "episodeNumber": 3, + "name": "The Solitary Experiment", + "overview": "What would happen if three best friends were trapped in a room, but only two could escape? Watch and see for yourself right now!", + "productionCode": "", + "seasonNumber": 1, + "showId": 249522, + "voteAverage": 4.6, + "stillPath": "/r6LRRaA2l2tMDttWbYl3dXdJUij.jpg" + }, + "nextEpisodeToAir": { + "id": 5802153, + "airDate": "2025-01-02", + "episodeNumber": 4, + "name": "Episode 4", + "overview": "", + "productionCode": "", + "seasonNumber": 1, + "showId": 249522, + "voteAverage": 0, + "stillPath": null + }, + "posterPath": "/3itZlypnOcVcqI5xxyO6nvJ52yM.jpg", + "credits": { + "cast": [ + { + "character": "Self - Host", + "creditId": "65f9449f15a4a1012c0c8d19", + "id": 2282154, + "name": "Jimmy Donaldson", + "order": 0, + "gender": 2, + "profilePath": "/dE6VIMrvZsY5a9gIaWoX40tzYkV.jpg" + }, + { + "character": "Self - Co-Host", + "creditId": "6765bca2330bce6ec990e041", + "id": 5131521, + "name": "Chandler Hallow", + "order": 1, + "gender": 2, + "profilePath": "/8MAvWDC4FiiSYlmpZ9p2oCovxl0.jpg" + }, + { + "character": "Self - Co-Host", + "creditId": "6765bcb9330bce6ec990e059", + "id": 5131522, + "name": "Karl Jacobs", + "order": 2, + "gender": 2, + "profilePath": "/9ZNCxiXh4l3i24KXLD67Lqyv04K.jpg" + }, + { + "character": "Self - Co-Host", + "creditId": "6765bcbf87b92a43be5e00bd", + "id": 5131524, + "name": "Nolan Hansen", + "order": 3, + "gender": 2, + "profilePath": "/uyEUcMieLKH6pn9JTPuoI5nURWB.jpg" + }, + { + "character": "Self - Co-Host", + "creditId": "6765bcca919287ef5390dc73", + "id": 5131525, + "name": "Tareq Salameh", + "order": 4, + "gender": 2, + "profilePath": "/bFZn6A8CSn7EPi9MHcIYxTpLAlF.jpg" + }, + { + "character": "Self - Co-Host", + "creditId": "6765bcd1919287ef5390dc7c", + "id": 5073789, + "name": "Mack Hopkins", + "order": 5, + "gender": 2, + "profilePath": "/5yaA2crWsdzTf2BFnlZ3ZuvFaL8.jpg" + }, + { + "character": "Self", + "creditId": "6767dae0ae1b411f405e4af9", + "id": 1126417, + "name": "Arturo del Puerto", + "order": 6, + "gender": 2, + "profilePath": "/tALGmngyUUtxyeQtMSy7KyzjdYc.jpg" + } + ], + "crew": [ + { + "creditId": "65f94625223e20016079bd7c", + "department": "Production", + "id": 2282154, + "job": "Executive Producer", + "name": "Jimmy Donaldson", + "gender": 2, + "profilePath": "/dE6VIMrvZsY5a9gIaWoX40tzYkV.jpg" + }, + { + "creditId": "673fcb2b7b825e685b4e0a34", + "department": "Production", + "id": 4886515, + "job": "Executive Producer", + "name": "Tyler Conklin", + "gender": 2, + "profilePath": null + }, + { + "creditId": "673fcb357b825e685b4e0a37", + "department": "Production", + "id": 572350, + "job": "Executive Producer", + "name": "Sean Klitzner", + "gender": 2, + "profilePath": "/rTVGa5hK5N9mhJwrd2oGOWzbeh7.jpg" + }, + { + "creditId": "673fcb41dae2e6a938254d38", + "department": "Production", + "id": 5073789, + "job": "Executive Producer", + "name": "Mack Hopkins", + "gender": 2, + "profilePath": "/5yaA2crWsdzTf2BFnlZ3ZuvFaL8.jpg" + }, + { + "creditId": "673fcb57dae2e6a938254d4e", + "department": "Production", + "id": 5073813, + "job": "Executive Producer", + "name": "Michael Cruz", + "gender": 0, + "profilePath": null + }, + { + "creditId": "673fcb605cac041cfae26a54", + "department": "Production", + "id": 4851894, + "job": "Executive Producer", + "name": "Matt Apps", + "gender": 0, + "profilePath": null + }, + { + "creditId": "673fcb6b7b825e685b4e0a3d", + "department": "Production", + "id": 4007900, + "job": "Executive Producer", + "name": "Charles Wachter", + "gender": 0, + "profilePath": null + }, + { + "creditId": "673fcb747b825e685b4e0a43", + "department": "Production", + "id": 2292335, + "job": "Executive Producer", + "name": "Keith Geller", + "gender": 0, + "profilePath": null + }, + { + "creditId": "673fcb807b825e685b4e0a4d", + "department": "Production", + "id": 1251868, + "job": "Executive Producer", + "name": "Joe Coleman", + "gender": 2, + "profilePath": null + }, + { + "creditId": "673fcb877b825e685b4e0a53", + "department": "Production", + "id": 1450643, + "job": "Executive Producer", + "name": "Rachel Skidmore", + "gender": 0, + "profilePath": null + }, + { + "creditId": "673fcb91dae2e6a938254d5c", + "department": "Production", + "id": 4338334, + "job": "Executive Producer", + "name": "Chris Keiper", + "gender": 0, + "profilePath": null + }, + { + "creditId": "673fcb9a5cac041cfae26a5c", + "department": "Production", + "id": 5073815, + "job": "Executive Producer", + "name": "Joshua Kulic", + "gender": 0, + "profilePath": null + } + ] + }, + "externalIds": { + "facebookId": null, + "freebaseId": null, + "freebaseMid": null, + "imdbId": "tt31812476", + "instagramId": "beastgamesonprime", + "tvdbId": 447806, + "tvrageId": null, + "twitterId": null + }, + "keywords": [ + { + "id": 271, + "name": "competition" + }, + { + "id": 4325, + "name": "game show" + }, + { + "id": 330122, + "name": "mrbeast" + } + ], + "mediaInfo": { + "downloadStatus": [], + "downloadStatus4k": [], + "id": 535, + "mediaType": "tv", + "tmdbId": 249522, + "tvdbId": 447806, + "imdbId": null, + "status": 4, + "status4k": 1, + "createdAt": "2024-12-26T14:37:30.000Z", + "updatedAt": "2024-12-26T14:45:00.000Z", + "lastSeasonChange": "2024-12-26T14:37:30.000Z", + "mediaAddedAt": "2024-12-26T14:39:56.000Z", + "serviceId": 0, + "serviceId4k": null, + "externalServiceId": 144, + "externalServiceId4k": null, + "externalServiceSlug": "beast-games", + "externalServiceSlug4k": null, + "ratingKey": "10189", + "ratingKey4k": null, + "requests": [ + { + "id": 14, + "status": 2, + "createdAt": "2024-12-26T14:37:30.000Z", + "updatedAt": "2024-12-26T14:37:30.000Z", + "type": "tv", + "is4k": false, + "serverId": 0, + "profileId": 7, + "rootFolder": "/media/tv", + "languageProfileId": 1, + "tags": [], + "isAutoRequest": false, + "media": { + "downloadStatus": [], + "downloadStatus4k": [], + "id": 535, + "mediaType": "tv", + "tmdbId": 249522, + "tvdbId": 447806, + "imdbId": null, + "status": 4, + "status4k": 1, + "createdAt": "2024-12-26T14:37:30.000Z", + "updatedAt": "2024-12-26T14:45:00.000Z", + "lastSeasonChange": "2024-12-26T14:37:30.000Z", + "mediaAddedAt": "2024-12-26T14:39:56.000Z", + "serviceId": 0, + "serviceId4k": null, + "externalServiceId": 144, + "externalServiceId4k": null, + "externalServiceSlug": "beast-games", + "externalServiceSlug4k": null, + "ratingKey": "10189", + "ratingKey4k": null, + "plexUrl": "https://app.plex.tv/desktop#!/server/aaaaaaa/details?key=%2Flibrary%2Fmetadata%2F10189", + "iOSPlexUrl": "plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F10189&server=aaaaaaa", + "serviceUrl": "http://192.168.0.2:8989/series/beast-games" + }, + "requestedBy": { + "permissions": 2, + "id": 1, + "email": "me@email.com", + "plexUsername": "somebody", + "username": null, + "recoveryLinkExpirationDate": null, + "userType": 1, + "plexId": 123123, + "avatar": "https://plex.tv/users/123123/avatar?c=123123", + "movieQuotaLimit": null, + "movieQuotaDays": null, + "tvQuotaLimit": null, + "tvQuotaDays": null, + "createdAt": "2024-12-16T21:13:58.000Z", + "updatedAt": "2024-12-16T23:59:03.000Z", + "requestCount": 11, + "displayName": "somebody" + }, + "modifiedBy": { + "permissions": 2, + "id": 1, + "email": "me@email.com", + "plexUsername": "somebody", + "username": null, + "recoveryLinkExpirationDate": null, + "userType": 1, + "plexId": 123123, + "avatar": "https://plex.tv/users/123123/avatar?c=123123", + "movieQuotaLimit": null, + "movieQuotaDays": null, + "tvQuotaLimit": null, + "tvQuotaDays": null, + "createdAt": "2024-12-16T21:13:58.000Z", + "updatedAt": "2024-12-16T23:59:03.000Z", + "requestCount": 11, + "displayName": "somebody" + }, + "seasons": [ + { + "id": 4, + "seasonNumber": 1, + "status": 2, + "createdAt": "2024-12-26T14:37:30.000Z", + "updatedAt": "2024-12-26T14:37:30.000Z" + } + ], + "seasonCount": 1 + } + ], + "issues": [ + { + "id": 1, + "issueType": 2, + "status": 1, + "problemSeason": 1, + "problemEpisode": 0, + "createdAt": "2024-12-28T14:22:29.000Z", + "updatedAt": "2024-12-28T14:22:54.000Z", + "media": { + "downloadStatus": [], + "downloadStatus4k": [], + "id": 535, + "mediaType": "tv", + "tmdbId": 249522, + "tvdbId": 447806, + "imdbId": null, + "status": 4, + "status4k": 1, + "createdAt": "2024-12-26T14:37:30.000Z", + "updatedAt": "2024-12-26T14:45:00.000Z", + "lastSeasonChange": "2024-12-26T14:37:30.000Z", + "mediaAddedAt": "2024-12-26T14:39:56.000Z", + "serviceId": 0, + "serviceId4k": null, + "externalServiceId": 144, + "externalServiceId4k": null, + "externalServiceSlug": "beast-games", + "externalServiceSlug4k": null, + "ratingKey": "10189", + "ratingKey4k": null, + "plexUrl": "https://app.plex.tv/desktop#!/server/aaaaaaa/details?key=%2Flibrary%2Fmetadata%2F10189", + "iOSPlexUrl": "plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F10189&server=aaaaaaa", + "serviceUrl": "http://192.168.0.2:8989/series/beast-games" + }, + "createdBy": { + "permissions": 2, + "id": 1, + "email": "me@email.com", + "plexUsername": "somebody", + "username": null, + "recoveryLinkExpirationDate": null, + "userType": 1, + "plexId": 123123, + "avatar": "https://plex.tv/users/123123/avatar?c=123123", + "movieQuotaLimit": null, + "movieQuotaDays": null, + "tvQuotaLimit": null, + "tvQuotaDays": null, + "createdAt": "2024-12-16T21:13:58.000Z", + "updatedAt": "2024-12-16T23:59:03.000Z", + "requestCount": 11, + "displayName": "somebody" + }, + "modifiedBy": null, + "comments": [ + { + "id": 2, + "message": "boop", + "createdAt": "2024-12-28T14:22:54.000Z", + "updatedAt": "2024-12-28T14:22:54.000Z" + }, + { + "id": 1, + "message": "test", + "createdAt": "2024-12-28T14:22:29.000Z", + "updatedAt": "2024-12-28T14:22:29.000Z" + } + ] + } + ], + "seasons": [ + { + "id": 577, + "seasonNumber": 1, + "status": 4, + "status4k": 1, + "createdAt": "2024-12-26T14:45:00.000Z", + "updatedAt": "2024-12-26T14:45:00.000Z" + } + ], + "plexUrl": "https://app.plex.tv/desktop#!/server/aaaaaaa/details?key=%2Flibrary%2Fmetadata%2F10189", + "iOSPlexUrl": "plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F10189&server=aaaaaaa", + "serviceUrl": "http://192.168.0.2:8989/series/beast-games" + }, + "watchProviders": [ + { + "iso_3166_1": "AD", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=AD", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "AE", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=AE", + "buy": [], + "flatrate": [ + { + "displayPriority": 9, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "AG", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=AG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "AL", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=AL", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "AR", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=AR", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "AT", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=AT", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 9, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 65, + "logoPath": "/8aBqoNeGGr0oSA85iopgNZUOTOc.jpg", + "id": 2100, + "name": "Amazon Prime Video with Ads" + } + ] + }, + { + "iso_3166_1": "AU", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=AU", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BA", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=BA", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BB", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=BB", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BE", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=BE", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BG", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=BG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BH", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=BH", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BM", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=BM", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BO", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=BO", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BR", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=BR", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "BS", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=BS", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CA", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=CA", + "buy": [], + "flatrate": [ + { + "displayPriority": 3, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 152, + "logoPath": "/8aBqoNeGGr0oSA85iopgNZUOTOc.jpg", + "id": 2100, + "name": "Amazon Prime Video with Ads" + } + ] + }, + { + "iso_3166_1": "CH", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=CH", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CI", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=CI", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CL", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=CL", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CO", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=CO", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CR", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=CR", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "CZ", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=CZ", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "DE", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=DE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 9, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 157, + "logoPath": "/8aBqoNeGGr0oSA85iopgNZUOTOc.jpg", + "id": 2100, + "name": "Amazon Prime Video with Ads" + } + ] + }, + { + "iso_3166_1": "DK", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=DK", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "DO", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=DO", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "DZ", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=DZ", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "EC", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=EC", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "EE", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=EE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "EG", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=EG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "ES", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=ES", + "buy": [], + "flatrate": [ + { + "displayPriority": 3, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "FI", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=FI", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "FR", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=FR", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GB", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=GB", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 9, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 136, + "logoPath": "/8aBqoNeGGr0oSA85iopgNZUOTOc.jpg", + "id": 2100, + "name": "Amazon Prime Video with Ads" + } + ] + }, + { + "iso_3166_1": "GF", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=GF", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GG", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=GG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GH", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=GH", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GI", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=GI", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GR", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=GR", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "GT", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=GT", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "HK", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=HK", + "buy": [], + "flatrate": [ + { + "displayPriority": 5, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "HN", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=HN", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "HR", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=HR", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "HU", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=HU", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "ID", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=ID", + "buy": [], + "flatrate": [ + { + "displayPriority": 7, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IE", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=IE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IL", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=IL", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IN", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=IN", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IQ", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=IQ", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IS", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=IS", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "IT", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=IT", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 15, + "logoPath": "/6FDKQWcR6JfmRKLqezSsvGgRuUY.jpg", + "id": 109, + "name": "Timvision" + } + ] + }, + { + "iso_3166_1": "JM", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=JM", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "JO", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=JO", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "JP", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=JP", + "buy": [], + "flatrate": [ + { + "displayPriority": 4, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 9, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "KE", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=KE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "KR", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=KR", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "KW", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=KW", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LB", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=LB", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LC", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=LC", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LI", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=LI", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LT", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=LT", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LV", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=LV", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "LY", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=LY", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MA", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=MA", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MC", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=MC", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MD", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=MD", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MK", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=MK", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MT", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=MT", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MX", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=MX", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MY", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=MY", + "buy": [], + "flatrate": [ + { + "displayPriority": 9, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "MZ", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=MZ", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "NE", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=NE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "NG", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=NG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "NL", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=NL", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "NO", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=NO", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "NZ", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=NZ", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "OM", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=OM", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PA", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=PA", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PE", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=PE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PF", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=PF", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PH", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=PH", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PK", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=PK", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PL", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=PL", + "buy": [], + "flatrate": [ + { + "displayPriority": 5, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PS", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=PS", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PT", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=PT", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "PY", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=PY", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "QA", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=QA", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "RO", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=RO", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "RS", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=RS", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SA", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=SA", + "buy": [], + "flatrate": [ + { + "displayPriority": 23, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SE", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=SE", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SG", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=SG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SI", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=SI", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SK", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=SK", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SM", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=SM", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "SV", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=SV", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TC", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=TC", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TH", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=TH", + "buy": [], + "flatrate": [ + { + "displayPriority": 3, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TN", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=TN", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TR", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=TR", + "buy": [], + "flatrate": [ + { + "displayPriority": 6, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TT", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=TT", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "TW", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=TW", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "UG", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=UG", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "US", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=US", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 9, + "name": "Amazon Prime Video" + }, + { + "displayPriority": 258, + "logoPath": "/8aBqoNeGGr0oSA85iopgNZUOTOc.jpg", + "id": 2100, + "name": "Amazon Prime Video with Ads" + } + ] + }, + { + "iso_3166_1": "UY", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=UY", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "VA", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=VA", + "buy": [], + "flatrate": [ + { + "displayPriority": 0, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "VE", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=VE", + "buy": [], + "flatrate": [ + { + "displayPriority": 2, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "YE", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=YE", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "ZA", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=ZA", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + }, + { + "iso_3166_1": "ZM", + "link": "https://www.themoviedb.org/tv/249522-beast-games/watch?locale=ZM", + "buy": [], + "flatrate": [ + { + "displayPriority": 1, + "logoPath": "/pvske1MyAoymrs5bguRfVqYiM9a.jpg", + "id": 119, + "name": "Amazon Prime Video" + } + ] + } + ] +} diff --git a/tests/components/overseerr/fixtures/webhook_config.json b/tests/components/overseerr/fixtures/webhook_config.json new file mode 100644 index 00000000000..40028e1f80f --- /dev/null +++ b/tests/components/overseerr/fixtures/webhook_config.json @@ -0,0 +1,8 @@ +{ + "enabled": true, + "types": 222, + "options": { + "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_idd\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}", + "webhookUrl": "http://10.10.10.10:8123/api/webhook/test-webhook-id" + } +} diff --git a/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json new file mode 100644 index 00000000000..75059bcaf96 --- /dev/null +++ b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json @@ -0,0 +1,23 @@ +{ + "notification_type": "MEDIA_AUTO_APPROVED", + "subject": "Something (2024)", + "message": "Here is an interesting Linux ISO that was automatically approved.", + "image": "https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg", + "media": { + "media_type": "movie", + "tmdb_id": "123", + "tvdb_id": "", + "status": "PENDING", + "status4k": "UNKNOWN" + }, + "request": { + "request_id": "16", + "requested_by_email": "my@email.com", + "requested_by_username": "henk", + "requested_by_avatar": "https://plex.tv/users/abc/avatar?c=123", + "requested_by_settings_discord_id": "123", + "requested_by_settings_telegram_chat_id": "" + }, + "issue": null, + "comment": null +} diff --git a/tests/components/overseerr/fixtures/webhook_test_notification.json b/tests/components/overseerr/fixtures/webhook_test_notification.json new file mode 100644 index 00000000000..4bae2ed742c --- /dev/null +++ b/tests/components/overseerr/fixtures/webhook_test_notification.json @@ -0,0 +1,12 @@ +{ + "notification_type": "TEST_NOTIFICATION", + "event": "", + "subject": "Test Notification", + "message": "Check check, 1, 2, 3. Are we coming in clear?", + "image": "", + "media": null, + "request": null, + "issue": null, + "comment": null, + "extra": [] +} diff --git a/tests/components/overseerr/snapshots/test_diagnostics.ambr b/tests/components/overseerr/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..164257bb9f1 --- /dev/null +++ b/tests/components/overseerr/snapshots/test_diagnostics.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_diagnostics_polling_instance + dict({ + 'coordinator_data': dict({ + 'approved': 11, + 'available': 8, + 'declined': 0, + 'movie': 9, + 'pending': 0, + 'processing': 3, + 'total': 11, + 'tv': 2, + }), + 'has_cloudhooks': False, + }) +# --- +# name: test_diagnostics_webhook_instance + dict({ + 'coordinator_data': dict({ + 'approved': 11, + 'available': 8, + 'declined': 0, + 'movie': 9, + 'pending': 0, + 'processing': 3, + 'total': 11, + 'tv': 2, + }), + 'has_cloudhooks': True, + }) +# --- diff --git a/tests/components/overseerr/snapshots/test_event.ambr b/tests/components/overseerr/snapshots/test_event.ambr new file mode 100644 index 00000000000..1002bc4cdad --- /dev/null +++ b/tests/components/overseerr/snapshots/test_event.ambr @@ -0,0 +1,83 @@ +# serializer version: 1 +# name: test_entities[event.overseerr_last_media_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pending', + 'approved', + 'available', + 'failed', + 'declined', + 'auto_approved', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.overseerr_last_media_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Last media event', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_media_event', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[event.overseerr_last_media_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg', + 'event_type': 'auto_approved', + 'event_types': list([ + 'pending', + 'approved', + 'available', + 'failed', + 'declined', + 'auto_approved', + ]), + 'friendly_name': 'Overseerr Last media event', + 'media': dict({ + 'media_type': 'movie', + 'status': 'pending', + 'status4k': 'unknown', + 'tmdb_id': 123, + 'tvdb_id': None, + }), + 'message': 'Here is an interesting Linux ISO that was automatically approved.', + 'request': dict({ + 'request_id': 16, + 'requested_by_avatar': 'https://plex.tv/users/abc/avatar?c=123', + 'requested_by_email': 'my@email.com', + 'requested_by_settings_discord_id': '123', + 'requested_by_settings_telegram_chat_id': '', + 'requested_by_username': 'henk', + }), + 'subject': 'Something (2024)', + }), + 'context': , + 'entity_id': 'event.overseerr_last_media_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:00.000+00:00', + }) +# --- diff --git a/tests/components/overseerr/snapshots/test_init.ambr b/tests/components/overseerr/snapshots/test_init.ambr new file mode 100644 index 00000000000..21b4b215ac5 --- /dev/null +++ b/tests/components/overseerr/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://overseerr.test', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'overseerr', + '01JG00V55WEVTJ0CJHM0GAD7PC', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Overseerr', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/overseerr/snapshots/test_sensor.ambr b/tests/components/overseerr/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..53a9b3dd82a --- /dev/null +++ b/tests/components/overseerr/snapshots/test_sensor.ambr @@ -0,0 +1,351 @@ +# serializer version: 1 +# name: test_all_entities[sensor.overseerr_available_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_available_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Available requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'available_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-available_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_available_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Available requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_available_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[sensor.overseerr_declined_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_declined_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Declined requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'declined_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-declined_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_declined_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Declined requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_declined_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.overseerr_movie_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_movie_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Movie requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'movie_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-movie_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_movie_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Movie requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_movie_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9', + }) +# --- +# name: test_all_entities[sensor.overseerr_pending_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_pending_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pending requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pending_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-pending_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_pending_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Pending requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_pending_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.overseerr_processing_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_processing_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Processing requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'processing_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-processing_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_processing_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Processing requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_processing_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[sensor.overseerr_total_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_total_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-total_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_total_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr Total requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_total_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11', + }) +# --- +# name: test_all_entities[sensor.overseerr_tv_requests-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overseerr_tv_requests', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TV requests', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tv_requests', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-tv_requests', + 'unit_of_measurement': 'requests', + }) +# --- +# name: test_all_entities[sensor.overseerr_tv_requests-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Overseerr TV requests', + 'state_class': , + 'unit_of_measurement': 'requests', + }), + 'context': , + 'entity_id': 'sensor.overseerr_tv_requests', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/overseerr/snapshots/test_services.ambr b/tests/components/overseerr/snapshots/test_services.ambr new file mode 100644 index 00000000000..5a0b0ce6586 --- /dev/null +++ b/tests/components/overseerr/snapshots/test_services.ambr @@ -0,0 +1,204 @@ +# serializer version: 1 +# name: test_service_get_requests + dict({ + 'requests': list([ + dict({ + 'created_at': datetime.datetime(2024, 12, 29, 10, 4, 16, tzinfo=datetime.timezone.utc), + 'id': 16, + 'is4k': False, + 'media': dict({ + 'adult': False, + 'budget': 0, + 'genres': list([ + dict({ + 'id': 10749, + 'name': 'Romance', + }), + dict({ + 'id': 18, + 'name': 'Drama', + }), + ]), + 'id': 1156593, + 'imdb_id': 'tt28510079', + 'keywords': list([ + dict({ + 'id': 818, + 'name': 'based on novel or book', + }), + dict({ + 'id': 9663, + 'name': 'sequel', + }), + ]), + 'media_info': dict({ + 'created_at': datetime.datetime(2024, 12, 29, 10, 4, 16, tzinfo=datetime.timezone.utc), + 'id': 537, + 'imdb_id': None, + 'media_type': , + 'status': , + 'tmdb_id': 1156593, + 'tvdb_id': None, + 'updated_at': datetime.datetime(2024, 12, 29, 10, 4, 17, tzinfo=datetime.timezone.utc), + }), + 'original_language': 'es', + 'original_title': 'Culpa tuya', + 'overview': "The love between Noah and Nick seems unwavering despite their parents' attempts to separate them. But his job and her entry into college open up their lives to new relationships that will shake the foundations of both their relationship and the Leister family itself.", + 'popularity': 3958.479, + 'release_date': datetime.date(2024, 12, 26), + 'revenue': 0, + 'runtime': 120, + 'tagline': 'Divided by family. Driven by love.', + 'title': 'Your Fault', + 'vote_average': 7.7, + 'vote_count': 190, + }), + 'modified_by': dict({ + 'avatar': 'https://plex.tv/users/aaaaa/avatar?c=aaaaa', + 'created_at': datetime.datetime(2024, 12, 16, 21, 13, 58, tzinfo=datetime.timezone.utc), + 'display_name': 'somebody', + 'email': 'one@email.com', + 'id': 1, + 'movie_quota_days': None, + 'movie_quota_limit': None, + 'plex_id': 321321321, + 'plex_username': 'somebody', + 'request_count': 11, + 'tv_quota_days': None, + 'tv_quota_limit': None, + 'updated_at': datetime.datetime(2024, 12, 16, 23, 59, 3, tzinfo=datetime.timezone.utc), + }), + 'requested_by': dict({ + 'avatar': 'https://plex.tv/users/aaaaa/avatar?c=aaaaa', + 'created_at': datetime.datetime(2024, 12, 16, 21, 13, 58, tzinfo=datetime.timezone.utc), + 'display_name': 'somebody', + 'email': 'one@email.com', + 'id': 1, + 'movie_quota_days': None, + 'movie_quota_limit': None, + 'plex_id': 321321321, + 'plex_username': 'somebody', + 'request_count': 11, + 'tv_quota_days': None, + 'tv_quota_limit': None, + 'updated_at': datetime.datetime(2024, 12, 16, 23, 59, 3, tzinfo=datetime.timezone.utc), + }), + 'season_count': 0, + 'status': , + 'updated_at': datetime.datetime(2024, 12, 29, 10, 4, 16, tzinfo=datetime.timezone.utc), + }), + dict({ + 'created_at': datetime.datetime(2024, 12, 26, 14, 37, 30, tzinfo=datetime.timezone.utc), + 'id': 14, + 'is4k': False, + 'media': dict({ + 'first_air_date': datetime.date(2024, 12, 19), + 'genres': list([ + dict({ + 'id': 10764, + 'name': 'Reality', + }), + ]), + 'id': 249522, + 'keywords': list([ + dict({ + 'id': 271, + 'name': 'competition', + }), + dict({ + 'id': 4325, + 'name': 'game show', + }), + dict({ + 'id': 330122, + 'name': 'mrbeast', + }), + ]), + 'languages': list([ + 'da', + 'en', + ]), + 'last_air_date': datetime.date(2024, 12, 26), + 'last_episode_to_air': dict({ + 'air_date': datetime.date(2024, 12, 26), + 'episode_number': 3, + 'id': 5802152, + 'name': 'The Solitary Experiment', + 'overview': 'What would happen if three best friends were trapped in a room, but only two could escape? Watch and see for yourself right now!', + 'still_path': '/r6LRRaA2l2tMDttWbYl3dXdJUij.jpg', + }), + 'media_info': dict({ + 'created_at': datetime.datetime(2024, 12, 26, 14, 37, 30, tzinfo=datetime.timezone.utc), + 'id': 535, + 'imdb_id': None, + 'media_type': , + 'status': , + 'tmdb_id': 249522, + 'tvdb_id': 447806, + 'updated_at': datetime.datetime(2024, 12, 26, 14, 45, tzinfo=datetime.timezone.utc), + }), + 'name': 'Beast Games', + 'next_episode_to_air': dict({ + 'air_date': datetime.date(2025, 1, 2), + 'episode_number': 4, + 'id': 5802153, + 'name': 'Episode 4', + 'overview': '', + 'still_path': 'None', + }), + 'number_of_episodes': 10, + 'number_of_seasons': 1, + 'original_language': 'en', + 'original_name': 'Beast Games', + 'overview': "I gathered 1,000 people to fight for $5,000,000, the LARGEST cash prize in TV history! We're also giving away a private island, Lamborghinis, and millions more in cash throughout the competition! Go watch to see the greatest show ever made!", + 'popularity': 769.189, + 'seasons': list([ + dict({ + 'air_date': datetime.date(2024, 12, 19), + 'episode_count': 10, + 'id': 384427, + 'name': 'Season 1', + 'overview': '', + 'poster_path': '/3itZlypnOcVcqI5xxyO6nvJ52yM.jpg', + 'season_number': 1, + }), + ]), + 'tagline': '1,000 players. 5 million dollars. 1 winner.', + }), + 'modified_by': dict({ + 'avatar': 'https://plex.tv/users/aaaaa/avatar?c=aaaaa', + 'created_at': datetime.datetime(2024, 12, 16, 21, 13, 58, tzinfo=datetime.timezone.utc), + 'display_name': 'somebody', + 'email': 'one@email.com', + 'id': 1, + 'movie_quota_days': None, + 'movie_quota_limit': None, + 'plex_id': 321321321, + 'plex_username': 'somebody', + 'request_count': 11, + 'tv_quota_days': None, + 'tv_quota_limit': None, + 'updated_at': datetime.datetime(2024, 12, 16, 23, 59, 3, tzinfo=datetime.timezone.utc), + }), + 'requested_by': dict({ + 'avatar': 'https://plex.tv/users/aaaaa/avatar?c=aaaaa', + 'created_at': datetime.datetime(2024, 12, 16, 21, 13, 58, tzinfo=datetime.timezone.utc), + 'display_name': 'somebody', + 'email': 'one@email.com', + 'id': 1, + 'movie_quota_days': None, + 'movie_quota_limit': None, + 'plex_id': 321321321, + 'plex_username': 'somebody', + 'request_count': 11, + 'tv_quota_days': None, + 'tv_quota_limit': None, + 'updated_at': datetime.datetime(2024, 12, 16, 23, 59, 3, tzinfo=datetime.timezone.utc), + }), + 'season_count': 1, + 'status': , + 'updated_at': datetime.datetime(2024, 12, 26, 14, 37, 30, tzinfo=datetime.timezone.utc), + }), + ]), + }) +# --- diff --git a/tests/components/overseerr/test_config_flow.py b/tests/components/overseerr/test_config_flow.py new file mode 100644 index 00000000000..6a3b086a8e2 --- /dev/null +++ b/tests/components/overseerr/test_config_flow.py @@ -0,0 +1,302 @@ +"""Tests for the Overseerr config flow.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from python_overseerr.exceptions import ( + OverseerrAuthenticationError, + OverseerrConnectionError, +) + +from homeassistant.components.overseerr.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_WEBHOOK_ID, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import WEBHOOK_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def patch_webhook_id() -> None: + """Patch webhook ID generation.""" + with patch( + "homeassistant.components.overseerr.config_flow.async_generate_id", + return_value=WEBHOOK_ID, + ): + yield + + +async def test_full_flow( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr.test", CONF_API_KEY: "test-key"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Overseerr" + assert result["data"] == { + CONF_HOST: "overseerr.test", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "test-key", + CONF_WEBHOOK_ID: "test-webhook-id", + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OverseerrAuthenticationError, "invalid_auth"), + (OverseerrConnectionError, "cannot_connect"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow errors.""" + mock_overseerr_client.get_request_count.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr.test", CONF_API_KEY: "test-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_overseerr_client.get_request_count.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr.test", CONF_API_KEY: "test-key"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_flow_invalid_host( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow invalid host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://", CONF_API_KEY: "test-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"url": "invalid_host"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr.test", CONF_API_KEY: "test-key"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr.test", CONF_API_KEY: "test-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new-test-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_API_KEY] == "new-test-key" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OverseerrAuthenticationError, "invalid_auth"), + (OverseerrConnectionError, "cannot_connect"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_overseerr_client.get_request_count.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new-test-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_overseerr_client.get_request_count.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new-test-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_API_KEY] == "new-test-key" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr2.test", CONF_API_KEY: "new-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "overseerr2.test", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "new-key", + CONF_WEBHOOK_ID: WEBHOOK_ID, + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OverseerrAuthenticationError, "invalid_auth"), + (OverseerrConnectionError, "cannot_connect"), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reconfigure flow errors.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_overseerr_client.get_request_count.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr2.test", CONF_API_KEY: "new-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_overseerr_client.get_request_count.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr2.test", CONF_API_KEY: "new-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/overseerr/test_diagnostics.py b/tests/components/overseerr/test_diagnostics.py new file mode 100644 index 00000000000..28b97e9514f --- /dev/null +++ b/tests/components/overseerr/test_diagnostics.py @@ -0,0 +1,47 @@ +"""Tests for the diagnostics data provided by the Overseerr integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics_polling_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +async def test_diagnostics_webhook_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_overseerr_client_cloudhook: AsyncMock, + mock_cloudhook_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_cloudhook_config_entry) + + assert ( + await get_diagnostics_for_config_entry( + hass, hass_client, mock_cloudhook_config_entry + ) + == snapshot + ) diff --git a/tests/components/overseerr/test_event.py b/tests/components/overseerr/test_event.py new file mode 100644 index 00000000000..3866ccc09ca --- /dev/null +++ b/tests/components/overseerr/test_event.py @@ -0,0 +1,170 @@ +"""Tests for the Overseerr event platform.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from future.backports.datetime import timedelta +import pytest +from python_overseerr import OverseerrConnectionError +from syrupy import SnapshotAssertion + +from homeassistant.components.overseerr import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_webhook, setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) +from tests.typing import ClientSessionGenerator + + +@pytest.mark.freeze_time("2023-10-21") +async def test_entities( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.overseerr.PLATFORMS", [Platform.EVENT]): + await setup_integration(hass, mock_config_entry) + + client = await hass_client_no_auth() + + await call_webhook( + hass, + load_json_object_fixture("webhook_request_automatically_approved.json", DOMAIN), + client, + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2023-10-21") +async def test_event_does_not_write_state( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test event entities don't write state on coordinator update.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_client_no_auth() + + await call_webhook( + hass, + load_json_object_fixture("webhook_request_automatically_approved.json", DOMAIN), + client, + ) + await hass.async_block_till_done() + + assert hass.states.get( + "event.overseerr_last_media_event" + ).last_reported == datetime(2023, 10, 21, 0, 0, 0, tzinfo=UTC) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get( + "event.overseerr_last_media_event" + ).last_reported == datetime(2023, 10, 21, 0, 0, 0, tzinfo=UTC) + + +async def test_event_goes_unavailable( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test event entities go unavailable when we can't fetch data.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("event.overseerr_last_media_event").state != STATE_UNAVAILABLE + ) + + mock_overseerr_client.get_request_count.side_effect = OverseerrConnectionError( + "Boom" + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("event.overseerr_last_media_event").state == STATE_UNAVAILABLE + ) + + +async def test_not_push_based( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test event entities aren't created if not push based.""" + + mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = ( + False + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.overseerr_last_media_event") is None + + +async def test_cant_fetch_webhook_config( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client: AsyncMock, +) -> None: + """Test event entities aren't created if not push based.""" + + mock_overseerr_client.get_webhook_notification_config.side_effect = ( + OverseerrConnectionError("Boom") + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.overseerr_last_media_event") is None + + +async def test_not_push_based_but_was_before( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test event entities are created if push based in the past.""" + + entity_registry.async_get_or_create( + Platform.EVENT, + DOMAIN, + f"{mock_config_entry.entry_id}-media", + suggested_object_id="overseerr_last_media_event", + disabled_by=None, + ) + + mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = ( + False + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.overseerr_last_media_event") is not None + + assert ( + hass.states.get("event.overseerr_last_media_event").state == STATE_UNAVAILABLE + ) diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py new file mode 100644 index 00000000000..6418e2103db --- /dev/null +++ b/tests/components/overseerr/test_init.py @@ -0,0 +1,412 @@ +"""Tests for the Overseerr integration.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from python_overseerr import OverseerrAuthenticationError, OverseerrConnectionError +from python_overseerr.models import WebhookNotificationOptions +from syrupy import SnapshotAssertion + +from homeassistant.components import cloud +from homeassistant.components.cloud import CloudNotAvailable +from homeassistant.components.overseerr import ( + CONF_CLOUDHOOK_URL, + JSON_PAYLOAD, + REGISTERED_NOTIFICATIONS, +) +from homeassistant.components.overseerr.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.cloud import mock_cloud + + +@pytest.mark.parametrize( + ("exception", "config_entry_state"), + [ + (OverseerrAuthenticationError, ConfigEntryState.SETUP_ERROR), + (OverseerrConnectionError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_initialization_errors( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test the Overseerr integration initialization errors.""" + mock_overseerr_client.get_request_count.side_effect = exception + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state == config_entry_state + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device_entry is not None + assert device_entry == snapshot + + +async def test_proper_webhook_configuration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client: AsyncMock, +) -> None: + """Test the webhook configuration.""" + await setup_integration(hass, mock_config_entry) + + assert REGISTERED_NOTIFICATIONS == 222 + + mock_overseerr_client.test_webhook_notification_config.assert_not_called() + mock_overseerr_client.set_webhook_notification_config.assert_not_called() + + +@pytest.mark.parametrize( + "update_mock", + [ + {"return_value.enabled": False}, + {"return_value.types": 4}, + {"return_value.types": 4062}, + { + "return_value.options": WebhookNotificationOptions( + webhook_url="http://example.com", json_payload=JSON_PAYLOAD + ) + }, + { + "return_value.options": WebhookNotificationOptions( + webhook_url="http://10.10.10.10:8123/api/webhook/test-webhook-id", + json_payload='"{\\"message\\": \\"{{title}}\\"}"', + ) + }, + ], + ids=[ + "Disabled", + "Smaller scope", + "Bigger scope", + "Webhook URL", + "JSON Payload", + ], +) +async def test_webhook_configuration_need_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client: AsyncMock, + update_mock: dict[str, Any], +) -> None: + """Test the webhook configuration.""" + mock_overseerr_client.get_webhook_notification_config.configure_mock(**update_mock) + + await setup_integration(hass, mock_config_entry) + + mock_overseerr_client.test_webhook_notification_config.assert_called_once() + mock_overseerr_client.set_webhook_notification_config.assert_called_once() + + +@pytest.mark.parametrize( + "update_mock", + [ + {"return_value.enabled": False}, + {"return_value.types": 4}, + {"return_value.types": 4062}, + { + "return_value.options": WebhookNotificationOptions( + webhook_url="http://example.com", json_payload=JSON_PAYLOAD + ) + }, + { + "return_value.options": WebhookNotificationOptions( + webhook_url="http://10.10.10.10:8123/api/webhook/test-webhook-id", + json_payload='"{\\"message\\": \\"{{title}}\\"}"', + ) + }, + ], + ids=[ + "Disabled", + "Smaller scope", + "Bigger scope", + "Webhook URL", + "JSON Payload", + ], +) +async def test_webhook_failing_test( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client: AsyncMock, + update_mock: dict[str, Any], +) -> None: + """Test the webhook configuration.""" + mock_overseerr_client.test_webhook_notification_config.return_value = False + mock_overseerr_client.get_webhook_notification_config.configure_mock(**update_mock) + + await setup_integration(hass, mock_config_entry) + + mock_overseerr_client.test_webhook_notification_config.assert_called_once() + mock_overseerr_client.set_webhook_notification_config.assert_not_called() + + +async def test_prefer_internal_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client: AsyncMock, +) -> None: + """Test the integration prefers internal IP.""" + mock_overseerr_client.test_webhook_notification_config.return_value = False + hass.config.internal_url = "http://192.168.0.123:8123" + hass.config.external_url = "https://www.example.com" + await hass.async_block_till_done(wait_background_tasks=True) + await setup_integration(hass, mock_config_entry) + + assert ( + mock_overseerr_client.test_webhook_notification_config.call_args_list[0][0][0] + == "http://192.168.0.123:8123/api/webhook/test-webhook-id" + ) + assert ( + mock_overseerr_client.test_webhook_notification_config.call_args_list[1][0][0] + == "https://www.example.com/api/webhook/test-webhook-id" + ) + + +async def test_cloudhook_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if set up with active cloud subscription and cloud hook.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [ + False, + True, + ] + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, + ): + await setup_integration(hass, mock_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_config_entry.data[CONF_CLOUDHOOK_URL] == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_called() + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_cloudhook_consistent( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if we keep the cloudhook if it is already set up.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [ + False, + True, + ] + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL] + == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() + + +async def test_cloudhook_needs_no_change( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_cloudhook: AsyncMock, +) -> None: + """Test if we keep the cloudhook if it is already set up.""" + + await setup_integration(hass, mock_cloudhook_config_entry) + + assert ( + len(mock_overseerr_client_cloudhook.test_webhook_notification_config.mock_calls) + == 0 + ) + + +async def test_cloudhook_not_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if we prefer local webhook over cloudhook.""" + + await hass.async_block_till_done() + + with ( + patch.object(cloud, "async_active_subscription", return_value=True), + ): + await setup_integration(hass, mock_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert CONF_CLOUDHOOK_URL not in mock_config_entry.data + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 1 + ) + assert ( + mock_overseerr_client_needs_change.test_webhook_notification_config.call_args_list[ + 0 + ][0][0] + == "http://10.10.10.10:8123/api/webhook/test-webhook-id" + ) + + +async def test_cloudhook_not_connecting( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test the cloudhook is not registered if Overseerr cannot connect to it.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = ( + False + ) + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL] + == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 3 + ) + + mock_overseerr_client_needs_change.set_webhook_notification_config.assert_not_called() + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() + + +async def test_removing_entry_with_cloud_unavailable( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client: AsyncMock, +) -> None: + """Test handling cloud unavailable when deleting entry.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), + patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook", + side_effect=CloudNotAvailable(), + ), + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/overseerr/test_sensor.py b/tests/components/overseerr/test_sensor.py new file mode 100644 index 00000000000..6689b1ebcc3 --- /dev/null +++ b/tests/components/overseerr/test_sensor.py @@ -0,0 +1,53 @@ +"""Tests for the Overseerr sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.overseerr import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_webhook, setup_integration + +from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform +from tests.typing import ClientSessionGenerator + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.overseerr.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_webhook_trigger_update( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.overseerr_available_requests").state == "8" + + mock_overseerr_client.get_request_count.return_value.available = 7 + client = await hass_client_no_auth() + + await call_webhook( + hass, + load_json_object_fixture("webhook_request_automatically_approved.json", DOMAIN), + client, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.overseerr_available_requests").state == "7" diff --git a/tests/components/overseerr/test_services.py b/tests/components/overseerr/test_services.py new file mode 100644 index 00000000000..a0b87b5deef --- /dev/null +++ b/tests/components/overseerr/test_services.py @@ -0,0 +1,156 @@ +"""Tests for the Overseerr services.""" + +from unittest.mock import AsyncMock + +import pytest +from python_overseerr import OverseerrConnectionError +from syrupy import SnapshotAssertion + +from homeassistant.components.overseerr.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_REQUESTED_BY, + ATTR_SORT_ORDER, + ATTR_STATUS, + DOMAIN, +) +from homeassistant.components.overseerr.services import SERVICE_GET_REQUESTS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_service_get_requests( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the get_requests service.""" + + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_REQUESTS, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_STATUS: "approved", + ATTR_SORT_ORDER: "added", + ATTR_REQUESTED_BY: 1, + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + for request in response["requests"]: + assert "requests" not in request["media"]["media_info"] + mock_overseerr_client.get_requests.assert_called_once_with( + status="approved", sort="added", requested_by=1 + ) + + +async def test_service_get_requests_no_meta( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the get_requests service.""" + mock_overseerr_client.get_movie_details.side_effect = OverseerrConnectionError + mock_overseerr_client.get_tv_details.side_effect = OverseerrConnectionError + + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_REQUESTS, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id}, + blocking=True, + return_response=True, + ) + for request in response["requests"]: + assert request["media"] == {} + + +@pytest.mark.parametrize( + ("service", "payload", "function", "exception", "raised_exception", "message"), + [ + ( + SERVICE_GET_REQUESTS, + {}, + "get_requests", + OverseerrConnectionError("Timeout"), + HomeAssistantError, + "Error connecting to the Overseerr instance: Timeout", + ) + ], +) +async def test_services_connection_error( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + payload: dict[str, str], + function: str, + exception: Exception, + raised_exception: type[Exception], + message: str, +) -> None: + """Test a connection error in the services.""" + + await setup_integration(hass, mock_config_entry) + + getattr(mock_overseerr_client, function).side_effect = exception + + with pytest.raises(raised_exception, match=message): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize( + ("service", "payload"), + [ + (SERVICE_GET_REQUESTS, {}), + ], +) +async def test_service_entry_availability( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + payload: dict[str, str], +) -> None: + """Test the services without valid entry.""" + mock_config_entry.add_to_hass(hass) + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id} | payload, + blocking=True, + return_response=True, + ) + + with pytest.raises( + ServiceValidationError, match='Integration "overseerr" not found in registry' + ): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id"} | payload, + blocking=True, + return_response=True, + ) diff --git a/tests/components/palazzetti/conftest.py b/tests/components/palazzetti/conftest.py index ec58afc324a..d3694653cd4 100644 --- a/tests/components/palazzetti/conftest.py +++ b/tests/components/palazzetti/conftest.py @@ -66,6 +66,7 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.has_on_off_switch = True mock_client.has_pellet_level = False mock_client.connected = True + mock_client.status = 6 mock_client.is_heating = True mock_client.room_temperature = 18 mock_client.T1 = 21.5 @@ -78,7 +79,12 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.target_temperature_max = 50 mock_client.pellet_quantity = 1248 mock_client.pellet_level = 0 + mock_client.has_second_fan = True + mock_client.has_second_fan = False mock_client.fan_speed = 3 + mock_client.current_fan_speed.return_value = 3 + mock_client.min_fan_speed.return_value = 0 + mock_client.max_fan_speed.return_value = 5 mock_client.connect.return_value = True mock_client.update_state.return_value = True mock_client.set_on.return_value = True @@ -87,6 +93,8 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.set_fan_silent.return_value = True mock_client.set_fan_high.return_value = True mock_client.set_fan_auto.return_value = True + mock_client.set_power_mode.return_value = True + mock_client.power_mode = 3 mock_client.list_temperatures.return_value = [ TemperatureDefinition( description_key=TemperatureDescriptionKey.ROOM_TEMP, diff --git a/tests/components/palazzetti/snapshots/test_button.ambr b/tests/components/palazzetti/snapshots/test_button.ambr new file mode 100644 index 00000000000..6827c9a1f22 --- /dev/null +++ b/tests/components/palazzetti/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_all_entities[button.stove_silent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.stove_silent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Silent', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'silent', + 'unique_id': '11:22:33:44:55:66-silent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.stove_silent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Stove Silent', + }), + 'context': , + 'entity_id': 'button.stove_silent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/palazzetti/snapshots/test_climate.ambr b/tests/components/palazzetti/snapshots/test_climate.ambr index e7cea3749a1..aa637039df9 100644 --- a/tests/components/palazzetti/snapshots/test_climate.ambr +++ b/tests/components/palazzetti/snapshots/test_climate.ambr @@ -6,7 +6,6 @@ 'area_id': None, 'capabilities': dict({ 'fan_modes': list([ - 'silent', '1', '2', '3', @@ -56,7 +55,6 @@ 'current_temperature': 18, 'fan_mode': '3', 'fan_modes': list([ - 'silent', '1', '2', '3', diff --git a/tests/components/palazzetti/snapshots/test_number.ambr b/tests/components/palazzetti/snapshots/test_number.ambr new file mode 100644 index 00000000000..7ace1149e0a --- /dev/null +++ b/tests/components/palazzetti/snapshots/test_number.ambr @@ -0,0 +1,169 @@ +# serializer version: 1 +# name: test_all_entities[number.stove_combustion_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.stove_combustion_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Combustion power', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'combustion_power', + 'unique_id': '11:22:33:44:55:66-combustion_power', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.stove_combustion_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Stove Combustion power', + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.stove_combustion_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[number.stove_left_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.stove_left_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Left fan speed', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_left_speed', + 'unique_id': '11:22:33:44:55:66-fan_left_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.stove_left_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Stove Left fan speed', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.stove_left_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[number.stove_right_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.stove_right_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Right fan speed', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_right_speed', + 'unique_id': '11:22:33:44:55:66-fan_right_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.stove_right_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Stove Right fan speed', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.stove_right_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/palazzetti/snapshots/test_sensor.ambr b/tests/components/palazzetti/snapshots/test_sensor.ambr index 107b818f195..aa98f3a4f59 100644 --- a/tests/components/palazzetti/snapshots/test_sensor.ambr +++ b/tests/components/palazzetti/snapshots/test_sensor.ambr @@ -305,6 +305,152 @@ 'state': '21.5', }) # --- +# name: test_all_entities[sensor.stove_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'off_timer', + 'test_fire', + 'heatup', + 'fueling', + 'ign_test', + 'burning', + 'burning_mod', + 'unknown', + 'cool_fluid', + 'fire_stop', + 'clean_fire', + 'cooling', + 'cleanup', + 'ecomode', + 'chimney_alarm', + 'grate_error', + 'pellet_water_error', + 't05_error', + 'hatch_door_open', + 'pressure_error', + 'main_probe_failure', + 'flue_probe_failure', + 'exhaust_temp_high', + 'pellet_finished', + 'off', + 'fueling', + 'ign_test', + 'burning', + 'firewood_finished', + 'cooling', + 'clean_fire', + 'general_error', + 'general_error', + 'door_open', + 'temp_too_high', + 'cleaning_warning', + 'fuel_error', + 'pellet_water_error', + 't05_error', + 'hatch_door_open', + 'pressure_error', + 'main_probe_failure', + 'flue_probe_failure', + 'exhaust_temp_high', + 'pellet_finished', + 'general_error', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stove_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': '11:22:33:44:55:66-status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stove_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Stove Status', + 'options': list([ + 'off', + 'off_timer', + 'test_fire', + 'heatup', + 'fueling', + 'ign_test', + 'burning', + 'burning_mod', + 'unknown', + 'cool_fluid', + 'fire_stop', + 'clean_fire', + 'cooling', + 'cleanup', + 'ecomode', + 'chimney_alarm', + 'grate_error', + 'pellet_water_error', + 't05_error', + 'hatch_door_open', + 'pressure_error', + 'main_probe_failure', + 'flue_probe_failure', + 'exhaust_temp_high', + 'pellet_finished', + 'off', + 'fueling', + 'ign_test', + 'burning', + 'firewood_finished', + 'cooling', + 'clean_fire', + 'general_error', + 'general_error', + 'door_open', + 'temp_too_high', + 'cleaning_warning', + 'fuel_error', + 'pellet_water_error', + 't05_error', + 'hatch_door_open', + 'pressure_error', + 'main_probe_failure', + 'flue_probe_failure', + 'exhaust_temp_high', + 'pellet_finished', + 'general_error', + ]), + }), + 'context': , + 'entity_id': 'sensor.stove_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'burning', + }) +# --- # name: test_all_entities[sensor.stove_tank_water_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/palazzetti/test_button.py b/tests/components/palazzetti/test_button.py new file mode 100644 index 00000000000..de0f26fe8aa --- /dev/null +++ b/tests/components/palazzetti/test_button.py @@ -0,0 +1,69 @@ +"""Tests for the Palazzetti button platform.""" + +from unittest.mock import AsyncMock, patch + +from pypalazzetti.exceptions import CommunicationError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "button.stove_silent" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.palazzetti.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_async_press( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing via service call.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_palazzetti_client.set_fan_silent.assert_called_once() + + +async def test_async_press_error( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing with error via service call.""" + await setup_integration(hass, mock_config_entry) + + mock_palazzetti_client.set_fan_silent.side_effect = CommunicationError() + error_message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) diff --git a/tests/components/palazzetti/test_climate.py b/tests/components/palazzetti/test_climate.py index 78af8f00bdb..22bd04f234e 100644 --- a/tests/components/palazzetti/test_climate.py +++ b/tests/components/palazzetti/test_climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.palazzetti.const import FAN_AUTO, FAN_HIGH, FAN_SILENT +from homeassistant.components.palazzetti.const import FAN_AUTO, FAN_HIGH from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -118,15 +118,6 @@ async def test_async_set_data( ) # Set Fan Mode: Success - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: FAN_SILENT}, - blocking=True, - ) - mock_palazzetti_client.set_fan_silent.assert_called_once() - mock_palazzetti_client.set_fan_silent.reset_mock() - await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, diff --git a/tests/components/palazzetti/test_config_flow.py b/tests/components/palazzetti/test_config_flow.py index 03c56c33d0c..8550f1a3de0 100644 --- a/tests/components/palazzetti/test_config_flow.py +++ b/tests/components/palazzetti/test_config_flow.py @@ -4,12 +4,12 @@ from unittest.mock import AsyncMock from pypalazzetti.exceptions import CommunicationError -from homeassistant.components import dhcp from homeassistant.components.palazzetti.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -101,7 +101,7 @@ async def test_dhcp_flow( """Test the DHCP flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" ), context={"source": SOURCE_DHCP}, @@ -130,7 +130,7 @@ async def test_dhcp_flow_error( result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" ), context={"source": SOURCE_DHCP}, diff --git a/tests/components/palazzetti/test_number.py b/tests/components/palazzetti/test_number.py new file mode 100644 index 00000000000..8f09384c1b7 --- /dev/null +++ b/tests/components/palazzetti/test_number.py @@ -0,0 +1,117 @@ +"""Tests for the Palazzetti sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from pypalazzetti.exceptions import CommunicationError, ValidationError +from pypalazzetti.fan import FanType +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +POWER_ENTITY_ID = "number.stove_combustion_power" +FAN_ENTITY_ID = "number.stove_left_fan_speed" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.palazzetti.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_async_set_data_power( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting number data via service call.""" + await setup_integration(hass, mock_config_entry) + + # Set value: Success + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_power_mode.assert_called_once_with(1) + mock_palazzetti_client.set_power_mode.reset_mock() + + # Set value: Error + mock_palazzetti_client.set_power_mode.side_effect = CommunicationError() + message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_power_mode.reset_mock() + + mock_palazzetti_client.set_power_mode.side_effect = ValidationError() + message = "Combustion power 1.0 is invalid" + with pytest.raises(ServiceValidationError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, + blocking=True, + ) + + +async def test_async_set_data_fan( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting number data via service call.""" + await setup_integration(hass, mock_config_entry) + + # Set value: Success + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_fan_speed.assert_called_once_with(1, FanType.LEFT) + mock_palazzetti_client.set_on.reset_mock() + + # Set value: Error + mock_palazzetti_client.set_fan_speed.side_effect = CommunicationError() + message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_on.reset_mock() + + mock_palazzetti_client.set_fan_speed.side_effect = ValidationError() + message = "Fan left speed 1.0 is invalid" + with pytest.raises(ServiceValidationError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, + blocking=True, + ) diff --git a/tests/components/peblar/__init__.py b/tests/components/peblar/__init__.py new file mode 100644 index 00000000000..9180d51e98b --- /dev/null +++ b/tests/components/peblar/__init__.py @@ -0,0 +1 @@ +"""Integration tests for the Peblar integration.""" diff --git a/tests/components/peblar/conftest.py b/tests/components/peblar/conftest.py new file mode 100644 index 00000000000..95daad545b5 --- /dev/null +++ b/tests/components/peblar/conftest.py @@ -0,0 +1,100 @@ +"""Fixtures for the Peblar integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import nullcontext +from unittest.mock import MagicMock, patch + +from peblar import ( + PeblarEVInterface, + PeblarMeter, + PeblarSystem, + PeblarSystemInformation, + PeblarUserConfiguration, + PeblarVersions, +) +import pytest + +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Peblar", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.127", + CONF_PASSWORD: "OMGSPIDERS", + }, + unique_id="23-45-A4O-MOF", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.peblar.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_peblar() -> Generator[MagicMock]: + """Return a mocked Peblar client.""" + with ( + patch("homeassistant.components.peblar.Peblar", autospec=True) as peblar_mock, + patch("homeassistant.components.peblar.config_flow.Peblar", new=peblar_mock), + ): + peblar = peblar_mock.return_value + peblar.available_versions.return_value = PeblarVersions.from_json( + load_fixture("available_versions.json", DOMAIN) + ) + peblar.current_versions.return_value = PeblarVersions.from_json( + load_fixture("current_versions.json", DOMAIN) + ) + peblar.user_configuration.return_value = PeblarUserConfiguration.from_json( + load_fixture("user_configuration.json", DOMAIN) + ) + peblar.system_information.return_value = PeblarSystemInformation.from_json( + load_fixture("system_information.json", DOMAIN) + ) + + api = peblar.rest_api.return_value + api.ev_interface.return_value = PeblarEVInterface.from_json( + load_fixture("ev_interface.json", DOMAIN) + ) + api.meter.return_value = PeblarMeter.from_json( + load_fixture("meter.json", DOMAIN) + ) + api.system.return_value = PeblarSystem.from_json( + load_fixture("system.json", DOMAIN) + ) + + yield peblar + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_peblar: MagicMock, + request: pytest.FixtureRequest, +) -> MockConfigEntry: + """Set up the Peblar integration for testing.""" + mock_config_entry.add_to_hass(hass) + + context = nullcontext() + if platform := getattr(request, "param", None): + context = patch("homeassistant.components.peblar.PLATFORMS", [platform]) + + with context: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/peblar/fixtures/available_versions.json b/tests/components/peblar/fixtures/available_versions.json new file mode 100644 index 00000000000..45b3255167c --- /dev/null +++ b/tests/components/peblar/fixtures/available_versions.json @@ -0,0 +1,4 @@ +{ + "Customization": "Peblar-1.9", + "Firmware": "1.6.2+1+WL-1" +} diff --git a/tests/components/peblar/fixtures/current_versions.json b/tests/components/peblar/fixtures/current_versions.json new file mode 100644 index 00000000000..c54fb71c457 --- /dev/null +++ b/tests/components/peblar/fixtures/current_versions.json @@ -0,0 +1,4 @@ +{ + "Customization": "Peblar-1.9", + "Firmware": "1.6.1+1+WL-1" +} diff --git a/tests/components/peblar/fixtures/ev_interface.json b/tests/components/peblar/fixtures/ev_interface.json new file mode 100644 index 00000000000..901807a7068 --- /dev/null +++ b/tests/components/peblar/fixtures/ev_interface.json @@ -0,0 +1,7 @@ +{ + "ChargeCurrentLimit": 16000, + "ChargeCurrentLimitActual": 6000, + "ChargeCurrentLimitSource": "Current limiter", + "CpState": "State C", + "Force1Phase": false +} diff --git a/tests/components/peblar/fixtures/meter.json b/tests/components/peblar/fixtures/meter.json new file mode 100644 index 00000000000..f426adf9b8a --- /dev/null +++ b/tests/components/peblar/fixtures/meter.json @@ -0,0 +1,14 @@ +{ + "CurrentPhase1": 14242, + "CurrentPhase2": 0, + "CurrentPhase3": 0, + "EnergySession": 381, + "EnergyTotal": 880703, + "PowerPhase1": 3185, + "PowerPhase2": 0, + "PowerPhase3": 0, + "PowerTotal": 3185, + "VoltagePhase1": 223, + "VoltagePhase2": null, + "VoltagePhase3": null +} diff --git a/tests/components/peblar/fixtures/system.json b/tests/components/peblar/fixtures/system.json new file mode 100644 index 00000000000..87bb60575da --- /dev/null +++ b/tests/components/peblar/fixtures/system.json @@ -0,0 +1,12 @@ +{ + "ActiveErrorCodes": [], + "ActiveWarningCodes": [], + "CellularSignalStrength": null, + "FirmwareVersion": "1.6.1+1+WL-1", + "Force1PhaseAllowed": true, + "PhaseCount": 3, + "ProductPn": "6004-2300-8002", + "ProductSn": "23-45-A4O-MOF", + "Uptime": 322094, + "WlanSignalStrength": null +} diff --git a/tests/components/peblar/fixtures/system_information.json b/tests/components/peblar/fixtures/system_information.json new file mode 100644 index 00000000000..dcec52a37fe --- /dev/null +++ b/tests/components/peblar/fixtures/system_information.json @@ -0,0 +1,57 @@ +{ + "BopCalIGainA": 264625, + "BopCalIGainB": 267139, + "BopCalIGainC": 239155, + "CanChangeChargingPhases": false, + "CanChargeSinglePhase": true, + "CanChargeThreePhases": false, + "CustomerId": "PBLR-0000645", + "CustomerUpdatePackagePubKey": "-----BEGIN PUBLIC KEY-----\nlorem ipsum\n-----END PUBLIC KEY-----\n", + "EthMacAddr": "00:0F:11:58:86:97", + "FwIdent": "1.6.1+1+WL-1", + "Hostname": "PBLR-0000645", + "HwFixedCableRating": 20, + "HwFwCompat": "wlac-2", + "HwHas4pRelay": false, + "HwHasBop": true, + "HwHasBuzzer": true, + "HwHasDualSocket": false, + "HwHasEichrechtLaserMarking": false, + "HwHasEthernet": true, + "HwHasLed": true, + "HwHasLte": false, + "HwHasMeter": true, + "HwHasMeterDisplay": true, + "HwHasPlc": false, + "HwHasRfid": true, + "HwHasRs485": true, + "HwHasShutter": false, + "HwHasSocket": false, + "HwHasTpm": false, + "HwHasWlan": true, + "HwMaxCurrent": 16, + "HwOneOrThreePhase": 3, + "HwUKCompliant": false, + "MainboardPn": "6004-2300-7600", + "MainboardSn": "23-38-A4E-2MC", + "MeterCalIGainA": 267369, + "MeterCalIGainB": 228286, + "MeterCalIGainC": 246455, + "MeterCalIRmsOffsetA": 15573, + "MeterCalIRmsOffsetB": 268422963, + "MeterCalIRmsOffsetC": 9082, + "MeterCalPhaseA": 250, + "MeterCalPhaseB": 271, + "MeterCalPhaseC": 271, + "MeterCalVGainA": 250551, + "MeterCalVGainB": 246074, + "MeterCalVGainC": 230191, + "MeterFwIdent": "b9cbcd", + "NorFlash": true, + "ProductModelName": "WLAC1-H11R0WE0ICR00", + "ProductPn": "6004-2300-8002", + "ProductSn": "23-45-A4O-MOF", + "ProductVendorName": "Peblar", + "WlanApMacAddr": "00:0F:11:58:86:98", + "WlanStaMacAddr": "00:0F:11:58:86:99" +} diff --git a/tests/components/peblar/fixtures/user_configuration.json b/tests/components/peblar/fixtures/user_configuration.json new file mode 100644 index 00000000000..b41aecd00ef --- /dev/null +++ b/tests/components/peblar/fixtures/user_configuration.json @@ -0,0 +1,59 @@ +{ + "BopFallbackCurrent": 6000, + "BopHomeWizardAddress": "p1meter-093586", + "BopSource": "homewizard", + "BopSourceParameters": "{}", + "ConnectedPhases": 3, + "CurrentCtrlBopCtType": "CTK05-14", + "CurrentCtrlBopEnable": true, + "CurrentCtrlBopFuseRating": 35, + "CurrentCtrlFixedChargeCurrentLimit": 16, + "GroundMonitoring": true, + "GroupLoadBalancingEnable": false, + "GroupLoadBalancingFallbackCurrent": 6, + "GroupLoadBalancingGroupId": 1, + "GroupLoadBalancingInterface": "RS485", + "GroupLoadBalancingMaxCurrent": 0, + "GroupLoadBalancingRole": "", + "HmiBuzzerVolume": 1, + "HmiLedIntensityManual": 0, + "HmiLedIntensityMax": 100, + "HmiLedIntensityMin": 1, + "HmiLedIntensityMode": "Fixed", + "LocalRestApiAccessMode": "ReadWrite", + "LocalRestApiAllowed": true, + "LocalRestApiEnable": true, + "LocalSmartChargingAllowed": true, + "ModbusServerAccessMode": "ReadOnly", + "ModbusServerAllowed": true, + "ModbusServerEnable": true, + "PhaseRotation": "RST", + "PowerLimitInputDi1Inverse": false, + "PowerLimitInputDi1Limit": 6, + "PowerLimitInputDi2Inverse": false, + "PowerLimitInputDi2Limit": 0, + "PowerLimitInputEnable": false, + "PredefinedCpoName": "", + "ScheduledChargingAllowed": true, + "ScheduledChargingEnable": false, + "SeccOcppActive": false, + "SeccOcppUri": "", + "SessionManagerChargeWithoutAuth": false, + "SolarChargingAllowed": true, + "SolarChargingEnable": true, + "SolarChargingMode": "PureSolar", + "SolarChargingSource": "homewizard", + "SolarChargingSourceParameters": "{\"address\":\"p1meter-093586\"}", + "TimeZone": "Europe/Amsterdam", + "UserDefinedChargeLimitCurrent": 16, + "UserDefinedChargeLimitCurrentAllowed": true, + "UserDefinedHouseholdPowerLimit": 20000, + "UserDefinedHouseholdPowerLimitAllowed": true, + "UserDefinedHouseholdPowerLimitEnable": false, + "UserDefinedHouseholdPowerLimitSource": "homewizard", + "UserDefinedHouseholdPowerLimitSourceParameters": "{\"address\":\"p1meter-093586\"}", + "UserKeepSocketLocked": false, + "VDEPhaseImbalanceEnable": false, + "VDEPhaseImbalanceLimit": 20, + "WebIfUpdateHelper": true +} diff --git a/tests/components/imgw_pib/snapshots/test_binary_sensor.ambr b/tests/components/peblar/snapshots/test_binary_sensor.ambr similarity index 51% rename from tests/components/imgw_pib/snapshots/test_binary_sensor.ambr rename to tests/components/peblar/snapshots/test_binary_sensor.ambr index c5ae6880022..72c3ac78a12 100644 --- a/tests/components/imgw_pib/snapshots/test_binary_sensor.ambr +++ b/tests/components/peblar/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_alarm-entry] +# name: test_entities[binary_sensor][binary_sensor.peblar_ev_charger_active_errors-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10,8 +10,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.river_name_station_name_flood_alarm', + 'entity_category': , + 'entity_id': 'binary_sensor.peblar_ev_charger_active_errors', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -21,33 +21,32 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Flood alarm', - 'platform': 'imgw_pib', + 'original_name': 'Active errors', + 'platform': 'peblar', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'flood_alarm', - 'unique_id': '123_flood_alarm', + 'translation_key': 'active_error_codes', + 'unique_id': '23-45-A4O-MOF_active_error_codes', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_alarm-state] +# name: test_entities[binary_sensor][binary_sensor.peblar_ev_charger_active_errors-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'safety', - 'friendly_name': 'River Name (Station Name) Flood alarm', + 'device_class': 'problem', + 'friendly_name': 'Peblar EV Charger Active errors', }), 'context': , - 'entity_id': 'binary_sensor.river_name_station_name_flood_alarm', + 'entity_id': 'binary_sensor.peblar_ev_charger_active_errors', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_warning-entry] +# name: test_entities[binary_sensor][binary_sensor.peblar_ev_charger_active_warnings-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -58,8 +57,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.river_name_station_name_flood_warning', + 'entity_category': , + 'entity_id': 'binary_sensor.peblar_ev_charger_active_warnings', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -69,26 +68,25 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Flood warning', - 'platform': 'imgw_pib', + 'original_name': 'Active warnings', + 'platform': 'peblar', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'flood_warning', - 'unique_id': '123_flood_warning', + 'translation_key': 'active_warning_codes', + 'unique_id': '23-45-A4O-MOF_active_warning_codes', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_warning-state] +# name: test_entities[binary_sensor][binary_sensor.peblar_ev_charger_active_warnings-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'safety', - 'friendly_name': 'River Name (Station Name) Flood warning', + 'device_class': 'problem', + 'friendly_name': 'Peblar EV Charger Active warnings', }), 'context': , - 'entity_id': 'binary_sensor.river_name_station_name_flood_warning', + 'entity_id': 'binary_sensor.peblar_ev_charger_active_warnings', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/peblar/snapshots/test_button.ambr b/tests/components/peblar/snapshots/test_button.ambr new file mode 100644 index 00000000000..96aab5c93ef --- /dev/null +++ b/tests/components/peblar/snapshots/test_button.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_entities[button][button.peblar_ev_charger_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.peblar_ev_charger_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23-45-A4O-MOF_identify', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[button][button.peblar_ev_charger_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Peblar EV Charger Identify', + }), + 'context': , + 'entity_id': 'button.peblar_ev_charger_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[button][button.peblar_ev_charger_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.peblar_ev_charger_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23-45-A4O-MOF_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[button][button.peblar_ev_charger_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Peblar EV Charger Restart', + }), + 'context': , + 'entity_id': 'button.peblar_ev_charger_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/peblar/snapshots/test_diagnostics.ambr b/tests/components/peblar/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..fbcdcfbaff5 --- /dev/null +++ b/tests/components/peblar/snapshots/test_diagnostics.ambr @@ -0,0 +1,168 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'ev': dict({ + 'ChargeCurrentLimit': 16000, + 'ChargeCurrentLimitActual': 6000, + 'ChargeCurrentLimitSource': 'Current limiter', + 'CpState': 'State C', + 'Force1Phase': False, + }), + 'meter': dict({ + 'CurrentPhase1': 14242, + 'CurrentPhase2': 0, + 'CurrentPhase3': 0, + 'EnergySession': 381, + 'EnergyTotal': 880703, + 'PowerPhase1': 3185, + 'PowerPhase2': 0, + 'PowerPhase3': 0, + 'PowerTotal': 3185, + 'VoltagePhase1': 223, + }), + 'system': dict({ + 'ActiveErrorCodes': list([ + ]), + 'ActiveWarningCodes': list([ + ]), + 'FirmwareVersion': '1.6.1+1+WL-1', + 'Force1PhaseAllowed': True, + 'PhaseCount': 3, + 'ProductPn': '6004-2300-8002', + 'ProductSn': '23-45-A4O-MOF', + 'Uptime': 322094, + }), + 'system_information': dict({ + 'BopCalIGainA': 264625, + 'BopCalIGainB': 267139, + 'BopCalIGainC': 239155, + 'CanChangeChargingPhases': False, + 'CanChargeSinglePhase': True, + 'CanChargeThreePhases': False, + 'CustomerId': 'PBLR-0000645', + 'CustomerUpdatePackagePubKey': ''' + -----BEGIN PUBLIC KEY----- + lorem ipsum + -----END PUBLIC KEY----- + + ''', + 'EthMacAddr': '00:0F:11:58:86:97', + 'FwIdent': '1.6.1+1+WL-1', + 'Hostname': 'PBLR-0000645', + 'HwFixedCableRating': 20, + 'HwFwCompat': 'wlac-2', + 'HwHasBop': True, + 'HwHasBuzzer': True, + 'HwHasEichrechtLaserMarking': False, + 'HwHasEthernet': True, + 'HwHasLed': True, + 'HwHasLte': False, + 'HwHasMeter': True, + 'HwHasMeterDisplay': True, + 'HwHasPlc': False, + 'HwHasRfid': True, + 'HwHasRs485': True, + 'HwHasSocket': False, + 'HwHasTpm': False, + 'HwHasWlan': True, + 'HwMaxCurrent': 16, + 'HwOneOrThreePhase': 3, + 'MainboardPn': '6004-2300-7600', + 'MainboardSn': '23-38-A4E-2MC', + 'MeterCalIGainA': 267369, + 'MeterCalIGainB': 228286, + 'MeterCalIGainC': 246455, + 'MeterCalIRmsOffsetA': 15573, + 'MeterCalIRmsOffsetB': 268422963, + 'MeterCalIRmsOffsetC': 9082, + 'MeterCalPhaseA': 250, + 'MeterCalPhaseB': 271, + 'MeterCalPhaseC': 271, + 'MeterCalVGainA': 250551, + 'MeterCalVGainB': 246074, + 'MeterCalVGainC': 230191, + 'MeterFwIdent': 'b9cbcd', + 'ProductModelName': 'WLAC1-H11R0WE0ICR00', + 'ProductPn': '6004-2300-8002', + 'ProductSn': '23-45-A4O-MOF', + 'ProductVendorName': 'Peblar', + 'WlanApMacAddr': '00:0F:11:58:86:98', + 'WlanStaMacAddr': '00:0F:11:58:86:99', + }), + 'user_configuration': dict({ + 'BopFallbackCurrent': 6000, + 'BopHomeWizardAddress': 'p1meter-093586', + 'BopSource': 'homewizard', + 'BopSourceParameters': '{}', + 'ConnectedPhases': 3, + 'CurrentCtrlBopCtType': 'CTK05-14', + 'CurrentCtrlBopEnable': True, + 'CurrentCtrlBopFuseRating': 35, + 'CurrentCtrlFixedChargeCurrentLimit': 16, + 'GroundMonitoring': True, + 'GroupLoadBalancingEnable': False, + 'GroupLoadBalancingFallbackCurrent': 6, + 'GroupLoadBalancingGroupId': 1, + 'GroupLoadBalancingInterface': 'RS485', + 'GroupLoadBalancingMaxCurrent': 0, + 'GroupLoadBalancingRole': '', + 'HmiBuzzerVolume': 1, + 'HmiLedIntensityManual': 0, + 'HmiLedIntensityMax': 100, + 'HmiLedIntensityMin': 1, + 'HmiLedIntensityMode': 'Fixed', + 'LocalRestApiAccessMode': 'ReadWrite', + 'LocalRestApiAllowed': True, + 'LocalRestApiEnable': True, + 'LocalSmartChargingAllowed': True, + 'ModbusServerAccessMode': 'ReadOnly', + 'ModbusServerAllowed': True, + 'ModbusServerEnable': True, + 'PhaseRotation': 'RST', + 'PowerLimitInputDi1Inverse': False, + 'PowerLimitInputDi1Limit': 6, + 'PowerLimitInputDi2Inverse': False, + 'PowerLimitInputDi2Limit': 0, + 'PowerLimitInputEnable': False, + 'PredefinedCpoName': '', + 'ScheduledChargingAllowed': True, + 'ScheduledChargingEnable': False, + 'SeccOcppActive': False, + 'SeccOcppUri': '', + 'SessionManagerChargeWithoutAuth': False, + 'SolarChargingAllowed': True, + 'SolarChargingEnable': True, + 'SolarChargingMode': 'PureSolar', + 'SolarChargingSource': 'homewizard', + 'SolarChargingSourceParameters': dict({ + 'address': 'p1meter-093586', + }), + 'TimeZone': 'Europe/Amsterdam', + 'UserDefinedChargeLimitCurrent': 16, + 'UserDefinedChargeLimitCurrentAllowed': True, + 'UserDefinedHouseholdPowerLimit': 20000, + 'UserDefinedHouseholdPowerLimitAllowed': True, + 'UserDefinedHouseholdPowerLimitEnable': False, + 'UserDefinedHouseholdPowerLimitSource': 'homewizard', + 'UserKeepSocketLocked': False, + 'VDEPhaseImbalanceEnable': False, + 'VDEPhaseImbalanceLimit': 20, + 'WebIfUpdateHelper': True, + 'smart_charging': 'pure_solar', + }), + 'versions': dict({ + 'available': dict({ + 'Customization': 'Peblar-1.9', + 'Firmware': '1.6.2+1+WL-1', + 'customization_version': '1.9', + 'firmware_version': '1.6.2', + }), + 'current': dict({ + 'Customization': 'Peblar-1.9', + 'Firmware': '1.6.1+1+WL-1', + 'customization_version': '1.9', + 'firmware_version': '1.6.1', + }), + }), + }) +# --- diff --git a/tests/components/peblar/snapshots/test_init.ambr b/tests/components/peblar/snapshots/test_init.ambr new file mode 100644 index 00000000000..ba79093b3ec --- /dev/null +++ b/tests/components/peblar/snapshots/test_init.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_peblar_device_entry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.127', + 'connections': set({ + tuple( + 'mac', + '00:0f:11:58:86:97', + ), + tuple( + 'mac', + '00:0f:11:58:86:99', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'peblar', + '23-45-A4O-MOF', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Peblar', + 'model': 'WLAC1-H11R0WE0ICR00', + 'model_id': '6004-2300-8002', + 'name': 'Peblar EV Charger', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '23-45-A4O-MOF', + 'suggested_area': None, + 'sw_version': '1.6.1+1+WL-1', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/peblar/snapshots/test_number.ambr b/tests/components/peblar/snapshots/test_number.ambr new file mode 100644 index 00000000000..d78067849f3 --- /dev/null +++ b/tests/components/peblar/snapshots/test_number.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_entities[number][number.peblar_ev_charger_charge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 16, + 'min': 6, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.peblar_ev_charger_charge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge limit', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_current_limit', + 'unique_id': '23-45-A4O-MOF_charge_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number][number.peblar_ev_charger_charge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Peblar EV Charger Charge limit', + 'max': 16, + 'min': 6, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.peblar_ev_charger_charge_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- diff --git a/tests/components/peblar/snapshots/test_select.ambr b/tests/components/peblar/snapshots/test_select.ambr new file mode 100644 index 00000000000..62e09325601 --- /dev/null +++ b/tests/components/peblar/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_entities[select][select.peblar_ev_charger_smart_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'default', + 'fast_solar', + 'pure_solar', + 'scheduled', + 'smart_solar', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.peblar_ev_charger_smart_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart charging', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smart_charging', + 'unique_id': '23-45-A4O-MOF_smart_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[select][select.peblar_ev_charger_smart_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Peblar EV Charger Smart charging', + 'options': list([ + 'default', + 'fast_solar', + 'pure_solar', + 'scheduled', + 'smart_solar', + ]), + }), + 'context': , + 'entity_id': 'select.peblar_ev_charger_smart_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pure_solar', + }) +# --- diff --git a/tests/components/peblar/snapshots/test_sensor.ambr b/tests/components/peblar/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..bb1a3eb34d6 --- /dev/null +++ b/tests/components/peblar/snapshots/test_sensor.ambr @@ -0,0 +1,899 @@ +# serializer version: 1 +# name: test_entities[sensor][sensor.peblar_ev_charger_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23-45-A4O-MOF_current_total', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Peblar EV Charger Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.242', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_current_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 1', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase_1', + 'unique_id': '23-45-A4O-MOF_current_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_current_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Peblar EV Charger Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_current_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.242', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_current_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 2', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase_2', + 'unique_id': '23-45-A4O-MOF_current_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_current_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Peblar EV Charger Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_current_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_current_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 3', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase_3', + 'unique_id': '23-45-A4O-MOF_current_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_current_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Peblar EV Charger Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_current_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_lifetime_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_lifetime_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': '23-45-A4O-MOF_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_lifetime_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Peblar EV Charger Lifetime energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_lifetime_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '880.703', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_limit_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging_cable', + 'current_limiter', + 'dynamic_load_balancing', + 'external_power_limit', + 'group_load_balancing', + 'hardware_limitation', + 'high_temperature', + 'household_power_limit', + 'installation_limit', + 'local_modbus_api', + 'local_rest_api', + 'local_scheduled_charging', + 'ocpp_smart_charging', + 'overcurrent_protection', + 'phase_imbalance', + 'power_factor', + 'solar_charging', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_limit_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Limit source', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_current_limit_source', + 'unique_id': '23-45-A4O-MOF_charge_current_limit_source', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_limit_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Peblar EV Charger Limit source', + 'options': list([ + 'charging_cable', + 'current_limiter', + 'dynamic_load_balancing', + 'external_power_limit', + 'group_load_balancing', + 'hardware_limitation', + 'high_temperature', + 'household_power_limit', + 'installation_limit', + 'local_modbus_api', + 'local_rest_api', + 'local_scheduled_charging', + 'ocpp_smart_charging', + 'overcurrent_protection', + 'phase_imbalance', + 'power_factor', + 'solar_charging', + ]), + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_limit_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'current_limiter', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.peblar_ev_charger_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23-45-A4O-MOF_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Peblar EV Charger Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3185', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 1', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_phase_1', + 'unique_id': '23-45-A4O-MOF_power_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Peblar EV Charger Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3185', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 2', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_phase_2', + 'unique_id': '23-45-A4O-MOF_power_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Peblar EV Charger Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 3', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_phase_3', + 'unique_id': '23-45-A4O-MOF_power_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Peblar EV Charger Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_session_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.peblar_ev_charger_session_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Session energy', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_session', + 'unique_id': '23-45-A4O-MOF_energy_session', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_session_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Peblar EV Charger Session energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_session_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.381', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'suspended', + 'charging', + 'charging', + 'error', + 'fault', + 'invalid', + 'no_ev_connected', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.peblar_ev_charger_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cp_state', + 'unique_id': '23-45-A4O-MOF_cp_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Peblar EV Charger State', + 'options': list([ + 'suspended', + 'charging', + 'charging', + 'error', + 'fault', + 'invalid', + 'no_ev_connected', + ]), + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': '23-45-A4O-MOF_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Peblar EV Charger Uptime', + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-12-18T04:16:46+00:00', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_voltage_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_1', + 'unique_id': '23-45-A4O-MOF_voltage_phase_1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_voltage_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Peblar EV Charger Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_voltage_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '223', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_voltage_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_2', + 'unique_id': '23-45-A4O-MOF_voltage_phase_2', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_voltage_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Peblar EV Charger Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_voltage_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_voltage_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.peblar_ev_charger_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_3', + 'unique_id': '23-45-A4O-MOF_voltage_phase_3', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor][sensor.peblar_ev_charger_voltage_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Peblar EV Charger Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.peblar_ev_charger_voltage_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/peblar/snapshots/test_switch.ambr b/tests/components/peblar/snapshots/test_switch.ambr new file mode 100644 index 00000000000..53829278593 --- /dev/null +++ b/tests/components/peblar/snapshots/test_switch.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_entities[switch][switch.peblar_ev_charger_force_single_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.peblar_ev_charger_force_single_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Force single phase', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'force_single_phase', + 'unique_id': '23-45-A4O-MOF_force_single_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch][switch.peblar_ev_charger_force_single_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Peblar EV Charger Force single phase', + }), + 'context': , + 'entity_id': 'switch.peblar_ev_charger_force_single_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/peblar/snapshots/test_update.ambr b/tests/components/peblar/snapshots/test_update.ambr new file mode 100644 index 00000000000..de8bb63150d --- /dev/null +++ b/tests/components/peblar/snapshots/test_update.ambr @@ -0,0 +1,118 @@ +# serializer version: 1 +# name: test_entities[update][update.peblar_ev_charger_customization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.peblar_ev_charger_customization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Customization', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'customization', + 'unique_id': '23-45-A4O-MOF_customization', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[update][update.peblar_ev_charger_customization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/peblar/icon.png', + 'friendly_name': 'Peblar EV Charger Customization', + 'in_progress': False, + 'installed_version': 'Peblar-1.9', + 'latest_version': 'Peblar-1.9', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.peblar_ev_charger_customization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[update][update.peblar_ev_charger_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.peblar_ev_charger_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23-45-A4O-MOF_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[update][update.peblar_ev_charger_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/peblar/icon.png', + 'friendly_name': 'Peblar EV Charger Firmware', + 'in_progress': False, + 'installed_version': '1.6.1+1+WL-1', + 'latest_version': '1.6.2+1+WL-1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.peblar_ev_charger_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/peblar/test_binary_sensor.py b/tests/components/peblar/test_binary_sensor.py new file mode 100644 index 00000000000..affcde483ea --- /dev/null +++ b/tests/components/peblar/test_binary_sensor.py @@ -0,0 +1,35 @@ +"""Tests for the Peblar binary sensor platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("init_integration", [Platform.BINARY_SENSOR], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the binary sensors entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Ensure all entities are correctly assigned to the Peblar EV charger + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "23-45-A4O-MOF")} + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id diff --git a/tests/components/peblar/test_button.py b/tests/components/peblar/test_button.py new file mode 100644 index 00000000000..a47f190a941 --- /dev/null +++ b/tests/components/peblar/test_button.py @@ -0,0 +1,153 @@ +"""Tests for the Peblar button platform.""" + +from unittest.mock import MagicMock + +from peblar import PeblarAuthenticationError, PeblarConnectionError, PeblarError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = [ + pytest.mark.freeze_time("2024-12-21 21:45:00"), + pytest.mark.parametrize("init_integration", [Platform.BUTTON], indirect=True), + pytest.mark.usefixtures("init_integration"), +] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the button entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Ensure all entities are correctly assigned to the Peblar EV charger + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "23-45-A4O-MOF")} + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("button.peblar_ev_charger_identify", "identify"), + ("button.peblar_ev_charger_restart", "reboot"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_buttons( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + method: str, +) -> None: + """Test the Peblar EV charger buttons.""" + mocked_method = getattr(mock_peblar, method) + + # Test normal happy path button press + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(mocked_method.mock_calls) == 1 + mocked_method.assert_called_with() + + # Test connection error handling + mocked_method.side_effect = PeblarConnectionError("Could not connect") + with pytest.raises( + HomeAssistantError, + match=( + r"An error occurred while communicating " + r"with the Peblar EV charger: Could not connect" + ), + ) as excinfo: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" + assert excinfo.value.translation_placeholders == {"error": "Could not connect"} + + # Test unknown error handling + mocked_method.side_effect = PeblarError("Unknown error") + with pytest.raises( + HomeAssistantError, + match=( + r"An unknown error occurred while communicating " + r"with the Peblar EV charger: Unknown error" + ), + ) as excinfo: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "unknown_error" + assert excinfo.value.translation_placeholders == {"error": "Unknown error"} + + # Test authentication error handling + mocked_method.side_effect = PeblarAuthenticationError("Authentication error") + mock_peblar.login.side_effect = PeblarAuthenticationError("Authentication error") + with pytest.raises( + HomeAssistantError, + match=( + r"An authentication failure occurred while communicating " + r"with the Peblar EV charger" + ), + ) as excinfo: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "authentication_error" + assert not excinfo.value.translation_placeholders + + # Ensure the device is reloaded on authentication error and triggers + # a reauthentication flow. + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/peblar/test_config_flow.py b/tests/components/peblar/test_config_flow.py new file mode 100644 index 00000000000..9f0806f0591 --- /dev/null +++ b/tests/components/peblar/test_config_flow.py @@ -0,0 +1,502 @@ +"""Configuration flow tests for the Peblar integration.""" + +from ipaddress import ip_address +from unittest.mock import MagicMock + +from peblar import PeblarAuthenticationError, PeblarConnectionError +import pytest + +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.usefixtures("mock_peblar") +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the full happy path user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "23-45-A4O-MOF" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPUPPIES", + } + assert not config_entry.options + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PeblarConnectionError, {CONF_HOST: "cannot_connect"}), + (PeblarAuthenticationError, {CONF_PASSWORD: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_peblar: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show user form on a connection error.""" + mock_peblar.login.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGCATS!", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == expected_error + + mock_peblar.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.2", + CONF_PASSWORD: "OMGPUPPIES!", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "23-45-A4O-MOF" + assert config_entry.data == { + CONF_HOST: "127.0.0.2", + CONF_PASSWORD: "OMGPUPPIES!", + } + assert not config_entry.options + + +@pytest.mark.usefixtures("mock_peblar") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test configuration flow aborts when the device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGSPIDERS", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_peblar") +async def test_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the full happy path reconfigure flow from start to finish.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + assert mock_config_entry.data == { + CONF_HOST: "127.0.0.127", + CONF_PASSWORD: "OMGSPIDERS", + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert mock_config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPUPPIES", + } + + +@pytest.mark.usefixtures("mock_peblar") +async def test_reconfigure_to_different_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring to a different device doesn't work.""" + mock_config_entry.add_to_hass(hass) + + # Change the unique ID of the entry, so we have a mismatch + hass.config_entries.async_update_entry(mock_config_entry, unique_id="mismatch") + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "different_device" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PeblarConnectionError, {CONF_HOST: "cannot_connect"}), + (PeblarAuthenticationError, {CONF_PASSWORD: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show user form on a connection error.""" + mock_config_entry.add_to_hass(hass) + mock_peblar.login.side_effect = side_effect + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == expected_error + + mock_peblar.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.2", + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + assert result["type"] is FlowResultType.ABORT + + assert mock_config_entry.data == { + CONF_HOST: "127.0.0.2", + CONF_PASSWORD: "OMGPUPPIES", + } + + +@pytest.mark.usefixtures("mock_peblar") +async def test_zeroconf_flow(hass: HomeAssistant) -> None: + """Test the zeroconf happy flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="pblr-0000645.local.", + name="mock_name", + properties={ + "sn": "23-45-A4O-MOF", + "version": "1.6.1+1+WL-1", + }, + type="mock_type", + ), + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] is FlowResultType.FORM + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0].get("flow_id") == result["flow_id"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "OMGPINEAPPLES"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "23-45-A4O-MOF" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPINEAPPLES", + } + assert not config_entry.options + + +async def test_zeroconf_flow_abort_no_serial(hass: HomeAssistant) -> None: + """Test the zeroconf aborts when it advertises incompatible data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="pblr-0000645.local.", + name="mock_name", + properties={}, + type="mock_type", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_serial_number" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PeblarConnectionError, {"base": "unknown"}), + (PeblarAuthenticationError, {CONF_PASSWORD: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + mock_peblar: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show form on a error.""" + mock_peblar.login.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="pblr-0000645.local.", + name="mock_name", + properties={ + "sn": "23-45-A4O-MOF", + "version": "1.6.1+1+WL-1", + }, + type="mock_type", + ), + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["errors"] == expected_error + + mock_peblar.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "23-45-A4O-MOF" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPUPPIES", + } + assert not config_entry.options + + +@pytest.mark.usefixtures("mock_peblar") +async def test_zeroconf_flow_not_discovered_again( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the zeroconf doesn't re-discover an existing device.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="pblr-0000645.local.", + name="mock_name", + properties={ + "sn": "23-45-A4O-MOF", + "version": "1.6.1+1+WL-1", + }, + type="mock_type", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_peblar") +async def test_user_flow_with_zeroconf_in_progress(hass: HomeAssistant) -> None: + """Test the full happy path user flow from start to finish. + + While zeroconf discovery is already in progress. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="pblr-0000645.local.", + name="mock_name", + properties={ + "sn": "23-45-A4O-MOF", + "version": "1.6.1+1+WL-1", + }, + type="mock_type", + ), + ) + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 2 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert not hass.config_entries.flow.async_progress() + + +@pytest.mark.usefixtures("mock_peblar") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauthentication configuration flow.""" + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_PASSWORD] == "OMGSPIDERS" + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "OMGPUPPIES"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data == { + CONF_HOST: "127.0.0.127", + CONF_PASSWORD: "OMGPUPPIES", + } + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PeblarConnectionError, {"base": "cannot_connect"}), + (PeblarAuthenticationError, {CONF_PASSWORD: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_peblar: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show form on a error.""" + mock_config_entry.add_to_hass(hass) + mock_peblar.login.side_effect = side_effect + + result = await mock_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == expected_error + + mock_peblar.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "OMGPUPPIES", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/peblar/test_coordinator.py b/tests/components/peblar/test_coordinator.py new file mode 100644 index 00000000000..7f073af9554 --- /dev/null +++ b/tests/components/peblar/test_coordinator.py @@ -0,0 +1,119 @@ +"""Tests for the Peblar coordinators.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from peblar import PeblarAuthenticationError, PeblarConnectionError, PeblarError +import pytest + +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + +pytestmark = [ + pytest.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True), + pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration"), +] + + +@pytest.mark.parametrize( + ("error", "log_message"), + [ + ( + PeblarConnectionError("Could not connect"), + ( + "An error occurred while communicating with the Peblar EV charger: " + "Could not connect" + ), + ), + ( + PeblarError("Unknown error"), + ( + "An unknown error occurred while communicating " + "with the Peblar EV charger: Unknown error" + ), + ), + ], +) +async def test_coordinator_error_handler( + hass: HomeAssistant, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + error: Exception, + log_message: str, +) -> None: + """Test the coordinators.""" + entity_id = "sensor.peblar_ev_charger_power" + + # Ensure we are set up and the coordinator is working. + # Confirming this through a sensor entity, that is available. + assert (state := hass.states.get(entity_id)) + assert state.state != STATE_UNAVAILABLE + + # Mock an error in the coordinator. + mock_peblar.rest_api.return_value.meter.side_effect = error + freezer.tick(timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Ensure the sensor entity is now unavailable. + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + # Ensure the error is logged + assert log_message in caplog.text + + # Recover + mock_peblar.rest_api.return_value.meter.side_effect = None + freezer.tick(timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Ensure the sensor entity is now available. + assert (state := hass.states.get("sensor.peblar_ev_charger_power")) + assert state.state != STATE_UNAVAILABLE + + +async def test_coordinator_error_handler_authentication_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_peblar: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the coordinator error handler with an authentication error.""" + + # Ensure the sensor entity is now available. + assert (state := hass.states.get("sensor.peblar_ev_charger_power")) + assert state.state != STATE_UNAVAILABLE + + # Mock an authentication in the coordinator + mock_peblar.rest_api.return_value.meter.side_effect = PeblarAuthenticationError( + "Authentication error" + ) + mock_peblar.login.side_effect = PeblarAuthenticationError("Authentication error") + freezer.tick(timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Ensure the sensor entity is now unavailable. + assert (state := hass.states.get("sensor.peblar_ev_charger_power")) + assert state.state == STATE_UNAVAILABLE + + # Ensure we have triggered a reauthentication flow + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/peblar/test_diagnostics.py b/tests/components/peblar/test_diagnostics.py new file mode 100644 index 00000000000..11f9af28b2d --- /dev/null +++ b/tests/components/peblar/test_diagnostics.py @@ -0,0 +1,22 @@ +"""Tests for the diagnostics data provided by the Peblar integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/peblar/test_init.py b/tests/components/peblar/test_init.py new file mode 100644 index 00000000000..6e6a9c2af05 --- /dev/null +++ b/tests/components/peblar/test_init.py @@ -0,0 +1,96 @@ +"""Integration tests for the Peblar integration.""" + +from unittest.mock import MagicMock + +from peblar import PeblarAuthenticationError, PeblarConnectionError, PeblarError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_peblar: MagicMock, +) -> None: + """Test the Peblar configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_peblar.login.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "exception", + [PeblarConnectionError, PeblarError], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_peblar: MagicMock, + exception: Exception, +) -> None: + """Test the Peblar configuration entry not ready.""" + mock_peblar.login.side_effect = exception + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_peblar.login.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_authentication_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_peblar: MagicMock, +) -> None: + """Test authentication error, aborts setup.""" + mock_config_entry.add_to_hass(hass) + + mock_peblar.login.side_effect = PeblarAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id + + +@pytest.mark.usefixtures("init_integration") +async def test_peblar_device_entry( + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test authentication error, aborts setup.""" + assert ( + device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, "23-45-A4O-MOF")} + ) + ) + assert device_entry == snapshot diff --git a/tests/components/peblar/test_number.py b/tests/components/peblar/test_number.py new file mode 100644 index 00000000000..57469fecbc6 --- /dev/null +++ b/tests/components/peblar/test_number.py @@ -0,0 +1,177 @@ +"""Tests for the Peblar number platform.""" + +from unittest.mock import MagicMock + +from peblar import PeblarAuthenticationError, PeblarConnectionError, PeblarError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = [ + pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True), + pytest.mark.usefixtures("init_integration"), +] + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the number entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Ensure all entities are correctly assigned to the Peblar EV charger + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "23-45-A4O-MOF")} + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_set_value( + hass: HomeAssistant, + mock_peblar: MagicMock, +) -> None: + """Test the Peblar EV charger numbers.""" + entity_id = "number.peblar_ev_charger_charge_limit" + mocked_method = mock_peblar.rest_api.return_value.ev_interface + mocked_method.reset_mock() + + # Test normal happy path number value change + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + assert len(mocked_method.mock_calls) == 2 + mocked_method.mock_calls[0].assert_called_with({"charge_current_limit": 10}) + + +@pytest.mark.parametrize( + ("error", "error_match", "translation_key", "translation_placeholders"), + [ + ( + PeblarConnectionError("Could not connect"), + ( + r"An error occurred while communicating " + r"with the Peblar EV charger: Could not connect" + ), + "communication_error", + {"error": "Could not connect"}, + ), + ( + PeblarError("Unknown error"), + ( + r"An unknown error occurred while communicating " + r"with the Peblar EV charger: Unknown error" + ), + "unknown_error", + {"error": "Unknown error"}, + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_set_value_communication_error( + hass: HomeAssistant, + mock_peblar: MagicMock, + error: Exception, + error_match: str, + translation_key: str, + translation_placeholders: dict, +) -> None: + """Test the Peblar EV charger when a communication error occurs.""" + entity_id = "number.peblar_ev_charger_charge_limit" + mock_peblar.rest_api.return_value.ev_interface.side_effect = error + + with pytest.raises( + HomeAssistantError, + match=error_match, + ) as excinfo: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == translation_key + assert excinfo.value.translation_placeholders == translation_placeholders + + +async def test_number_set_value_authentication_error( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Peblar EV charger when an authentication error occurs.""" + entity_id = "number.peblar_ev_charger_charge_limit" + mock_peblar.rest_api.return_value.ev_interface.side_effect = ( + PeblarAuthenticationError("Authentication error") + ) + mock_peblar.login.side_effect = PeblarAuthenticationError("Authentication error") + with pytest.raises( + HomeAssistantError, + match=( + r"An authentication failure occurred while communicating " + r"with the Peblar EV charger" + ), + ) as excinfo: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "authentication_error" + assert not excinfo.value.translation_placeholders + + # Ensure the device is reloaded on authentication error and triggers + # a reauthentication flow. + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/peblar/test_select.py b/tests/components/peblar/test_select.py new file mode 100644 index 00000000000..be7e182dc39 --- /dev/null +++ b/tests/components/peblar/test_select.py @@ -0,0 +1,184 @@ +"""Tests for the Peblar select platform.""" + +from unittest.mock import MagicMock + +from peblar import ( + PeblarAuthenticationError, + PeblarConnectionError, + PeblarError, + SmartChargingMode, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = [ + pytest.mark.parametrize("init_integration", [Platform.SELECT], indirect=True), + pytest.mark.usefixtures("init_integration"), +] + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the select entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Ensure all entities are correctly assigned to the Peblar EV charger + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "23-45-A4O-MOF")} + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_option( + hass: HomeAssistant, + mock_peblar: MagicMock, +) -> None: + """Test the Peblar EV charger selects.""" + entity_id = "select.peblar_ev_charger_smart_charging" + mocked_method = mock_peblar.smart_charging + mocked_method.reset_mock() + + # Test normal happy path for changing the select option + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "default", + }, + blocking=True, + ) + + assert len(mocked_method.mock_calls) == 1 + mocked_method.assert_called_with(SmartChargingMode.DEFAULT) + + +@pytest.mark.parametrize( + ("error", "error_match", "translation_key", "translation_placeholders"), + [ + ( + PeblarConnectionError("Could not connect"), + ( + r"An error occurred while communicating " + r"with the Peblar EV charger: Could not connect" + ), + "communication_error", + {"error": "Could not connect"}, + ), + ( + PeblarError("Unknown error"), + ( + r"An unknown error occurred while communicating " + r"with the Peblar EV charger: Unknown error" + ), + "unknown_error", + {"error": "Unknown error"}, + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_option_communication_error( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, + error: Exception, + error_match: str, + translation_key: str, + translation_placeholders: dict, +) -> None: + """Test the Peblar EV charger when a communication error occurs.""" + entity_id = "select.peblar_ev_charger_smart_charging" + mock_peblar.smart_charging.side_effect = error + + with pytest.raises( + HomeAssistantError, + match=error_match, + ) as excinfo: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "default", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == translation_key + assert excinfo.value.translation_placeholders == translation_placeholders + + +async def test_select_option_authentication_error( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Peblar EV charger when an authentication error occurs.""" + entity_id = "select.peblar_ev_charger_smart_charging" + mock_peblar.smart_charging.side_effect = PeblarAuthenticationError( + "Authentication error" + ) + mock_peblar.login.side_effect = PeblarAuthenticationError("Authentication error") + + with pytest.raises( + HomeAssistantError, + match=( + r"An authentication failure occurred while communicating " + r"with the Peblar EV charger" + ), + ) as excinfo: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "default", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "authentication_error" + assert not excinfo.value.translation_placeholders + + # Ensure the device is reloaded on authentication error and triggers + # a reauthentication flow. + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/peblar/test_sensor.py b/tests/components/peblar/test_sensor.py new file mode 100644 index 00000000000..d689e66e944 --- /dev/null +++ b/tests/components/peblar/test_sensor.py @@ -0,0 +1,36 @@ +"""Tests for the Peblar sensor platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.freeze_time("2024-12-21 21:45:00") +@pytest.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Ensure all entities are correctly assigned to the Peblar EV charger + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "23-45-A4O-MOF")} + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id diff --git a/tests/components/peblar/test_switch.py b/tests/components/peblar/test_switch.py new file mode 100644 index 00000000000..75deeb2d5d3 --- /dev/null +++ b/tests/components/peblar/test_switch.py @@ -0,0 +1,183 @@ +"""Tests for the Peblar switch platform.""" + +from unittest.mock import MagicMock + +from peblar import PeblarAuthenticationError, PeblarConnectionError, PeblarError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = [ + pytest.mark.parametrize("init_integration", [Platform.SWITCH], indirect=True), + pytest.mark.usefixtures("init_integration"), +] + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the switch entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Ensure all entities are correctly assigned to the Peblar EV charger + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "23-45-A4O-MOF")} + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("service", "force_single_phase"), + [ + (SERVICE_TURN_ON, True), + (SERVICE_TURN_OFF, False), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switch( + hass: HomeAssistant, + mock_peblar: MagicMock, + service: str, + force_single_phase: bool, +) -> None: + """Test the Peblar EV charger switches.""" + entity_id = "switch.peblar_ev_charger_force_single_phase" + mocked_method = mock_peblar.rest_api.return_value.ev_interface + mocked_method.reset_mock() + + # Test normal happy path for changing the switch state + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(mocked_method.mock_calls) == 2 + mocked_method.mock_calls[0].assert_called_with( + {"force_single_phase": force_single_phase} + ) + + +@pytest.mark.parametrize( + ("error", "error_match", "translation_key", "translation_placeholders"), + [ + ( + PeblarConnectionError("Could not connect"), + ( + r"An error occurred while communicating " + r"with the Peblar EV charger: Could not connect" + ), + "communication_error", + {"error": "Could not connect"}, + ), + ( + PeblarError("Unknown error"), + ( + r"An unknown error occurred while communicating " + r"with the Peblar EV charger: Unknown error" + ), + "unknown_error", + {"error": "Unknown error"}, + ), + ], +) +@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switch_communication_error( + hass: HomeAssistant, + mock_peblar: MagicMock, + error: Exception, + error_match: str, + translation_key: str, + translation_placeholders: dict, + service: str, +) -> None: + """Test the Peblar EV charger when a communication error occurs.""" + entity_id = "switch.peblar_ev_charger_force_single_phase" + mock_peblar.rest_api.return_value.ev_interface.side_effect = error + with pytest.raises( + HomeAssistantError, + match=error_match, + ) as excinfo: + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == translation_key + assert excinfo.value.translation_placeholders == translation_placeholders + + +@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) +async def test_switch_authentication_error( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, + service: str, +) -> None: + """Test the Peblar EV charger when an authentication error occurs.""" + entity_id = "switch.peblar_ev_charger_force_single_phase" + mock_peblar.rest_api.return_value.ev_interface.side_effect = ( + PeblarAuthenticationError("Authentication error") + ) + mock_peblar.login.side_effect = PeblarAuthenticationError("Authentication error") + + with pytest.raises( + HomeAssistantError, + match=( + r"An authentication failure occurred while communicating " + r"with the Peblar EV charger" + ), + ) as excinfo: + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "authentication_error" + assert not excinfo.value.translation_placeholders + + # Ensure the device is reloaded on authentication error and triggers + # a reauthentication flow. + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/peblar/test_update.py b/tests/components/peblar/test_update.py new file mode 100644 index 00000000000..54eb77abc24 --- /dev/null +++ b/tests/components/peblar/test_update.py @@ -0,0 +1,35 @@ +"""Tests for the Peblar update platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the update entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Ensure all entities are correctly assigned to the Peblar EV charger + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "23-45-A4O-MOF")} + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id diff --git a/tests/components/peco/test_sensor.py b/tests/components/peco/test_sensor.py index 9cbef9fa1e6..4c9a3fca104 100644 --- a/tests/components/peco/test_sensor.py +++ b/tests/components/peco/test_sensor.py @@ -39,7 +39,7 @@ async def test_sensor_available( "peco.PecoOutageApi.get_outage_totals", return_value=OutageResults( customers_out=123, - percent_customers_out=15.589, + percent_customers_out=15, outage_count=456, customers_served=789, ), @@ -74,7 +74,7 @@ async def test_sensor_available( "peco.PecoOutageApi.get_outage_count", return_value=OutageResults( customers_out=123, - percent_customers_out=15.589, + percent_customers_out=15, outage_count=456, customers_served=789, ), diff --git a/tests/components/pegel_online/test_init.py b/tests/components/pegel_online/test_init.py index c1b8f1861c4..ac153193983 100644 --- a/tests/components/pegel_online/test_init.py +++ b/tests/components/pegel_online/test_init.py @@ -10,6 +10,7 @@ from homeassistant.components.pegel_online.const import ( DOMAIN, MIN_TIME_BETWEEN_UPDATES, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.util import utcnow @@ -24,6 +25,27 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed +async def test_setup_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Tests error during config entry setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_ENTRY_DATA_DRESDEN, + unique_id=MOCK_CONFIG_ENTRY_DATA_DRESDEN[CONF_STATION], + ) + entry.add_to_hass(hass) + with patch("homeassistant.components.pegel_online.PegelOnline") as pegelonline: + pegelonline.return_value = PegelOnlineMock( + station_details=MOCK_STATION_DETAILS_DRESDEN, + station_measurements=MOCK_STATION_MEASUREMENT_DRESDEN, + ) + pegelonline().override_side_effect(ClientError("Boom")) + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.SETUP_RETRY + + async def test_update_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/ping/test_helpers.py b/tests/components/ping/test_helpers.py new file mode 100644 index 00000000000..5a90c6b75b2 --- /dev/null +++ b/tests/components/ping/test_helpers.py @@ -0,0 +1,59 @@ +"""Test the exception handling in subprocess version of async_ping.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.ping.helpers import PingDataSubProcess +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +class MockAsyncSubprocess: + """Minimal mock implementation of asyncio.subprocess.Process for exception testing.""" + + def __init__(self, killsig=ProcessLookupError, **kwargs) -> None: + """Store provided exception type for later.""" + self.killsig = killsig + + async def communicate(self) -> None: + """Fails immediately with a timeout.""" + raise TimeoutError + + async def kill(self) -> None: + """Raise preset exception when called.""" + raise self.killsig + + +@pytest.mark.parametrize("exc", [TypeError, ProcessLookupError]) +async def test_async_ping_expected_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + exc: Exception, +) -> None: + """Test PingDataSubProcess.async_ping handles expected exceptions.""" + with patch( + "asyncio.create_subprocess_exec", return_value=MockAsyncSubprocess(killsig=exc) + ): + # Actual parameters irrelevant, as subprocess will not be created + ping = PingDataSubProcess(hass, host="10.10.10.10", count=3, privileged=False) + assert await ping.async_ping() is None + + +async def test_async_ping_unexpected_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test PingDataSubProcess.async_ping does not suppress unexpected exceptions.""" + with patch( + "asyncio.create_subprocess_exec", + return_value=MockAsyncSubprocess(killsig=KeyboardInterrupt), + ): + # Actual parameters irrelevant, as subprocess will not be created + ping = PingDataSubProcess(hass, host="10.10.10.10", count=3, privileged=False) + with pytest.raises(KeyboardInterrupt): + assert await ping.async_ping() is None diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index c4ec108bb6b..42dcf449168 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -523,7 +523,7 @@ async def test_callback_view( assert result["type"] is FlowResultType.EXTERNAL_STEP client = await hass_client_no_auth() - forward_url = f'{config_flow.AUTH_CALLBACK_PATH}?flow_id={result["flow_id"]}' + forward_url = f"{config_flow.AUTH_CALLBACK_PATH}?flow_id={result['flow_id']}" resp = await client.get(forward_url) assert resp.status == HTTPStatus.OK diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index f18c96d36c5..92ed42aa03a 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -30,6 +30,24 @@ def _read_json(environment: str, call: str) -> dict[str, Any]: return json.loads(fixture) +@pytest.fixture +def chosen_env(request: pytest.FixtureRequest) -> str: + """Pass the chosen_env string. + + Used with fixtures that require parametrization of the user-data fixture. + """ + return request.param + + +@pytest.fixture +def gateway_id(request: pytest.FixtureRequest) -> str: + """Pass the gateway_id string. + + Used with fixtures that require parametrization of the gateway_id. + """ + return request.param + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -76,10 +94,16 @@ def mock_smile_config_flow() -> Generator[MagicMock]: def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam environment for testing exceptions.""" chosen_env = "m_adam_multiple_devices_per_zone" - - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: + all_data = _read_json(chosen_env, "all_data") + with ( + patch( + "homeassistant.components.plugwise.coordinator.Smile", autospec=True + ) as smile_mock, + patch( + "homeassistant.components.plugwise.config_flow.Smile", + new=smile_mock, + ), + ): smile = smile_mock.return_value smile.gateway_id = "fe799307f1624099878210aa0b9f1475" @@ -91,24 +115,26 @@ def mock_smile_adam() -> Generator[MagicMock]: smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" smile.connect.return_value = Version("3.0.15") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @pytest.fixture -def mock_smile_adam_2() -> Generator[MagicMock]: - """Create a 2nd Mock Adam environment for testing exceptions.""" - chosen_env = "m_adam_heating" - +def mock_smile_adam_heat_cool(chosen_env: str) -> Generator[MagicMock]: + """Create a special base Mock Adam type for testing with different datasets.""" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = PlugwiseData( + all_data["devices"], all_data["gateway"] + ) + smile.connect.return_value = Version("3.6.4") smile.gateway_id = "da224107914542988a88561b4452b0f6" smile.heater_id = "056ee145a816487eaa69243c3280f8bf" smile.smile_version = "3.6.4" @@ -117,47 +143,15 @@ def mock_smile_adam_2() -> Generator[MagicMock]: smile.smile_model = "Gateway" smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" - smile.connect.return_value = Version("3.6.4") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] - ) yield smile @pytest.fixture -def mock_smile_adam_3() -> Generator[MagicMock]: - """Create a 3rd Mock Adam environment for testing exceptions.""" - chosen_env = "m_adam_cooling" - - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "da224107914542988a88561b4452b0f6" - smile.heater_id = "056ee145a816487eaa69243c3280f8bf" - smile.smile_version = "3.6.4" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_open_therm" - smile.smile_name = "Adam" - smile.connect.return_value = Version("3.6.4") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] - ) - - yield smile - - -@pytest.fixture -def mock_smile_adam_4() -> Generator[MagicMock]: - """Create a 4th Mock Adam environment for testing exceptions.""" +def mock_smile_adam_jip() -> Generator[MagicMock]: + """Create a Mock adam-jip type for testing exceptions.""" chosen_env = "m_adam_jip" - + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: @@ -172,23 +166,26 @@ def mock_smile_adam_4() -> Generator[MagicMock]: smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" smile.connect.return_value = Version("3.2.8") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @pytest.fixture -def mock_smile_anna() -> Generator[MagicMock]: - """Create a Mock Anna environment for testing exceptions.""" - chosen_env = "anna_heatpump_heating" +def mock_smile_anna(chosen_env: str) -> Generator[MagicMock]: + """Create a Mock Anna type for testing.""" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = PlugwiseData( + all_data["devices"], all_data["gateway"] + ) + smile.connect.return_value = Version("4.0.15") smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" smile.smile_version = "4.0.15" @@ -197,115 +194,31 @@ def mock_smile_anna() -> Generator[MagicMock]: smile.smile_model = "Gateway" smile.smile_model_id = "smile_thermo" smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] - ) yield smile @pytest.fixture -def mock_smile_anna_2() -> Generator[MagicMock]: - """Create a 2nd Mock Anna environment for testing exceptions.""" - chosen_env = "m_anna_heatpump_cooling" +def mock_smile_p1(chosen_env: str, gateway_id: str) -> Generator[MagicMock]: + """Create a base Mock P1 type for testing with different datasets and gateway-ids.""" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" - smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.smile_version = "4.0.15" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_thermo" - smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) - - yield smile - - -@pytest.fixture -def mock_smile_anna_3() -> Generator[MagicMock]: - """Create a 3rd Mock Anna environment for testing exceptions.""" - chosen_env = "m_anna_heatpump_idle" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" - smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.smile_version = "4.0.15" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_thermo" - smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] - ) - - yield smile - - -@pytest.fixture -def mock_smile_p1() -> Generator[MagicMock]: - """Create a Mock P1 DSMR environment for testing exceptions.""" - chosen_env = "p1v4_442_single" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "a455b61e52394b2db5081ce025a430f3" + smile.connect.return_value = Version("4.4.2") + smile.gateway_id = gateway_id smile.heater_id = None - smile.smile_version = "4.4.2" - smile.smile_type = "power" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile" smile.smile_name = "Smile P1" - smile.connect.return_value = Version("4.4.2") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] - ) - - yield smile - - -@pytest.fixture -def mock_smile_p1_2() -> Generator[MagicMock]: - """Create a Mock P1 3-phase DSMR environment for testing exceptions.""" - chosen_env = "p1v4_442_triple" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "03e65b16e4b247a29ae0d75a78cb492e" - smile.heater_id = None - smile.smile_version = "4.4.2" smile.smile_type = "power" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile" - smile.smile_name = "Smile P1" - smile.connect.return_value = Version("4.4.2") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] - ) + smile.smile_version = "4.4.2" yield smile @@ -314,6 +227,7 @@ def mock_smile_p1_2() -> Generator[MagicMock]: def mock_smile_legacy_anna() -> Generator[MagicMock]: """Create a Mock legacy Anna environment for testing exceptions.""" chosen_env = "legacy_anna" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: @@ -328,9 +242,8 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: smile.smile_model_id = None smile.smile_name = "Smile Anna" smile.connect.return_value = Version("1.8.22") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile @@ -340,6 +253,7 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: def mock_stretch() -> Generator[MagicMock]: """Create a Mock Stretch environment for testing exceptions.""" chosen_env = "stretch_v31" + all_data = _read_json(chosen_env, "all_data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: @@ -354,9 +268,8 @@ def mock_stretch() -> Generator[MagicMock]: smile.smile_model_id = None smile.smile_name = "Stretch" smile.connect.return_value = Version("3.1.11") - all_data = _read_json(chosen_env, "all_data") smile.async_update.return_value = PlugwiseData( - all_data["gateway"], all_data["devices"] + all_data["devices"], all_data["gateway"] ) yield smile diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index 5fc2a114b2f..3a54c3fb9a2 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -62,6 +62,7 @@ "active_preset": "home", "available_schedules": ["standaard", "off"], "climate_mode": "auto", + "control_state": "heating", "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/fixtures/legacy_anna/all_data.json b/tests/components/plugwise/fixtures/legacy_anna/all_data.json index c5ee4b2b103..9275b82cde9 100644 --- a/tests/components/plugwise/fixtures/legacy_anna/all_data.json +++ b/tests/components/plugwise/fixtures/legacy_anna/all_data.json @@ -37,6 +37,7 @@ "0d266432d64443e283b5d708ae98b455": { "active_preset": "home", "climate_mode": "heat", + "control_state": "heating", "dev_class": "thermostat", "firmware": "2017-03-13T11:54:58+01:00", "hardware": "6539-1301-500", @@ -45,7 +46,7 @@ "name": "Anna", "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], "sensors": { - "illuminance": 151, + "illuminance": 150.8, "setpoint": 20.5, "temperature": 20.4 }, diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 6edd4c5901d..af6d4b83380 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -31,7 +31,7 @@ "binary_sensors": { "low_battery": false }, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "f871b8c4d63549319221e294e4f88074", @@ -40,6 +40,7 @@ "name": "Tom Badkamer", "sensors": { "battery": 99, + "setpoint": 18.0, "temperature": 21.6, "temperature_difference": -0.2, "valve_position": 100 @@ -54,34 +55,16 @@ "zigbee_mac_address": "000D6F000C8FF5EE" }, "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "active_preset": "home", "available": true, - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "cool", - "control_state": "cooling", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "model": "ThermoTouch", "model_id": "143.1", "name": "Anna", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "off", "sensors": { "setpoint": 23.5, "temperature": 25.8 }, - "thermostat": { - "lower_bound": 1.0, - "resolution": 0.01, - "setpoint": 23.5, - "upper_bound": 35.0 - }, "vendor": "Plugwise" }, "da224107914542988a88561b4452b0f6": { @@ -113,20 +96,10 @@ "zigbee_mac_address": "000D6F000D5A168D" }, "e2f4322d57924fa090fbbc48b3a140dc": { - "active_preset": "home", "available": true, - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], "binary_sensors": { "low_battery": true }, - "climate_mode": "auto", - "control_state": "preheating", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -134,8 +107,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Lisa Badkamer", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "Badkamer", "sensors": { "battery": 14, "setpoint": 23.5, @@ -147,12 +118,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 25.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "000D6F000C869B61" }, @@ -166,14 +131,81 @@ "name": "Test", "switches": { "relay": true - } + }, + "vendor": "Plugwise" + }, + "f2bf9048bef64cc5b6d5110154e33c81": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "cool", + "control_state": "cooling", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Living room", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 149.9, + "electricity_produced": 0.0, + "temperature": 25.8 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 23.5, + "upper_bound": 35.0 + }, + "thermostats": { + "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "f871b8c4d63549319221e294e4f88074": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "auto", + "control_state": "cooling", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bathroom", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "Badkamer", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 23.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 25.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], + "secondary": ["1772a4ea304041adb83f357b751341ff"] + }, + "vendor": "Plugwise" } }, "gateway": { "cooling_present": true, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 157, + "item_count": 89, "notifications": {}, "reboot": true, "smile_name": "Adam" diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index 7a3fb6e3b5c..bb24faeebfa 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -36,7 +36,7 @@ "binary_sensors": { "low_battery": false }, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "f871b8c4d63549319221e294e4f88074", @@ -45,6 +45,7 @@ "name": "Tom Badkamer", "sensors": { "battery": 99, + "setpoint": 18.0, "temperature": 18.6, "temperature_difference": -0.2, "valve_position": 100 @@ -59,34 +60,16 @@ "zigbee_mac_address": "000D6F000C8FF5EE" }, "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "active_preset": "home", "available": true, - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "heat", - "control_state": "preheating", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "model": "ThermoTouch", "model_id": "143.1", "name": "Anna", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "off", "sensors": { "setpoint": 20.0, "temperature": 19.1 }, - "thermostat": { - "lower_bound": 1.0, - "resolution": 0.01, - "setpoint": 20.0, - "upper_bound": 35.0 - }, "vendor": "Plugwise" }, "da224107914542988a88561b4452b0f6": { @@ -112,20 +95,10 @@ "zigbee_mac_address": "000D6F000D5A168D" }, "e2f4322d57924fa090fbbc48b3a140dc": { - "active_preset": "home", "available": true, - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], "binary_sensors": { "low_battery": true }, - "climate_mode": "auto", - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -133,8 +106,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Lisa Badkamer", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "Badkamer", "sensors": { "battery": 14, "setpoint": 15.0, @@ -146,12 +117,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 15.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "000D6F000C869B61" }, @@ -165,14 +130,81 @@ "name": "Test", "switches": { "relay": true - } + }, + "vendor": "Plugwise" + }, + "f2bf9048bef64cc5b6d5110154e33c81": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "heat", + "control_state": "preheating", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Living room", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 149.9, + "electricity_produced": 0.0, + "temperature": 19.1 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 20.0, + "upper_bound": 35.0 + }, + "thermostats": { + "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "f871b8c4d63549319221e294e4f88074": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bathroom", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "Badkamer", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 17.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], + "secondary": ["1772a4ea304041adb83f357b751341ff"] + }, + "vendor": "Plugwise" } }, "gateway": { "cooling_present": false, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 157, + "item_count": 89, "notifications": {}, "reboot": true, "smile_name": "Adam" diff --git a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json index 61d6baaf88f..1a3ef66c147 100644 --- a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json @@ -1,13 +1,55 @@ { "devices": { - "1346fbd8498d4dbcab7e18d51b771f3d": { + "06aecb3d00354375924f50c47af36bd2": { "active_preset": "no_frost", + "climate_mode": "off", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Slaapkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 24.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["1346fbd8498d4dbcab7e18d51b771f3d"], + "secondary": ["356b65335e274d769c338223e7af9c33"] + }, + "vendor": "Plugwise" + }, + "13228dab8ce04617af318a2888b3c548": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Woonkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 27.4 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.01, + "setpoint": 9.0, + "upper_bound": 30.0 + }, + "thermostats": { + "primary": ["f61f1a2535f54f52ad006a3d18e459ca"], + "secondary": ["833de10f269c4deab58fb9df69901b4e"] + }, + "vendor": "Plugwise" + }, + "1346fbd8498d4dbcab7e18d51b771f3d": { "available": true, "binary_sensors": { "low_battery": false }, - "climate_mode": "off", - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -15,7 +57,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Slaapkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { "battery": 92, "setpoint": 13.0, @@ -27,18 +68,12 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A03" }, "1da4d325838e4ad8aac12177214505c9": { "available": true, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "d58fec52899f4f1c92e4f8fad6d8c48c", @@ -62,7 +97,7 @@ }, "356b65335e274d769c338223e7af9c33": { "available": true, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "06aecb3d00354375924f50c47af36bd2", @@ -102,13 +137,10 @@ "zigbee_mac_address": "ABCD012345670A06" }, "6f3e9d7084214c21b9dfa46f6eeb8700": { - "active_preset": "home", "available": true, "binary_sensors": { "low_battery": false }, - "climate_mode": "heat", - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -116,7 +148,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Kinderkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { "battery": 79, "setpoint": 13.0, @@ -128,18 +159,12 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A02" }, "833de10f269c4deab58fb9df69901b4e": { "available": true, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "13228dab8ce04617af318a2888b3c548", @@ -162,13 +187,10 @@ "zigbee_mac_address": "ABCD012345670A09" }, "a6abc6a129ee499c88a4d420cc413b47": { - "active_preset": "home", "available": true, "binary_sensors": { "low_battery": false }, - "climate_mode": "heat", - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -176,7 +198,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Logeerkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { "battery": 80, "setpoint": 13.0, @@ -188,12 +209,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A01" }, @@ -219,9 +234,32 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670101" }, + "d27aede973b54be484f6842d1b2802ad": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Kinderkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["6f3e9d7084214c21b9dfa46f6eeb8700"], + "secondary": ["d4496250d0e942cfa7aea3476e9070d5"] + }, + "vendor": "Plugwise" + }, "d4496250d0e942cfa7aea3476e9070d5": { "available": true, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "d27aede973b54be484f6842d1b2802ad", @@ -243,6 +281,29 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A04" }, + "d58fec52899f4f1c92e4f8fad6d8c48c": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Logeerkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["a6abc6a129ee499c88a4d420cc413b47"], + "secondary": ["1da4d325838e4ad8aac12177214505c9"] + }, + "vendor": "Plugwise" + }, "e4684553153b44afbef2200885f379dc": { "available": true, "binary_sensors": { @@ -280,13 +341,10 @@ "vendor": "Remeha B.V." }, "f61f1a2535f54f52ad006a3d18e459ca": { - "active_preset": "home", "available": true, "binary_sensors": { "low_battery": false }, - "climate_mode": "heat", - "control_state": "off", "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", "hardware": "1", @@ -294,7 +352,6 @@ "model": "Jip", "model_id": "168-01", "name": "Woonkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { "battery": 100, "humidity": 56.2, @@ -307,12 +364,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.01, - "setpoint": 9.0, - "upper_bound": 30.0 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A08" } @@ -321,7 +372,7 @@ "cooling_present": false, "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", "heater_id": "e4684553153b44afbef2200885f379dc", - "item_count": 228, + "item_count": 244, "notifications": {}, "reboot": true, "smile_name": "Adam" diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json index 7c3962b832f..8da184a7a3e 100644 --- a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json @@ -21,6 +21,75 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A15" }, + "08963fec7c53423ca5680aa4cb502c63": { + "active_preset": "away", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Badkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Badkamer Schema", + "sensors": { + "temperature": 18.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 14.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": [ + "f1fee6043d3642a9b0a65297455f008e", + "680423ff840043738f42cc7f1ff97a36" + ], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "12493538af164a409c6a1c79e38afe1c": { + "active_preset": "away", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bios", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 16.5 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["df4a4a8169904cdb9c03d61a21f42140"], + "secondary": ["a2c3583e0a6349358998b760cea82d2a"] + }, + "vendor": "Plugwise" + }, "21f2b542c49845e6bb416884c55778d6": { "available": true, "dev_class": "game_console_plug", @@ -42,6 +111,29 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A12" }, + "446ac08dd04d4eff8ac57489757b7314": { + "active_preset": "no_frost", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Garage", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 15.6 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 5.5, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["e7693eb9582644e5b865dba8d4447cf1"], + "secondary": [] + }, + "vendor": "Plugwise" + }, "4a810418d5394b3f82727340b91ba740": { "available": true, "dev_class": "router_plug", @@ -89,13 +181,13 @@ "binary_sensors": { "low_battery": false }, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "08963fec7c53423ca5680aa4cb502c63", "model": "Tom/Floor", "model_id": "106-03", - "name": "Thermostatic Radiator Badkamer", + "name": "Thermostatic Radiator Badkamer 1", "sensors": { "battery": 51, "setpoint": 14.0, @@ -113,20 +205,10 @@ "zigbee_mac_address": "ABCD012345670A17" }, "6a3bf693d05e48e0b460c815a4fdd09d": { - "active_preset": "asleep", "available": true, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], "binary_sensors": { "low_battery": false }, - "climate_mode": "auto", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -134,8 +216,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Zone Thermostat Jessie", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "CV Jessie", "sensors": { "battery": 37, "setpoint": 15.0, @@ -147,12 +227,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 15.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A03" }, @@ -176,6 +250,38 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A05" }, + "82fa13f017d240daa0d0ea1775420f24": { + "active_preset": "asleep", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Jessie", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "CV Jessie", + "sensors": { + "temperature": 17.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["6a3bf693d05e48e0b460c815a4fdd09d"], + "secondary": ["d3da73bde12a47d5a6b8f9dad971f2ec"] + }, + "vendor": "Plugwise" + }, "90986d591dcd426cae3ec3e8111ff730": { "binary_sensors": { "heating_state": true @@ -216,7 +322,7 @@ "binary_sensors": { "low_battery": false }, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "12493538af164a409c6a1c79e38afe1c", @@ -241,7 +347,7 @@ }, "b310b72a0e354bfab43089919b9a88bf": { "available": true, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "c50f167537524366a5af7aa3942feb1e", @@ -264,20 +370,10 @@ "zigbee_mac_address": "ABCD012345670A02" }, "b59bcebaf94b499ea7d46e4a66fb62d8": { - "active_preset": "home", "available": true, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], "binary_sensors": { "low_battery": false }, - "climate_mode": "auto", "dev_class": "zone_thermostat", "firmware": "2016-08-02T02:00:00+02:00", "hardware": "255", @@ -285,8 +381,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Zone Lisa WK", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "GF7 Woonkamer", "sensors": { "battery": 34, "setpoint": 21.5, @@ -298,14 +392,42 @@ "setpoint": 0.0, "upper_bound": 2.0 }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "c50f167537524366a5af7aa3942feb1e": { + "active_preset": "home", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "heating", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Woonkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "GF7 Woonkamer", + "sensors": { + "electricity_consumed": 35.6, + "electricity_produced": 0.0, + "temperature": 20.9 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, "setpoint": 21.5, - "upper_bound": 99.9 + "upper_bound": 100.0 }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07" + "thermostats": { + "primary": ["b59bcebaf94b499ea7d46e4a66fb62d8"], + "secondary": ["b310b72a0e354bfab43089919b9a88bf"] + }, + "vendor": "Plugwise" }, "cd0ddb54ef694e11ac18ed1cbce5dbbd": { "available": true, @@ -333,7 +455,7 @@ "binary_sensors": { "low_battery": false }, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "82fa13f017d240daa0d0ea1775420f24", @@ -357,20 +479,10 @@ "zigbee_mac_address": "ABCD012345670A10" }, "df4a4a8169904cdb9c03d61a21f42140": { - "active_preset": "away", "available": true, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], "binary_sensors": { "low_battery": false }, - "climate_mode": "heat", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -378,8 +490,6 @@ "model": "Lisa", "model_id": "158-01", "name": "Zone Lisa Bios", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "off", "sensors": { "battery": 67, "setpoint": 13.0, @@ -391,22 +501,14 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A06" }, "e7693eb9582644e5b865dba8d4447cf1": { - "active_preset": "no_frost", "available": true, "binary_sensors": { "low_battery": false }, - "climate_mode": "heat", "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", @@ -414,7 +516,6 @@ "model": "Tom/Floor", "model_id": "106-03", "name": "CV Kraan Garage", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { "battery": 68, "setpoint": 5.5, @@ -428,39 +529,21 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 5.5, - "upper_bound": 100.0 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A11" }, "f1fee6043d3642a9b0a65297455f008e": { - "active_preset": "away", "available": true, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], "binary_sensors": { "low_battery": false }, - "climate_mode": "auto", - "dev_class": "zone_thermostat", + "dev_class": "thermostatic_radiator_valve", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "08963fec7c53423ca5680aa4cb502c63", "model": "Lisa", "model_id": "158-01", - "name": "Zone Thermostat Badkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "Badkamer Schema", + "name": "Thermostatic Radiator Badkamer 2", "sensors": { "battery": 92, "setpoint": 14.0, @@ -472,12 +555,6 @@ "setpoint": 0.0, "upper_bound": 2.0 }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 14.0, - "upper_bound": 99.9 - }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A08" }, @@ -505,7 +582,7 @@ "cooling_present": false, "gateway_id": "fe799307f1624099878210aa0b9f1475", "heater_id": "90986d591dcd426cae3ec3e8111ff730", - "item_count": 340, + "item_count": 369, "notifications": { "af82e4ccf9c548528166d38e560662a4": { "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index 74f20379d68..eaa42facf10 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -62,6 +62,7 @@ "active_preset": "home", "available_schedules": ["standaard", "off"], "climate_mode": "auto", + "control_state": "cooling", "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 3b1e9bf8cac..52645b0f317 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -62,6 +62,7 @@ "active_preset": "home", "available_schedules": ["standaard", "off"], "climate_mode": "auto", + "control_state": "idle", "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index a875324fc13..b1675116bdf 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -96,7 +96,8 @@ "name": "Schakel", "switches": { "relay": true - } + }, + "vendor": "Plugwise" }, "d950b314e9d8499f968e6db8d82ef78c": { "dev_class": "report", @@ -111,7 +112,8 @@ "name": "Stroomvreters", "switches": { "relay": true - } + }, + "vendor": "Plugwise" }, "e1c884e7dede431dadee09506ec4f859": { "dev_class": "refrigerator", diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 2a8223a2568..806c92fe7cb 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -23,6 +23,92 @@ 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A15', }), + '08963fec7c53423ca5680aa4cb502c63': dict({ + 'active_preset': 'away', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Badkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'Badkamer Schema', + 'sensors': dict({ + 'temperature': 18.9, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 14.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'f1fee6043d3642a9b0a65297455f008e', + '680423ff840043738f42cc7f1ff97a36', + ]), + 'secondary': list([ + ]), + }), + 'vendor': 'Plugwise', + }), + '12493538af164a409c6a1c79e38afe1c': dict({ + 'active_preset': 'away', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'heat', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Bios', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'off', + 'sensors': dict({ + 'electricity_consumed': 0.0, + 'electricity_produced': 0.0, + 'temperature': 16.5, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 13.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'df4a4a8169904cdb9c03d61a21f42140', + ]), + 'secondary': list([ + 'a2c3583e0a6349358998b760cea82d2a', + ]), + }), + 'vendor': 'Plugwise', + }), '21f2b542c49845e6bb416884c55778d6': dict({ 'available': True, 'dev_class': 'game_console_plug', @@ -44,6 +130,38 @@ 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A12', }), + '446ac08dd04d4eff8ac57489757b7314': dict({ + 'active_preset': 'no_frost', + 'climate_mode': 'heat', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Garage', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'sensors': dict({ + 'temperature': 15.6, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 5.5, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'e7693eb9582644e5b865dba8d4447cf1', + ]), + 'secondary': list([ + ]), + }), + 'vendor': 'Plugwise', + }), '4a810418d5394b3f82727340b91ba740': dict({ 'available': True, 'dev_class': 'router_plug', @@ -91,13 +209,13 @@ 'binary_sensors': dict({ 'low_battery': False, }), - 'dev_class': 'thermo_sensor', + 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': '08963fec7c53423ca5680aa4cb502c63', 'model': 'Tom/Floor', 'model_id': '106-03', - 'name': 'Thermostatic Radiator Badkamer', + 'name': 'Thermostatic Radiator Badkamer 1', 'sensors': dict({ 'battery': 51, 'setpoint': 14.0, @@ -115,20 +233,10 @@ 'zigbee_mac_address': 'ABCD012345670A17', }), '6a3bf693d05e48e0b460c815a4fdd09d': dict({ - 'active_preset': 'asleep', 'available': True, - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', - ]), 'binary_sensors': dict({ 'low_battery': False, }), - 'climate_mode': 'auto', 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', @@ -136,14 +244,6 @@ 'model': 'Lisa', 'model_id': '158-01', 'name': 'Zone Thermostat Jessie', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), - 'select_schedule': 'CV Jessie', 'sensors': dict({ 'battery': 37, 'setpoint': 15.0, @@ -155,12 +255,6 @@ 'setpoint': 0.0, 'upper_bound': 2.0, }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 15.0, - 'upper_bound': 99.9, - }), 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A03', }), @@ -184,6 +278,48 @@ 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A05', }), + '82fa13f017d240daa0d0ea1775420f24': dict({ + 'active_preset': 'asleep', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Jessie', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'CV Jessie', + 'sensors': dict({ + 'temperature': 17.2, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 15.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + '6a3bf693d05e48e0b460c815a4fdd09d', + ]), + 'secondary': list([ + 'd3da73bde12a47d5a6b8f9dad971f2ec', + ]), + }), + 'vendor': 'Plugwise', + }), '90986d591dcd426cae3ec3e8111ff730': dict({ 'binary_sensors': dict({ 'heating_state': True, @@ -224,7 +360,7 @@ 'binary_sensors': dict({ 'low_battery': False, }), - 'dev_class': 'thermo_sensor', + 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': '12493538af164a409c6a1c79e38afe1c', @@ -249,7 +385,7 @@ }), 'b310b72a0e354bfab43089919b9a88bf': dict({ 'available': True, - 'dev_class': 'thermo_sensor', + 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': 'c50f167537524366a5af7aa3942feb1e', @@ -272,20 +408,10 @@ 'zigbee_mac_address': 'ABCD012345670A02', }), 'b59bcebaf94b499ea7d46e4a66fb62d8': dict({ - 'active_preset': 'home', 'available': True, - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', - ]), 'binary_sensors': dict({ 'low_battery': False, }), - 'climate_mode': 'auto', 'dev_class': 'zone_thermostat', 'firmware': '2016-08-02T02:00:00+02:00', 'hardware': '255', @@ -293,14 +419,6 @@ 'model': 'Lisa', 'model_id': '158-01', 'name': 'Zone Lisa WK', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), - 'select_schedule': 'GF7 Woonkamer', 'sensors': dict({ 'battery': 34, 'setpoint': 21.5, @@ -312,14 +430,52 @@ 'setpoint': 0.0, 'upper_bound': 2.0, }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A07', + }), + 'c50f167537524366a5af7aa3942feb1e': dict({ + 'active_preset': 'home', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'heating', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Woonkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'GF7 Woonkamer', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'electricity_produced': 0.0, + 'temperature': 20.9, + }), 'thermostat': dict({ 'lower_bound': 0.0, 'resolution': 0.01, 'setpoint': 21.5, - 'upper_bound': 99.9, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'b59bcebaf94b499ea7d46e4a66fb62d8', + ]), + 'secondary': list([ + 'b310b72a0e354bfab43089919b9a88bf', + ]), }), 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A07', }), 'cd0ddb54ef694e11ac18ed1cbce5dbbd': dict({ 'available': True, @@ -347,7 +503,7 @@ 'binary_sensors': dict({ 'low_battery': False, }), - 'dev_class': 'thermo_sensor', + 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': '82fa13f017d240daa0d0ea1775420f24', @@ -371,20 +527,10 @@ 'zigbee_mac_address': 'ABCD012345670A10', }), 'df4a4a8169904cdb9c03d61a21f42140': dict({ - 'active_preset': 'away', 'available': True, - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', - ]), 'binary_sensors': dict({ 'low_battery': False, }), - 'climate_mode': 'heat', 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', @@ -392,14 +538,6 @@ 'model': 'Lisa', 'model_id': '158-01', 'name': 'Zone Lisa Bios', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), - 'select_schedule': 'off', 'sensors': dict({ 'battery': 67, 'setpoint': 13.0, @@ -411,22 +549,14 @@ 'setpoint': 0.0, 'upper_bound': 2.0, }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 13.0, - 'upper_bound': 99.9, - }), 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A06', }), 'e7693eb9582644e5b865dba8d4447cf1': dict({ - 'active_preset': 'no_frost', 'available': True, 'binary_sensors': dict({ 'low_battery': False, }), - 'climate_mode': 'heat', 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', @@ -434,13 +564,6 @@ 'model': 'Tom/Floor', 'model_id': '106-03', 'name': 'CV Kraan Garage', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), 'sensors': dict({ 'battery': 68, 'setpoint': 5.5, @@ -454,45 +577,21 @@ 'setpoint': 0.0, 'upper_bound': 2.0, }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 5.5, - 'upper_bound': 100.0, - }), 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A11', }), 'f1fee6043d3642a9b0a65297455f008e': dict({ - 'active_preset': 'away', 'available': True, - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', - ]), 'binary_sensors': dict({ 'low_battery': False, }), - 'climate_mode': 'auto', - 'dev_class': 'zone_thermostat', + 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', 'location': '08963fec7c53423ca5680aa4cb502c63', 'model': 'Lisa', 'model_id': '158-01', - 'name': 'Zone Thermostat Badkamer', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), - 'select_schedule': 'Badkamer Schema', + 'name': 'Thermostatic Radiator Badkamer 2', 'sensors': dict({ 'battery': 92, 'setpoint': 14.0, @@ -504,12 +603,6 @@ 'setpoint': 0.0, 'upper_bound': 2.0, }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 14.0, - 'upper_bound': 99.9, - }), 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A08', }), @@ -537,7 +630,7 @@ 'cooling_present': False, 'gateway_id': 'fe799307f1624099878210aa0b9f1475', 'heater_id': '90986d591dcd426cae3ec3e8111ff730', - 'item_count': 340, + 'item_count': 369, 'notifications': dict({ 'af82e4ccf9c548528166d38e560662a4': dict({ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index 5c0e3fbdd2e..554326a72b1 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity @@ -9,32 +11,30 @@ from homeassistant.helpers.entity_component import async_update_entity from tests.common import MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize( + ("entity_id", "expected_state"), + [ + ("binary_sensor.opentherm_secondary_boiler_state", STATE_OFF), + ("binary_sensor.opentherm_dhw_state", STATE_OFF), + ("binary_sensor.opentherm_heating", STATE_ON), + ("binary_sensor.opentherm_cooling_enabled", STATE_OFF), + ("binary_sensor.opentherm_compressor_state", STATE_ON), + ], +) async def test_anna_climate_binary_sensor_entities( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_anna: MagicMock, + init_integration: MockConfigEntry, + entity_id: str, + expected_state: str, ) -> None: """Test creation of climate related binary_sensor entities.""" - - state = hass.states.get("binary_sensor.opentherm_secondary_boiler_state") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_dhw_state") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_heating") - assert state - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.opentherm_cooling_enabled") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_compressor_state") - assert state - assert state.state == STATE_ON + state = hass.states.get(entity_id) + assert state.state == expected_state +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_climate_binary_sensor_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -66,8 +66,12 @@ async def test_adam_climate_binary_sensor_change( assert not state.attributes.get("other_msg") -async def test_p1_v4_binary_sensor_entity( - hass: HomeAssistant, mock_smile_p1_2: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) +async def test_p1_binary_sensor_entity( + hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry ) -> None: """Test of a Smile P1 related plugwise-notification binary_sensor.""" state = hass.states.get("binary_sensor.smile_p1_plugwise_notification") diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index f846e818b6e..ab6bd3d4f29 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -8,12 +8,31 @@ from plugwise.exceptions import PlugwiseError import pytest from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, + PRESET_AWAY, + PRESET_HOME, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, + HVACAction, HVACMode, ) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -28,88 +47,90 @@ async def test_adam_climate_entity_attributes( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: """Test creation of adam climate device environment.""" - state = hass.states.get("climate.zone_lisa_wk") + state = hass.states.get("climate.woonkamer") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT] - # hvac_action is not asserted as the fixture is not in line with recent firmware functionality + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT] + assert ATTR_PRESET_MODES in state.attributes + assert "no_frost" in state.attributes[ATTR_PRESET_MODES] + assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] + assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.9 + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 17 + assert state.attributes[ATTR_TEMPERATURE] == 21.5 + assert state.attributes[ATTR_MIN_TEMP] == 0.0 + assert state.attributes[ATTR_MAX_TEMP] == 35.0 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 - assert "preset_modes" in state.attributes - assert "no_frost" in state.attributes["preset_modes"] - assert "home" in state.attributes["preset_modes"] - - assert state.attributes["current_temperature"] == 20.9 - assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 17 - assert state.attributes["temperature"] == 21.5 - assert state.attributes["min_temp"] == 0.0 - assert state.attributes["max_temp"] == 35.0 - assert state.attributes["target_temp_step"] == 0.1 - - state = hass.states.get("climate.zone_thermostat_jessie") + state = hass.states.get("climate.jessie") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT] - # hvac_action is not asserted as the fixture is not in line with recent firmware functionality - - assert "preset_modes" in state.attributes - assert "no_frost" in state.attributes["preset_modes"] - assert "home" in state.attributes["preset_modes"] - - assert state.attributes["current_temperature"] == 17.2 - assert state.attributes["preset_mode"] == "asleep" - assert state.attributes["temperature"] == 15.0 - assert state.attributes["min_temp"] == 0.0 - assert state.attributes["max_temp"] == 35.0 - assert state.attributes["target_temp_step"] == 0.1 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT] + assert ATTR_PRESET_MODES in state.attributes + assert "no_frost" in state.attributes[ATTR_PRESET_MODES] + assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] + assert state.attributes[ATTR_PRESET_MODE] == "asleep" + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 17.2 + assert state.attributes[ATTR_TEMPERATURE] == 15.0 + assert state.attributes[ATTR_MIN_TEMP] == 0.0 + assert state.attributes[ATTR_MAX_TEMP] == 35.0 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) async def test_adam_2_climate_entity_attributes( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test creation of adam climate device environment.""" - state = hass.states.get("climate.anna") + state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.HEAT - assert state.attributes["hvac_action"] == "preheating" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.PREHEATING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT, ] - state = hass.states.get("climate.lisa_badkamer") + state = hass.states.get("climate.bathroom") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "idle" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT, ] +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) async def test_adam_3_climate_entity_attributes( hass: HomeAssistant, - mock_smile_adam_3: MagicMock, + mock_smile_adam_heat_cool: MagicMock, init_integration: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test creation of adam climate device environment.""" - state = hass.states.get("climate.anna") + state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.COOL - assert state.attributes["hvac_action"] == "cooling" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, ] - data = mock_smile_adam_3.async_update.return_value + data = mock_smile_adam_heat_cool.async_update.return_value data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( "heating" ) - data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["control_state"] = "heating" + data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = ( + HVACAction.HEATING + ) data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = False @@ -121,21 +142,23 @@ async def test_adam_3_climate_entity_attributes( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("climate.anna") + state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.HEAT - assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT, ] - data = mock_smile_adam_3.async_update.return_value + data = mock_smile_adam_heat_cool.async_update.return_value data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( "cooling" ) - data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["control_state"] = "cooling" + data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = ( + HVACAction.COOLING + ) data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = True @@ -147,11 +170,11 @@ async def test_adam_3_climate_entity_attributes( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("climate.anna") + state = hass.states.get("climate.living_room") assert state assert state.state == HVACMode.COOL - assert state.attributes["hvac_action"] == "cooling" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, @@ -168,7 +191,7 @@ async def test_adam_climate_adjust_negative_testing( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {"entity_id": "climate.zone_lisa_wk", "temperature": 25}, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25}, blocking=True, ) @@ -180,7 +203,7 @@ async def test_adam_climate_entity_climate_changes( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {"entity_id": "climate.zone_lisa_wk", "temperature": 25}, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25}, blocking=True, ) assert mock_smile_adam.set_temperature.call_count == 1 @@ -192,9 +215,9 @@ async def test_adam_climate_entity_climate_changes( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - "entity_id": "climate.zone_lisa_wk", - "hvac_mode": "heat", - "temperature": 25, + ATTR_ENTITY_ID: "climate.woonkamer", + ATTR_HVAC_MODE: HVACMode.HEAT, + ATTR_TEMPERATURE: 25, }, blocking=True, ) @@ -203,43 +226,43 @@ async def test_adam_climate_entity_climate_changes( "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} ) - with pytest.raises(ServiceValidationError): + with pytest.raises(ServiceValidationError, match="Accepted range"): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {"entity_id": "climate.zone_lisa_wk", "temperature": 150}, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 150}, blocking=True, ) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {"entity_id": "climate.zone_lisa_wk", "preset_mode": "away"}, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, ) assert mock_smile_adam.set_preset.call_count == 1 mock_smile_adam.set_preset.assert_called_with( - "c50f167537524366a5af7aa3942feb1e", "away" + "c50f167537524366a5af7aa3942feb1e", PRESET_AWAY ) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {"entity_id": "climate.zone_lisa_wk", "hvac_mode": "heat"}, + {ATTR_ENTITY_ID: "climate.woonkamer", ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) assert mock_smile_adam.set_schedule_state.call_count == 2 mock_smile_adam.set_schedule_state.assert_called_with( - "c50f167537524366a5af7aa3942feb1e", "off" + "c50f167537524366a5af7aa3942feb1e", HVACMode.OFF ) - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError, match="valid modes are"): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - "entity_id": "climate.zone_thermostat_jessie", - "hvac_mode": "dry", + ATTR_ENTITY_ID: "climate.jessie", + ATTR_HVAC_MODE: HVACMode.DRY, }, blocking=True, ) @@ -247,7 +270,7 @@ async def test_adam_climate_entity_climate_changes( async def test_adam_climate_off_mode_change( hass: HomeAssistant, - mock_smile_adam_4: MagicMock, + mock_smile_adam_jip: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test handling of user requests in adam climate device environment.""" @@ -258,14 +281,14 @@ async def test_adam_climate_off_mode_change( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - "entity_id": "climate.slaapkamer", - "hvac_mode": "heat", + ATTR_ENTITY_ID: "climate.slaapkamer", + ATTR_HVAC_MODE: HVACMode.HEAT, }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 1 - mock_smile_adam_4.set_regulation_mode.assert_called_with("heating") + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 1 + mock_smile_adam_jip.set_regulation_mode.assert_called_with("heating") state = hass.states.get("climate.kinderkamer") assert state @@ -274,14 +297,14 @@ async def test_adam_climate_off_mode_change( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - "entity_id": "climate.kinderkamer", - "hvac_mode": "off", + ATTR_ENTITY_ID: "climate.kinderkamer", + ATTR_HVAC_MODE: HVACMode.OFF, }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 2 - mock_smile_adam_4.set_regulation_mode.assert_called_with("off") + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 2 + mock_smile_adam_jip.set_regulation_mode.assert_called_with("off") state = hass.states.get("climate.logeerkamer") assert state @@ -290,15 +313,16 @@ async def test_adam_climate_off_mode_change( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - "entity_id": "climate.logeerkamer", - "hvac_mode": "heat", + ATTR_ENTITY_ID: "climate.logeerkamer", + ATTR_HVAC_MODE: HVACMode.HEAT, }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 2 + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 2 +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -308,57 +332,60 @@ async def test_anna_climate_entity_attributes( state = hass.states.get("climate.anna") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [HVACMode.AUTO, HVACMode.HEAT_COOL] + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT_COOL] - assert "no_frost" in state.attributes["preset_modes"] - assert "home" in state.attributes["preset_modes"] + assert "no_frost" in state.attributes[ATTR_PRESET_MODES] + assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES] - assert state.attributes["current_temperature"] == 19.3 - assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 18 - assert state.attributes["target_temp_high"] == 30 - assert state.attributes["target_temp_low"] == 20.5 - assert state.attributes["min_temp"] == 4 - assert state.attributes["max_temp"] == 30 - assert state.attributes["target_temp_step"] == 0.1 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 19.3 + assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18 + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30 + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 + assert state.attributes[ATTR_MIN_TEMP] == 4 + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) async def test_anna_2_climate_entity_attributes( hass: HomeAssistant, - mock_smile_anna_2: MagicMock, + mock_smile_anna: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of anna climate device environment.""" state = hass.states.get("climate.anna") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "cooling" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.AUTO, HVACMode.HEAT_COOL, ] - assert state.attributes["supported_features"] == 18 - assert state.attributes["target_temp_high"] == 30 - assert state.attributes["target_temp_low"] == 20.5 + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18 + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30 + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) async def test_anna_3_climate_entity_attributes( hass: HomeAssistant, - mock_smile_anna_3: MagicMock, + mock_smile_anna: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of anna climate device environment.""" state = hass.states.get("climate.anna") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "idle" - assert state.attributes["hvac_modes"] == [ + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.AUTO, HVACMode.HEAT_COOL, ] +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_climate_entity_climate_changes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -369,7 +396,11 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {"entity_id": "climate.anna", "target_temp_high": 30, "target_temp_low": 20}, + { + ATTR_ENTITY_ID: "climate.anna", + ATTR_TARGET_TEMP_HIGH: 30, + ATTR_TARGET_TEMP_LOW: 20, + }, blocking=True, ) assert mock_smile_anna.set_temperature.call_count == 1 @@ -381,18 +412,18 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {"entity_id": "climate.anna", "preset_mode": "away"}, + {ATTR_ENTITY_ID: "climate.anna", ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, ) assert mock_smile_anna.set_preset.call_count == 1 mock_smile_anna.set_preset.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "away" + "c784ee9fdab44e1395b8dee7d7a497d5", PRESET_AWAY ) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {"entity_id": "climate.anna", "hvac_mode": "auto"}, + {ATTR_ENTITY_ID: "climate.anna", ATTR_HVAC_MODE: HVACMode.AUTO}, blocking=True, ) # hvac_mode is already auto so not called. @@ -401,12 +432,12 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {"entity_id": "climate.anna", "hvac_mode": "heat_cool"}, + {ATTR_ENTITY_ID: "climate.anna", ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, blocking=True, ) assert mock_smile_anna.set_schedule_state.call_count == 1 mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "off" + "c784ee9fdab44e1395b8dee7d7a497d5", HVACMode.OFF ) data = mock_smile_anna.async_update.return_value @@ -418,4 +449,4 @@ async def test_anna_climate_entity_climate_changes( state = hass.states.get("climate.anna") assert state.state == HVACMode.HEAT - assert state.attributes["hvac_modes"] == [HVACMode.HEAT_COOL] + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT_COOL] diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index baf6edea9c7..16af7065c49 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -13,8 +13,7 @@ from plugwise.exceptions import ( import pytest from homeassistant.components.plugwise.const import DEFAULT_PORT, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -25,6 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -35,6 +35,7 @@ TEST_PASSWORD = "test_password" TEST_PORT = 81 TEST_USERNAME = "smile" TEST_USERNAME2 = "stretch" +TEST_SMILE_HOST = "smile12345" TEST_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), @@ -128,6 +129,8 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_smile_config_flow.connect.mock_calls) == 1 + assert result2["result"].unique_id == TEST_SMILE_HOST + @pytest.mark.parametrize( ("discovery", "username"), @@ -172,6 +175,8 @@ async def test_zeroconf_flow( assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_smile_config_flow.connect.mock_calls) == 1 + assert result2["result"].unique_id == TEST_SMILE_HOST + async def test_zeroconf_flow_stretch( hass: HomeAssistant, @@ -269,7 +274,7 @@ async def test_flow_errors( side_effect: Exception, reason: str, ) -> None: - """Test we handle invalid auth.""" + """Test we handle each exception error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, @@ -280,6 +285,7 @@ async def test_flow_errors( assert "flow_id" in result mock_smile_config_flow.connect.side_effect = side_effect + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD}, @@ -311,6 +317,69 @@ async def test_flow_errors( assert len(mock_smile_config_flow.connect.mock_calls) == 2 +async def test_user_abort_existing_anna( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_smile_config_flow: MagicMock, +) -> None: + """Test the full user configuration flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=CONF_NAME, + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id=TEST_SMILE_HOST, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: TEST_HOST, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "already_configured" + + +async def test_zeroconf_abort_existing_anna( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_smile_config_flow: MagicMock, +) -> None: + """Test the full user configuration flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=CONF_NAME, + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id=TEST_HOSTNAME, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_ZEROCONF}, + data=TEST_DISCOVERY_ANNA, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + async def test_zeroconf_abort_anna_with_existing_config_entries( hass: HomeAssistant, mock_smile_adam: MagicMock, @@ -367,3 +436,91 @@ async def test_zeroconf_abort_anna_with_adam(hass: HomeAssistant) -> None: flows_in_progress = hass.config_entries.flow._handler_progress_index[DOMAIN] assert len(flows_in_progress) == 1 assert list(flows_in_progress)[0].product == "smile_open_therm" + + +async def _start_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + host_ip: str, +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + + return await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], {CONF_HOST: host_ip} + ) + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_smile_adam: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_HOST) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert mock_config_entry.data.get(CONF_HOST) == TEST_HOST + + +async def test_reconfigure_flow_smile_mismatch( + hass: HomeAssistant, + mock_smile_adam: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow aborts on other Smile ID.""" + mock_smile_adam.smile_hostname = TEST_SMILE_HOST + + result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_HOST) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_the_same_smile" + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (ConnectionFailedError, "cannot_connect"), + (InvalidAuthentication, "invalid_auth"), + (InvalidSetupError, "invalid_setup"), + (InvalidXMLError, "response_error"), + (RuntimeError, "unknown"), + (UnsupportedDeviceError, "unsupported"), + ], +) +async def test_reconfigure_flow_connect_errors( + hass: HomeAssistant, + mock_smile_adam: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + reason: str, +) -> None: + """Test we handle each reconfigure exception error and recover.""" + + mock_smile_adam.connect.side_effect = side_effect + + result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_HOST) + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": reason} + assert result.get("step_id") == "reconfigure" + + mock_smile_adam.connect.side_effect = None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert mock_config_entry.data.get(CONF_HOST) == TEST_HOST diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 5b276d5018d..874c4b61a47 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -19,7 +19,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_time_changed @@ -34,17 +33,18 @@ SECONDARY_ID = ( TOM = { "01234567890abcdefghijklmnopqrstu": { "available": True, - "dev_class": "thermo_sensor", + "dev_class": "thermostatic_radiator_valve", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "f871b8c4d63549319221e294e4f88074", "model": "Tom/Floor", - "name": "Tom Zolder", + "name": "Tom Badkamer 2", "binary_sensors": { "low_battery": False, }, "sensors": { "battery": 99, + "setpoint": 18.0, "temperature": 18.6, "temperature_difference": 2.3, "valve_position": 0.0, @@ -61,6 +61,7 @@ TOM = { } +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -77,10 +78,10 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize( ("side_effect", "entry_state"), [ @@ -110,6 +111,10 @@ async def test_gateway_config_entry_not_ready( assert mock_config_entry.state is entry_state +@pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["a455b61e52394b2db5081ce025a430f3"], indirect=True +) async def test_device_in_dr( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -118,7 +123,7 @@ async def test_device_in_dr( ) -> None: """Test Gateway device registry data.""" mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() device_entry = device_registry.async_get_device( @@ -132,6 +137,7 @@ async def test_device_in_dr( assert device_entry.sw_version == "4.4.2" +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize( ("entitydata", "old_unique_id", "new_unique_id"), [ @@ -225,19 +231,20 @@ async def test_migrate_unique_id_relay( assert entity_migrated.unique_id == new_unique_id +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) async def test_update_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smile_adam_2: MagicMock, + mock_smile_adam_heat_cool: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test a clean-up of the device_registry.""" - data = mock_smile_adam_2.async_update.return_value + data = mock_smile_adam_heat_cool.async_update.return_value mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert ( @@ -246,7 +253,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 31 + == 38 ) assert ( len( @@ -254,11 +261,19 @@ async def test_update_device( device_registry, mock_config_entry.entry_id ) ) - == 6 + == 8 ) # Add a 2nd Tom/Floor data.devices.update(TOM) + data.devices["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( + { + "secondary": [ + "01234567890abcdefghijklmnopqrstu", + "1772a4ea304041adb83f357b751341ff", + ] + } + ) with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) @@ -270,7 +285,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 37 + == 45 ) assert ( len( @@ -278,7 +293,7 @@ async def test_update_device( device_registry, mock_config_entry.entry_id ) ) - == 7 + == 9 ) item_list: list[str] = [] for device_entry in list(device_registry.devices.values()): @@ -286,6 +301,9 @@ async def test_update_device( assert "01234567890abcdefghijklmnopqrstu" in item_list # Remove the existing Tom/Floor + data.devices["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( + {"secondary": ["01234567890abcdefghijklmnopqrstu"]} + ) data.devices.pop("1772a4ea304041adb83f357b751341ff") with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) @@ -298,7 +316,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 31 + == 38 ) assert ( len( @@ -306,7 +324,7 @@ async def test_update_device( device_registry, mock_config_entry.entry_id ) ) - == 6 + == 8 ) item_list: list[str] = [] for device_entry in list(device_registry.devices.values()): diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index e10a7caa9e9..c5361433388 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -9,10 +11,12 @@ from homeassistant.components.number import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from tests.common import MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_number_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -22,6 +26,7 @@ async def test_anna_number_entities( assert float(state.state) == 60.0 +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_max_boiler_temp_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -42,19 +47,17 @@ async def test_anna_max_boiler_temp_change( ) -async def test_adam_number_entities( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +async def test_adam_dhw_setpoint_change( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: - """Test creation of a number.""" + """Test changing of number entities.""" state = hass.states.get("number.opentherm_domestic_hot_water_setpoint") assert state assert float(state.state) == 60.0 - -async def test_adam_dhw_setpoint_change( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test changing of number entities.""" await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -65,8 +68,8 @@ async def test_adam_dhw_setpoint_change( blocking=True, ) - assert mock_smile_adam_2.set_number.call_count == 1 - mock_smile_adam_2.set_number.assert_called_with( + assert mock_smile_adam_heat_cool.set_number.call_count == 1 + mock_smile_adam_heat_cool.set_number.assert_called_with( "056ee145a816487eaa69243c3280f8bf", "max_dhw_temperature", 55.0 ) @@ -101,3 +104,19 @@ async def test_adam_temperature_offset_change( mock_smile_adam.set_number.assert_called_with( "6a3bf693d05e48e0b460c815a4fdd09d", "temperature_offset", 1.0 ) + + +async def test_adam_temperature_offset_out_of_bounds_change( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test changing of the temperature_offset number beyond limits.""" + with pytest.raises(ServiceValidationError, match="valid range"): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.zone_thermostat_jessie_temperature_offset", + ATTR_VALUE: 3.0, + }, + blocking=True, + ) diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index f521787714b..f06d07767f3 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -9,6 +11,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from tests.common import MockConfigEntry @@ -18,7 +21,7 @@ async def test_adam_select_entities( ) -> None: """Test a thermostat Select.""" - state = hass.states.get("select.zone_lisa_wk_thermostat_schedule") + state = hass.states.get("select.woonkamer_thermostat_schedule") assert state assert state.state == "GF7 Woonkamer" @@ -32,7 +35,7 @@ async def test_adam_change_select_entity( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { - ATTR_ENTITY_ID: "select.zone_lisa_wk_thermostat_schedule", + ATTR_ENTITY_ID: "select.woonkamer_thermostat_schedule", ATTR_OPTION: "Badkamer Schema", }, blocking=True, @@ -47,8 +50,11 @@ async def test_adam_change_select_entity( ) +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) async def test_adam_select_regulation_mode( - hass: HomeAssistant, mock_smile_adam_3: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test a regulation_mode select. @@ -65,13 +71,13 @@ async def test_adam_select_regulation_mode( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { - "entity_id": "select.adam_regulation_mode", - "option": "heating", + ATTR_ENTITY_ID: "select.adam_regulation_mode", + ATTR_OPTION: "heating", }, blocking=True, ) - assert mock_smile_adam_3.set_select.call_count == 1 - mock_smile_adam_3.set_select.assert_called_with( + assert mock_smile_adam_heat_cool.set_select.call_count == 1 + mock_smile_adam_heat_cool.set_select.assert_called_with( "select_regulation_mode", "bc93488efab249e5bc54fd7e175a6f91", "heating", @@ -86,3 +92,21 @@ async def test_legacy_anna_select_entities( ) -> None: """Test not creating a select-entity for a legacy Anna without a thermostat-schedule.""" assert not hass.states.get("select.anna_thermostat_schedule") + + +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +async def test_adam_select_unavailable_regulation_mode( + hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test a regulation_mode non-available preset.""" + + with pytest.raises(ServiceValidationError, match="valid options"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.anna_thermostat_schedule", + ATTR_OPTION: "freezing", + }, + blocking=True, + ) diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index 0745adb786a..b3243d6b127 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.components.plugwise.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -39,7 +41,9 @@ async def test_adam_climate_sensor_entities( async def test_adam_climate_sensor_entity_2( - hass: HomeAssistant, mock_smile_adam_4: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_jip: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test creation of climate related sensor entities.""" state = hass.states.get("sensor.woonkamer_humidity") @@ -50,7 +54,7 @@ async def test_adam_climate_sensor_entity_2( async def test_unique_id_migration_humidity( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_adam_4: MagicMock, + mock_smile_adam_jip: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test unique ID migration of -relative_humidity to -humidity.""" @@ -90,6 +94,7 @@ async def test_unique_id_migration_humidity( assert entity_entry.unique_id == "f61f1a2535f54f52ad006a3d18e459ca-battery" +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) async def test_anna_as_smt_climate_sensor_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -111,6 +116,10 @@ async def test_anna_as_smt_climate_sensor_entities( assert float(state.state) == 86.0 +@pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["a455b61e52394b2db5081ce025a430f3"], indirect=True +) async def test_p1_dsmr_sensor_entities( hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -135,10 +144,15 @@ async def test_p1_dsmr_sensor_entities( assert not state +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_p1_3ph_dsmr_sensor_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_p1_2: MagicMock, + mock_smile_p1: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of power related sensor entities.""" @@ -154,21 +168,27 @@ async def test_p1_3ph_dsmr_sensor_entities( assert state assert int(state.state) == 2080 - entity_id = "sensor.p1_voltage_phase_one" - state = hass.states.get(entity_id) - assert not state - - entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) - await hass.async_block_till_done() - - await hass.config_entries.async_reload(init_integration.entry_id) - await hass.async_block_till_done() - + # Default disabled sensor test state = hass.states.get("sensor.p1_voltage_phase_one") assert state assert float(state.state) == 233.2 +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) +async def test_p1_3ph_dsmr_sensor_disabled_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_smile_p1: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test disabled power related sensor entities intent.""" + state = hass.states.get("sensor.p1_voltage_phase_one") + assert not state + + async def test_stretch_sensor_entities( hass: HomeAssistant, mock_stretch: MagicMock, init_integration: MockConfigEntry ) -> None: diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py index d9a4792ddb1..fa8a8a434e7 100644 --- a/tests/components/plugwise/test_switch.py +++ b/tests/components/plugwise/test_switch.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.plugwise.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -44,7 +45,7 @@ async def test_adam_climate_switch_negative_testing( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {"entity_id": "switch.cv_pomp_relay"}, + {ATTR_ENTITY_ID: "switch.cv_pomp_relay"}, blocking=True, ) @@ -57,7 +58,7 @@ async def test_adam_climate_switch_negative_testing( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {"entity_id": "switch.fibaro_hc2_relay"}, + {ATTR_ENTITY_ID: "switch.fibaro_hc2_relay"}, blocking=True, ) @@ -74,7 +75,7 @@ async def test_adam_climate_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {"entity_id": "switch.cv_pomp_relay"}, + {ATTR_ENTITY_ID: "switch.cv_pomp_relay"}, blocking=True, ) @@ -86,7 +87,7 @@ async def test_adam_climate_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TOGGLE, - {"entity_id": "switch.fibaro_hc2_relay"}, + {ATTR_ENTITY_ID: "switch.fibaro_hc2_relay"}, blocking=True, ) @@ -98,7 +99,7 @@ async def test_adam_climate_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {"entity_id": "switch.fibaro_hc2_relay"}, + {ATTR_ENTITY_ID: "switch.fibaro_hc2_relay"}, blocking=True, ) @@ -128,7 +129,7 @@ async def test_stretch_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {"entity_id": "switch.koelkast_92c4a_relay"}, + {ATTR_ENTITY_ID: "switch.koelkast_92c4a_relay"}, blocking=True, ) assert mock_stretch.set_switch_state.call_count == 1 @@ -139,7 +140,7 @@ async def test_stretch_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TOGGLE, - {"entity_id": "switch.droger_52559_relay"}, + {ATTR_ENTITY_ID: "switch.droger_52559_relay"}, blocking=True, ) assert mock_stretch.set_switch_state.call_count == 2 @@ -150,7 +151,7 @@ async def test_stretch_switch_changes( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {"entity_id": "switch.droger_52559_relay"}, + {ATTR_ENTITY_ID: "switch.droger_52559_relay"}, blocking=True, ) assert mock_stretch.set_switch_state.call_count == 3 diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index bd1e3cfac29..ea003af86c7 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -47,7 +47,7 @@ async def test_full_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - state = config_entry_oauth2_flow._encode_jwt( # noqa: SLF001 + state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], diff --git a/tests/components/powerfox/__init__.py b/tests/components/powerfox/__init__.py new file mode 100644 index 00000000000..d24e52eba9b --- /dev/null +++ b/tests/components/powerfox/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Powerfox integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_DIRECT_HOST = "1.1.1.1" + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/powerfox/conftest.py b/tests/components/powerfox/conftest.py new file mode 100644 index 00000000000..1d930394254 --- /dev/null +++ b/tests/components/powerfox/conftest.py @@ -0,0 +1,103 @@ +"""Common fixtures for the Powerfox tests.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from powerfox import Device, DeviceType, HeatMeter, PowerMeter, WaterMeter +import pytest + +from homeassistant.components.powerfox.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.powerfox.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_powerfox_client() -> Generator[AsyncMock]: + """Mock a Powerfox client.""" + with ( + patch( + "homeassistant.components.powerfox.Powerfox", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.powerfox.config_flow.Powerfox", + new=mock_client, + ), + ): + client = mock_client.return_value + client.all_devices.return_value = [ + Device( + id="9x9x1f12xx3x", + date_added=datetime(2024, 11, 26, 9, 22, 35, tzinfo=UTC), + main_device=True, + bidirectional=True, + type=DeviceType.POWER_METER, + name="Poweropti", + ), + Device( + id="9x9x1f12xx4x", + date_added=datetime(2024, 11, 26, 9, 22, 35, tzinfo=UTC), + main_device=False, + bidirectional=False, + type=DeviceType.COLD_WATER_METER, + name="Wateropti", + ), + Device( + id="9x9x1f12xx5x", + date_added=datetime(2024, 11, 26, 9, 22, 35, tzinfo=UTC), + main_device=False, + bidirectional=False, + type=DeviceType.HEAT_METER, + name="Heatopti", + ), + ] + client.device.side_effect = [ + PowerMeter( + outdated=False, + timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC), + power=111, + energy_usage=1111.111, + energy_return=111.111, + energy_usage_high_tariff=111.111, + energy_usage_low_tariff=111.111, + ), + WaterMeter( + outdated=False, + timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC), + cold_water=1111.111, + warm_water=0.0, + ), + HeatMeter( + outdated=False, + timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC), + total_energy=1111.111, + delta_energy=111, + total_volume=1111.111, + delta_volume=0.111, + ), + ] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a Powerfox config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Powerfox", + data={ + CONF_EMAIL: "test@powerfox.test", + CONF_PASSWORD: "test-password", + }, + ) diff --git a/tests/components/powerfox/snapshots/test_diagnostics.ambr b/tests/components/powerfox/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..d749a1b5b60 --- /dev/null +++ b/tests/components/powerfox/snapshots/test_diagnostics.ambr @@ -0,0 +1,36 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'devices': list([ + dict({ + 'power_meter': dict({ + 'energy_return': 111.111, + 'energy_usage': 1111.111, + 'energy_usage_high_tariff': 111.111, + 'energy_usage_low_tariff': 111.111, + 'outdated': False, + 'power': 111, + 'timestamp': '2024-11-26 10:48:51', + }), + }), + dict({ + 'water_meter': dict({ + 'cold_water': 1111.111, + 'outdated': False, + 'timestamp': '2024-11-26 10:48:51', + 'warm_water': 0.0, + }), + }), + dict({ + 'heat_meter': dict({ + 'delta_energy': 111, + 'delta_volume': 0.111, + 'outdated': False, + 'timestamp': '2024-11-26 10:48:51', + 'total_energy': 1111.111, + 'total_volume': 1111.111, + }), + }), + ]), + }) +# --- diff --git a/tests/components/powerfox/snapshots/test_sensor.ambr b/tests/components/powerfox/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a2aa8a9c72c --- /dev/null +++ b/tests/components/powerfox/snapshots/test_sensor.ambr @@ -0,0 +1,559 @@ +# serializer version: 1 +# name: test_all_sensors[sensor.heatopti_delta_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heatopti_delta_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Delta energy', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_delta_energy', + 'unique_id': '9x9x1f12xx5x_heat_delta_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.heatopti_delta_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heatopti Delta energy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heatopti_delta_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_all_sensors[sensor.heatopti_delta_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heatopti_delta_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Delta volume', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_delta_volume', + 'unique_id': '9x9x1f12xx5x_heat_delta_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.heatopti_delta_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Heatopti Delta volume', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heatopti_delta_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.111', + }) +# --- +# name: test_all_sensors[sensor.heatopti_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heatopti_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_total_energy', + 'unique_id': '9x9x1f12xx5x_heat_total_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.heatopti_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heatopti Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heatopti_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1111.111', + }) +# --- +# name: test_all_sensors[sensor.heatopti_total_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heatopti_total_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total volume', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_total_volume', + 'unique_id': '9x9x1f12xx5x_heat_total_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.heatopti_total_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Heatopti Total volume', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heatopti_total_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1111.111', + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_return-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.poweropti_energy_return', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy return', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_return', + 'unique_id': '9x9x1f12xx3x_energy_return', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_return-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Poweropti Energy return', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.poweropti_energy_return', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.poweropti_energy_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy usage', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage', + 'unique_id': '9x9x1f12xx3x_energy_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Poweropti Energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.poweropti_energy_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1111.111', + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage_high_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.poweropti_energy_usage_high_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy usage high tariff', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage_high_tariff', + 'unique_id': '9x9x1f12xx3x_energy_usage_high_tariff', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage_high_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Poweropti Energy usage high tariff', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.poweropti_energy_usage_high_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage_low_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.poweropti_energy_usage_low_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy usage low tariff', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_usage_low_tariff', + 'unique_id': '9x9x1f12xx3x_energy_usage_low_tariff', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.poweropti_energy_usage_low_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Poweropti Energy usage low tariff', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.poweropti_energy_usage_low_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_all_sensors[sensor.poweropti_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.poweropti_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '9x9x1f12xx3x_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.poweropti_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Poweropti Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.poweropti_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_all_sensors[sensor.wateropti_cold_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wateropti_cold_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cold water', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cold_water', + 'unique_id': '9x9x1f12xx4x_cold_water', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.wateropti_cold_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Wateropti Cold water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wateropti_cold_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1111.111', + }) +# --- +# name: test_all_sensors[sensor.wateropti_warm_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wateropti_warm_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Warm water', + 'platform': 'powerfox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'warm_water', + 'unique_id': '9x9x1f12xx4x_warm_water', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.wateropti_warm_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Wateropti Warm water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wateropti_warm_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/powerfox/test_config_flow.py b/tests/components/powerfox/test_config_flow.py new file mode 100644 index 00000000000..377ae9c622c --- /dev/null +++ b/tests/components/powerfox/test_config_flow.py @@ -0,0 +1,323 @@ +"""Test the Powerfox config flow.""" + +from unittest.mock import AsyncMock, patch + +from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError +import pytest + +from homeassistant.components.powerfox.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from . import MOCK_DIRECT_HOST + +from tests.common import MockConfigEntry + +MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( + ip_address=MOCK_DIRECT_HOST, + ip_addresses=[MOCK_DIRECT_HOST], + hostname="powerfox.local", + name="Powerfox", + port=443, + type="_http._tcp", + properties={}, +) + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "test@powerfox.test" + assert result.get("data") == { + CONF_EMAIL: "test@powerfox.test", + CONF_PASSWORD: "test-password", + } + assert len(mock_powerfox_client.all_devices.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY_INFO, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "test@powerfox.test" + assert result.get("data") == { + CONF_EMAIL: "test@powerfox.test", + CONF_PASSWORD: "test-password", + } + assert len(mock_powerfox_client.all_devices.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_powerfox_client: AsyncMock, +) -> None: + """Test abort when setting up duplicate entry.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_duplicate_entry_reconfiguration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_powerfox_client: AsyncMock, +) -> None: + """Test abort when setting up duplicate entry on reconfiguration.""" + # Add two config entries + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_EMAIL: "new@powerfox.test", CONF_PASSWORD: "new-password"}, + ) + mock_config_entry_2.add_to_hass(hass) + assert len(hass.config_entries.async_entries()) == 2 + + # Reconfigure the second entry + result = await mock_config_entry_2.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PowerfoxConnectionError, "cannot_connect"), + (PowerfoxAuthenticationError, "invalid_auth"), + ], +) +async def test_exceptions( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test exceptions during config flow.""" + mock_powerfox_client.all_devices.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": error} + + # Recover from error + mock_powerfox_client.all_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@powerfox.test", CONF_PASSWORD: "test-password"}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_step_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test re-authentication flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + with patch( + "homeassistant.components.powerfox.config_flow.Powerfox", + autospec=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PowerfoxConnectionError, "cannot_connect"), + (PowerfoxAuthenticationError, "invalid_auth"), + ], +) +async def test_step_reauth_exceptions( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test exceptions during re-authentication flow.""" + mock_powerfox_client.all_devices.side_effect = exception + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": error} + + # Recover from error + mock_powerfox_client.all_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +async def test_reconfigure( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration of existing entry.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EMAIL: "new-email@powerfox.test", + CONF_PASSWORD: "new-password", + }, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_EMAIL] == "new-email@powerfox.test" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PowerfoxConnectionError, "cannot_connect"), + (PowerfoxAuthenticationError, "invalid_auth"), + ], +) +async def test_reconfigure_exceptions( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test exceptions during reconfiguration flow.""" + mock_powerfox_client.all_devices.side_effect = exception + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EMAIL: "new-email@powerfox.test", + CONF_PASSWORD: "new-password", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": error} + + # Recover from error + mock_powerfox_client.all_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EMAIL: "new-email@powerfox.test", + CONF_PASSWORD: "new-password", + }, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert mock_config_entry.data[CONF_EMAIL] == "new-email@powerfox.test" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" diff --git a/tests/components/powerfox/test_diagnostics.py b/tests/components/powerfox/test_diagnostics.py new file mode 100644 index 00000000000..7dc2c3c7263 --- /dev/null +++ b/tests/components/powerfox/test_diagnostics.py @@ -0,0 +1,30 @@ +"""Test for PowerFox diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the PowerFox entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot diff --git a/tests/components/powerfox/test_init.py b/tests/components/powerfox/test_init.py new file mode 100644 index 00000000000..1ad60babc04 --- /dev/null +++ b/tests/components/powerfox/test_init.py @@ -0,0 +1,62 @@ +"""Test the Powerfox init module.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Powerfox configuration entry not ready.""" + mock_powerfox_client.all_devices.side_effect = PowerfoxConnectionError + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_exception( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test ConfigEntryNotReady when API raises an exception during entry setup.""" + mock_config_entry.add_to_hass(hass) + mock_powerfox_client.device.side_effect = PowerfoxAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" diff --git a/tests/components/powerfox/test_sensor.py b/tests/components/powerfox/test_sensor.py new file mode 100644 index 00000000000..547d8de202c --- /dev/null +++ b/tests/components/powerfox/test_sensor.py @@ -0,0 +1,53 @@ +"""Test the sensors provided by the Powerfox integration.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from powerfox import PowerfoxConnectionError +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_sensors( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Powerfox sensors.""" + with patch("homeassistant.components.powerfox.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_update_failed( + hass: HomeAssistant, + mock_powerfox_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entities become unavailable after failed update.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert hass.states.get("sensor.poweropti_energy_usage").state is not None + + mock_powerfox_client.device.side_effect = PowerfoxConnectionError + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.poweropti_energy_usage").state == STATE_UNAVAILABLE diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index 1ff1470f81c..cd4f1250aa4 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -11,12 +11,12 @@ from tesla_powerwall import ( ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.powerwall.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo import homeassistant.util.dt as dt_util from .mocks import ( @@ -161,7 +161,7 @@ async def test_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="any", @@ -188,7 +188,7 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -230,7 +230,7 @@ async def test_dhcp_discovery_manual_configure(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="any", @@ -272,7 +272,7 @@ async def test_dhcp_discovery_auto_configure(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -316,7 +316,7 @@ async def test_dhcp_discovery_cannot_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -394,7 +394,7 @@ async def test_dhcp_discovery_update_ip_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -431,7 +431,7 @@ async def test_dhcp_discovery_does_not_update_ip_when_auth_fails( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -468,7 +468,7 @@ async def test_dhcp_discovery_does_not_update_ip_when_auth_successful( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -503,7 +503,7 @@ async def test_dhcp_discovery_updates_unique_id(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -542,7 +542,7 @@ async def test_dhcp_discovery_updates_unique_id_when_entry_is_failed( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -581,7 +581,7 @@ async def test_discovered_wifi_does_not_update_ip_if_is_still_online( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -630,7 +630,7 @@ async def test_discovered_wifi_does_not_update_ip_online_but_access_denied( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), diff --git a/tests/components/powerwall/test_init.py b/tests/components/powerwall/test_init.py index e271cde0fc4..dd70dbb7c65 100644 --- a/tests/components/powerwall/test_init.py +++ b/tests/components/powerwall/test_init.py @@ -1,17 +1,23 @@ """Tests for the PowerwallDataManager.""" import datetime -from unittest.mock import patch +from http.cookies import Morsel +from unittest.mock import MagicMock, patch +from aiohttp import CookieJar from tesla_powerwall import AccessDeniedError, LoginResponse -from homeassistant.components.powerwall.const import DOMAIN +from homeassistant.components.powerwall.const import ( + AUTH_COOKIE_KEY, + CONFIG_ENTRY_COOKIE, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow -from .mocks import _mock_powerwall_with_fixtures +from .mocks import MOCK_GATEWAY_DIN, _mock_powerwall_with_fixtures from tests.common import MockConfigEntry, async_fire_time_changed @@ -37,7 +43,11 @@ async def test_update_data_reauthenticate_on_access_denied(hass: HomeAssistant) mock_powerwall.is_authenticated.return_value = True config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4", CONF_PASSWORD: "password"} + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "password", + }, ) config_entry.add_to_hass(hass) with ( @@ -72,3 +82,226 @@ async def test_update_data_reauthenticate_on_access_denied(hass: HomeAssistant) assert len(flows) == 1 reauth_flow = flows[0] assert reauth_flow["context"]["source"] == "reauth" + + +async def test_init_uses_cookie_if_present(hass: HomeAssistant) -> None: + """Tests if the init will use the auth cookie if present. + + If the cookie is present, the login step will be skipped and info will be fetched directly (see _login_and_fetch_base_info). + """ + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert not mock_powerwall.login.called + assert mock_powerwall.get_gateway_din.called + + +async def test_init_uses_password_if_no_cookies(hass: HomeAssistant) -> None: + """Tests if the init will use the password if no auth cookie present.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_charge.called + + +async def test_init_saves_the_cookie(hass: HomeAssistant) -> None: + """Tests that the cookie is properly saved.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + mock_jar = MagicMock(CookieJar) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + patch("homeassistant.components.powerwall.CookieJar", return_value=mock_jar), + ): + auth_cookie = Morsel() + auth_cookie.set(AUTH_COOKIE_KEY, "somecookie", "somecookie") + mock_jar.__iter__.return_value = [auth_cookie] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data[CONFIG_ENTRY_COOKIE] == "somecookie" + + +async def test_retry_ignores_cookie(hass: HomeAssistant) -> None: + """Tests that retrying uses the password instead.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert not mock_powerwall.login.called + assert mock_powerwall.get_gateway_din.called + + mock_powerwall.login.reset_mock() + mock_powerwall.get_charge.reset_mock() + + mock_powerwall.get_charge.side_effect = [AccessDeniedError("test"), 90.0] + + async_fire_time_changed(hass, utcnow() + datetime.timedelta(minutes=1)) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_charge.call_count == 2 + + +async def test_reauth_ignores_and_clears_cookie(hass: HomeAssistant) -> None: + """Tests that the reauth flow uses password and clears the cookie.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_powerwall.login.reset_mock() + mock_powerwall.get_charge.reset_mock() + + mock_powerwall.get_charge.side_effect = [ + AccessDeniedError("test"), + AccessDeniedError("test"), + ] + + async_fire_time_changed(hass, utcnow() + datetime.timedelta(minutes=1)) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_charge.call_count == 2 + + flows = hass.config_entries.flow.async_progress(DOMAIN) + assert len(flows) == 1 + reauth_flow = flows[0] + assert reauth_flow["context"]["source"] == "reauth" + + mock_powerwall.login.reset_mock() + assert config_entry.data[CONFIG_ENTRY_COOKIE] is not None + + await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], {CONF_PASSWORD: "somepassword"} + ) + + mock_powerwall.login.assert_called_with("somepassword") + assert config_entry.data[CONFIG_ENTRY_COOKIE] is None + + +async def test_init_retries_with_password(hass: HomeAssistant) -> None: + """Tests that the init retries with password if cookie fails.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + mock_powerwall.get_gateway_din.side_effect = [ + AccessDeniedError("get_gateway_din"), + MOCK_GATEWAY_DIN, + ] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_gateway_din.call_count == 2 diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 37940df437b..540e644aca4 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -5,7 +5,6 @@ from functools import lru_cache import logging import os from pathlib import Path -import sys from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory @@ -71,9 +70,6 @@ async def test_basic_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() -@pytest.mark.skipif( - sys.version_info >= (3, 13), reason="not yet available on Python 3.13" -) async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: """Test we can setup and the service is registered.""" test_dir = tmp_path / "profiles" @@ -105,24 +101,6 @@ async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() -@pytest.mark.skipif(sys.version_info < (3, 13), reason="still works on python 3.12") -async def test_memory_usage_py313(hass: HomeAssistant, tmp_path: Path) -> None: - """Test raise an error on python3.13.""" - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert hass.services.has_service(DOMAIN, SERVICE_MEMORY) - with pytest.raises( - HomeAssistantError, - match="Memory profiling is not supported on Python 3.13. Please use Python 3.12.", - ): - await hass.services.async_call( - DOMAIN, SERVICE_MEMORY, {CONF_SECONDS: 0.000001}, blocking=True - ) - - async def test_object_growth_logging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -211,9 +189,10 @@ async def test_dump_log_object( assert hass.services.has_service(DOMAIN, SERVICE_DUMP_LOG_OBJECTS) - await hass.services.async_call( - DOMAIN, SERVICE_DUMP_LOG_OBJECTS, {CONF_TYPE: "DumpLogDummy"}, blocking=True - ) + with patch("objgraph.by_type", return_value=[obj1, obj2]): + await hass.services.async_call( + DOMAIN, SERVICE_DUMP_LOG_OBJECTS, {CONF_TYPE: "DumpLogDummy"}, blocking=True + ) assert "" in caplog.text assert "Failed to serialize" in caplog.text diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 043a9cc4389..bbd58619b12 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -541,8 +541,7 @@ async def test_view_empty_namespace( assert "# HELP python_info Python platform information" in body assert ( - "# HELP python_gc_objects_collected_total " - "Objects collected during gc" in body + "# HELP python_gc_objects_collected_total Objects collected during gc" in body ) EntityMetric( @@ -569,8 +568,7 @@ async def test_view_default_namespace( assert "# HELP python_info Python platform information" in body assert ( - "# HELP python_gc_objects_collected_total " - "Objects collected during gc" in body + "# HELP python_gc_objects_collected_total Objects collected during gc" in body ) EntityMetric( diff --git a/tests/components/prusalink/test_binary_sensor.py b/tests/components/prusalink/test_binary_sensor.py index c39b15471c6..474a4e265d1 100644 --- a/tests/components/prusalink/test_binary_sensor.py +++ b/tests/components/prusalink/test_binary_sensor.py @@ -1,6 +1,6 @@ """Test Prusalink sensors.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import patch import pytest @@ -12,16 +12,13 @@ from homeassistant.setup import async_setup_component @pytest.fixture(autouse=True) def setup_binary_sensor_platform_only(): """Only setup sensor platform.""" - with ( - patch("homeassistant.components.prusalink.PLATFORMS", [Platform.BINARY_SENSOR]), - patch( - "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", - PropertyMock(return_value=True), - ), + with patch( + "homeassistant.components.prusalink.PLATFORMS", [Platform.BINARY_SENSOR] ): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors_no_job( hass: HomeAssistant, mock_config_entry, mock_api ) -> None: diff --git a/tests/components/prusalink/test_sensor.py b/tests/components/prusalink/test_sensor.py index c0693626600..ead56f6493d 100644 --- a/tests/components/prusalink/test_sensor.py +++ b/tests/components/prusalink/test_sensor.py @@ -1,7 +1,7 @@ """Test Prusalink sensors.""" from datetime import UTC, datetime -from unittest.mock import PropertyMock, patch +from unittest.mock import patch import pytest @@ -27,16 +27,11 @@ from homeassistant.setup import async_setup_component @pytest.fixture(autouse=True) def setup_sensor_platform_only(): """Only setup sensor platform.""" - with ( - patch("homeassistant.components.prusalink.PLATFORMS", [Platform.SENSOR]), - patch( - "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", - PropertyMock(return_value=True), - ), - ): + with patch("homeassistant.components.prusalink.PLATFORMS", [Platform.SENSOR]): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) -> None: """Test sensors while no job active.""" assert await async_setup_component(hass, "prusalink", {}) @@ -140,6 +135,7 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors_idle_job_mk3( hass: HomeAssistant, mock_config_entry, @@ -248,6 +244,7 @@ async def test_sensors_idle_job_mk3( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors_active_job( hass: HomeAssistant, mock_config_entry, diff --git a/tests/components/ps4/test_config_flow.py b/tests/components/ps4/test_config_flow.py index 4e0505a8644..a4e6b039a92 100644 --- a/tests/components/ps4/test_config_flow.py +++ b/tests/components/ps4/test_config_flow.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.util import location +from homeassistant.util import location as location_util from tests.common import MockConfigEntry @@ -64,7 +64,7 @@ MOCK_TCP_PORT = 997 MOCK_AUTO = {"Config Mode": "Auto Discover"} MOCK_MANUAL = {"Config Mode": "Manual Entry", CONF_IP_ADDRESS: MOCK_HOST} -MOCK_LOCATION = location.LocationInfo( +MOCK_LOCATION = location_util.LocationInfo( "0.0.0.0", "US", "USD", @@ -83,7 +83,8 @@ MOCK_LOCATION = location.LocationInfo( def location_info_fixture(): """Mock location info.""" with patch( - "homeassistant.components.ps4.config_flow.location.async_detect_location_info", + "homeassistant.components.ps4." + "config_flow.location_util.async_detect_location_info", return_value=MOCK_LOCATION, ): yield @@ -359,7 +360,8 @@ async def test_0_pin(hass: HomeAssistant) -> None: "pyps4_2ndscreen.Helper.has_devices", return_value=[{"host-ip": MOCK_HOST}] ), patch( - "homeassistant.components.ps4.config_flow.location.async_detect_location_info", + "homeassistant.components.ps4." + "config_flow.location_util.async_detect_location_info", return_value=MOCK_LOCATION, ), ): diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index d14f367b2bd..ede6b3b5147 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -29,9 +29,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import location +from homeassistant.util import location as location_util from tests.common import MockConfigEntry @@ -58,7 +58,7 @@ MOCK_ENTRY_ID = "SomeID" MOCK_CONFIG = MockConfigEntry(domain=DOMAIN, data=MOCK_DATA, entry_id=MOCK_ENTRY_ID) -MOCK_LOCATION = location.LocationInfo( +MOCK_LOCATION = location_util.LocationInfo( "0.0.0.0", "US", "USD", @@ -80,8 +80,6 @@ MOCK_DEVICE_VERSION_1 = { MOCK_DATA_VERSION_1 = {CONF_TOKEN: MOCK_CREDS, "devices": [MOCK_DEVICE_VERSION_1]} -MOCK_DEVICE_ID = "somedeviceid" - MOCK_ENTRY_VERSION_1 = MockConfigEntry( domain=DOMAIN, data=MOCK_DATA_VERSION_1, entry_id=MOCK_ENTRY_ID, version=1 ) @@ -141,20 +139,26 @@ async def test_creating_entry_sets_up_media_player(hass: HomeAssistant) -> None: async def test_config_flow_entry_migrate( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test that config flow entry is migrated correctly.""" # Start with the config entry at Version 1. manager = hass.config_entries mock_entry = MOCK_ENTRY_VERSION_1 mock_entry.add_to_manager(manager) + mock_device_entry = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) mock_entity_id = f"media_player.ps4_{MOCK_UNIQUE_ID}" mock_e_entry = entity_registry.async_get_or_create( "media_player", "ps4", MOCK_UNIQUE_ID, config_entry=mock_entry, - device_id=MOCK_DEVICE_ID, + device_id=mock_device_entry.id, ) assert len(entity_registry.entities) == 1 assert mock_e_entry.entity_id == mock_entity_id @@ -180,7 +184,7 @@ async def test_config_flow_entry_migrate( # Test that entity_id remains the same. assert mock_entity.entity_id == mock_entity_id - assert mock_entity.device_id == MOCK_DEVICE_ID + assert mock_entity.device_id == mock_device_entry.id # Test that last four of credentials is appended to the unique_id. assert mock_entity.unique_id == f"{MOCK_UNIQUE_ID}_{MOCK_CREDS[-4:]}" diff --git a/tests/components/pure_energie/test_config_flow.py b/tests/components/pure_energie/test_config_flow.py index 4305dab2236..96704e900dd 100644 --- a/tests/components/pure_energie/test_config_flow.py +++ b/tests/components/pure_energie/test_config_flow.py @@ -5,12 +5,12 @@ from unittest.mock import MagicMock from gridnet import GridNetConnectionError -from homeassistant.components import zeroconf from homeassistant.components.pure_energie.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo async def test_full_user_flow_implementation( @@ -48,7 +48,7 @@ async def test_full_zeroconf_flow_implementationn( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -104,7 +104,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/purpleair/conftest.py b/tests/components/purpleair/conftest.py index 3d6776dd12e..1809b16bd75 100644 --- a/tests/components/purpleair/conftest.py +++ b/tests/components/purpleair/conftest.py @@ -73,7 +73,7 @@ def config_entry_options_fixture() -> dict[str, Any]: @pytest.fixture(name="get_sensors_response", scope="package") def get_sensors_response_fixture() -> GetSensorsResponse: """Define a fixture to mock an aiopurpleair GetSensorsResponse object.""" - return GetSensorsResponse.parse_raw( + return GetSensorsResponse.model_validate_json( load_fixture("get_sensors_response.json", "purpleair") ) diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index c0f181396ab..46144771cc1 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -9,16 +9,12 @@ import pytest from homeassistant.components.pyload.const import DEFAULT_NAME, DOMAIN from homeassistant.const import ( CONF_HOST, - CONF_MONITORED_VARIABLES, - CONF_NAME, CONF_PASSWORD, - CONF_PLATFORM, CONF_PORT, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.helpers.typing import ConfigType from tests.common import MockConfigEntry @@ -31,16 +27,6 @@ USER_INPUT = { CONF_VERIFY_SSL: False, } -YAML_INPUT = { - CONF_HOST: "pyload.local", - CONF_MONITORED_VARIABLES: ["speed"], - CONF_NAME: "test-name", - CONF_PASSWORD: "test-password", - CONF_PLATFORM: "pyload", - CONF_PORT: 8000, - CONF_SSL: True, - CONF_USERNAME: "test-username", -} REAUTH_INPUT = { CONF_PASSWORD: "new-password", CONF_USERNAME: "new-username", @@ -65,12 +51,6 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -@pytest.fixture -def pyload_config() -> ConfigType: - """Mock pyload configuration entry.""" - return {"sensor": YAML_INPUT} - - @pytest.fixture def mock_pyloadapi() -> Generator[MagicMock]: """Mock PyLoadAPI.""" diff --git a/tests/components/pyload/test_config_flow.py b/tests/components/pyload/test_config_flow.py index 5ada856d78e..492e4a4b652 100644 --- a/tests/components/pyload/test_config_flow.py +++ b/tests/components/pyload/test_config_flow.py @@ -6,11 +6,11 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.components.pyload.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import NEW_INPUT, REAUTH_INPUT, USER_INPUT, YAML_INPUT +from .conftest import NEW_INPUT, REAUTH_INPUT, USER_INPUT from tests.common import MockConfigEntry @@ -103,69 +103,6 @@ async def test_flow_user_already_configured( assert result["reason"] == "already_configured" -async def test_flow_import( - hass: HomeAssistant, - mock_pyloadapi: AsyncMock, -) -> None: - """Test that we can import a YAML config.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=YAML_INPUT, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-name" - assert result["data"] == USER_INPUT - - -async def test_flow_import_already_configured( - hass: HomeAssistant, config_entry: MockConfigEntry, mock_pyloadapi: AsyncMock -) -> None: - """Test we abort import data set when entry is already configured.""" - - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=YAML_INPUT, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -@pytest.mark.parametrize( - ("exception", "reason"), - [ - (InvalidAuth, "invalid_auth"), - (CannotConnect, "cannot_connect"), - (ParserError, "cannot_connect"), - (ValueError, "unknown"), - ], -) -async def test_flow_import_errors( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_pyloadapi: AsyncMock, - exception: Exception, - reason: str, -) -> None: - """Test we abort import data set when entry is already configured.""" - - mock_pyloadapi.login.side_effect = exception - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=YAML_INPUT, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason - - async def test_reauth( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/pyload/test_sensor.py b/tests/components/pyload/test_sensor.py index 8c194a111ea..33ad3435083 100644 --- a/tests/components/pyload/test_sensor.py +++ b/tests/components/pyload/test_sensor.py @@ -8,15 +8,11 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.pyload.const import DOMAIN from homeassistant.components.pyload.coordinator import SCAN_INTERVAL -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -101,64 +97,6 @@ async def test_sensor_invalid_auth( ) -async def test_platform_setup_triggers_import_flow( - hass: HomeAssistant, - pyload_config: ConfigType, - mock_setup_entry: AsyncMock, - mock_pyloadapi: AsyncMock, -) -> None: - """Test if an issue is created when attempting setup from yaml config.""" - - assert await async_setup_component(hass, SENSOR_DOMAIN, pyload_config) - await hass.async_block_till_done() - - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("exception", "reason"), - [ - (InvalidAuth, "invalid_auth"), - (CannotConnect, "cannot_connect"), - (ParserError, "cannot_connect"), - (ValueError, "unknown"), - ], -) -async def test_deprecated_yaml_import_issue( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - pyload_config: ConfigType, - mock_pyloadapi: AsyncMock, - exception: Exception, - reason: str, -) -> None: - """Test an issue is created when attempting setup from yaml config and an error happens.""" - - mock_pyloadapi.login.side_effect = exception - await async_setup_component(hass, SENSOR_DOMAIN, pyload_config) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id=f"deprecated_yaml_import_issue_{reason}" - ) - - -async def test_deprecated_yaml( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - pyload_config: ConfigType, - mock_pyloadapi: AsyncMock, -) -> None: - """Test an issue is created when we import from yaml config.""" - - await async_setup_component(hass, SENSOR_DOMAIN, pyload_config) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue( - domain=HOMEASSISTANT_DOMAIN, issue_id=f"deprecated_yaml_{DOMAIN}" - ) - - async def test_pyload_pre_0_5_0( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index c4dc00c448a..14229e83662 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -688,3 +688,27 @@ async def test_prohibited_augmented_assignment_operations( hass.async_add_executor_job(execute, hass, "aug_assign_prohibited.py", case, {}) await hass.async_block_till_done(wait_background_tasks=True) assert error in caplog.text + + +async def test_import_allow_strptime( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test calling datetime.datetime.strptime works.""" + source = """ +test_date = datetime.datetime.strptime('2024-04-01', '%Y-%m-%d') +logger.info(f'Date {test_date}') + """ + hass.async_add_executor_job(execute, hass, "test.py", source, {}) + await hass.async_block_till_done(wait_background_tasks=True) + assert "Error executing script: Not allowed to import _strptime" not in caplog.text + assert "Date 2024-04-01 00:00:00" in caplog.text + + +async def test_no_other_imports_allowed( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test imports are not allowed.""" + source = "import sys" + hass.async_add_executor_job(execute, hass, "test.py", source, {}) + await hass.async_block_till_done(wait_background_tasks=True) + assert "ImportError: Not allowed to import sys" in caplog.text diff --git a/tests/components/qbus/__init__.py b/tests/components/qbus/__init__.py new file mode 100644 index 00000000000..e8c002d1ed9 --- /dev/null +++ b/tests/components/qbus/__init__.py @@ -0,0 +1 @@ +"""Tests for the Qbus integration.""" diff --git a/tests/components/qbus/conftest.py b/tests/components/qbus/conftest.py new file mode 100644 index 00000000000..8268d091bda --- /dev/null +++ b/tests/components/qbus/conftest.py @@ -0,0 +1,33 @@ +"""Test fixtures for qbus.""" + +import pytest + +from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType + +from .const import FIXTURE_PAYLOAD_CONFIG + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="000001", + data={ + CONF_ID: "UL1", + CONF_SERIAL_NUMBER: "000001", + }, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def payload_config() -> JsonObjectType: + """Return the config topic payload.""" + return load_json_object_fixture(FIXTURE_PAYLOAD_CONFIG, DOMAIN) diff --git a/tests/components/qbus/const.py b/tests/components/qbus/const.py new file mode 100644 index 00000000000..408ef59d5b1 --- /dev/null +++ b/tests/components/qbus/const.py @@ -0,0 +1,4 @@ +"""Define const for unit tests.""" + +FIXTURE_PAYLOAD_CONFIG = "payload_config.json" +TOPIC_CONFIG = "cloudapp/QBUSMQTTGW/config" diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json new file mode 100644 index 00000000000..2ee38a9927e --- /dev/null +++ b/tests/components/qbus/fixtures/payload_config.json @@ -0,0 +1,49 @@ +{ + "app": "abc", + "devices": [ + { + "id": "UL1", + "ip": "192.168.1.123", + "mac": "001122334455", + "name": "", + "serialNr": "000001", + "type": "Qbus", + "version": "3.14.0", + "properties": { + "connectable": { + "read": true, + "type": "boolean", + "write": false + }, + "connected": { + "read": true, + "type": "boolean", + "write": false + } + }, + "functionBlocks": [ + { + "id": "UL10", + "location": "Living", + "locationId": 0, + "name": "LIVING", + "originalName": "LIVING", + "refId": "000001/10", + "type": "onoff", + "variant": [null], + "actions": { + "off": null, + "on": null + }, + "properties": { + "value": { + "read": true, + "type": "boolean", + "write": true + } + } + } + ] + } + ] +} diff --git a/tests/components/qbus/test_config_flow.py b/tests/components/qbus/test_config_flow.py new file mode 100644 index 00000000000..4f94f2bb277 --- /dev/null +++ b/tests/components/qbus/test_config_flow.py @@ -0,0 +1,202 @@ +"""Test config flow.""" + +import json +import time +from unittest.mock import patch + +import pytest +from qbusmqttapi.discovery import QbusDiscovery + +from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.components.qbus.coordinator import QbusConfigCoordinator +from homeassistant.config_entries import SOURCE_MQTT, SOURCE_USER +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.util.json import JsonObjectType + +from .const import TOPIC_CONFIG + +_PAYLOAD_DEVICE_STATE = '{"id":"UL1","properties":{"connected":true},"type":"event"}' + + +async def test_step_discovery_confirm_create_entry( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test mqtt confirm step and entry creation.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL1/state", + payload=_PAYLOAD_DEVICE_STATE, + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch.object( + QbusConfigCoordinator, + "async_get_or_request_config", + return_value=QbusDiscovery(payload_config), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("data") == { + CONF_ID: "UL1", + CONF_SERIAL_NUMBER: "000001", + } + assert result.get("result").unique_id == "000001" + + +@pytest.mark.parametrize( + ("topic", "payload"), + [ + ("cloudapp/QBUSMQTTGW/state", b""), + ("invalid/topic", b"{}"), + ], +) +async def test_step_mqtt_invalid( + hass: HomeAssistant, topic: str, payload: bytes +) -> None: + """Test mqtt discovery with empty payload.""" + discovery = MqttServiceInfo( + subscribed_topic=topic, + topic=topic, + payload=payload, + qos=0, + retain=False, + timestamp=time.time(), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +@pytest.mark.parametrize( + ("payload", "mqtt_publish"), + [ + ('{ "online": true }', True), + ('{ "online": false }', False), + ], +) +async def test_handle_gateway_topic_when_online( + hass: HomeAssistant, payload: str, mqtt_publish: bool +) -> None: + """Test handling of gateway topic with payload indicating online.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/state", + topic="cloudapp/QBUSMQTTGW/state", + payload=payload, + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch("homeassistant.components.mqtt.client.async_publish") as mock_publish, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert mock_publish.called is mqtt_publish + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "discovery_in_progress" + + +async def test_handle_config_topic( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test handling of config topic.""" + + discovery = MqttServiceInfo( + subscribed_topic=TOPIC_CONFIG, + topic=TOPIC_CONFIG, + payload=json.dumps(payload_config), + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch("homeassistant.components.mqtt.client.async_publish") as mock_publish, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert mock_publish.called + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "discovery_in_progress" + + +async def test_handle_device_topic_missing_config(hass: HomeAssistant) -> None: + """Test handling of device topic when config is missing.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL1/state", + payload=_PAYLOAD_DEVICE_STATE, + qos=0, + retain=False, + timestamp=time.time(), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +async def test_handle_device_topic_device_not_found( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test handling of device topic when device is not found.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL2/state", + payload='{"id":"UL2","properties":{"connected":true},"type":"event"}', + qos=0, + retain=False, + timestamp=time.time(), + ) + + with patch.object( + QbusConfigCoordinator, + "async_get_or_request_config", + return_value=QbusDiscovery(payload_config), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +async def test_step_user_not_supported(hass: HomeAssistant) -> None: + """Test user step, which should abort.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "not_supported" diff --git a/tests/components/qbus/test_switch.py b/tests/components/qbus/test_switch.py new file mode 100644 index 00000000000..83bb667e4eb --- /dev/null +++ b/tests/components/qbus/test_switch.py @@ -0,0 +1,84 @@ +"""Test Qbus switch entities.""" + +import json + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType + +from .const import TOPIC_CONFIG + +from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.typing import MqttMockHAClient + +_PAYLOAD_SWITCH_STATE_ON = '{"id":"UL10","properties":{"value":true},"type":"state"}' +_PAYLOAD_SWITCH_STATE_OFF = '{"id":"UL10","properties":{"value":false},"type":"state"}' +_PAYLOAD_SWITCH_SET_STATE_ON = ( + '{"id": "UL10", "type": "state", "properties": {"value": true}}' +) +_PAYLOAD_SWITCH_SET_STATE_OFF = ( + '{"id": "UL10", "type": "state", "properties": {"value": false}}' +) + +_TOPIC_SWITCH_STATE = "cloudapp/QBUSMQTTGW/UL1/UL10/state" +_TOPIC_SWITCH_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL10/setState" + +_SWITCH_ENTITY_ID = "switch.living" + + +async def test_switch_turn_on_off( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> None: + """Test turning on and off.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) + await hass.async_block_till_done() + + # Switch ON + mqtt_mock.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: _SWITCH_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_SWITCH_SET_STATE, _PAYLOAD_SWITCH_SET_STATE_ON, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_SWITCH_STATE, _PAYLOAD_SWITCH_STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(_SWITCH_ENTITY_ID).state == STATE_ON + + # Switch OFF + mqtt_mock.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: _SWITCH_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_SWITCH_SET_STATE, _PAYLOAD_SWITCH_SET_STATE_OFF, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_SWITCH_STATE, _PAYLOAD_SWITCH_STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(_SWITCH_ENTITY_ID).state == STATE_OFF diff --git a/tests/components/qnap_qsw/test_config_flow.py b/tests/components/qnap_qsw/test_config_flow.py index 94e80d3cd16..f09cf7493b5 100644 --- a/tests/components/qnap_qsw/test_config_flow.py +++ b/tests/components/qnap_qsw/test_config_flow.py @@ -6,19 +6,19 @@ from aioqsw.const import API_MAC_ADDR, API_PRODUCT, API_RESULT from aioqsw.exceptions import LoginError, QswError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.qnap_qsw.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .util import CONFIG, LIVE_MOCK, SYSTEM_BOARD_MOCK, USERS_LOGIN_MOCK from tests.common import MockConfigEntry -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="qsw-m408-4c", ip="192.168.1.200", macaddress="245ebe000000", diff --git a/tests/components/qnap_qsw/test_sensor.py b/tests/components/qnap_qsw/test_sensor.py index 16335e878fd..1f56efded16 100644 --- a/tests/components/qnap_qsw/test_sensor.py +++ b/tests/components/qnap_qsw/test_sensor.py @@ -1,14 +1,12 @@ """The sensor tests for the QNAP QSW platform.""" -from unittest.mock import patch - from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.qnap_qsw.const import ATTR_MAX, DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er from .util import async_init_integration, init_config_entry @@ -383,39 +381,6 @@ async def test_qnap_qsw_create_sensors( assert state.state == "0" -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_deprecated_uptime_seconds( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test deprecation warning of the Uptime seconds sensor entity.""" - original_id = "sensor.qsw_m408_4c_uptime" - domain = Platform.SENSOR - - config_entry = init_config_entry(hass) - - entity = entity_registry.async_get_or_create( - domain=domain, - platform=DOMAIN, - unique_id=original_id, - config_entry=config_entry, - suggested_object_id=original_id, - disabled_by=None, - ) - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - - with patch( - "homeassistant.components.qnap_qsw.sensor.automations_with_entity", - return_value=["item"], - ): - await async_init_integration(hass, config_entry=config_entry) - assert issue_registry.async_get_issue( - DOMAIN, f"uptime_seconds_deprecated_{entity.entity_id}_item" - ) - - async def test_cleanup_deprecated_uptime_seconds( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/rabbitair/test_config_flow.py b/tests/components/rabbitair/test_config_flow.py index 7f9479339a5..db4f4de6c49 100644 --- a/tests/components/rabbitair/test_config_flow.py +++ b/tests/components/rabbitair/test_config_flow.py @@ -10,12 +10,12 @@ import pytest from rabbitair import Mode, Model, Speed from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.rabbitair.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = "1.1.1.1" TEST_NAME = "abcdef1234_123456789012345678" @@ -26,7 +26,7 @@ TEST_HARDWARE = "1.0.0.4" TEST_UNIQUE_ID = format_mac(TEST_MAC) TEST_TITLE = "Rabbit Air" -ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], port=9009, diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py index 586b31b092f..6448d46a8a1 100644 --- a/tests/components/rachio/test_config_flow.py +++ b/tests/components/rachio/test_config_flow.py @@ -4,7 +4,6 @@ from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.rachio.const import ( CONF_CUSTOM_URL, CONF_MANUAL_RUN_MINS, @@ -13,6 +12,10 @@ from homeassistant.components.rachio.const import ( from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -120,13 +123,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -145,13 +148,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -171,13 +174,13 @@ async def test_form_homekit_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py index a188f8fcb70..a84f3870357 100644 --- a/tests/components/radiotherm/test_config_flow.py +++ b/tests/components/radiotherm/test_config_flow.py @@ -6,11 +6,11 @@ from radiotherm import CommonThermostat from radiotherm.validate import RadiothermTstatError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.radiotherm.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -112,7 +112,7 @@ async def test_dhcp_can_confirm(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -156,7 +156,7 @@ async def test_dhcp_fails_to_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -185,7 +185,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/rainforest_raven/const.py b/tests/components/rainforest_raven/const.py index 7e75440c30d..320299d2e60 100644 --- a/tests/components/rainforest_raven/const.py +++ b/tests/components/rainforest_raven/const.py @@ -13,8 +13,9 @@ from aioraven.data import ( from iso4217 import Currency from homeassistant.components import usb +from homeassistant.helpers.service_info.usb import UsbServiceInfo -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device="/dev/ttyACM0", pid="0x0003", vid="0x04B4", diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 5838dcc35c8..cd8b2cb39c8 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -7,7 +7,6 @@ import pytest from regenmaschine.errors import RainMachineError from homeassistant import config_entries, setup -from homeassistant.components import zeroconf from homeassistant.components.rainmachine import ( CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, @@ -18,6 +17,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo async def test_duplicate_error(hass: HomeAssistant, config, config_entry) -> None: @@ -168,7 +168,7 @@ async def test_step_homekit_zeroconf_ip_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -196,7 +196,7 @@ async def test_step_homekit_zeroconf_ip_change( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.2"), ip_addresses=[ip_address("192.168.1.2")], hostname="mock_hostname", @@ -225,7 +225,7 @@ async def test_step_homekit_zeroconf_new_controller_when_some_exist( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -279,7 +279,7 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -299,7 +299,7 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", diff --git a/tests/components/recorder/auto_repairs/events/test_schema.py b/tests/components/recorder/auto_repairs/events/test_schema.py index cae181a6270..91f5bd50298 100644 --- a/tests/components/recorder/auto_repairs/events/test_schema.py +++ b/tests/components/recorder/auto_repairs/events/test_schema.py @@ -8,12 +8,12 @@ from homeassistant.core import HomeAssistant from ...common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -22,7 +22,7 @@ async def mock_recorder_before_hass( @pytest.mark.parametrize("db_engine", ["mysql", "postgresql"]) async def test_validate_db_schema_fix_float_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -58,7 +58,7 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_event_data( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -91,7 +91,7 @@ async def test_validate_db_schema_fix_utf8_issue_event_data( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, diff --git a/tests/components/recorder/auto_repairs/states/test_schema.py b/tests/components/recorder/auto_repairs/states/test_schema.py index 915ac1f3500..982a6a732b6 100644 --- a/tests/components/recorder/auto_repairs/states/test_schema.py +++ b/tests/components/recorder/auto_repairs/states/test_schema.py @@ -8,12 +8,12 @@ from homeassistant.core import HomeAssistant from ...common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -22,7 +22,7 @@ async def mock_recorder_before_hass( @pytest.mark.parametrize("db_engine", ["mysql", "postgresql"]) async def test_validate_db_schema_fix_float_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -60,7 +60,7 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_states( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -92,7 +92,7 @@ async def test_validate_db_schema_fix_utf8_issue_states( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_state_attributes( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -125,7 +125,7 @@ async def test_validate_db_schema_fix_utf8_issue_state_attributes( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 9e287d13594..78a7ddaa300 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -22,12 +22,12 @@ import homeassistant.util.dt as dt_util from ...common import async_wait_recording_done from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -134,7 +134,7 @@ def _create_engine_28(*args, **kwargs): @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_delete_metadata_duplicates( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics.""" @@ -242,7 +242,7 @@ async def test_delete_metadata_duplicates( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_delete_metadata_duplicates_many( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics.""" diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 34a075afbc7..352a2345052 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -8,12 +8,12 @@ from homeassistant.core import HomeAssistant from ...common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -22,7 +22,7 @@ async def mock_recorder_before_hass( @pytest.mark.parametrize("enable_schema_validation", [True]) async def test_validate_db_schema_fix_utf8_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -56,7 +56,7 @@ async def test_validate_db_schema_fix_utf8_issue( @pytest.mark.parametrize("db_engine", ["mysql", "postgresql"]) async def test_validate_db_schema_fix_float_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, table: str, db_engine: str, @@ -100,7 +100,7 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_dialect_name: None, db_engine: str, diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py index 857c0f6572f..bf2a925df17 100644 --- a/tests/components/recorder/auto_repairs/test_schema.py +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -18,12 +18,12 @@ from homeassistant.core import HomeAssistant from ..common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 60168f5e6ef..fbb0991c960 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -428,14 +428,6 @@ def get_schema_module_path(schema_version_postfix: str) -> str: return f"tests.components.recorder.db_schema_{schema_version_postfix}" -@dataclass(slots=True) -class MockMigrationTask(migration.MigrationTask): - """Mock migration task which does nothing.""" - - def run(self, instance: Recorder) -> None: - """Run migration task.""" - - @contextmanager def old_db_schema(schema_version_postfix: str) -> Iterator[None]: """Fixture to initialize the db with the old schema.""" @@ -453,7 +445,6 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), patch.object(core, "StateAttributes", old_db_schema.StateAttributes), - patch.object(migration.EntityIDMigration, "task", MockMigrationTask), patch( CREATE_ENGINE_TARGET, new=partial( diff --git a/tests/components/recorder/db_schema_16.py b/tests/components/recorder/db_schema_16.py index d7ca35c9341..522bd6ea367 100644 --- a/tests/components/recorder/db_schema_16.py +++ b/tests/components/recorder/db_schema_16.py @@ -347,11 +347,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_18.py b/tests/components/recorder/db_schema_18.py index adb71dffb9e..026227f68a0 100644 --- a/tests/components/recorder/db_schema_18.py +++ b/tests/components/recorder/db_schema_18.py @@ -360,11 +360,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_22.py b/tests/components/recorder/db_schema_22.py index c0d607b12a7..770d25c9cf2 100644 --- a/tests/components/recorder/db_schema_22.py +++ b/tests/components/recorder/db_schema_22.py @@ -479,11 +479,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_23.py b/tests/components/recorder/db_schema_23.py index f60b7b49df4..8cf3e16e5a8 100644 --- a/tests/components/recorder/db_schema_23.py +++ b/tests/components/recorder/db_schema_23.py @@ -469,11 +469,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 4cc1074de41..2ba62ba78f5 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -593,11 +593,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_25.py b/tests/components/recorder/db_schema_25.py index d989cacb76a..3b7c4a300c2 100644 --- a/tests/components/recorder/db_schema_25.py +++ b/tests/components/recorder/db_schema_25.py @@ -529,12 +529,12 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", + "_attr_cache", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", - "_attr_cache", + "_row", ] def __init__( # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 8c984b61f6c..4d7f893de25 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -694,12 +694,12 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", + "_attr_cache", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", - "_attr_cache", + "_row", ] def __init__( # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index 6da0272da87..39ddb8e3148 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -254,7 +254,7 @@ class Events(Base): # type: ignore[misc,valid-type] event_data=None, origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), time_fired=None, - time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), + time_fired_ts=event.time_fired.timestamp(), context_id=event.context.id, context_user_id=event.context.user_id, context_parent_id=event.context.parent_id, @@ -429,16 +429,16 @@ class States(Base): # type: ignore[misc,valid-type] # None state means the state was removed from the state machine if state is None: dbstate.state = "" - dbstate.last_updated_ts = dt_util.utc_to_timestamp(event.time_fired) + dbstate.last_updated_ts = event.time_fired.timestamp() dbstate.last_changed_ts = None return dbstate dbstate.state = state.state - dbstate.last_updated_ts = dt_util.utc_to_timestamp(state.last_updated) + dbstate.last_updated_ts = state.last_updated.timestamp() if state.last_updated == state.last_changed: dbstate.last_changed_ts = None else: - dbstate.last_changed_ts = dt_util.utc_to_timestamp(state.last_changed) + dbstate.last_changed_ts = state.last_changed.timestamp() return dbstate diff --git a/tests/components/recorder/db_schema_42.py b/tests/components/recorder/db_schema_42.py index 99bdbb28f2c..efeade46562 100644 --- a/tests/components/recorder/db_schema_42.py +++ b/tests/components/recorder/db_schema_42.py @@ -687,7 +687,7 @@ class StatisticsBase: created=None, created_ts=time.time(), start=None, - start_ts=dt_util.utc_to_timestamp(stats["start"]), + start_ts=stats["start"].timestamp(), mean=stats.get("mean"), min=stats.get("min"), max=stats.get("max"), diff --git a/tests/components/recorder/db_schema_43.py b/tests/components/recorder/db_schema_43.py index 26d8ecd6856..8e77e8782ee 100644 --- a/tests/components/recorder/db_schema_43.py +++ b/tests/components/recorder/db_schema_43.py @@ -697,7 +697,7 @@ class StatisticsBase: created=None, created_ts=time.time(), start=None, - start_ts=dt_util.utc_to_timestamp(stats["start"]), + start_ts=stats["start"].timestamp(), mean=stats.get("mean"), min=stats.get("min"), max=stats.get("max"), diff --git a/tests/components/recorder/table_managers/test_recorder_runs.py b/tests/components/recorder/table_managers/test_recorder_runs.py index 41f3a8fef4d..e79def01bad 100644 --- a/tests/components/recorder/table_managers/test_recorder_runs.py +++ b/tests/components/recorder/table_managers/test_recorder_runs.py @@ -21,6 +21,11 @@ async def test_run_history(recorder_mock: Recorder, hass: HomeAssistant) -> None two_days_ago = now - timedelta(days=2) one_day_ago = now - timedelta(days=1) + # Test that the first run falls back to the current run + assert process_timestamp( + instance.recorder_runs_manager.first.start + ) == process_timestamp(instance.recorder_runs_manager.current.start) + with instance.get_session() as session: session.add(RecorderRuns(start=three_days_ago, created=three_days_ago)) session.add(RecorderRuns(start=two_days_ago, created=two_days_ago)) @@ -29,32 +34,7 @@ async def test_run_history(recorder_mock: Recorder, hass: HomeAssistant) -> None instance.recorder_runs_manager.load_from_db(session) assert ( - process_timestamp( - instance.recorder_runs_manager.get( - three_days_ago + timedelta(microseconds=1) - ).start - ) - == three_days_ago - ) - assert ( - process_timestamp( - instance.recorder_runs_manager.get( - two_days_ago + timedelta(microseconds=1) - ).start - ) - == two_days_ago - ) - assert ( - process_timestamp( - instance.recorder_runs_manager.get( - one_day_ago + timedelta(microseconds=1) - ).start - ) - == one_day_ago - ) - assert ( - process_timestamp(instance.recorder_runs_manager.get(now).start) - == instance.recorder_runs_manager.recording_start + process_timestamp(instance.recorder_runs_manager.first.start) == three_days_ago ) diff --git a/tests/components/recorder/test_entity_registry.py b/tests/components/recorder/test_entity_registry.py index ad438dcc525..8a5ce23799c 100644 --- a/tests/components/recorder/test_entity_registry.py +++ b/tests/components/recorder/test_entity_registry.py @@ -23,7 +23,7 @@ from .common import ( ) from tests.common import MockEntity, MockEntityPlatform -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager def _count_entity_id_in_states_meta( @@ -40,7 +40,7 @@ def _count_entity_id_in_states_meta( @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 28b8275247c..d9dbbf191f6 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -33,12 +33,12 @@ from .common import ( async_wait_recording_done, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 3ee6edd8e1e..bfe5c852ca6 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -28,16 +28,27 @@ from .common import ( old_db_schema, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" +@pytest.fixture +def disable_states_meta_manager(): + """Disable the states meta manager.""" + with patch.object( + recorder.table_managers.states_meta.StatesMetaManager, + "active", + False, + ): + yield + + @pytest.fixture(autouse=True) def db_schema_32(): """Fixture to initialize the db with the old schema 32.""" @@ -46,7 +57,9 @@ def db_schema_32(): @pytest.fixture(autouse=True) -def setup_recorder(db_schema_32, recorder_mock: Recorder) -> recorder.Recorder: +def setup_recorder( + db_schema_32, disable_states_meta_manager, recorder_mock: Recorder +) -> recorder.Recorder: """Set up recorder.""" diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 85badeea281..23ac6f9fb8a 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -31,12 +31,12 @@ from .common import ( ) from .db_schema_42 import StateAttributes, States, StatesMeta -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index d16712e0c70..f8d1ac4af57 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -9,7 +9,7 @@ import sqlite3 import sys import threading from typing import Any, cast -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -98,12 +98,12 @@ from tests.common import ( async_test_home_assistant, mock_platform, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager, RecorderInstanceGenerator @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -964,12 +964,17 @@ async def test_recorder_setup_failure(hass: HomeAssistant) -> None: hass.stop() -async def test_recorder_validate_schema_failure(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "function_to_patch", ["_get_current_schema_version", "_get_initial_schema_version"] +) +async def test_recorder_validate_schema_failure( + hass: HomeAssistant, function_to_patch: str +) -> None: """Test some exceptions.""" recorder_helper.async_initialize_recorder(hass) with ( patch( - "homeassistant.components.recorder.migration._get_schema_version" + f"homeassistant.components.recorder.migration.{function_to_patch}" ) as inspect_schema_version, patch("homeassistant.components.recorder.core.time.sleep"), ): @@ -1368,7 +1373,7 @@ async def test_statistics_runs_initiated( @pytest.mark.parametrize("enable_missing_statistics", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_compile_missing_statistics( - async_test_recorder: RecorderInstanceGenerator, freezer: FrozenDateTimeFactory + async_test_recorder: RecorderInstanceContextManager, freezer: FrozenDateTimeFactory ) -> None: """Test missing statistics are compiled on startup.""" now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) @@ -1627,7 +1632,7 @@ async def test_service_disable_states_not_recording( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_service_disable_run_information_recorded( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test that runs are still recorded when recorder is disabled.""" @@ -2570,23 +2575,25 @@ async def test_clean_shutdown_when_recorder_thread_raises_during_validate_db_sch @pytest.mark.parametrize( ("func_to_patch", "expected_setup_result"), - [("migrate_schema_non_live", False), ("migrate_schema_live", False)], + [ + ("migrate_schema_non_live", False), + ("migrate_schema_live", True), + ], ) async def test_clean_shutdown_when_schema_migration_fails( - hass: HomeAssistant, func_to_patch: str, expected_setup_result: bool + hass: HomeAssistant, + func_to_patch: str, + expected_setup_result: bool, + caplog: pytest.LogCaptureFixture, ) -> None: """Test we still shutdown cleanly when schema migration fails.""" with ( - patch.object( - migration, - "validate_db_schema", - return_value=MagicMock(valid=False, current_version=1), - ), + patch.object(migration, "_get_current_schema_version", side_effect=[None, 1]), patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch.object( migration, func_to_patch, - side_effect=Exception, + side_effect=Exception("Boom!"), ), ): if recorder.DOMAIN not in hass.data: @@ -2605,9 +2612,53 @@ async def test_clean_shutdown_when_schema_migration_fails( assert setup_result == expected_setup_result await hass.async_block_till_done() + instance = recorder.get_instance(hass) + await hass.async_stop() + assert instance.engine is None + + assert "Error during schema migration" in caplog.text + # Check the injected exception was logged + assert "Boom!" in caplog.text + + +async def test_setup_fails_after_downgrade( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test we fail to setup after a downgrade. + + Also test we shutdown cleanly. + """ + with ( + patch.object( + migration, + "_get_current_schema_version", + side_effect=[None, SCHEMA_VERSION + 1], + ), + patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), + ): + if recorder.DOMAIN not in hass.data: + recorder_helper.async_initialize_recorder(hass) + assert not await async_setup_component( + hass, + recorder.DOMAIN, + { + recorder.DOMAIN: { + CONF_DB_URL: "sqlite://", + CONF_DB_RETRY_WAIT: 0, + CONF_DB_MAX_RETRIES: 1, + } + }, + ) + await hass.async_block_till_done() + instance = recorder.get_instance(hass) await hass.async_stop() assert instance.engine is None + assert ( + f"The database schema version {SCHEMA_VERSION + 1} is newer " + f"than {SCHEMA_VERSION} which is the maximum database schema " + "version supported by the installed version of Home Assistant Core" + ) in caplog.text async def test_events_are_recorded_until_final_write( diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 14978bee5a9..e60a4705ac8 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -37,12 +37,12 @@ from .common import async_wait_recording_done, create_engine_test from .conftest import InstrumentedMigration from tests.common import async_fire_time_changed -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager, RecorderInstanceGenerator @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -97,6 +97,7 @@ async def test_schema_update_calls( session_maker, migration.SchemaValidationStatus( current_version=0, + initial_version=0, migration_needed=True, non_live_data_migration_needed=True, schema_errors=set(), @@ -111,6 +112,7 @@ async def test_schema_update_calls( session_maker, migration.SchemaValidationStatus( current_version=42, + initial_version=0, migration_needed=True, non_live_data_migration_needed=True, schema_errors=set(), @@ -598,7 +600,7 @@ async def test_schema_migrate( start=self.recorder_runs_manager.recording_start, created=dt_util.utcnow() ) - def _sometimes_failing_create_index(*args): + def _sometimes_failing_create_index(*args, **kwargs): """Make the first index create raise a retryable error to ensure we retry.""" if recorder_db_url.startswith("mysql://"): nonlocal create_calls @@ -607,7 +609,7 @@ async def test_schema_migrate( mysql_exception = OperationalError("statement", {}, []) mysql_exception.orig = Exception(1205, "retryable") raise mysql_exception - real_create_index(*args) + real_create_index(*args, **kwargs) with ( patch( @@ -710,7 +712,7 @@ def test_forgiving_add_index(recorder_db_url: str) -> None: instance = Mock() instance.get_session = Mock(return_value=session) migration._create_index( - instance.get_session, "states", "ix_states_context_id_bin" + instance, instance.get_session, "states", "ix_states_context_id_bin" ) engine.dispose() @@ -786,7 +788,7 @@ def test_forgiving_add_index_with_other_db_types( with patch( "homeassistant.components.recorder.migration.Table", return_value=mocked_table ): - migration._create_index(Mock(), "states", "ix_states_context_id") + migration._create_index(Mock(), Mock(), "states", "ix_states_context_id") assert "already exists on states" in caplog.text assert "continuing" in caplog.text diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index dcf2d792407..94b7518edb7 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -13,6 +13,7 @@ import pytest from sqlalchemy import create_engine, inspect from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from sqlalchemy.schema import Index from homeassistant.components import recorder from homeassistant.components.recorder import ( @@ -44,7 +45,6 @@ import homeassistant.util.dt as dt_util from homeassistant.util.ulid import bytes_to_ulid, ulid_at_time, ulid_to_bytes from .common import ( - MockMigrationTask, async_attach_db_engine, async_recorder_block_till_done, async_wait_recording_done, @@ -52,15 +52,15 @@ from .common import ( from .conftest import instrument_migration from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" -SCHEMA_MODULE = "tests.components.recorder.db_schema_32" +SCHEMA_MODULE_32 = "tests.components.recorder.db_schema_32" @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -81,8 +81,8 @@ def _create_engine_test(*args, **kwargs): This simulates an existing db with the old schema. """ - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] engine = create_engine(*args, **kwargs) old_db_schema.Base.metadata.create_all(engine) with Session(engine) as session: @@ -101,8 +101,8 @@ def _create_engine_test(*args, **kwargs): @pytest.fixture def db_schema_32(): """Fixture to initialize the db with the old schema.""" - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] with ( patch.object(recorder, "db_schema", old_db_schema), @@ -114,7 +114,6 @@ def db_schema_32(): patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), patch.object(core, "StateAttributes", old_db_schema.StateAttributes), - patch.object(migration.EntityIDMigration, "task", MockMigrationTask), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): yield @@ -122,13 +121,15 @@ def db_schema_32(): @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_event_context_ids", [True]) +@pytest.mark.parametrize("indices_to_drop", [[], [("events", "ix_events_context_id")]]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_events_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, + indices_to_drop: list[tuple[str, str]], ) -> None: """Test we can migrate old uuid context ids and ulid context ids to binary format.""" - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] test_uuid = uuid.uuid4() uuid_hex = test_uuid.hex @@ -239,6 +240,13 @@ async def test_migrate_events_context_ids( ] await _async_wait_migration_done(hass) + # Remove index + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + for table, index in indices_to_drop: + with session_scope(hass=hass) as session: + assert get_index_by_name(session, table, index) is not None + migration._drop_index(instance.get_session, table, index) + await hass.async_stop() await hass.async_block_till_done() @@ -268,7 +276,13 @@ async def test_migrate_events_context_ids( # Run again with new schema, let migration run async with async_test_home_assistant() as hass: - with freeze_time(now), instrument_migration(hass) as instrumented_migration: + with ( + freeze_time(now), + instrument_migration(hass) as instrumented_migration, + patch( + "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create + ) as wrapped_idx_create, + ): async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False ) as instance: @@ -299,6 +313,10 @@ async def test_migrate_events_context_ids( await hass.async_stop() await hass.async_block_till_done() + # Check the index we removed was recreated + index_names = [call[1][0].name for call in wrapped_idx_create.mock_calls] + assert index_names == [index for _, index in indices_to_drop] + old_uuid_context_id_event = events_by_type["old_uuid_context_id_event"] assert old_uuid_context_id_event["context_id"] is None assert old_uuid_context_id_event["context_user_id"] is None @@ -378,7 +396,7 @@ async def test_migrate_events_context_ids( @pytest.mark.parametrize("enable_migrate_event_context_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_finish_migrate_events_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we re migrate old uuid context ids and ulid context ids to binary format. @@ -386,8 +404,8 @@ async def test_finish_migrate_events_context_ids( mark the migration as done before ensuring unused indices were dropped. This test makes sure we drop the unused indices. """ - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] def _insert_migration(): with session_scope(hass=hass) as session: @@ -484,13 +502,15 @@ async def test_finish_migrate_events_context_ids( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) +@pytest.mark.parametrize("indices_to_drop", [[], [("states", "ix_states_context_id")]]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_states_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, + indices_to_drop: list[tuple[str, str]], ) -> None: """Test we can migrate old uuid context ids and ulid context ids to binary format.""" - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] test_uuid = uuid.uuid4() uuid_hex = test_uuid.hex @@ -579,6 +599,13 @@ async def test_migrate_states_context_ids( await async_wait_recording_done(hass) await _async_wait_migration_done(hass) + # Remove index + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + for table, index in indices_to_drop: + with session_scope(hass=hass) as session: + assert get_index_by_name(session, table, index) is not None + migration._drop_index(instance.get_session, table, index) + await hass.async_stop() await hass.async_block_till_done() @@ -608,7 +635,12 @@ async def test_migrate_states_context_ids( # Run again with new schema, let migration run async with async_test_home_assistant() as hass: - with instrument_migration(hass) as instrumented_migration: + with ( + instrument_migration(hass) as instrumented_migration, + patch( + "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create + ) as wrapped_idx_create, + ): async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False ) as instance: @@ -639,6 +671,10 @@ async def test_migrate_states_context_ids( await hass.async_stop() await hass.async_block_till_done() + # Check the index we removed was recreated + index_names = [call[1][0].name for call in wrapped_idx_create.mock_calls] + assert index_names == [index for _, index in indices_to_drop] + old_uuid_context_id = states_by_entity_id["state.old_uuid_context_id"] assert old_uuid_context_id["context_id"] is None assert old_uuid_context_id["context_user_id"] is None @@ -722,7 +758,7 @@ async def test_migrate_states_context_ids( @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_finish_migrate_states_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we re migrate old uuid context ids and ulid context ids to binary format. @@ -730,8 +766,8 @@ async def test_finish_migrate_states_context_ids( mark the migration as done before ensuring unused indices were dropped. This test makes sure we drop the unused indices. """ - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] def _insert_migration(): with session_scope(hass=hass) as session: @@ -826,15 +862,15 @@ async def test_finish_migrate_states_context_ids( await hass.async_block_till_done() +@pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) -@pytest.mark.usefixtures("db_schema_32") +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_event_type_ids( - hass: HomeAssistant, recorder_mock: Recorder + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we can migrate event_types to the EventTypes table.""" - await async_wait_recording_done(hass) - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] def _insert_events(): with session_scope(hass=hass) as session: @@ -858,14 +894,24 @@ async def test_migrate_event_type_ids( ) ) - await recorder_mock.async_add_executor_job(_insert_events) + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EventTypeIDMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_events) - await async_wait_recording_done(hass) - # This is a threadsafe way to add a task to the recorder - migrator = migration.EventTypeIDMigration(None, None) - recorder_mock.queue_task(migrator.task(migrator)) - await _async_wait_migration_done(hass) - await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) + + await hass.async_stop() + await hass.async_block_till_done() def _fetch_migrated_events(): with session_scope(hass=hass, read_only=True) as session: @@ -896,36 +942,53 @@ async def test_migrate_event_type_ids( ) return result - events_by_type = await recorder_mock.async_add_executor_job(_fetch_migrated_events) - assert len(events_by_type["event_type_one"]) == 2 - assert len(events_by_type["event_type_two"]) == 1 - def _get_many(): with session_scope(hass=hass, read_only=True) as session: - return recorder_mock.event_type_manager.get_many( + return instance.event_type_manager.get_many( ("event_type_one", "event_type_two"), session ) - mapped = await recorder_mock.async_add_executor_job(_get_many) + # Run again with new schema, let migration run + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + mapped = await instance.async_add_executor_job(_get_many) + migration_changes = await instance.async_add_executor_job( + _get_migration_id, hass + ) + + await hass.async_stop() + await hass.async_block_till_done() + + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type["event_type_two"]) == 1 + assert mapped["event_type_one"] is not None assert mapped["event_type_two"] is not None - migration_changes = await recorder_mock.async_add_executor_job( - _get_migration_id, hass - ) assert ( migration_changes[migration.EventTypeIDMigration.migration_id] == migration.EventTypeIDMigration.migration_version ) +@pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) -@pytest.mark.usefixtures("db_schema_32") -async def test_migrate_entity_ids(hass: HomeAssistant, recorder_mock: Recorder) -> None: +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage +async def test_migrate_entity_ids( + async_test_recorder: RecorderInstanceContextManager, +) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" - await async_wait_recording_done(hass) - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] def _insert_states(): with session_scope(hass=hass) as session: @@ -949,14 +1012,24 @@ async def test_migrate_entity_ids(hass: HomeAssistant, recorder_mock: Recorder) ) ) - await recorder_mock.async_add_executor_job(_insert_states) + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EntityIDMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_states) - await _async_wait_migration_done(hass) - # This is a threadsafe way to add a task to the recorder - migrator = migration.EntityIDMigration(old_db_schema.SCHEMA_VERSION, {}) - recorder_mock.queue_task(migration.CommitBeforeMigrationTask(migrator)) - await _async_wait_migration_done(hass) - await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) + + await hass.async_stop() + await hass.async_block_till_done() def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -982,30 +1055,49 @@ async def test_migrate_entity_ids(hass: HomeAssistant, recorder_mock: Recorder) ) return result - states_by_entity_id = await recorder_mock.async_add_executor_job( - _fetch_migrated_states - ) + # Run again with new schema, let migration run + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + states_by_entity_id = await instance.async_add_executor_job( + _fetch_migrated_states + ) + migration_changes = await instance.async_add_executor_job( + _get_migration_id, hass + ) + + await hass.async_stop() + await hass.async_block_till_done() + assert len(states_by_entity_id["sensor.two"]) == 2 assert len(states_by_entity_id["sensor.one"]) == 1 - migration_changes = await recorder_mock.async_add_executor_job( - _get_migration_id, hass - ) assert ( migration_changes[migration.EntityIDMigration.migration_id] == migration.EntityIDMigration.migration_version ) +@pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) -@pytest.mark.usefixtures("db_schema_32") +@pytest.mark.parametrize( + "indices_to_drop", [[], [("states", "ix_states_entity_id_last_updated_ts")]] +) +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_post_migrate_entity_ids( - hass: HomeAssistant, recorder_mock: Recorder + async_test_recorder: RecorderInstanceContextManager, + indices_to_drop: list[tuple[str, str]], ) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" - await async_wait_recording_done(hass) - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] def _insert_events(): with session_scope(hass=hass) as session: @@ -1029,14 +1121,32 @@ async def test_post_migrate_entity_ids( ) ) - await recorder_mock.async_add_executor_job(_insert_events) + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EntityIDMigration, "migrate_data"), + patch.object(migration.EntityIDPostMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_events) - await _async_wait_migration_done(hass) - # This is a threadsafe way to add a task to the recorder - migrator = migration.EntityIDPostMigration(None, None) - recorder_mock.queue_task(migrator.task(migrator)) - await _async_wait_migration_done(hass) - await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) + + # Remove index + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + for table, index in indices_to_drop: + with session_scope(hass=hass) as session: + assert get_index_by_name(session, table, index) is not None + migration._drop_index(instance.get_session, table, index) + + await hass.async_stop() + await hass.async_block_till_done() def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -1047,21 +1157,54 @@ async def test_post_migrate_entity_ids( assert len(states) == 3 return {state.state: state.entity_id for state in states} - states_by_state = await recorder_mock.async_add_executor_job(_fetch_migrated_states) + # Run again with new schema, let migration run + with patch( + "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create + ) as wrapped_idx_create: + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + states_by_state = await instance.async_add_executor_job( + _fetch_migrated_states + ) + + # Check the index which will be removed by the migrator no longer exists + with session_scope(hass=hass) as session: + assert ( + get_index_by_name( + session, "states", "ix_states_entity_id_last_updated_ts" + ) + is None + ) + + await hass.async_stop() + await hass.async_block_till_done() + + # Check the index we removed was recreated + index_names = [call[1][0].name for call in wrapped_idx_create.mock_calls] + assert index_names == [index for _, index in indices_to_drop] + assert states_by_state["one_1"] is None assert states_by_state["two_2"] is None assert states_by_state["two_1"] is None +@pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) -@pytest.mark.usefixtures("db_schema_32") +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_null_entity_ids( - hass: HomeAssistant, recorder_mock: Recorder + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" - await async_wait_recording_done(hass) - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] def _insert_states(): with session_scope(hass=hass) as session: @@ -1088,14 +1231,24 @@ async def test_migrate_null_entity_ids( ), ) - await recorder_mock.async_add_executor_job(_insert_states) + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EntityIDMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_states) - await _async_wait_migration_done(hass) - # This is a threadsafe way to add a task to the recorder - migrator = migration.EntityIDMigration(old_db_schema.SCHEMA_VERSION, {}) - recorder_mock.queue_task(migration.CommitBeforeMigrationTask(migrator)) - await _async_wait_migration_done(hass) - await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) + + await hass.async_stop() + await hass.async_block_till_done() def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -1121,32 +1274,47 @@ async def test_migrate_null_entity_ids( ) return result - states_by_entity_id = await recorder_mock.async_add_executor_job( - _fetch_migrated_states - ) - assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000 - assert len(states_by_entity_id["sensor.one"]) == 2 - def _get_migration_id(): with session_scope(hass=hass, read_only=True) as session: return dict(execute_stmt_lambda_element(session, get_migration_changes())) - migration_changes = await recorder_mock.async_add_executor_job(_get_migration_id) + # Run again with new schema, let migration run + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + states_by_entity_id = await instance.async_add_executor_job( + _fetch_migrated_states + ) + migration_changes = await instance.async_add_executor_job(_get_migration_id) + + await hass.async_stop() + await hass.async_block_till_done() + + assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000 + assert len(states_by_entity_id["sensor.one"]) == 2 + assert ( migration_changes[migration.EntityIDMigration.migration_id] == migration.EntityIDMigration.migration_version ) +@pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) -@pytest.mark.usefixtures("db_schema_32") +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_null_event_type_ids( - hass: HomeAssistant, recorder_mock: Recorder + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" - await async_wait_recording_done(hass) - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] def _insert_events(): with session_scope(hass=hass) as session: @@ -1173,14 +1341,24 @@ async def test_migrate_null_event_type_ids( ), ) - await recorder_mock.async_add_executor_job(_insert_events) + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EventTypeIDMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_events) - await _async_wait_migration_done(hass) - # This is a threadsafe way to add a task to the recorder - migrator = migration.EventTypeIDMigration(None, None) - recorder_mock.queue_task(migrator.task(migrator)) - await _async_wait_migration_done(hass) - await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) + + await hass.async_stop() + await hass.async_block_till_done() def _fetch_migrated_events(): with session_scope(hass=hass, read_only=True) as session: @@ -1211,15 +1389,29 @@ async def test_migrate_null_event_type_ids( ) return result - events_by_type = await recorder_mock.async_add_executor_job(_fetch_migrated_events) - assert len(events_by_type["event_type_one"]) == 2 - assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 - def _get_migration_id(): with session_scope(hass=hass, read_only=True) as session: return dict(execute_stmt_lambda_element(session, get_migration_changes())) - migration_changes = await recorder_mock.async_add_executor_job(_get_migration_id) + # Run again with new schema, let migration run + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + migration_changes = await instance.async_add_executor_job(_get_migration_id) + + await hass.async_stop() + await hass.async_block_till_done() + + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 assert ( migration_changes[migration.EventTypeIDMigration.migration_id] == migration.EventTypeIDMigration.migration_version @@ -1233,8 +1425,8 @@ async def test_stats_timestamp_conversion_is_reentrant( """Test stats migration is reentrant.""" await async_wait_recording_done(hass) await async_attach_db_engine(hass) - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] now = dt_util.utcnow() one_year_ago = now - datetime.timedelta(days=365) six_months_ago = now - datetime.timedelta(days=180) @@ -1386,8 +1578,8 @@ async def test_stats_timestamp_with_one_by_one( """Test stats migration with one by one.""" await async_wait_recording_done(hass) await async_attach_db_engine(hass) - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] now = dt_util.utcnow() one_year_ago = now - datetime.timedelta(days=365) six_months_ago = now - datetime.timedelta(days=180) @@ -1606,8 +1798,8 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( """Test stats migration with one by one removes duplicates.""" await async_wait_recording_done(hass) await async_attach_db_engine(hass) - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] now = dt_util.utcnow() one_year_ago = now - datetime.timedelta(days=365) six_months_ago = now - datetime.timedelta(days=180) @@ -1798,13 +1990,13 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage -async def test_migrate_times( - async_test_recorder: RecorderInstanceGenerator, +async def test_stats_migrate_times( + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: """Test we can migrate times in the statistics tables.""" - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] now = dt_util.utcnow() now_timestamp = now.timestamp() @@ -1950,3 +2142,143 @@ async def test_migrate_times( ) await hass.async_stop() + + +@pytest.mark.parametrize("persistent_database", [True]) +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage +async def test_cleanup_unmigrated_state_timestamps( + async_test_recorder: RecorderInstanceContextManager, +) -> None: + """Ensure schema 48 migration cleans up any unmigrated state timestamps.""" + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] + + test_uuid = uuid.uuid4() + uuid_hex = test_uuid.hex + + def _object_as_dict(obj): + return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} + + def _insert_states(): + with session_scope(hass=hass) as session: + state1 = old_db_schema.States( + entity_id="state.test_state1", + last_updated=datetime.datetime( + 2016, 10, 28, 20, 13, 52, 452529, tzinfo=datetime.UTC + ), + last_updated_ts=None, + last_changed=datetime.datetime( + 2016, 10, 28, 20, 13, 52, 452529, tzinfo=datetime.UTC + ), + last_changed_ts=None, + context_id=uuid_hex, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ) + state2 = old_db_schema.States( + entity_id="state.test_state2", + last_updated=datetime.datetime( + 2016, 10, 28, 20, 13, 52, 552529, tzinfo=datetime.UTC + ), + last_updated_ts=None, + last_changed=datetime.datetime( + 2016, 10, 28, 20, 13, 52, 452529, tzinfo=datetime.UTC + ), + last_changed_ts=None, + context_id=None, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ) + session.add_all((state1, state2)) + # There is a default of now() for last_updated_ts so make sure it's not set + session.query(old_db_schema.States).update( + {old_db_schema.States.last_updated_ts: None} + ) + state3 = old_db_schema.States( + entity_id="state.already_migrated", + last_updated=None, + last_updated_ts=1477685632.452529, + last_changed=None, + last_changed_ts=1477685632.452529, + context_id=uuid_hex, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ) + session.add_all((state3,)) + + with session_scope(hass=hass, read_only=True) as session: + states = session.query(old_db_schema.States).all() + assert len(states) == 3 + + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await instance.async_add_executor_job(_insert_states) + + await async_wait_recording_done(hass) + now = dt_util.utcnow() + await _async_wait_migration_done(hass) + await async_wait_recording_done(hass) + + await hass.async_stop() + await hass.async_block_till_done() + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = session.query(States).all() + assert len(states) == 3 + return {state.state_id: _object_as_dict(state) for state in states} + + # Run again with new schema, let migration run + async with async_test_home_assistant() as hass: + with ( + freeze_time(now), + instrument_migration(hass) as instrumented_migration, + ): + async with async_test_recorder( + hass, wait_recorder=False, wait_recorder_setup=False + ) as instance: + # Check the context ID migrator is considered non-live + assert recorder.util.async_migration_is_live(hass) is False + instrumented_migration.migration_stall.set() + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + states_by_metadata_id = await instance.async_add_executor_job( + _fetch_migrated_states + ) + + await hass.async_stop() + await hass.async_block_till_done() + + assert len(states_by_metadata_id) == 3 + for state in states_by_metadata_id.values(): + assert state["last_updated_ts"] is not None + + by_entity_id = { + state["entity_id"]: state for state in states_by_metadata_id.values() + } + assert by_entity_id["state.test_state1"]["last_updated_ts"] == 1477685632.452529 + assert by_entity_id["state.test_state2"]["last_updated_ts"] == 1477685632.552529 + assert ( + by_entity_id["state.already_migrated"]["last_updated_ts"] == 1477685632.452529 + ) diff --git a/tests/components/recorder/test_migration_run_time_migrations_remember.py b/tests/components/recorder/test_migration_run_time_migrations_remember.py index 93fa16b8364..43a1b028348 100644 --- a/tests/components/recorder/test_migration_run_time_migrations_remember.py +++ b/tests/components/recorder/test_migration_run_time_migrations_remember.py @@ -1,15 +1,18 @@ """Test run time migrations are remembered in the migration_changes table.""" +from collections.abc import Callable, Generator import importlib import sys -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session +from sqlalchemy.schema import Index from homeassistant.components import recorder from homeassistant.components.recorder import core, migration, statistics +from homeassistant.components.recorder.db_schema import SCHEMA_VERSION from homeassistant.components.recorder.migration import MigrationTask from homeassistant.components.recorder.queries import get_migration_changes from homeassistant.components.recorder.util import ( @@ -19,22 +22,19 @@ from homeassistant.components.recorder.util import ( from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from .common import ( - MockMigrationTask, - async_recorder_block_till_done, - async_wait_recording_done, -) +from .common import async_recorder_block_till_done, async_wait_recording_done from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" -SCHEMA_MODULE = "tests.components.recorder.db_schema_32" +SCHEMA_MODULE_32 = "tests.components.recorder.db_schema_32" +SCHEMA_MODULE_CURRENT = "homeassistant.components.recorder.db_schema" @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -50,33 +50,231 @@ def _get_migration_id(hass: HomeAssistant) -> dict[str, int]: return dict(execute_stmt_lambda_element(session, get_migration_changes())) -def _create_engine_test(*args, **kwargs): +def _create_engine_test( + schema_module: str, *, initial_version: int | None = None +) -> Callable: """Test version of create_engine that initializes with old schema. This simulates an existing db with the old schema. """ - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] - engine = create_engine(*args, **kwargs) - old_db_schema.Base.metadata.create_all(engine) - with Session(engine) as session: - session.add( - recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) - ) - session.add( - recorder.db_schema.SchemaChanges( - schema_version=old_db_schema.SCHEMA_VERSION + + def _create_engine_test(*args, **kwargs): + """Test version of create_engine that initializes with old schema. + + This simulates an existing db with the old schema. + """ + importlib.import_module(schema_module) + old_db_schema = sys.modules[schema_module] + engine = create_engine(*args, **kwargs) + old_db_schema.Base.metadata.create_all(engine) + with Session(engine) as session: + session.add( + recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) ) + if initial_version is not None: + session.add( + recorder.db_schema.SchemaChanges(schema_version=initial_version) + ) + session.add( + recorder.db_schema.SchemaChanges( + schema_version=old_db_schema.SCHEMA_VERSION + ) + ) + session.commit() + return engine + + return _create_engine_test + + +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage +@pytest.mark.parametrize( + ("initial_version", "expected_migrator_calls", "expected_created_indices"), + # expected_migrator_calls is a dict of + # migrator_id: (needs_migrate_calls, migrate_data_calls) + [ + ( + 27, + { + "state_context_id_as_binary": (0, 1), + "event_context_id_as_binary": (0, 1), + "event_type_id_migration": (2, 1), + "entity_id_migration": (2, 1), + "event_id_post_migration": (1, 1), + "entity_id_post_migration": (0, 1), + }, + [ + "ix_states_context_id", + "ix_events_context_id", + "ix_states_entity_id_last_updated_ts", + ], + ), + ( + 28, + { + "state_context_id_as_binary": (0, 1), + "event_context_id_as_binary": (0, 1), + "event_type_id_migration": (2, 1), + "entity_id_migration": (2, 1), + "event_id_post_migration": (0, 0), + "entity_id_post_migration": (0, 1), + }, + [ + "ix_states_context_id", + "ix_events_context_id", + "ix_states_entity_id_last_updated_ts", + ], + ), + ( + 36, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (2, 1), + "entity_id_migration": (2, 1), + "event_id_post_migration": (0, 0), + "entity_id_post_migration": (0, 1), + }, + ["ix_states_entity_id_last_updated_ts"], + ), + ( + 37, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (0, 0), + "entity_id_migration": (2, 1), + "event_id_post_migration": (0, 0), + "entity_id_post_migration": (0, 1), + }, + ["ix_states_entity_id_last_updated_ts"], + ), + ( + 38, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (0, 0), + "entity_id_migration": (0, 0), + "event_id_post_migration": (0, 0), + "entity_id_post_migration": (0, 0), + }, + [], + ), + ( + SCHEMA_VERSION, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (0, 0), + "entity_id_migration": (0, 0), + "event_id_post_migration": (0, 0), + "entity_id_post_migration": (0, 0), + }, + [], + ), + ], +) +async def test_data_migrator_logic( + async_test_recorder: RecorderInstanceContextManager, + initial_version: int, + expected_migrator_calls: dict[str, tuple[int, int]], + expected_created_indices: list[str], +) -> None: + """Test the data migrator logic. + + - The data migrators should not be executed on a new database. + - Indices needed by the migrators should be created if missing. + """ + config = {recorder.CONF_COMMIT_INTERVAL: 1} + + def migrator_mock() -> dict[str, Mock]: + return { + "needs_migrate": Mock( + spec_set=[], + return_value=migration.DataMigrationStatus( + needs_migrate=True, migration_done=False + ), + ), + "migrate_data": Mock(spec_set=[], return_value=True), + } + + migrator_mocks = { + "state_context_id_as_binary": migrator_mock(), + "event_context_id_as_binary": migrator_mock(), + "event_type_id_migration": migrator_mock(), + "entity_id_migration": migrator_mock(), + "event_id_post_migration": migrator_mock(), + "entity_id_post_migration": migrator_mock(), + } + + def patch_check( + migrator_id: str, migrator_class: type[migration.BaseMigration] + ) -> Generator[None]: + return patch.object( + migrator_class, + "needs_migrate_impl", + side_effect=migrator_mocks[migrator_id]["needs_migrate"], ) - session.commit() - return engine + + def patch_migrate( + migrator_id: str, migrator_class: type[migration.BaseMigration] + ) -> Generator[None]: + return patch.object( + migrator_class, + "migrate_data", + side_effect=migrator_mocks[migrator_id]["migrate_data"], + ) + + with ( + patch_check("state_context_id_as_binary", migration.StatesContextIDMigration), + patch_check("event_context_id_as_binary", migration.EventsContextIDMigration), + patch_check("event_type_id_migration", migration.EventTypeIDMigration), + patch_check("entity_id_migration", migration.EntityIDMigration), + patch_check("event_id_post_migration", migration.EventIDPostMigration), + patch_check("entity_id_post_migration", migration.EntityIDPostMigration), + patch_migrate("state_context_id_as_binary", migration.StatesContextIDMigration), + patch_migrate("event_context_id_as_binary", migration.EventsContextIDMigration), + patch_migrate("event_type_id_migration", migration.EventTypeIDMigration), + patch_migrate("entity_id_migration", migration.EntityIDMigration), + patch_migrate("event_id_post_migration", migration.EventIDPostMigration), + patch_migrate("entity_id_post_migration", migration.EntityIDPostMigration), + patch( + CREATE_ENGINE_TARGET, + new=_create_engine_test( + SCHEMA_MODULE_CURRENT, initial_version=initial_version + ), + ), + patch( + "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create + ) as wrapped_idx_create, + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass, config), + ): + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + await hass.async_stop() + + index_names = [call[1][0].name for call in wrapped_idx_create.mock_calls] + assert index_names == expected_created_indices + + # Check each data migrator's needs_migrate_impl and migrate_data methods were called + # the expected number of times. + for migrator, mock in migrator_mocks.items(): + needs_migrate_calls, migrate_data_calls = expected_migrator_calls[migrator] + assert len(mock["needs_migrate"].mock_calls) == needs_migrate_calls + assert len(mock["migrate_data"].mock_calls) == migrate_data_calls @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migration_changes_prevent_trying_to_migrate_again( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test that we do not try to migrate when migration_changes indicate its already migrated. @@ -88,8 +286,8 @@ async def test_migration_changes_prevent_trying_to_migrate_again( """ config = {recorder.CONF_COMMIT_INTERVAL: 1} - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] # Start with db schema that needs migration (version 32) with ( @@ -102,8 +300,7 @@ async def test_migration_changes_prevent_trying_to_migrate_again( patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), patch.object(core, "StateAttributes", old_db_schema.StateAttributes), - patch.object(migration.EntityIDMigration, "task", MockMigrationTask), - patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), ): async with ( async_test_home_assistant() as hass, diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 9078b2e861c..b2894883ff2 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -325,6 +325,7 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( state="off", attributes='{"shared":true}', last_updated_ts=now.timestamp(), + last_reported_ts=now.timestamp(), last_changed_ts=(now - timedelta(seconds=60)).timestamp(), ) lstate = LazyState( @@ -339,6 +340,7 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( } assert lstate.last_updated.timestamp() == row.last_updated_ts assert lstate.last_changed.timestamp() == row.last_changed_ts + assert lstate.last_reported.timestamp() == row.last_updated_ts assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -346,6 +348,9 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } + assert lstate.last_changed_timestamp == row.last_changed_ts + assert lstate.last_updated_timestamp == row.last_updated_ts + assert lstate.last_reported_timestamp == row.last_updated_ts async def test_lazy_state_handles_same_last_updated_and_last_changed( @@ -359,6 +364,7 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( attributes='{"shared":true}', last_updated_ts=now.timestamp(), last_changed_ts=now.timestamp(), + last_reported_ts=None, ) lstate = LazyState( row, {}, None, row.entity_id, row.state, row.last_updated_ts, False @@ -372,6 +378,7 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( } assert lstate.last_updated.timestamp() == row.last_updated_ts assert lstate.last_changed.timestamp() == row.last_changed_ts + assert lstate.last_reported.timestamp() == row.last_updated_ts assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -379,3 +386,37 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } + assert lstate.last_changed_timestamp == row.last_changed_ts + assert lstate.last_updated_timestamp == row.last_updated_ts + assert lstate.last_reported_timestamp == row.last_updated_ts + + +async def test_lazy_state_handles_different_last_reported( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the LazyState handles last_reported different from last_updated.""" + now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) + row = PropertyMock( + entity_id="sensor.valid", + state="off", + attributes='{"shared":true}', + last_updated_ts=(now - timedelta(seconds=60)).timestamp(), + last_reported_ts=now.timestamp(), + last_changed_ts=(now - timedelta(seconds=60)).timestamp(), + ) + lstate = LazyState( + row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + ) + assert lstate.as_dict() == { + "attributes": {"shared": True}, + "entity_id": "sensor.valid", + "last_changed": "2021-06-12T03:03:01.000323+00:00", + "last_updated": "2021-06-12T03:03:01.000323+00:00", + "state": "off", + } + assert lstate.last_updated.timestamp() == row.last_updated_ts + assert lstate.last_changed.timestamp() == row.last_changed_ts + assert lstate.last_reported.timestamp() == row.last_reported_ts + assert lstate.last_changed_timestamp == row.last_changed_ts + assert lstate.last_updated_timestamp == row.last_updated_ts + assert lstate.last_reported_timestamp == row.last_reported_ts diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index ca160e5201b..e5eea0cf89f 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -45,7 +45,7 @@ from .common import ( convert_pending_states_to_meta, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager TEST_EVENT_TYPES = ( "EVENT_TEST_AUTOPURGE", @@ -59,7 +59,7 @@ TEST_EVENT_TYPES = ( @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -112,6 +112,9 @@ async def test_purge_big_database(hass: HomeAssistant, recorder_mock: Recorder) async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> None: """Test deleting old states.""" + assert recorder_mock.states_manager.oldest_ts is None + oldest_ts = recorder_mock.states_manager.oldest_ts + await _add_test_states(hass) # make sure we start with 6 states @@ -127,6 +130,10 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> events = session.query(Events).filter(Events.event_type == "state_changed") assert events.count() == 0 + assert recorder_mock.states_manager.oldest_ts != oldest_ts + assert recorder_mock.states_manager.oldest_ts == states[0].last_updated_ts + oldest_ts = recorder_mock.states_manager.oldest_ts + assert "test.recorder2" in recorder_mock.states_manager._last_committed_id purge_before = dt_util.utcnow() - timedelta(days=4) @@ -140,6 +147,8 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> repack=False, ) assert not finished + # states_manager.oldest_ts is not updated until after the purge is complete + assert recorder_mock.states_manager.oldest_ts == oldest_ts with session_scope(hass=hass) as session: states = session.query(States) @@ -162,6 +171,8 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> finished = purge_old_data(recorder_mock, purge_before, repack=False) assert finished + # states_manager.oldest_ts should now be updated + assert recorder_mock.states_manager.oldest_ts != oldest_ts with session_scope(hass=hass) as session: states = session.query(States) @@ -169,6 +180,10 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> assert states.count() == 2 assert state_attributes.count() == 1 + assert recorder_mock.states_manager.oldest_ts != oldest_ts + assert recorder_mock.states_manager.oldest_ts == states[0].last_updated_ts + oldest_ts = recorder_mock.states_manager.oldest_ts + assert "test.recorder2" in recorder_mock.states_manager._last_committed_id # run purge_old_data again @@ -181,6 +196,8 @@ async def test_purge_old_states(hass: HomeAssistant, recorder_mock: Recorder) -> repack=False, ) assert not finished + # states_manager.oldest_ts is not updated until after the purge is complete + assert recorder_mock.states_manager.oldest_ts == oldest_ts with session_scope(hass=hass) as session: assert states.count() == 0 @@ -547,7 +564,7 @@ async def test_purge_edge_case( event_type="EVENT_TEST_PURGE", event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) session.add( @@ -555,8 +572,8 @@ async def test_purge_edge_case( entity_id="test.recorder2", state="purgeme", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), event_id=1001, attributes_id=1002, ) @@ -618,7 +635,7 @@ async def test_purge_cutoff_date(hass: HomeAssistant, recorder_mock: Recorder) - event_type="KEEP", event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp_keep), + time_fired_ts=timestamp_keep.timestamp(), ) ) session.add( @@ -626,8 +643,8 @@ async def test_purge_cutoff_date(hass: HomeAssistant, recorder_mock: Recorder) - entity_id="test.cutoff", state="keep", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp_keep), - last_updated_ts=dt_util.utc_to_timestamp(timestamp_keep), + last_changed_ts=timestamp_keep.timestamp(), + last_updated_ts=timestamp_keep.timestamp(), event_id=1000, attributes_id=1000, ) @@ -646,7 +663,7 @@ async def test_purge_cutoff_date(hass: HomeAssistant, recorder_mock: Recorder) - event_type="PURGE", event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp_purge), + time_fired_ts=timestamp_purge.timestamp(), ) ) session.add( @@ -654,8 +671,8 @@ async def test_purge_cutoff_date(hass: HomeAssistant, recorder_mock: Recorder) - entity_id="test.cutoff", state="purge", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp_purge), - last_updated_ts=dt_util.utc_to_timestamp(timestamp_purge), + last_changed_ts=timestamp_purge.timestamp(), + last_updated_ts=timestamp_purge.timestamp(), event_id=1000 + row, attributes_id=1000 + row, ) @@ -804,8 +821,8 @@ async def test_purge_filtered_states( entity_id="sensor.excluded", state="purgeme", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), ) ) # Add states and state_changed events that should be keeped @@ -830,8 +847,8 @@ async def test_purge_filtered_states( entity_id="sensor.linked_old_state_id", state="keep", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), old_state_id=1, state_attributes=state_attrs, ) @@ -840,8 +857,8 @@ async def test_purge_filtered_states( entity_id="sensor.linked_old_state_id", state="keep", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), old_state_id=2, state_attributes=state_attrs, ) @@ -849,8 +866,8 @@ async def test_purge_filtered_states( entity_id="sensor.linked_old_state_id", state="keep", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), old_state_id=62, # keep state_attributes=state_attrs, ) @@ -862,7 +879,7 @@ async def test_purge_filtered_states( event_type="EVENT_KEEP", event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) convert_pending_states_to_meta(recorder_mock, session) @@ -999,8 +1016,8 @@ async def test_purge_filtered_states_multiple_rounds( entity_id="sensor.excluded", state="purgeme", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), ) ) # Add states and state_changed events that should be keeped @@ -1025,8 +1042,8 @@ async def test_purge_filtered_states_multiple_rounds( entity_id="sensor.linked_old_state_id", state="keep", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), old_state_id=1, state_attributes=state_attrs, ) @@ -1035,8 +1052,8 @@ async def test_purge_filtered_states_multiple_rounds( entity_id="sensor.linked_old_state_id", state="keep", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), old_state_id=2, state_attributes=state_attrs, ) @@ -1044,8 +1061,8 @@ async def test_purge_filtered_states_multiple_rounds( entity_id="sensor.linked_old_state_id", state="keep", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), old_state_id=62, # keep state_attributes=state_attrs, ) @@ -1057,7 +1074,7 @@ async def test_purge_filtered_states_multiple_rounds( event_type="EVENT_KEEP", event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) convert_pending_states_to_meta(recorder_mock, session) @@ -1211,8 +1228,8 @@ async def test_purge_without_state_attributes_filtered_states_to_empty( entity_id="sensor.old_format", state=STATE_ON, attributes=json.dumps({"old": "not_using_state_attributes"}), - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), event_id=event_id, state_attributes=None, ) @@ -1223,7 +1240,7 @@ async def test_purge_without_state_attributes_filtered_states_to_empty( event_type=EVENT_STATE_CHANGED, event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) session.add( @@ -1232,7 +1249,7 @@ async def test_purge_without_state_attributes_filtered_states_to_empty( event_type=EVENT_THEMES_UPDATED, event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) convert_pending_states_to_meta(recorder_mock, session) @@ -1287,7 +1304,7 @@ async def test_purge_filtered_events( event_type="EVENT_PURGE", event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) @@ -1394,7 +1411,7 @@ async def test_purge_filtered_events_state_changed( event_type="EVENT_KEEP", event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) # Add states with linked old_state_ids that need to be handled @@ -1403,8 +1420,8 @@ async def test_purge_filtered_events_state_changed( entity_id="sensor.linked_old_state_id", state="keep", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), old_state_id=1, ) timestamp = dt_util.utcnow() - timedelta(days=4) @@ -1412,16 +1429,16 @@ async def test_purge_filtered_events_state_changed( entity_id="sensor.linked_old_state_id", state="keep", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), old_state_id=2, ) state_3 = States( entity_id="sensor.linked_old_state_id", state="keep", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), old_state_id=62, # keep ) session.add_all((state_1, state_2, state_3)) @@ -1431,7 +1448,7 @@ async def test_purge_filtered_events_state_changed( event_type="excluded_event", event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) session.add( @@ -1439,8 +1456,8 @@ async def test_purge_filtered_events_state_changed( entity_id="sensor.old_format", state="remove", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), ) ) convert_pending_events_to_event_types(recorder_mock, session) @@ -1806,8 +1823,8 @@ def _add_state_without_event_linkage( entity_id=entity_id, state=state, attributes=None, - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), event_id=None, state_attributes=state_attrs, ) @@ -1831,8 +1848,8 @@ def _add_state_with_state_attributes( entity_id=entity_id, state=state, attributes=None, - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), event_id=event_id, state_attributes=state_attrs, ) @@ -1913,8 +1930,6 @@ async def test_purge_old_events_purges_the_event_type_ids( hass: HomeAssistant, recorder_mock: Recorder ) -> None: """Test deleting old events purges event type ids.""" - assert recorder_mock.event_type_manager.active is True - utcnow = dt_util.utcnow() five_days_ago = utcnow - timedelta(days=5) eleven_days_ago = utcnow - timedelta(days=11) @@ -1954,7 +1969,7 @@ async def test_purge_old_events_purges_the_event_type_ids( Events( event_type=None, event_type_id=event_type.event_type_id, - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) return recorder_mock.event_type_manager.get_many( @@ -2084,7 +2099,7 @@ async def test_purge_old_states_purges_the_state_metadata_ids( States( metadata_id=metadata_id, state="any", - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=timestamp.timestamp(), ) ) return recorder_mock.states_meta_manager.get_many( diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 468fd38c855..45bef68dabd 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -47,12 +47,12 @@ from .db_schema_32 import ( StatisticsShortTerm, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -509,7 +509,7 @@ async def test_purge_edge_case(hass: HomeAssistant, use_sqlite: bool) -> None: event_type="EVENT_TEST_PURGE", event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) session.add( @@ -517,8 +517,8 @@ async def test_purge_edge_case(hass: HomeAssistant, use_sqlite: bool) -> None: entity_id="test.recorder2", state="purgeme", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), event_id=1001, attributes_id=1002, ) @@ -576,7 +576,7 @@ async def test_purge_cutoff_date(hass: HomeAssistant, recorder_mock: Recorder) - event_type="KEEP", event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp_keep), + time_fired_ts=timestamp_keep.timestamp(), ) ) session.add( @@ -584,8 +584,8 @@ async def test_purge_cutoff_date(hass: HomeAssistant, recorder_mock: Recorder) - entity_id="test.cutoff", state="keep", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp_keep), - last_updated_ts=dt_util.utc_to_timestamp(timestamp_keep), + last_changed_ts=timestamp_keep.timestamp(), + last_updated_ts=timestamp_keep.timestamp(), event_id=1000, attributes_id=1000, ) @@ -604,7 +604,7 @@ async def test_purge_cutoff_date(hass: HomeAssistant, recorder_mock: Recorder) - event_type="PURGE", event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp_purge), + time_fired_ts=timestamp_purge.timestamp(), ) ) session.add( @@ -612,8 +612,8 @@ async def test_purge_cutoff_date(hass: HomeAssistant, recorder_mock: Recorder) - entity_id="test.cutoff", state="purge", attributes="{}", - last_changed_ts=dt_util.utc_to_timestamp(timestamp_purge), - last_updated_ts=dt_util.utc_to_timestamp(timestamp_purge), + last_changed_ts=timestamp_purge.timestamp(), + last_updated_ts=timestamp_purge.timestamp(), event_id=1000 + row, attributes_id=1000 + row, ) @@ -771,7 +771,7 @@ async def _add_test_events(hass: HomeAssistant, iterations: int = 1): event_type=event_type, event_data=json.dumps(event_data), origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) @@ -808,7 +808,7 @@ async def _add_events_with_event_data(hass: HomeAssistant, iterations: int = 1): Events( event_type=event_type, origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), event_data_rel=event_data, ) ) @@ -910,8 +910,8 @@ def _add_state_without_event_linkage( entity_id=entity_id, state=state, attributes=None, - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), event_id=None, state_attributes=state_attrs, ) @@ -935,8 +935,8 @@ def _add_state_and_state_changed_event( entity_id=entity_id, state=state, attributes=None, - last_changed_ts=dt_util.utc_to_timestamp(timestamp), - last_updated_ts=dt_util.utc_to_timestamp(timestamp), + last_changed_ts=timestamp.timestamp(), + last_updated_ts=timestamp.timestamp(), event_id=event_id, state_attributes=state_attrs, ) @@ -947,7 +947,7 @@ def _add_state_and_state_changed_event( event_type=EVENT_STATE_CHANGED, event_data="{}", origin="LOCAL", - time_fired_ts=dt_util.utc_to_timestamp(timestamp), + time_fired_ts=timestamp.timestamp(), ) ) @@ -1027,7 +1027,7 @@ async def test_purge_can_mix_legacy_and_new_format( def _recreate_legacy_events_index(): """Recreate the legacy events index since its no longer created on new instances.""" migration._create_index( - recorder_mock.get_session, "states", "ix_states_event_id" + recorder_mock, recorder_mock.get_session, "states", "ix_states_event_id" ) recorder_mock.use_legacy_events_index = True @@ -1178,7 +1178,7 @@ async def test_purge_can_mix_legacy_and_new_format_with_detached_state( def _recreate_legacy_events_index(): """Recreate the legacy events index since its no longer created on new instances.""" migration._create_index( - recorder_mock.get_session, "states", "ix_states_event_id" + recorder_mock, recorder_mock.get_session, "states", "ix_states_event_id" ) recorder_mock.use_legacy_events_index = True diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index bdf39c5ef4a..2baf7f2bcbc 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -54,12 +54,12 @@ from .common import ( ) from tests.common import MockPlatform, mock_platform -from tests.typing import RecorderInstanceGenerator, WebSocketGenerator +from tests.typing import RecorderInstanceContextManager, WebSocketGenerator @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -337,12 +337,12 @@ def mock_from_stats(): counter = 0 real_from_stats = StatisticsShortTerm.from_stats - def from_stats(metadata_id, stats): + def from_stats(metadata_id, stats, now_timestamp): nonlocal counter if counter == 0 and metadata_id == 2: counter += 1 return None - return real_from_stats(metadata_id, stats) + return real_from_stats(metadata_id, stats, now_timestamp) with patch( "homeassistant.components.recorder.statistics.StatisticsShortTerm.from_stats", diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index 1f9be0cabee..dafa4da81ee 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -27,7 +27,7 @@ from .common import ( ) from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager SCHEMA_VERSION_POSTFIX = "23_with_newer_columns" SCHEMA_MODULE = get_schema_module_path(SCHEMA_VERSION_POSTFIX) @@ -37,7 +37,8 @@ SCHEMA_MODULE = get_schema_module_path(SCHEMA_VERSION_POSTFIX) @pytest.mark.usefixtures("skip_by_db_engine") @pytest.mark.parametrize("persistent_database", [True]) async def test_delete_duplicates( - async_test_recorder: RecorderInstanceGenerator, caplog: pytest.LogCaptureFixture + async_test_recorder: RecorderInstanceContextManager, + caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics. @@ -224,7 +225,8 @@ async def test_delete_duplicates( @pytest.mark.usefixtures("skip_by_db_engine") @pytest.mark.parametrize("persistent_database", [True]) async def test_delete_duplicates_many( - async_test_recorder: RecorderInstanceGenerator, caplog: pytest.LogCaptureFixture + async_test_recorder: RecorderInstanceContextManager, + caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics. @@ -418,7 +420,7 @@ async def test_delete_duplicates_many( @pytest.mark.usefixtures("skip_by_db_engine") @pytest.mark.parametrize("persistent_database", [True]) async def test_delete_duplicates_non_identical( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, tmp_path: Path, ) -> None: @@ -613,7 +615,7 @@ async def test_delete_duplicates_non_identical( @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) @pytest.mark.usefixtures("skip_by_db_engine") async def test_delete_duplicates_short_term( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, tmp_path: Path, ) -> None: diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 4904bdecc4d..c9020762d4b 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -9,6 +9,7 @@ import threading from typing import Any from unittest.mock import MagicMock, Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy import lambda_stmt, text from sqlalchemy.engine.result import ChunkedIteratorResult @@ -34,7 +35,6 @@ from homeassistant.components.recorder.models import ( from homeassistant.components.recorder.util import ( MIN_VERSION_SQLITE, RETRYABLE_MYSQL_ERRORS, - UPCOMING_MIN_VERSION_SQLITE, database_job_retry_wrapper, end_incomplete_runs, is_second_sunday, @@ -55,12 +55,12 @@ from .common import ( ) from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager, RecorderInstanceGenerator @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -235,7 +235,7 @@ def test_setup_connection_for_dialect_mysql(mysql_version) -> None: @pytest.mark.parametrize( "sqlite_version", - [str(UPCOMING_MIN_VERSION_SQLITE)], + [str(MIN_VERSION_SQLITE)], ) def test_setup_connection_for_dialect_sqlite(sqlite_version: str) -> None: """Test setting up the connection for a sqlite dialect.""" @@ -288,7 +288,7 @@ def test_setup_connection_for_dialect_sqlite(sqlite_version: str) -> None: @pytest.mark.parametrize( "sqlite_version", - [str(UPCOMING_MIN_VERSION_SQLITE)], + [str(MIN_VERSION_SQLITE)], ) def test_setup_connection_for_dialect_sqlite_zero_commit_interval( sqlite_version: str, @@ -501,7 +501,7 @@ def test_supported_pgsql(caplog: pytest.LogCaptureFixture, pgsql_version) -> Non assert "minimum supported version" not in caplog.text assert database_engine is not None - assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_range_in_select is True @pytest.mark.parametrize( @@ -509,11 +509,11 @@ def test_supported_pgsql(caplog: pytest.LogCaptureFixture, pgsql_version) -> Non [ ( "3.30.0", - "Version 3.30.0 of SQLite is not supported; minimum supported version is 3.31.0.", + "Version 3.30.0 of SQLite is not supported; minimum supported version is 3.40.1.", ), ( "2.0.0", - "Version 2.0.0 of SQLite is not supported; minimum supported version is 3.31.0.", + "Version 2.0.0 of SQLite is not supported; minimum supported version is 3.40.1.", ), ], ) @@ -551,8 +551,8 @@ def test_fail_outdated_sqlite( @pytest.mark.parametrize( "sqlite_version", [ - ("3.31.0"), - ("3.33.0"), + ("3.40.1"), + ("3.41.0"), ], ) def test_supported_sqlite(caplog: pytest.LogCaptureFixture, sqlite_version) -> None: @@ -733,63 +733,6 @@ async def test_no_issue_for_mariadb_with_MDEV_25020( assert database_engine.optimizer.slow_range_in_select is False -async def test_issue_for_old_sqlite( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test we create and delete an issue for old sqlite versions.""" - instance_mock = MagicMock() - instance_mock.hass = hass - execute_args = [] - close_mock = MagicMock() - min_version = str(MIN_VERSION_SQLITE) - - def execute_mock(statement): - nonlocal execute_args - execute_args.append(statement) - - def fetchall_mock(): - nonlocal execute_args - if execute_args[-1] == "SELECT sqlite_version()": - return [[min_version]] - return None - - def _make_cursor_mock(*_): - return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) - - dbapi_connection = MagicMock(cursor=_make_cursor_mock) - - database_engine = await hass.async_add_executor_job( - util.setup_connection_for_dialect, - instance_mock, - "sqlite", - dbapi_connection, - True, - ) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue(DOMAIN, "sqlite_too_old") - assert issue is not None - assert issue.translation_placeholders == { - "min_version": str(UPCOMING_MIN_VERSION_SQLITE), - "server_version": min_version, - } - - min_version = str(UPCOMING_MIN_VERSION_SQLITE) - database_engine = await hass.async_add_executor_job( - util.setup_connection_for_dialect, - instance_mock, - "sqlite", - dbapi_connection, - True, - ) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue(DOMAIN, "sqlite_too_old") - assert issue is None - assert database_engine is not None - - @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) @pytest.mark.usefixtures("skip_by_db_engine") async def test_basic_sanity_check( @@ -1052,55 +995,82 @@ async def test_execute_stmt_lambda_element( assert rows == ["mock_row"] -@pytest.mark.freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=UTC)) -async def test_resolve_period(hass: HomeAssistant) -> None: - """Test statistic_during_period.""" +@pytest.mark.parametrize( + ("start_time", "periods"), + [ + ( + # Test 00:25 local time, during DST + datetime(2022, 10, 21, 7, 25, 50, 123, tzinfo=UTC), + { + ("hour", 0): ("2022-10-21T07:00:00", "2022-10-21T08:00:00"), + ("hour", -1): ("2022-10-21T06:00:00", "2022-10-21T07:00:00"), + ("hour", 1): ("2022-10-21T08:00:00", "2022-10-21T09:00:00"), + ("day", 0): ("2022-10-21T07:00:00", "2022-10-22T07:00:00"), + ("day", -1): ("2022-10-20T07:00:00", "2022-10-21T07:00:00"), + ("day", 1): ("2022-10-22T07:00:00", "2022-10-23T07:00:00"), + ("week", 0): ("2022-10-17T07:00:00", "2022-10-24T07:00:00"), + ("week", -1): ("2022-10-10T07:00:00", "2022-10-17T07:00:00"), + ("week", 1): ("2022-10-24T07:00:00", "2022-10-31T07:00:00"), + ("month", 0): ("2022-10-01T07:00:00", "2022-11-01T07:00:00"), + ("month", -1): ("2022-09-01T07:00:00", "2022-10-01T07:00:00"), + ("month", -12): ("2021-10-01T07:00:00", "2021-11-01T07:00:00"), + ("month", 1): ("2022-11-01T07:00:00", "2022-12-01T08:00:00"), + ("month", 2): ("2022-12-01T08:00:00", "2023-01-01T08:00:00"), + ("month", 3): ("2023-01-01T08:00:00", "2023-02-01T08:00:00"), + ("month", 12): ("2023-10-01T07:00:00", "2023-11-01T07:00:00"), + ("month", 13): ("2023-11-01T07:00:00", "2023-12-01T08:00:00"), + ("month", 14): ("2023-12-01T08:00:00", "2024-01-01T08:00:00"), + ("year", 0): ("2022-01-01T08:00:00", "2023-01-01T08:00:00"), + ("year", -1): ("2021-01-01T08:00:00", "2022-01-01T08:00:00"), + ("year", 1): ("2023-01-01T08:00:00", "2024-01-01T08:00:00"), + }, + ), + ( + # Test 00:25 local time, standard time, February 28th a leap year + datetime(2024, 2, 28, 8, 25, 50, 123, tzinfo=UTC), + { + ("hour", 0): ("2024-02-28T08:00:00", "2024-02-28T09:00:00"), + ("hour", -1): ("2024-02-28T07:00:00", "2024-02-28T08:00:00"), + ("hour", 1): ("2024-02-28T09:00:00", "2024-02-28T10:00:00"), + ("day", 0): ("2024-02-28T08:00:00", "2024-02-29T08:00:00"), + ("day", -1): ("2024-02-27T08:00:00", "2024-02-28T08:00:00"), + ("day", 1): ("2024-02-29T08:00:00", "2024-03-01T08:00:00"), + ("week", 0): ("2024-02-26T08:00:00", "2024-03-04T08:00:00"), + ("week", -1): ("2024-02-19T08:00:00", "2024-02-26T08:00:00"), + ("week", 1): ("2024-03-04T08:00:00", "2024-03-11T07:00:00"), + ("month", 0): ("2024-02-01T08:00:00", "2024-03-01T08:00:00"), + ("month", -1): ("2024-01-01T08:00:00", "2024-02-01T08:00:00"), + ("month", -2): ("2023-12-01T08:00:00", "2024-01-01T08:00:00"), + ("month", -3): ("2023-11-01T07:00:00", "2023-12-01T08:00:00"), + ("month", -12): ("2023-02-01T08:00:00", "2023-03-01T08:00:00"), + ("month", -13): ("2023-01-01T08:00:00", "2023-02-01T08:00:00"), + ("month", -14): ("2022-12-01T08:00:00", "2023-01-01T08:00:00"), + ("month", 1): ("2024-03-01T08:00:00", "2024-04-01T07:00:00"), + ("year", 0): ("2024-01-01T08:00:00", "2025-01-01T08:00:00"), + ("year", -1): ("2023-01-01T08:00:00", "2024-01-01T08:00:00"), + ("year", 1): ("2025-01-01T08:00:00", "2026-01-01T08:00:00"), + }, + ), + ], +) +async def test_resolve_period( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + start_time: datetime, + periods: dict[tuple[str, int], tuple[str, str]], +) -> None: + """Test resolve_period.""" + assert hass.config.time_zone == "US/Pacific" + freezer.move_to(start_time) now = dt_util.utcnow() - start_t, end_t = resolve_period({"calendar": {"period": "hour"}}) - assert start_t.isoformat() == "2022-10-21T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T08:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "hour"}}) - assert start_t.isoformat() == "2022-10-21T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T08:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "hour", "offset": -1}}) - assert start_t.isoformat() == "2022-10-21T06:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "day"}}) - assert start_t.isoformat() == "2022-10-21T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-22T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "day", "offset": -1}}) - assert start_t.isoformat() == "2022-10-20T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-21T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "week"}}) - assert start_t.isoformat() == "2022-10-17T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-24T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "week", "offset": -1}}) - assert start_t.isoformat() == "2022-10-10T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-17T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "month"}}) - assert start_t.isoformat() == "2022-10-01T07:00:00+00:00" - assert end_t.isoformat() == "2022-11-01T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "month", "offset": -1}}) - assert start_t.isoformat() == "2022-09-01T07:00:00+00:00" - assert end_t.isoformat() == "2022-10-01T07:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "year"}}) - assert start_t.isoformat() == "2022-01-01T08:00:00+00:00" - assert end_t.isoformat() == "2023-01-01T08:00:00+00:00" - - start_t, end_t = resolve_period({"calendar": {"period": "year", "offset": -1}}) - assert start_t.isoformat() == "2021-01-01T08:00:00+00:00" - assert end_t.isoformat() == "2022-01-01T08:00:00+00:00" + for period_def, expected_period in periods.items(): + start_t, end_t = resolve_period( + {"calendar": {"period": period_def[0], "offset": period_def[1]}} + ) + assert start_t.isoformat() == f"{expected_period[0]}+00:00" + assert end_t.isoformat() == f"{expected_period[1]}+00:00" # Fixed period assert resolve_period({}) == (None, None) diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index d59486b61f0..58be23bdc85 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -23,14 +23,16 @@ from .common import async_wait_recording_done from .conftest import instrument_migration from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" SCHEMA_MODULE_30 = "tests.components.recorder.db_schema_30" SCHEMA_MODULE_32 = "tests.components.recorder.db_schema_32" -def _create_engine_test(schema_module: str) -> Callable: +def _create_engine_test( + schema_module: str, *, initial_version: int | None = None +) -> Callable: """Test version of create_engine that initializes with old schema. This simulates an existing db with the old schema. @@ -49,6 +51,10 @@ def _create_engine_test(schema_module: str) -> Callable: session.add( recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) ) + if initial_version is not None: + session.add( + recorder.db_schema.SchemaChanges(schema_version=initial_version) + ) session.add( recorder.db_schema.SchemaChanges( schema_version=old_db_schema.SCHEMA_VERSION @@ -67,10 +73,13 @@ def _create_engine_test(schema_module: str) -> Callable: @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_times( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: - """Test we can migrate times in the events and states tables.""" + """Test we can migrate times in the events and states tables. + + Also tests entity id post migration. + """ importlib.import_module(SCHEMA_MODULE_30) old_db_schema = sys.modules[SCHEMA_MODULE_30] now = dt_util.utcnow() @@ -122,7 +131,13 @@ async def test_migrate_times( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), - patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_30)), + patch( + CREATE_ENGINE_TARGET, + new=_create_engine_test( + SCHEMA_MODULE_30, + initial_version=27, # Set to 27 for the entity id post migration to run + ), + ), ): async with ( async_test_home_assistant() as hass, @@ -225,7 +240,7 @@ async def test_migrate_times( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_can_resume_entity_id_post_migration( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: @@ -274,7 +289,13 @@ async def test_migrate_can_resume_entity_id_post_migration( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), - patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), + patch( + CREATE_ENGINE_TARGET, + new=_create_engine_test( + SCHEMA_MODULE_32, + initial_version=27, # Set to 27 for the entity id post migration to run + ), + ), ): async with ( async_test_home_assistant() as hass, @@ -330,7 +351,7 @@ async def test_migrate_can_resume_entity_id_post_migration( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_can_resume_ix_states_event_id_removed( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: @@ -394,7 +415,13 @@ async def test_migrate_can_resume_ix_states_event_id_removed( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), - patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), + patch( + CREATE_ENGINE_TARGET, + new=_create_engine_test( + SCHEMA_MODULE_32, + initial_version=27, # Set to 27 for the entity id post migration to run + ), + ), ): async with ( async_test_home_assistant() as hass, @@ -463,7 +490,7 @@ async def test_migrate_can_resume_ix_states_event_id_removed( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_out_of_disk_space_while_rebuild_states_table( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: @@ -527,7 +554,13 @@ async def test_out_of_disk_space_while_rebuild_states_table( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), - patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), + patch( + CREATE_ENGINE_TARGET, + new=_create_engine_test( + SCHEMA_MODULE_32, + initial_version=27, # Set to 27 for the entity id post migration to run + ), + ), ): async with ( async_test_home_assistant() as hass, @@ -637,7 +670,7 @@ async def test_out_of_disk_space_while_rebuild_states_table( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_out_of_disk_space_while_removing_foreign_key( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: @@ -705,7 +738,13 @@ async def test_out_of_disk_space_while_removing_foreign_key( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), - patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), + patch( + CREATE_ENGINE_TARGET, + new=_create_engine_test( + SCHEMA_MODULE_32, + initial_version=27, # Set to 27 for the entity id post migration to run + ), + ), ): async with ( async_test_home_assistant() as hass, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 403384aee9f..94ed8da1b92 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -41,7 +41,11 @@ from .common import ( from .conftest import InstrumentedMigration from tests.common import async_fire_time_changed -from tests.typing import RecorderInstanceGenerator, WebSocketGenerator +from tests.typing import ( + RecorderInstanceContextManager, + RecorderInstanceGenerator, + WebSocketGenerator, +) @pytest.fixture @@ -2623,7 +2627,7 @@ async def test_recorder_info_no_instance( async def test_recorder_info_migration_queue_exhausted( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, instrument_migration: InstrumentedMigration, ) -> None: """Test getting recorder status when recorder queue is exhausted.""" diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index 575e69015fe..51728d02ef3 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -1,7 +1,5 @@ """The tests for the Remote component, adapted from Light Test.""" -import pytest - from homeassistant.components import remote from homeassistant.components.remote import ( ATTR_ALTERNATIVE, @@ -23,11 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from tests.common import ( - async_mock_service, - help_test_all, - import_and_test_deprecated_constant_enum, -) +from tests.common import async_mock_service TEST_PLATFORM = {DOMAIN: {CONF_PLATFORM: "test"}} SERVICE_SEND_COMMAND = "send_command" @@ -146,37 +140,3 @@ async def test_delete_command(hass: HomeAssistant) -> None: assert call.domain == remote.DOMAIN assert call.service == SERVICE_DELETE_COMMAND assert call.data[ATTR_ENTITY_ID] == ENTITY_ID - - -def test_all() -> None: - """Test module.__all__ is correctly set.""" - help_test_all(remote) - - -@pytest.mark.parametrize(("enum"), list(remote.RemoteEntityFeature)) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: remote.RemoteEntityFeature, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum(caplog, remote, enum, "SUPPORT_", "2025.1") - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockRemote(remote.RemoteEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockRemote() - assert entity.supported_features_compat is remote.RemoteEntityFeature(1) - assert "MockRemote" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "RemoteEntityFeature.LEARN_COMMAND" in caplog.text - caplog.clear() - assert entity.supported_features_compat is remote.RemoteEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index 56e0c8a99d7..781b7efe226 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -101,6 +101,7 @@ async def test_config_flow_single_account( assert result["data"][CONF_PASSWORD] == "test" assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" assert result["data"][CONF_LOCALE] == "fr_FR" + assert result["context"]["unique_id"] == "account_id_1" assert len(mock_setup_entry.mock_calls) == 1 @@ -189,6 +190,7 @@ async def test_config_flow_multiple_accounts( assert result["data"][CONF_PASSWORD] == "test" assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_2" assert result["data"][CONF_LOCALE] == "fr_FR" + assert result["context"]["unique_id"] == "account_id_2" assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index 0f9d9cbaf5b..a71192dda47 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -9,7 +9,7 @@ import pytest from renault_api.gigya.exceptions import GigyaException, InvalidCredentialsException from homeassistant.components.renault.const import DOMAIN -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -62,6 +62,11 @@ async def test_setup_entry_bad_password( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + assert flows[0]["context"]["entry_id"] == config_entry.entry_id + @pytest.mark.parametrize("side_effect", [aiohttp.ClientConnectionError, GigyaException]) async def test_setup_entry_exception( diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index bb896428b99..5950fc49966 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -15,7 +15,6 @@ from reolink_aio.exceptions import ( ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN @@ -32,6 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import ( DHCP_FORMATTED_MAC, @@ -85,6 +85,7 @@ async def test_config_flow_manual_success( assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, } + assert result["result"].unique_id == TEST_MAC async def test_config_flow_errors( @@ -286,51 +287,6 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> } -async def test_change_connection_settings( - hass: HomeAssistant, mock_setup_entry: MagicMock -) -> None: - """Test changing connection settings by issuing a second user config flow.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=format_mac(TEST_MAC), - data={ - CONF_HOST: TEST_HOST, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_PORT: TEST_PORT, - CONF_USE_HTTPS: TEST_USE_HTTPS, - }, - options={ - CONF_PROTOCOL: DEFAULT_PROTOCOL, - }, - title=TEST_NVR_NAME, - ) - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: TEST_HOST2, - CONF_USERNAME: TEST_USERNAME2, - CONF_PASSWORD: TEST_PASSWORD2, - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert config_entry.data[CONF_HOST] == TEST_HOST2 - assert config_entry.data[CONF_USERNAME] == TEST_USERNAME2 - assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 - - async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reauth flow.""" config_entry = MockConfigEntry( @@ -355,14 +311,6 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: result = await config_entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} @@ -382,9 +330,58 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 +async def test_reauth_abort_unique_id_mismatch( + hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_connect: MagicMock +) -> None: + """Test a reauth flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + + result = await config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME2, + CONF_PASSWORD: TEST_PASSWORD2, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert config_entry.data[CONF_HOST] == TEST_HOST + assert config_entry.data[CONF_USERNAME] == TEST_USERNAME + assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD + + reolink_connect.mac_address = TEST_MAC + + async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, @@ -420,37 +417,97 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No } +async def test_dhcp_ip_update_aborted_if_wrong_mac( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + reolink_connect_class: MagicMock, + reolink_connect: MagicMock, +) -> None: + """Test dhcp discovery does not update the IP if the mac address does not match.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + # ensure the last_update_succes is False for the device_coordinator. + reolink_connect.get_states.side_effect = ReolinkError("Test error") + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + dhcp_data = DhcpServiceInfo( + ip=TEST_HOST2, + hostname="Reolink", + macaddress=DHCP_FORMATTED_MAC, + ) + + reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data + ) + + for host in (TEST_HOST, TEST_HOST2): + expected_call = call( + host, + TEST_USERNAME, + TEST_PASSWORD, + port=TEST_PORT, + use_https=TEST_USE_HTTPS, + protocol=DEFAULT_PROTOCOL, + timeout=DEFAULT_TIMEOUT, + aiohttp_get_session_callback=ANY, + ) + assert expected_call in reolink_connect_class.call_args_list + + for exc_call in reolink_connect_class.call_args_list: + assert exc_call[0][0] in [TEST_HOST, TEST_HOST2] + get_session = exc_call[1]["aiohttp_get_session_callback"] + assert isinstance(get_session(), ClientSession) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + await hass.async_block_till_done() + # Check that IP was not updated + assert config_entry.data[CONF_HOST] == TEST_HOST + + reolink_connect.get_states.side_effect = None + reolink_connect_class.reset_mock() + reolink_connect.mac_address = TEST_MAC + + @pytest.mark.parametrize( - ("last_update_success", "attr", "value", "expected", "host_call_list"), + ("attr", "value", "expected", "host_call_list"), [ ( - False, None, None, TEST_HOST2, [TEST_HOST, TEST_HOST2], ), ( - True, - None, - None, - TEST_HOST, - [TEST_HOST], - ), - ( - False, "get_state", AsyncMock(side_effect=ReolinkError("Test error")), TEST_HOST, [TEST_HOST, TEST_HOST2], ), - ( - False, - "mac_address", - "aa:aa:aa:aa:aa:aa", - TEST_HOST, - [TEST_HOST, TEST_HOST2], - ), ], ) async def test_dhcp_ip_update( @@ -458,7 +515,6 @@ async def test_dhcp_ip_update( freezer: FrozenDateTimeFactory, reolink_connect_class: MagicMock, reolink_connect: MagicMock, - last_update_success: bool, attr: str, value: Any, expected: str, @@ -486,14 +542,13 @@ async def test_dhcp_ip_update( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - if not last_update_success: - # ensure the last_update_succes is False for the device_coordinator. - reolink_connect.get_states.side_effect = ReolinkError("Test error") - freezer.tick(DEVICE_UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() + # ensure the last_update_succes is False for the device_coordinator. + reolink_connect.get_states.side_effect = ReolinkError("Test error") + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST2, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, @@ -537,6 +592,71 @@ async def test_dhcp_ip_update( setattr(reolink_connect, attr, original) +async def test_dhcp_ip_update_ingnored_if_still_connected( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + reolink_connect_class: MagicMock, + reolink_connect: MagicMock, +) -> None: + """Test dhcp discovery is ignored when the camera is still properly connected to HA.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + dhcp_data = DhcpServiceInfo( + ip=TEST_HOST2, + hostname="Reolink", + macaddress=DHCP_FORMATTED_MAC, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data + ) + + expected_call = call( + TEST_HOST, + TEST_USERNAME, + TEST_PASSWORD, + port=TEST_PORT, + use_https=TEST_USE_HTTPS, + protocol=DEFAULT_PROTOCOL, + timeout=DEFAULT_TIMEOUT, + aiohttp_get_session_callback=ANY, + ) + assert expected_call in reolink_connect_class.call_args_list + + for exc_call in reolink_connect_class.call_args_list: + assert exc_call[0][0] == TEST_HOST + get_session = exc_call[1]["aiohttp_get_session_callback"] + assert isinstance(get_session(), ClientSession) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + await hass.async_block_till_done() + assert config_entry.data[CONF_HOST] == TEST_HOST + + reolink_connect.get_states.side_effect = None + reolink_connect_class.reset_mock() + + async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reconfiguration flow.""" config_entry = MockConfigEntry( @@ -579,3 +699,53 @@ async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> Non assert config_entry.data[CONF_HOST] == TEST_HOST2 assert config_entry.data[CONF_USERNAME] == TEST_USERNAME assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD + + +async def test_reconfig_abort_unique_id_mismatch( + hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_connect: MagicMock +) -> None: + """Test a reconfiguration flow aborts if the unique id does not match.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST2, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert config_entry.data[CONF_HOST] == TEST_HOST + assert config_entry.data[CONF_USERNAME] == TEST_USERNAME + assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD + + reolink_connect.mac_address = TEST_MAC diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index 2286ca5d266..c777e4064f0 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -21,13 +21,15 @@ from homeassistant.components.reolink.host import ( ) from homeassistant.components.webhook import async_handle_webhook from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError from homeassistant.util.aiohttp import MockRequest +from .conftest import TEST_NVR_NAME + from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -92,23 +94,32 @@ async def test_webhook_callback( entity_registry: er.EntityRegistry, ) -> None: """Test webhook callback with motion sensor.""" - assert await hass.config_entries.async_setup(config_entry.entry_id) + reolink_connect.motion_detected.return_value = False + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion" webhook_id = config_entry.runtime_data.host.webhook_id + unique_id = config_entry.runtime_data.host.unique_id signal_all = MagicMock() signal_ch = MagicMock() - async_dispatcher_connect(hass, f"{webhook_id}_all", signal_all) - async_dispatcher_connect(hass, f"{webhook_id}_0", signal_ch) + async_dispatcher_connect(hass, f"{unique_id}_all", signal_all) + async_dispatcher_connect(hass, f"{unique_id}_0", signal_ch) client = await hass_client_no_auth() + assert hass.states.get(entity_id).state == STATE_OFF + # test webhook callback success all channels + reolink_connect.motion_detected.return_value = True reolink_connect.ONVIF_event_callback.return_value = None await client.post(f"/api/webhook/{webhook_id}") signal_all.assert_called_once() + assert hass.states.get(entity_id).state == STATE_ON freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) async_fire_time_changed(hass) @@ -120,10 +131,14 @@ async def test_webhook_callback( await client.post(f"/api/webhook/{webhook_id}") signal_all.assert_not_called() + assert hass.states.get(entity_id).state == STATE_ON + # test webhook callback success single channel + reolink_connect.motion_detected.return_value = False reolink_connect.ONVIF_event_callback.return_value = [0] await client.post(f"/api/webhook/{webhook_id}", data="test_data") signal_ch.assert_called_once() + assert hass.states.get(entity_id).state == STATE_OFF # test webhook callback single channel with error in event callback signal_ch.reset_mock() diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 32afd1f73ca..9c5be08e9b6 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -109,11 +109,17 @@ async def test_resolve( ) assert play_media.mime_type == TEST_MIME_TYPE_MP4 + reolink_connect.is_nvr = False + + play_media = await async_resolve_media( + hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None + ) + assert play_media.mime_type == TEST_MIME_TYPE_MP4 + file_id = ( f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" ) reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) - reolink_connect.is_nvr = False play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index b2e82040ad4..2e7c1556201 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -29,38 +29,6 @@ from .conftest import TEST_CAM_NAME, TEST_NVR_NAME, TEST_UID from tests.common import MockConfigEntry, async_fire_time_changed -async def test_cleanup_hdr_switch( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_connect: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test cleanup of the HDR switch entity.""" - original_id = f"{TEST_UID}_hdr" - domain = Platform.SWITCH - - reolink_connect.channels = [0] - reolink_connect.supported.return_value = True - - entity_registry.async_get_or_create( - domain=domain, - platform=DOMAIN, - unique_id=original_id, - config_entry=config_entry, - suggested_object_id=original_id, - disabled_by=er.RegistryEntryDisabler.USER, - ) - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - - # setup CH 0 and host entities/device - with patch("homeassistant.components.reolink.PLATFORMS", [domain]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None - - @pytest.mark.parametrize( ( "original_id", @@ -132,41 +100,6 @@ async def test_cleanup_hub_switches( reolink_connect.supported.return_value = True -async def test_hdr_switch_deprecated_repair_issue( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_connect: MagicMock, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test repairs issue is raised when hdr switch entity used.""" - original_id = f"{TEST_UID}_hdr" - domain = Platform.SWITCH - - reolink_connect.channels = [0] - reolink_connect.supported.return_value = True - - entity_registry.async_get_or_create( - domain=domain, - platform=DOMAIN, - unique_id=original_id, - config_entry=config_entry, - suggested_object_id=original_id, - disabled_by=None, - ) - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - - # setup CH 0 and host entities/device - with patch("homeassistant.components.reolink.PLATFORMS", [domain]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - - assert (DOMAIN, "hdr_switch_deprecated") in issue_registry.issues - - @pytest.mark.parametrize( ( "original_id", @@ -248,16 +181,17 @@ async def test_switch( ) -> None: """Test switch entity.""" reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_connect.audio_record.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record" + entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record_audio" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.recording_enabled.return_value = False + reolink_connect.audio_record.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -271,9 +205,9 @@ async def test_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_recording.assert_called_with(0, True) + reolink_connect.set_audio.assert_called_with(0, True) - reolink_connect.set_recording.side_effect = ReolinkError("Test error") + reolink_connect.set_audio.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -283,16 +217,16 @@ async def test_switch( ) # test switch turn off - reolink_connect.set_recording.reset_mock(side_effect=True) + reolink_connect.set_audio.reset_mock(side_effect=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_recording.assert_called_with(0, False) + reolink_connect.set_audio.assert_called_with(0, False) - reolink_connect.set_recording.side_effect = ReolinkError("Test error") + reolink_connect.set_audio.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -301,7 +235,7 @@ async def test_switch( blocking=True, ) - reolink_connect.set_recording.reset_mock(side_effect=True) + reolink_connect.set_audio.reset_mock(side_effect=True) reolink_connect.camera_online.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) @@ -321,17 +255,17 @@ async def test_host_switch( ) -> None: """Test host switch entity.""" reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.recording_enabled.return_value = True + reolink_connect.email_enabled.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record" + entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_email_on_event" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.recording_enabled.return_value = False + reolink_connect.email_enabled.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -345,9 +279,9 @@ async def test_host_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_recording.assert_called_with(None, True) + reolink_connect.set_email.assert_called_with(None, True) - reolink_connect.set_recording.side_effect = ReolinkError("Test error") + reolink_connect.set_email.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -357,16 +291,16 @@ async def test_host_switch( ) # test switch turn off - reolink_connect.set_recording.reset_mock(side_effect=True) + reolink_connect.set_email.reset_mock(side_effect=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_recording.assert_called_with(None, False) + reolink_connect.set_email.assert_called_with(None, False) - reolink_connect.set_recording.side_effect = ReolinkError("Test error") + reolink_connect.set_email.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -375,7 +309,7 @@ async def test_host_switch( blocking=True, ) - reolink_connect.set_recording.reset_mock(side_effect=True) + reolink_connect.set_email.reset_mock(side_effect=True) async def test_chime_switch( diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py new file mode 100644 index 00000000000..f66f4682b98 --- /dev/null +++ b/tests/components/reolink/test_util.py @@ -0,0 +1,115 @@ +"""Test the Reolink util functions.""" + +from unittest.mock import MagicMock, patch + +import pytest +from reolink_aio.exceptions import ( + ApiError, + CredentialsInvalidError, + InvalidContentTypeError, + InvalidParameterError, + LoginError, + NoDataError, + NotSupportedError, + ReolinkConnectionError, + ReolinkError, + ReolinkTimeoutError, + SubscriptionError, + UnexpectedDataError, +) + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from .conftest import TEST_NVR_NAME + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("side_effect", "expected"), + [ + ( + ApiError("Test error"), + HomeAssistantError, + ), + ( + CredentialsInvalidError("Test error"), + HomeAssistantError, + ), + ( + InvalidContentTypeError("Test error"), + HomeAssistantError, + ), + ( + InvalidParameterError("Test error"), + ServiceValidationError, + ), + ( + LoginError("Test error"), + HomeAssistantError, + ), + ( + NoDataError("Test error"), + HomeAssistantError, + ), + ( + NotSupportedError("Test error"), + HomeAssistantError, + ), + ( + ReolinkConnectionError("Test error"), + HomeAssistantError, + ), + ( + ReolinkError("Test error"), + HomeAssistantError, + ), + ( + ReolinkTimeoutError("Test error"), + HomeAssistantError, + ), + ( + SubscriptionError("Test error"), + HomeAssistantError, + ), + ( + UnexpectedDataError("Test error"), + HomeAssistantError, + ), + ], +) +async def test_try_function( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + side_effect: ReolinkError, + expected: Exception, +) -> None: + """Test try_function error translations using number entity.""" + reolink_connect.volume.return_value = 80 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" + + reolink_connect.set_volume.side_effect = side_effect + with pytest.raises(expected): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + + reolink_connect.set_volume.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py new file mode 100644 index 00000000000..c994cc59c5d --- /dev/null +++ b/tests/components/reolink/test_views.py @@ -0,0 +1,243 @@ +"""Tests for the Reolink views platform.""" + +from http import HTTPStatus +import logging +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientConnectionError, ClientResponse +import pytest +from reolink_aio.enums import VodRequestType +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.reolink.views import async_generate_playback_proxy_url +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + +TEST_YEAR = 2023 +TEST_MONTH = 11 +TEST_DAY = 14 +TEST_DAY2 = 15 +TEST_HOUR = 13 +TEST_MINUTE = 12 +TEST_FILE_NAME_MP4 = f"Mp4Record/{TEST_YEAR}-{TEST_MONTH}-{TEST_DAY}/RecS04_{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00_123456_AB123C.mp4" +TEST_STREAM = "sub" +TEST_CHANNEL = "0" +TEST_VOD_TYPE = VodRequestType.PLAYBACK.value +TEST_MIME_TYPE_MP4 = "video/mp4" +TEST_URL = "http://test_url&token=test" +TEST_ERROR = "TestError" + + +def get_mock_session( + response: list[Any] | None = None, + content_length: int = 8, + content_type: str = TEST_MIME_TYPE_MP4, +) -> Mock: + """Get a mock session to mock the camera response.""" + if response is None: + response = [b"test", b"test", StopAsyncIteration()] + + content = Mock() + content.__anext__ = AsyncMock(side_effect=response) + content.__aiter__ = Mock(return_value=content) + + mock_response = Mock() + mock_response.content_length = content_length + mock_response.content_type = content_type + mock_response.content.iter_chunked = Mock(return_value=content) + + mock_session = Mock() + mock_session.get = AsyncMock(return_value=mock_response) + return mock_session + + +async def test_playback_proxy( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test successful playback proxy URL.""" + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + + mock_session = get_mock_session() + + with patch( + "homeassistant.components.reolink.views.async_get_clientsession", + return_value=mock_session, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + caplog.set_level(logging.DEBUG) + + proxy_url = async_generate_playback_proxy_url( + config_entry.entry_id, + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(proxy_url)) + + assert await response.content.read() == b"testtest" + assert response.status == 200 + + +async def test_proxy_get_source_error( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test error while getting source for playback proxy URL.""" + reolink_connect.get_vod_source.side_effect = ReolinkError(TEST_ERROR) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + proxy_url = async_generate_playback_proxy_url( + config_entry.entry_id, + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = await http_client.get(proxy_url) + + assert await response.content.read() == bytes(TEST_ERROR, "utf-8") + assert response.status == HTTPStatus.BAD_REQUEST + reolink_connect.get_vod_source.side_effect = None + + +async def test_proxy_invalid_config_entry_id( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test config entry id not found for playback proxy URL.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + proxy_url = async_generate_playback_proxy_url( + "wrong_config_id", + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = await http_client.get(proxy_url) + + assert await response.content.read() == bytes( + "Reolink playback proxy could not find config entry id: wrong_config_id", + "utf-8", + ) + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_playback_proxy_timeout( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test playback proxy URL with a timeout in the second chunk.""" + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + + mock_session = get_mock_session([b"test", TimeoutError()], 4) + + with patch( + "homeassistant.components.reolink.views.async_get_clientsession", + return_value=mock_session, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + proxy_url = async_generate_playback_proxy_url( + config_entry.entry_id, + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(proxy_url)) + + assert await response.content.read() == b"test" + assert response.status == 200 + + +async def test_playback_wrong_content( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test playback proxy URL with a wrong content type in the response.""" + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + + mock_session = get_mock_session(content_type="video/x-flv") + + with patch( + "homeassistant.components.reolink.views.async_get_clientsession", + return_value=mock_session, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + proxy_url = async_generate_playback_proxy_url( + config_entry.entry_id, + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(proxy_url)) + + assert response.status == HTTPStatus.BAD_REQUEST + + +async def test_playback_connect_error( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test playback proxy URL with a connection error.""" + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + + mock_session = Mock() + mock_session.get = AsyncMock(side_effect=ClientConnectionError(TEST_ERROR)) + + with patch( + "homeassistant.components.reolink.views.async_get_clientsession", + return_value=mock_session, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + proxy_url = async_generate_playback_proxy_url( + config_entry.entry_id, + TEST_CHANNEL, + TEST_FILE_NAME_MP4, + TEST_STREAM, + TEST_VOD_TYPE, + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(proxy_url)) + + assert response.status == HTTPStatus.BAD_REQUEST diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 2e02063b215..d5fc5eca55c 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -591,7 +591,7 @@ async def test_update_with_no_template(hass: HomeAssistant) -> None: assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") - assert state.state == '{"key": "some_json_value"}' + assert state.state == '{"key":"some_json_value"}' @respx.mock diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index 8f212b6e976..79736d418b5 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -58,17 +58,17 @@ async def test_one_pt2262(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" await rfxtrx.signal("0913000022670e013970") - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state.state == "on" await rfxtrx.signal("09130000226707013d70") - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state.state == "off" @@ -85,10 +85,10 @@ async def test_pt2262_unconfigured(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226707") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226707" state = hass.states.get("binary_sensor.pt2262_226707") assert state @@ -318,7 +318,7 @@ async def test_pt2262_duplicate_id(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 1e23bdaf982..6fd4fc14bc5 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -726,7 +726,6 @@ async def test_options_add_and_configure_device( result["flow_id"], user_input={ "data_bits": 4, - "off_delay": "abcdef", "command_on": "xyz", "command_off": "xyz", }, @@ -735,7 +734,6 @@ async def test_options_add_and_configure_device( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "set_device_options" assert result["errors"] - assert result["errors"]["off_delay"] == "invalid_input_off_delay" assert result["errors"]["command_on"] == "invalid_input_2262_on" assert result["errors"]["command_off"] == "invalid_input_2262_off" @@ -745,7 +743,7 @@ async def test_options_add_and_configure_device( "data_bits": 4, "command_on": "0xE", "command_off": "0x7", - "off_delay": "9", + "off_delay": 9, }, ) @@ -758,10 +756,10 @@ async def test_options_add_and_configure_device( assert entry.data["devices"]["0913000022670e013970"] assert entry.data["devices"]["0913000022670e013970"]["off_delay"] == 9 - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py index 4336798768f..f17fd8743f1 100644 --- a/tests/components/rfxtrx/test_sensor.py +++ b/tests/components/rfxtrx/test_sensor.py @@ -330,10 +330,10 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "unknown" - assert state.attributes.get("friendly_name") == "PT2262 22670e Signal strength" + assert state.attributes.get("friendly_name") == "PT2262 226700 Signal strength" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SIGNAL_STRENGTH_DECIBELS_MILLIWATT @@ -351,7 +351,7 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await rfxtrx.signal("0913000022670e013b70") await rfxtrx.signal("0b1100cd0213c7f230010f71") - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "-64" @@ -362,7 +362,7 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await rfxtrx.signal("0913000022670e013b60") await rfxtrx.signal("0b1100cd0213c7f230010f61") - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "-72" diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index 7acc008cc8a..964c5ccb2e6 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -70,23 +70,23 @@ async def test_one_pt2262_switch(hass: HomeAssistant, rfxtrx) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.pt2262_22670e"}, blocking=True + "switch", "turn_on", {"entity_id": "switch.pt2262_226700"}, blocking=True ) - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state.state == "on" await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.pt2262_22670e"}, blocking=True + "switch", "turn_off", {"entity_id": "switch.pt2262_226700"}, blocking=True ) - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state.state == "off" assert rfxtrx.transport.send.mock_calls == [ @@ -220,26 +220,26 @@ async def test_pt2262_switch_events(hass: HomeAssistant, rfxtrx) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" # "Command: 0xE" await rfxtrx.signal("0913000022670e013970") - assert hass.states.get("switch.pt2262_22670e").state == "on" + assert hass.states.get("switch.pt2262_226700").state == "on" # "Command: 0x0" await rfxtrx.signal("09130000226700013970") - assert hass.states.get("switch.pt2262_22670e").state == "on" + assert hass.states.get("switch.pt2262_226700").state == "on" # "Command: 0x7" await rfxtrx.signal("09130000226707013d70") - assert hass.states.get("switch.pt2262_22670e").state == "off" + assert hass.states.get("switch.pt2262_226700").state == "off" # "Command: 0x1" await rfxtrx.signal("09130000226701013d70") - assert hass.states.get("switch.pt2262_22670e").state == "off" + assert hass.states.get("switch.pt2262_226700").state == "off" async def test_discover_switch(hass: HomeAssistant, rfxtrx_automatic) -> None: diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index 409cdac55aa..778bad67d77 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -6,12 +6,12 @@ import pytest import ring_doorbell from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.ring import DOMAIN from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import MOCK_HARDWARE_ID @@ -269,9 +269,7 @@ async def test_dhcp_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip=ip_address, macaddress=mac_address, hostname=hostname - ), + data=DhcpServiceInfo(ip=ip_address, macaddress=mac_address, hostname=hostname), ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -302,9 +300,7 @@ async def test_dhcp_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip=ip_address, macaddress=mac_address, hostname=hostname - ), + data=DhcpServiceInfo(ip=ip_address, macaddress=mac_address, hostname=hostname), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 27d4813f02d..7c3b93e5114 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -444,6 +444,7 @@ async def test_no_listen_start( version=1, data={"username": "foo", "token": {}}, ) + mock_entry.add_to_hass(hass) # Create a binary sensor entity so it is not ignored by the deprecation check # and the listener will start entity_registry.async_get_or_create( @@ -457,7 +458,6 @@ async def test_no_listen_start( mock_ring_event_listener_class.return_value.started = False - mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index 435e762a646..d4d7376a564 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -46,6 +46,7 @@ async def test_entity_id_migration( ) -> None: """Test the migration of unique IDs on config entry setup.""" config_entry = mock_config_entry(unique_id="binary_sensor_test_diffuser_v1") + config_entry.add_to_hass(hass) # Pre-create old style unique IDs charging = entity_registry.async_get_or_create( diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 357c644e2fe..d65bf7c61d7 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -1,5 +1,6 @@ """Global fixtures for Roborock integration.""" +from collections.abc import Generator from copy import deepcopy from unittest.mock import patch @@ -14,7 +15,7 @@ from homeassistant.components.roborock.const import ( CONF_USER_DATA, DOMAIN, ) -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -161,18 +162,27 @@ def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_USER_DATA: USER_DATA.as_dict(), CONF_BASE_URL: BASE_URL, }, + unique_id=USER_EMAIL, ) mock_entry.add_to_hass(hass) return mock_entry +@pytest.fixture(name="platforms") +def mock_platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, -) -> MockConfigEntry: + platforms: list[Platform], +) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - return mock_roborock_entry + with patch("homeassistant.components.roborock.PLATFORMS", platforms): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_roborock_entry diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index e70dac5ffc9..0e4b338f469 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -1,10 +1,19 @@ """Test Roborock Binary Sensor.""" +import pytest + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.BINARY_SENSOR] + + async def test_binary_sensors( hass: HomeAssistant, setup_entry: MockConfigEntry ) -> None: diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 43ef043f79c..0a7efe83513 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -6,12 +6,19 @@ import pytest import roborock from homeassistant.components.button import SERVICE_PRESS +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.BUTTON] + + @pytest.mark.parametrize( ("entity_id"), [ diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 39d8117847c..13bc23e6e2b 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -244,3 +244,28 @@ async def test_reauth_flow( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert mock_roborock_entry.data["user_data"]["rriot"]["s"] == "new_password_hash" + + +async def test_account_already_configured( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Handle the config flow and make sure it succeeds.""" + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USER_EMAIL} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_account" diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index c884baef123..e240dccf7eb 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -5,10 +5,11 @@ from datetime import timedelta from http import HTTPStatus from unittest.mock import patch +import pytest from roborock import RoborockException from homeassistant.components.roborock import DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +20,12 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.IMAGE] + + async def test_floorplan_image( hass: HomeAssistant, setup_entry: MockConfigEntry, diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index cace9a8ed67..f4f490e68d9 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -4,7 +4,12 @@ from copy import deepcopy from unittest.mock import patch import pytest -from roborock import RoborockException, RoborockInvalidCredentials +from roborock import ( + RoborockException, + RoborockInvalidCredentials, + RoborockInvalidUserAgreement, + RoborockNoUserAgreement, +) from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -128,20 +133,18 @@ async def test_local_client_fails_props( assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY -async def test_fails_maps_continue( +async def test_fail_maps( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture_v1_only, ) -> None: - """Test that if we fail to get the maps, we still setup.""" + """Test that the integration fails to load if we fail to get the maps.""" with patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", side_effect=RoborockException(), ): await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.LOADED - # No map data means no images - assert len(hass.states.async_all("image")) == 0 + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY async def test_reauth_started( @@ -194,3 +197,35 @@ async def test_not_supported_a01_device( await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert "The device you added is not yet supported" in caplog.text + + +async def test_invalid_user_agreement( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test that we fail setting up if the user agreement is out of date.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + side_effect=RoborockInvalidUserAgreement(), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + mock_roborock_entry.error_reason_translation_key == "invalid_user_agreement" + ) + + +async def test_no_user_agreement( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test that we fail setting up if the user has no agreement.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + side_effect=RoborockNoUserAgreement(), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_roborock_entry.error_reason_translation_key == "no_user_agreement" diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py index 7e87b49253e..bfd8cc6da2b 100644 --- a/tests/components/roborock/test_number.py +++ b/tests/components/roborock/test_number.py @@ -6,12 +6,19 @@ import pytest import roborock from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.NUMBER] + + @pytest.mark.parametrize( ("entity_id", "value"), [ diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 784150e24c7..7f25141306b 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -7,16 +7,22 @@ import pytest from roborock.exceptions import RoborockException from homeassistant.components.roborock import DOMAIN -from homeassistant.const import SERVICE_SELECT_OPTION, STATE_UNKNOWN +from homeassistant.const import SERVICE_SELECT_OPTION, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from .mock_data import PROP +from .mock_data import MULTI_MAP_LIST, PROP from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SELECT] + + @pytest.mark.parametrize( ("entity_id", "value"), [ @@ -86,3 +92,33 @@ async def test_none_map_select( await async_setup_component(hass, DOMAIN, {}) select_entity = hass.states.get("select.roborock_s7_maxv_selected_map") assert select_entity.state == STATE_UNKNOWN + + +async def test_selected_map_name( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test that the selected map is set to the correct map name.""" + await async_setup_component(hass, DOMAIN, {}) + select_entity = hass.states.get("select.roborock_s7_maxv_selected_map") + assert select_entity.state == "Upstairs" + + +async def test_selected_map_without_name( + hass: HomeAssistant, + bypass_api_fixture_v1_only, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test that maps without a name are given a placeholder name.""" + map_list = copy.deepcopy(MULTI_MAP_LIST) + map_list.map_info[0].name = "" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", + return_value=map_list, + ): + await async_setup_component(hass, DOMAIN, {}) + + select_entity = hass.states.get("select.roborock_s7_maxv_selected_map") + assert select_entity + assert select_entity.state == "Map 0" diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 908754f3b92..e33d3aa78d5 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import pytest from roborock import DeviceData, HomeDataDevice from roborock.const import ( FILTER_REPLACE_TIME, @@ -12,6 +13,7 @@ from roborock.const import ( from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from roborock.version_1_apis import RoborockMqttClientV1 +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .mock_data import CONSUMABLE, STATUS, USER_DATA @@ -19,9 +21,15 @@ from .mock_data import CONSUMABLE, STATUS, USER_DATA from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SENSOR] + + async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 38 + assert len(hass.states.async_all("sensor")) == 40 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -46,6 +54,7 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_vacuum_error").state == "none" assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" assert hass.states.get("sensor.roborock_s7_maxv_dock_error").state == "ok" + assert hass.states.get("sensor.roborock_s7_maxv_total_cleaning_count").state == "31" assert ( hass.states.get("sensor.roborock_s7_maxv_last_clean_begin").state == "2023-01-01T03:22:10+00:00" diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 5de3c208c1e..2476bfe497c 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -6,12 +6,19 @@ import pytest import roborock from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SWITCH] + + @pytest.mark.parametrize( ("entity_id"), [ diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index 836a86bd114..eb48e8e537f 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -7,12 +7,19 @@ import pytest import roborock from homeassistant.components.time import SERVICE_SET_VALUE +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.TIME] + + @pytest.mark.parametrize( ("entity_id"), [ diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 5080711d0f9..d9d4340ec83 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -8,9 +8,14 @@ import pytest from roborock import RoborockException from roborock.roborock_typing import RoborockCommand from syrupy.assertion import SnapshotAssertion +from vacuum_map_parser_base.map_data import Point from homeassistant.components.roborock import DOMAIN -from homeassistant.components.roborock.const import GET_MAPS_SERVICE_NAME +from homeassistant.components.roborock.const import ( + GET_MAPS_SERVICE_NAME, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, +) from homeassistant.components.vacuum import ( SERVICE_CLEAN_SPOT, SERVICE_LOCATE, @@ -27,7 +32,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .mock_data import PROP +from .mock_data import MAP_DATA, PROP from tests.common import MockConfigEntry @@ -35,6 +40,15 @@ ENTITY_ID = "vacuum.roborock_s7_maxv" DEVICE_ID = "abc123" +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + # Note: Currently the Image platform is required to make these tests pass since + # some initialization of the coordinator happens as a side effect of loading + # image platform. Fix that and remove IMAGE here. + return [Platform.VACUUM, Platform.IMAGE] + + async def test_registry_entries( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -181,3 +195,112 @@ async def test_get_maps( return_response=True, ) assert response == snapshot + + +async def test_goto( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test sending the vacuum to specific coordinates.""" + vacuum = hass.states.get(ENTITY_ID) + assert vacuum + + data = {ATTR_ENTITY_ID: ENTITY_ID, "x": 25500, "y": 25500} + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_command" + ) as mock_send_command: + await hass.services.async_call( + DOMAIN, + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, + data, + blocking=True, + ) + assert mock_send_command.call_count == 1 + assert mock_send_command.call_args[0][0] == RoborockCommand.APP_GOTO_TARGET + assert mock_send_command.call_args[0][1] == [25500, 25500] + + +async def test_get_current_position( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test that the service for getting the current position outputs the correct coordinates.""" + map_data = copy.deepcopy(MAP_DATA) + map_data.vacuum_position = Point(x=123, y=456) + map_data.image = None + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=b"", + ), + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=map_data, + ), + ): + response = await hass.services.async_call( + DOMAIN, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + return_response=True, + ) + assert response == { + "vacuum.roborock_s7_maxv": { + "x": 123, + "y": 456, + }, + } + + +async def test_get_current_position_no_map_data( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test that the service for getting the current position handles no map data error.""" + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=None, + ), + pytest.raises(HomeAssistantError, match="Failed to retrieve map data."), + ): + await hass.services.async_call( + DOMAIN, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + return_response=True, + ) + + +async def test_get_current_position_no_robot_position( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test that the service for getting the current position handles no robot position error.""" + map_data = copy.deepcopy(MAP_DATA) + map_data.vacuum_position = None + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=b"", + ), + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=map_data, + ), + pytest.raises(HomeAssistantError, match="Robot position not found"), + ): + await hass.services.async_call( + DOMAIN, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index fe3ef215524..3165e5c4ba0 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -2,8 +2,15 @@ from ipaddress import ip_address -from homeassistant.components import ssdp, zeroconf -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) NAME = "Roku 3" NAME_ROKUTV = '58" Onn Roku TV' @@ -13,7 +20,7 @@ SSDP_LOCATION = "http://192.168.1.160/" UPNP_FRIENDLY_NAME = "My Roku 3" UPNP_SERIAL = "1GU48T017973" -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -25,14 +32,14 @@ MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( HOMEKIT_HOST = "192.168.1.161" -MOCK_HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_HOMEKIT_DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address(HOMEKIT_HOST), ip_addresses=[ip_address(HOMEKIT_HOST)], hostname="mock_hostname", name="onn._hap._tcp.local.", port=None, properties={ - zeroconf.ATTR_PROPERTIES_ID: "2d:97:da:ee:dc:99", + ATTR_PROPERTIES_ID: "2d:97:da:ee:dc:99", }, type="mock_type", ) diff --git a/tests/components/roku/test_config_flow.py b/tests/components/roku/test_config_flow.py index 7144c77cad9..57ddf5d51a6 100644 --- a/tests/components/roku/test_config_flow.py +++ b/tests/components/roku/test_config_flow.py @@ -1,13 +1,18 @@ """Test the Roku config flow.""" import dataclasses -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest -from rokuecp import RokuConnectionError +from rokuecp import Device as RokuDevice, RokuConnectionError from homeassistant.components.roku.const import CONF_PLAY_MEDIA_APP_ID, DOMAIN -from homeassistant.config_entries import SOURCE_HOMEKIT, SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_HOMEKIT, + SOURCE_SSDP, + SOURCE_USER, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -23,6 +28,8 @@ from . import ( from tests.common import MockConfigEntry +RECONFIGURE_HOST = "192.168.1.190" + async def test_duplicate_error( hass: HomeAssistant, @@ -276,3 +283,56 @@ async def test_options_flow( assert result2.get("data") == { CONF_PLAY_MEDIA_APP_ID: "782875", } + + +async def _start_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "user" + + return await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], + {CONF_HOST: RECONFIGURE_HOST}, + ) + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_roku_config_flow: MagicMock, +) -> None: + """Test reconfigure flow.""" + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data == { + CONF_HOST: RECONFIGURE_HOST, + } + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + mock_device: RokuDevice, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_roku_config_flow: MagicMock, +) -> None: + """Ensure reconfigure flow aborts when the device changes.""" + mock_device.info.serial_number = "RECONFIG" + + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/roku/test_init.py b/tests/components/roku/test_init.py index a4fc8477ac3..9c414bcf62a 100644 --- a/tests/components/roku/test_init.py +++ b/tests/components/roku/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import AsyncMock, MagicMock, patch from rokuecp import RokuConnectionError -from homeassistant.components.roku.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -38,12 +37,7 @@ async def test_config_entry_no_unique_id( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.entry_id in hass.data[DOMAIN] assert mock_config_entry.state is ConfigEntryState.LOADED - assert ( - hass.data[DOMAIN][mock_config_entry.entry_id].device_id - == mock_config_entry.entry_id - ) async def test_load_unload_config_entry( @@ -56,10 +50,9 @@ async def test_load_unload_config_entry( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.entry_id in hass.data[DOMAIN] assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.entry_id not in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/romy/test_config_flow.py b/tests/components/romy/test_config_flow.py index a29f899ee9d..55d54f3a80b 100644 --- a/tests/components/romy/test_config_flow.py +++ b/tests/components/romy/test_config_flow.py @@ -6,11 +6,14 @@ from unittest.mock import Mock, PropertyMock, patch from romy import RomyRobot from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.romy.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) def _create_mocked_romy( @@ -164,14 +167,14 @@ async def test_show_user_form_robot_reachable_again(hass: HomeAssistant) -> None assert result2["type"] is FlowResultType.CREATE_ENTRY -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=8080, hostname="aicu-aicgsbksisfapcjqmqjq.local", type="mock_type", name="myROMY", - properties={zeroconf.ATTR_PROPERTIES_ID: "aicu-aicgsbksisfapcjqmqjqZERO"}, + properties={ATTR_PROPERTIES_ID: "aicu-aicgsbksisfapcjqmqjqZERO"}, ) diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index dedccc14249..5b6766f7eb9 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest from roombapy import RoombaConnectionError, RoombaInfo -from homeassistant.components import dhcp, zeroconf from homeassistant.components.roomba import config_flow from homeassistant.components.roomba.const import ( CONF_BLID, @@ -23,6 +22,8 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -32,7 +33,7 @@ VALID_CONFIG = {CONF_HOST: MOCK_IP, CONF_BLID: "BLID", CONF_PASSWORD: "password" DISCOVERY_DEVICES = [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=MOCK_IP, macaddress="501479ddeeff", hostname="irobot-blid", @@ -40,7 +41,7 @@ DISCOVERY_DEVICES = [ ), ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=MOCK_IP, macaddress="80a589ddeeff", hostname="roomba-blid", @@ -48,7 +49,7 @@ DISCOVERY_DEVICES = [ ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(MOCK_IP), ip_addresses=[ip_address(MOCK_IP)], hostname="irobot-blid.local.", @@ -60,7 +61,7 @@ DISCOVERY_DEVICES = [ ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(MOCK_IP), ip_addresses=[ip_address(MOCK_IP)], hostname="roomba-blid.local.", @@ -74,12 +75,12 @@ DISCOVERY_DEVICES = [ DHCP_DISCOVERY_DEVICES_WITHOUT_MATCHING_IP = [ - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip="4.4.4.4", macaddress="50:14:79:DD:EE:FF", hostname="irobot-blid", ), - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip="5.5.5.5", macaddress="80:A5:89:DD:EE:FF", hostname="roomba-blid", @@ -692,7 +693,7 @@ async def test_form_user_discovery_and_password_fetch_gets_connection_refused( @pytest.mark.parametrize("discovery_data", DISCOVERY_DEVICES) async def test_dhcp_discovery_and_roomba_discovery_finds( hass: HomeAssistant, - discovery_data: tuple[str, dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo], + discovery_data: tuple[str, DhcpServiceInfo | ZeroconfServiceInfo], ) -> None: """Test we can process the discovery from dhcp and roomba discovery matches the device.""" @@ -910,7 +911,7 @@ async def test_dhcp_discovery_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -933,7 +934,7 @@ async def test_dhcp_discovery_already_configured_host(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -959,7 +960,7 @@ async def test_dhcp_discovery_already_configured_blid(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -985,7 +986,7 @@ async def test_dhcp_discovery_not_irobot(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="Notirobot-blid", @@ -1006,7 +1007,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -1023,7 +1024,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blidthatislonger", @@ -1044,7 +1045,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-bl", @@ -1082,7 +1083,7 @@ async def test_dhcp_discovery_when_user_flow_in_progress(hass: HomeAssistant) -> result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blidthatislonger", diff --git a/tests/components/russound_rio/__init__.py b/tests/components/russound_rio/__init__.py index d0e6d77f1ee..d8764285dd3 100644 --- a/tests/components/russound_rio/__init__.py +++ b/tests/components/russound_rio/__init__.py @@ -1,5 +1,9 @@ """Tests for the Russound RIO integration.""" +from unittest.mock import AsyncMock + +from aiorussound.models import CallbackType + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -11,3 +15,11 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +async def mock_state_update( + client: AsyncMock, callback_type: CallbackType = CallbackType.STATE +) -> None: + """Trigger a callback in the media player.""" + for callback in client.register_state_update_callbacks.call_args_list: + await callback[0][0](client, callback_type) diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 09cccd7d83f..2516bd81650 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -9,9 +9,10 @@ from aiorussound.util import controller_device_str, zone_device_str import pytest from homeassistant.components.russound_rio.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from .const import HARDWARE_MAC, HOST, MOCK_CONFIG, MODEL, PORT +from .const import API_VERSION, HARDWARE_MAC, MOCK_CONFIG, MODEL from tests.common import MockConfigEntry, load_json_object_fixture @@ -46,28 +47,64 @@ def mock_russound_client() -> Generator[AsyncMock]: ), ): client = mock_client.return_value - zones = { - int(k): ZoneControlSurface.from_dict(v) - for k, v in load_json_object_fixture("get_zones.json", DOMAIN).items() + controller_zones = { + int(controller_id): { + int(zone_id): ZoneControlSurface.from_dict(zone) + for zone_id, zone in v["zones"].items() + } + for controller_id, v in load_json_object_fixture("get_zones.json", DOMAIN)[ + "controllers" + ].items() } client.sources = { int(k): Source.from_dict(v) for k, v in load_json_object_fixture("get_sources.json", DOMAIN).items() } - for k, v in zones.items(): - v.device_str = zone_device_str(1, k) - v.fetch_current_source = Mock( - side_effect=lambda current_source=v.current_source: client.sources.get( - int(current_source) + client.state = load_json_object_fixture("get_state.json", DOMAIN) + for controller_id, zones in controller_zones.items(): + for zone_id, zone in zones.items(): + zone.device_str = zone_device_str(controller_id, zone_id) + zone.fetch_current_source = Mock( + side_effect=lambda current_source=zone.current_source: client.sources.get( + int(current_source) + ) ) - ) + zone.volume_up = AsyncMock() + zone.volume_down = AsyncMock() + zone.set_volume = AsyncMock() + zone.zone_on = AsyncMock() + zone.zone_off = AsyncMock() + zone.select_source = AsyncMock() + zone.mute = AsyncMock() + zone.unmute = AsyncMock() + zone.toggle_mute = AsyncMock() + zone.set_seek_time = AsyncMock() client.controllers = { 1: Controller( - 1, "MCA-C5", client, controller_device_str(1), HARDWARE_MAC, None, zones - ) + 1, + MODEL, + client, + controller_device_str(1), + HARDWARE_MAC, + None, + controller_zones[1], + ), + 2: Controller( + 2, + MODEL, + client, + controller_device_str(2), + None, + None, + controller_zones[2], + ), } - client.connection_handler = RussoundTcpConnectionHandler(HOST, PORT) + client.connection_handler = RussoundTcpConnectionHandler( + MOCK_CONFIG[CONF_HOST], MOCK_CONFIG[CONF_PORT] + ) client.is_connected = Mock(return_value=True) client.unregister_state_update_callbacks.return_value = True + client.rio_version = API_VERSION + yield client diff --git a/tests/components/russound_rio/const.py b/tests/components/russound_rio/const.py index 3d2924693d2..8269e825e33 100644 --- a/tests/components/russound_rio/const.py +++ b/tests/components/russound_rio/const.py @@ -1,21 +1,21 @@ """Constants for russound_rio tests.""" -from collections import namedtuple - from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT -HOST = "127.0.0.1" -PORT = 9621 MODEL = "MCA-C5" HARDWARE_MAC = "00:11:22:33:44:55" +API_VERSION = "1.08.00" MOCK_CONFIG = { - "host": HOST, - "port": PORT, + CONF_HOST: "192.168.20.75", + CONF_PORT: 9621, } -_CONTROLLER = namedtuple("Controller", ["mac_address", "controller_type"]) # noqa: PYI024 -MOCK_CONTROLLERS = {1: _CONTROLLER(mac_address=HARDWARE_MAC, controller_type=MODEL)} +MOCK_RECONFIGURATION_CONFIG = { + CONF_HOST: "192.168.20.70", + CONF_PORT: 9622, +} DEVICE_NAME = "mca_c5" NAME_ZONE_1 = "backyard" diff --git a/tests/components/russound_rio/fixtures/get_state.json b/tests/components/russound_rio/fixtures/get_state.json new file mode 100644 index 00000000000..931b7611d01 --- /dev/null +++ b/tests/components/russound_rio/fixtures/get_state.json @@ -0,0 +1,75 @@ +{ + "S": { + "3": { + "name": "Streamer", + "type": "Misc Audio" + }, + "2": { + "name": "Liv. Rm TV", + "type": "Misc Audio" + }, + "5": { + "name": "Source 5", + "type": null + }, + "4": { + "name": "Basement TV", + "type": null + }, + "1": { + "name": "Tuner", + "type": "DMS-3.1 Media Streamer", + "channelName": null, + "coverArtURL": null, + "mode": "Unknown", + "shuffleMode": null, + "repeatMode": null, + "volume": "0", + "rating": null, + "playlistName": "Please Wait...", + "artistName": null, + "albumName": null, + "songName": "Connecting to media source." + }, + "6": { + "name": "Source 6", + "type": null + }, + "8": { + "name": "Source 8", + "type": null + }, + "7": { + "name": "Source 7", + "type": null + } + }, + "System": { + "status": "OFF" + }, + "C": { + "1": { + "Z": { + "1": { + "name": "Deck", + "treble": "0", + "balance": "0", + "loudness": "OFF", + "turnOnVolume": "10", + "doNotDisturb": "OFF", + "currentSource": "2", + "volume": "0", + "status": "OFF", + "mute": "OFF", + "partyMode": "OFF", + "bass": "0", + "page": "OFF", + "sharedSource": "OFF", + "sleepTimeRemaining": "0", + "lastError": null, + "enabled_sources": [3, 2] + } + } + } + } +} diff --git a/tests/components/russound_rio/fixtures/get_zones.json b/tests/components/russound_rio/fixtures/get_zones.json index 396310339b3..e1077944593 100644 --- a/tests/components/russound_rio/fixtures/get_zones.json +++ b/tests/components/russound_rio/fixtures/get_zones.json @@ -1,22 +1,38 @@ { - "1": { - "name": "Backyard", - "volume": "10", - "status": "ON", - "enabled": "True", - "current_source": "1" - }, - "2": { - "name": "Kitchen", - "volume": "50", - "status": "OFF", - "enabled": "True", - "current_source": "2" - }, - "3": { - "name": "Bedroom", - "volume": "10", - "status": "OFF", - "enabled": "False" + "controllers": { + "1": { + "zones": { + "1": { + "name": "Backyard", + "volume": "10", + "status": "ON", + "enabled": "True", + "current_source": "1" + }, + "2": { + "name": "Kitchen", + "volume": "50", + "status": "OFF", + "enabled": "True", + "current_source": "2" + }, + "3": { + "name": "Bedroom", + "volume": "10", + "status": "OFF", + "enabled": "False" + } + } + }, + "2": { + "zones": { + "9": { + "name": "Living Room", + "volume": "10", + "status": "OFF", + "enabled": "True" + } + } + } } } diff --git a/tests/components/russound_rio/snapshots/test_diagnostics.ambr b/tests/components/russound_rio/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ff3a8bf757f --- /dev/null +++ b/tests/components/russound_rio/snapshots/test_diagnostics.ambr @@ -0,0 +1,81 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'C': dict({ + '1': dict({ + 'Z': dict({ + '1': dict({ + 'balance': '0', + 'bass': '0', + 'currentSource': '2', + 'doNotDisturb': 'OFF', + 'enabled_sources': list([ + 3, + 2, + ]), + 'lastError': None, + 'loudness': 'OFF', + 'mute': 'OFF', + 'name': 'Deck', + 'page': 'OFF', + 'partyMode': 'OFF', + 'sharedSource': 'OFF', + 'sleepTimeRemaining': '0', + 'status': 'OFF', + 'treble': '0', + 'turnOnVolume': '10', + 'volume': '0', + }), + }), + }), + }), + 'S': dict({ + '1': dict({ + 'albumName': None, + 'artistName': None, + 'channelName': None, + 'coverArtURL': None, + 'mode': 'Unknown', + 'name': 'Tuner', + 'playlistName': 'Please Wait...', + 'rating': None, + 'repeatMode': None, + 'shuffleMode': None, + 'songName': 'Connecting to media source.', + 'type': 'DMS-3.1 Media Streamer', + 'volume': '0', + }), + '2': dict({ + 'name': 'Liv. Rm TV', + 'type': 'Misc Audio', + }), + '3': dict({ + 'name': 'Streamer', + 'type': 'Misc Audio', + }), + '4': dict({ + 'name': 'Basement TV', + 'type': None, + }), + '5': dict({ + 'name': 'Source 5', + 'type': None, + }), + '6': dict({ + 'name': 'Source 6', + 'type': None, + }), + '7': dict({ + 'name': 'Source 7', + 'type': None, + }), + '8': dict({ + 'name': 'Source 8', + 'type': None, + }), + }), + 'System': dict({ + 'status': 'OFF', + }), + }) +# --- diff --git a/tests/components/russound_rio/snapshots/test_init.ambr b/tests/components/russound_rio/snapshots/test_init.ambr index fcd59dd06f7..c92f06c4bc0 100644 --- a/tests/components/russound_rio/snapshots/test_init.ambr +++ b/tests/components/russound_rio/snapshots/test_init.ambr @@ -3,7 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'configuration_url': 'http://127.0.0.1', + 'configuration_url': 'http://192.168.20.75', 'connections': set({ tuple( 'mac', diff --git a/tests/components/russound_rio/test_config_flow.py b/tests/components/russound_rio/test_config_flow.py index cf754852731..550ea9404df 100644 --- a/tests/components/russound_rio/test_config_flow.py +++ b/tests/components/russound_rio/test_config_flow.py @@ -1,13 +1,35 @@ """Test the Russound RIO config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock from homeassistant.components.russound_rio.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import MOCK_CONFIG, MODEL +from .const import MOCK_CONFIG, MOCK_RECONFIGURATION_CONFIG, MODEL + +from tests.common import MockConfigEntry + +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("192.168.20.17"), + ip_addresses=[ip_address("192.168.20.17")], + hostname="controller1.local.", + name="controller1._stream-magic._tcp.local.", + port=9621, + type="_rio._tcp.local.", + properties={ + "txtvers": "0", + "productType": "2", + "productId": "59", + "version": "07.04.00", + "buildDate": "Jul 8 2019", + "localName": "0", + }, +) async def test_form( @@ -29,6 +51,7 @@ async def test_form( assert result["title"] == MODEL assert result["data"] == MOCK_CONFIG assert len(mock_setup_entry.mock_calls) == 1 + assert result["result"].unique_id == "00:11:22:33:44:55" async def test_form_cannot_connect( @@ -60,31 +83,239 @@ async def test_form_cannot_connect( assert len(mock_setup_entry.mock_calls) == 1 -async def test_import( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound_client: AsyncMock +async def test_duplicate( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test we import a config entry.""" + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_IMPORT}, - data=MOCK_CONFIG, + context={"source": SOURCE_USER}, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == MODEL - assert result["data"] == MOCK_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_cannot_connect( - hass: HomeAssistant, mock_russound_client: AsyncMock -) -> None: - """Test we handle import cannot connect error.""" - mock_russound_client.connect.side_effect = TimeoutError - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "MCA-C5" + assert result["data"] == { + CONF_HOST: "192.168.20.17", + CONF_PORT: 9621, + } + assert result["result"].unique_id == "00:11:22:33:44:55" + + +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf flow.""" + mock_russound_client.connect.side_effect = TimeoutError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + mock_russound_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "MCA-C5" + assert result["data"] == { + CONF_HOST: "192.168.20.17", + CONF_PORT: 9621, + } + assert result["result"].unique_id == "00:11:22:33:44:55" + + +async def test_zeroconf_duplicate( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_duplicate_different_ip( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow with different IP.""" + mock_config_entry.add_to_hass(hass) + + ZEROCONF_DISCOVERY_DIFFERENT_IP = ZeroconfServiceInfo( + ip_address=ip_address("192.168.20.18"), + ip_addresses=[ip_address("192.168.20.18")], + hostname="controller1.local.", + name="controller1._stream-magic._tcp.local.", + port=9621, + type="_rio._tcp.local.", + properties={ + "txtvers": "0", + "productType": "2", + "productId": "59", + "version": "07.04.00", + "buildDate": "Jul 8 2019", + "localName": "0", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY_DIFFERENT_IP, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data == { + CONF_HOST: "192.168.20.18", + CONF_PORT: 9621, + } + + +async def test_user_flow_works_discovery( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user flow can continue after discovery happened.""" + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert len(hass.config_entries.flow.async_progress(DOMAIN)) == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert not hass.config_entries.flow.async_progress(DOMAIN) + + +async def _start_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + + return reconfigure_result + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + + reconfigure_result = await _start_reconfigure_flow(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], + MOCK_RECONFIGURATION_CONFIG, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data == { + CONF_HOST: "192.168.20.70", + CONF_PORT: 9622, + } + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure reconfigure flow aborts when the bride changes.""" + mock_russound_client.controllers[1].mac_address = "different_mac" + + reconfigure_result = await _start_reconfigure_flow(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], + MOCK_RECONFIGURATION_CONFIG, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/russound_rio/test_diagnostics.py b/tests/components/russound_rio/test_diagnostics.py new file mode 100644 index 00000000000..c6c5441128d --- /dev/null +++ b/tests/components/russound_rio/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the Russound RIO integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert result == snapshot diff --git a/tests/components/russound_rio/test_init.py b/tests/components/russound_rio/test_init.py index 6787ee37c79..d654eea32bd 100644 --- a/tests/components/russound_rio/test_init.py +++ b/tests/components/russound_rio/test_init.py @@ -1,7 +1,9 @@ """Tests for the Russound RIO integration.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock +from aiorussound.models import CallbackType +import pytest from syrupy import SnapshotAssertion from homeassistant.components.russound_rio.const import DOMAIN @@ -9,7 +11,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import setup_integration +from . import mock_state_update, setup_integration from tests.common import MockConfigEntry @@ -42,3 +44,23 @@ async def test_device_info( ) assert device_entry is not None assert device_entry == snapshot + + +async def test_disconnect_reconnect_log( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + + mock_russound_client.is_connected = Mock(return_value=False) + await mock_state_update(mock_russound_client, CallbackType.CONNECTION) + assert "Disconnected from device at 192.168.20.75" in caplog.text + + mock_russound_client.is_connected = Mock(return_value=True) + await mock_state_update(mock_russound_client, CallbackType.CONNECTION) + assert "Reconnected to device at 192.168.20.75" in caplog.text diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index e720e2c7f65..d0c18a9b1e7 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -2,10 +2,28 @@ from unittest.mock import AsyncMock -from aiorussound.models import CallbackType, PlayStatus +from aiorussound.const import FeatureFlag +from aiorussound.exceptions import CommandError +from aiorussound.models import PlayStatus import pytest +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_SEEK_POSITION, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MP_DOMAIN, + SERVICE_SELECT_SOURCE, +) from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_MEDIA_SEEK, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, STATE_BUFFERING, STATE_IDLE, STATE_OFF, @@ -14,19 +32,14 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError -from . import setup_integration +from . import mock_state_update, setup_integration from .const import ENTITY_ID_ZONE_1 from tests.common import MockConfigEntry -async def mock_state_update(client: AsyncMock) -> None: - """Trigger a callback in the media player.""" - for callback in client.register_state_update_callbacks.call_args_list: - await callback[0][0](client, CallbackType.STATE) - - @pytest.mark.parametrize( ("zone_status", "source_play_status", "media_player_state"), [ @@ -56,3 +69,187 @@ async def test_entity_state( state = hass.states.get(ENTITY_ID_ZONE_1) assert state.state == media_player_state + + +async def test_media_volume( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test volume service.""" + await setup_integration(hass, mock_config_entry) + + # Test volume up + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_UP, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1}, + blocking=True, + ) + + mock_russound_client.controllers[1].zones[1].volume_up.assert_called_once() + + # Test volume down + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_DOWN, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1}, + blocking=True, + ) + + mock_russound_client.controllers[1].zones[1].volume_down.assert_called_once() + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, ATTR_MEDIA_VOLUME_LEVEL: 0.30}, + blocking=True, + ) + + mock_russound_client.controllers[1].zones[1].set_volume.assert_called_once_with( + "15" + ) + + +async def test_volume_mute( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test mute service.""" + await setup_integration(hass, mock_config_entry) + + # Test mute (w/ toggle mute support) + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + + mock_russound_client.controllers[1].zones[1].toggle_mute.assert_called_once() + mock_russound_client.controllers[1].zones[1].toggle_mute.reset_mock() + + mock_russound_client.controllers[1].zones[1].is_mute = True + + # Test mute when already muted (w/ toggle mute support) + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + + mock_russound_client.controllers[1].zones[1].toggle_mute.assert_not_called() + mock_russound_client.supported_features = [FeatureFlag.COMMANDS_ZONE_MUTE_OFF_ON] + + # Test mute (w/ dedicated commands) + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + + mock_russound_client.controllers[1].zones[1].mute.assert_called_once() + + # Test unmute (w/ dedicated commands) + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, ATTR_MEDIA_VOLUME_MUTED: False}, + blocking=True, + ) + + mock_russound_client.controllers[1].zones[1].unmute.assert_called_once() + + +@pytest.mark.parametrize( + ("source_name", "source_id"), + [ + ("Aux", 1), + ("Spotify", 2), + ], +) +async def test_source_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, + source_name: str, + source_id: int, +) -> None: + """Test source service.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, ATTR_INPUT_SOURCE: source_name}, + blocking=True, + ) + + mock_russound_client.controllers[1].zones[1].select_source.assert_called_once_with( + source_id + ) + + +async def test_invalid_source_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test source service with invalid source ID.""" + await setup_integration(hass, mock_config_entry) + + mock_russound_client.controllers[1].zones[ + 1 + ].select_source.side_effect = CommandError + + with pytest.raises( + HomeAssistantError, + match="Error executing async_select_source on entity media_player.mca_c5_backyard", + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, ATTR_INPUT_SOURCE: "Aux"}, + blocking=True, + ) + + +async def test_power_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test power service.""" + await setup_integration(hass, mock_config_entry) + + data = {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1} + + await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_ON, data, blocking=True) + + mock_russound_client.controllers[1].zones[1].zone_on.assert_called_once() + + await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_OFF, data, blocking=True) + + mock_russound_client.controllers[1].zones[1].zone_off.assert_called_once() + + +async def test_media_seek( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test media seek service.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_SEEK, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, ATTR_MEDIA_SEEK_POSITION: 100}, + ) + + mock_russound_client.controllers[1].zones[1].set_seek_time.assert_called_once_with( + 100 + ) diff --git a/tests/components/ruuvi_gateway/test_config_flow.py b/tests/components/ruuvi_gateway/test_config_flow.py index c4ecf929f94..14f74a7add7 100644 --- a/tests/components/ruuvi_gateway/test_config_flow.py +++ b/tests/components/ruuvi_gateway/test_config_flow.py @@ -6,10 +6,10 @@ from aioruuvigateway.excs import CannotConnect, InvalidAuth import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.ruuvi_gateway.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .consts import ( BASE_DATA, @@ -32,7 +32,7 @@ DHCP_DATA = {**BASE_DATA, "host": DHCP_IP} BASE_DATA, ), ( - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname="RuuviGateway1234", ip=DHCP_IP, macaddress="1234567890ab", diff --git a/tests/components/sabnzbd/fixtures/queue.json b/tests/components/sabnzbd/fixtures/queue.json index 342500aea39..7acef65f2e9 100644 --- a/tests/components/sabnzbd/fixtures/queue.json +++ b/tests/components/sabnzbd/fixtures/queue.json @@ -15,7 +15,7 @@ "diskspacetotal2": "7448.42", "speedlimit": "85", "speedlimit_abs": "22282240", - "have_warnings": "0", + "have_warnings": "1", "finishaction": null, "quota": "0 ", "have_quota": false, diff --git a/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr b/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..9f3087df3d1 --- /dev/null +++ b/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_sensor[binary_sensor.sabnzbd_warnings-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.sabnzbd_warnings', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Warnings', + 'platform': 'sabnzbd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'warnings', + 'unique_id': '01JD2YVVPBC62D620DGYNG2R8H_warnings', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[binary_sensor.sabnzbd_warnings-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Sabnzbd Warnings', + }), + 'context': , + 'entity_id': 'binary_sensor.sabnzbd_warnings', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/sabnzbd/test_binary_sensor.py b/tests/components/sabnzbd/test_binary_sensor.py new file mode 100644 index 00000000000..48a3c006488 --- /dev/null +++ b/tests/components/sabnzbd/test_binary_sensor.py @@ -0,0 +1,23 @@ +"""Binary sensor tests for the Sabnzbd component.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.sabnzbd.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensor setup.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/samsungtv/const.py b/tests/components/samsungtv/const.py index 1a7347ff0ce..c1a9da4e284 100644 --- a/tests/components/samsungtv/const.py +++ b/tests/components/samsungtv/const.py @@ -2,18 +2,11 @@ from samsungtvws.event import ED_INSTALLED_APP_EVENT -from homeassistant.components import ssdp from homeassistant.components.samsungtv.const import ( CONF_SESSION_ID, METHOD_LEGACY, METHOD_WEBSOCKET, ) -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_UDN, -) from homeassistant.const import ( CONF_HOST, CONF_IP_ADDRESS, @@ -24,6 +17,13 @@ from homeassistant.const import ( CONF_PORT, CONF_TOKEN, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) MOCK_CONFIG = { CONF_HOST: "fake_host", @@ -61,7 +61,7 @@ MOCK_ENTRY_WS_WITH_MAC = { CONF_TOKEN: "123456789", } -MOCK_SSDP_DATA_RENDERING_CONTROL_ST = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_RENDERING_CONTROL_ST = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="urn:schemas-upnp-org:service:RenderingControl:1", ssdp_location="https://fake_host:12345/test", @@ -72,7 +72,7 @@ MOCK_SSDP_DATA_RENDERING_CONTROL_ST = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", }, ) -MOCK_SSDP_DATA_MAIN_TV_AGENT_ST = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_MAIN_TV_AGENT_ST = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="urn:samsung.com:service:MainTVAgent2:1", ssdp_location="https://fake_host:12345/tv_agent", diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 32e169ffb24..576a5f6d534 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -14,17 +14,9 @@ from samsungtvws.exceptions import ( UnauthorizedError, ) from websockets import frames - -# WebSocketProtocolError was deprecated in websockets '14.0' -# pylint: disable-next=no-name-in-module -from websockets.exceptions import ( - ConnectionClosedError, - WebSocketException, - WebSocketProtocolError, -) +from websockets.exceptions import ConnectionClosedError, WebSocketException from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.samsungtv.config_flow import SamsungTVConfigFlow from homeassistant.components.samsungtv.const import ( CONF_MANUFACTURER, @@ -40,13 +32,6 @@ from homeassistant.components.samsungtv.const import ( TIMEOUT_REQUEST, TIMEOUT_WEBSOCKET, ) -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_UDN, - SsdpServiceInfo, -) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -61,6 +46,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from .const import ( @@ -90,7 +84,7 @@ MOCK_IMPORT_WSDATA = { CONF_PORT: 8002, } MOCK_USER_DATA = {CONF_HOST: "fake_host", CONF_NAME: "fake_name"} -MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://fake_host:12345/test", @@ -101,7 +95,7 @@ MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", }, ) -MOCK_SSDP_DATA_NO_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_NO_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://fake_host:12345/test", @@ -111,7 +105,7 @@ MOCK_SSDP_DATA_NO_MANUFACTURER = ssdp.SsdpServiceInfo( }, ) -MOCK_SSDP_DATA_NOPREFIX = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_NOPREFIX = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://fake2_host:12345/test", @@ -122,7 +116,7 @@ MOCK_SSDP_DATA_NOPREFIX = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", }, ) -MOCK_SSDP_DATA_WRONGMODEL = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_WRONGMODEL = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://fake2_host:12345/test", @@ -133,11 +127,11 @@ MOCK_SSDP_DATA_WRONGMODEL = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", }, ) -MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( +MOCK_DHCP_DATA = DhcpServiceInfo( ip="fake_host", macaddress="aabbccddeeff", hostname="fake_hostname" ) EXISTING_IP = "192.168.40.221" -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -404,7 +398,7 @@ async def test_user_websocket_not_supported(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote.open", - side_effect=WebSocketProtocolError("Boom"), + side_effect=WebSocketException("Boom"), ), ): # websocket device not supported @@ -787,12 +781,12 @@ async def test_ssdp_websocket_cannot_connect(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", - side_effect=WebSocketProtocolError("Boom"), + side_effect=WebSocketException("Boom"), ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote", ) as remotews, - patch.object(remotews, "open", side_effect=WebSocketProtocolError("Boom")), + patch.object(remotews, "open", side_effect=WebSocketException("Boom")), ): # device not supported result = await hass.config_entries.flow.async_init( @@ -1711,7 +1705,7 @@ async def test_update_legacy_missing_mac_from_dhcp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" ), ) @@ -1742,13 +1736,13 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", - side_effect=WebSocketProtocolError("Boom"), + side_effect=WebSocketException("Boom"), ), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" ), ) diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index ef7e58251e8..1a7c8713b17 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -76,7 +76,8 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ServiceNotSupported +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from . import async_wait_config_entry_reload, setup_samsungtv_entry @@ -1021,8 +1022,9 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: """Test turn on.""" + await async_setup_component(hass, "homeassistant", {}) await setup_samsungtv_entry(hass, MOCK_CONFIG) - with pytest.raises(HomeAssistantError, match="does not support this service"): + with pytest.raises(ServiceNotSupported, match="does not support action"): await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) diff --git a/tests/components/schlage/__init__.py b/tests/components/schlage/__init__.py index c6cd3fec0bc..613621b2fb8 100644 --- a/tests/components/schlage/__init__.py +++ b/tests/components/schlage/__init__.py @@ -1 +1,7 @@ """Tests for the Schlage integration.""" + +from homeassistant.components.schlage.coordinator import SchlageDataUpdateCoordinator + +from tests.common import MockConfigEntry + +type MockSchlageConfigEntry = MockConfigEntry[SchlageDataUpdateCoordinator] diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index f774b8cfb89..6695191dcf0 100644 --- a/tests/components/schlage/conftest.py +++ b/tests/components/schlage/conftest.py @@ -11,11 +11,13 @@ from homeassistant.components.schlage.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from . import MockSchlageConfigEntry + from tests.common import MockConfigEntry @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_config_entry() -> MockSchlageConfigEntry: """Mock ConfigEntry.""" return MockConfigEntry( title="asdf@asdf.com", @@ -31,11 +33,11 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture async def mock_added_config_entry( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + mock_config_entry: MockSchlageConfigEntry, mock_pyschlage_auth: Mock, mock_schlage: Mock, mock_lock: Mock, -) -> MockConfigEntry: +) -> MockSchlageConfigEntry: """Mock ConfigEntry that's been added to HA.""" mock_schlage.locks.return_value = [mock_lock] mock_schlage.users.return_value = [] diff --git a/tests/components/schlage/test_binary_sensor.py b/tests/components/schlage/test_binary_sensor.py index 91bd996ba5b..a073097f755 100644 --- a/tests/components/schlage/test_binary_sensor.py +++ b/tests/components/schlage/test_binary_sensor.py @@ -7,10 +7,11 @@ from freezegun.api import FrozenDateTimeFactory from pyschlage.exceptions import UnknownError from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant +from . import MockSchlageConfigEntry + from tests.common import async_fire_time_changed @@ -18,7 +19,7 @@ async def test_keypad_disabled_binary_sensor( hass: HomeAssistant, mock_schlage: Mock, mock_lock: Mock, - mock_added_config_entry: ConfigEntry, + mock_added_config_entry: MockSchlageConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test the keypad_disabled binary_sensor.""" @@ -42,7 +43,7 @@ async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure( hass: HomeAssistant, mock_schlage: Mock, mock_lock: Mock, - mock_added_config_entry: ConfigEntry, + mock_added_config_entry: MockSchlageConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test the keypad_disabled binary_sensor.""" diff --git a/tests/components/schlage/test_config_flow.py b/tests/components/schlage/test_config_flow.py index 7f4a40f9b53..3161ebe4097 100644 --- a/tests/components/schlage/test_config_flow.py +++ b/tests/components/schlage/test_config_flow.py @@ -10,6 +10,8 @@ from homeassistant.components.schlage.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import MockSchlageConfigEntry + from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -54,6 +56,32 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_requires_unique_id( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pyschlage_auth: Mock, +) -> None: + """Test entries have unique ids.""" + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert init_result["type"] is FlowResultType.FORM + assert init_result["errors"] == {} + + create_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + mock_pyschlage_auth.authenticate.assert_called_once_with() + assert create_result["type"] is FlowResultType.ABORT + assert create_result["reason"] == "already_configured" + + async def test_form_invalid_auth( hass: HomeAssistant, mock_pyschlage_auth: Mock ) -> None: @@ -95,8 +123,7 @@ async def test_form_unknown(hass: HomeAssistant, mock_pyschlage_auth: Mock) -> N async def test_reauth( hass: HomeAssistant, - mock_added_config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, + mock_added_config_entry: MockSchlageConfigEntry, mock_pyschlage_auth: Mock, ) -> None: """Test reauth flow.""" @@ -104,8 +131,7 @@ async def test_reauth( await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - [result] = flows + result = flows[-1] assert result["step_id"] == "reauth_confirm" result2 = await hass.config_entries.flow.async_configure( @@ -121,12 +147,11 @@ async def test_reauth( "username": "asdf@asdf.com", "password": "new-password", } - assert len(mock_setup_entry.mock_calls) == 1 async def test_reauth_invalid_auth( hass: HomeAssistant, - mock_added_config_entry: MockConfigEntry, + mock_added_config_entry: MockSchlageConfigEntry, mock_setup_entry: AsyncMock, mock_pyschlage_auth: Mock, ) -> None: @@ -154,7 +179,7 @@ async def test_reauth_invalid_auth( async def test_reauth_wrong_account( hass: HomeAssistant, - mock_added_config_entry: MockConfigEntry, + mock_added_config_entry: MockSchlageConfigEntry, mock_setup_entry: AsyncMock, mock_pyschlage_auth: Mock, ) -> None: diff --git a/tests/components/schlage/test_diagnostics.py b/tests/components/schlage/test_diagnostics.py index 15b2316bf38..0b0dc856c1a 100644 --- a/tests/components/schlage/test_diagnostics.py +++ b/tests/components/schlage/test_diagnostics.py @@ -4,7 +4,8 @@ from unittest.mock import Mock from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from . import MockSchlageConfigEntry + from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -12,7 +13,7 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_added_config_entry: MockConfigEntry, + mock_added_config_entry: MockSchlageConfigEntry, mock_lock: Mock, ) -> None: """Test Schlage diagnostics.""" diff --git a/tests/components/schlage/test_init.py b/tests/components/schlage/test_init.py index e40fc83a7ac..57a139e582e 100644 --- a/tests/components/schlage/test_init.py +++ b/tests/components/schlage/test_init.py @@ -10,12 +10,14 @@ from pyschlage.lock import Lock from syrupy.assertion import SnapshotAssertion from homeassistant.components.schlage.const import DOMAIN, UPDATE_INTERVAL -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceRegistry -from tests.common import MockConfigEntry, async_fire_time_changed +from . import MockSchlageConfigEntry + +from tests.common import async_fire_time_changed @patch( @@ -23,7 +25,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed side_effect=WarrantException, ) async def test_auth_failed( - mock_auth: Mock, hass: HomeAssistant, mock_config_entry: MockConfigEntry + mock_auth: Mock, hass: HomeAssistant, mock_config_entry: MockSchlageConfigEntry ) -> None: """Test failed auth on setup.""" mock_config_entry.add_to_hass(hass) @@ -36,7 +38,7 @@ async def test_auth_failed( async def test_update_data_fails( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + mock_config_entry: MockSchlageConfigEntry, mock_pyschlage_auth: Mock, mock_schlage: Mock, ) -> None: @@ -52,7 +54,7 @@ async def test_update_data_fails( async def test_update_data_auth_error( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + mock_config_entry: MockSchlageConfigEntry, mock_pyschlage_auth: Mock, mock_schlage: Mock, ) -> None: @@ -68,7 +70,7 @@ async def test_update_data_auth_error( async def test_update_data_get_logs_auth_error( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + mock_config_entry: MockSchlageConfigEntry, mock_pyschlage_auth: Mock, mock_schlage: Mock, mock_lock: Mock, @@ -87,7 +89,7 @@ async def test_update_data_get_logs_auth_error( async def test_load_unload_config_entry( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + mock_config_entry: MockSchlageConfigEntry, mock_pyschlage_auth: Mock, mock_schlage: Mock, ) -> None: @@ -106,7 +108,7 @@ async def test_load_unload_config_entry( async def test_lock_device_registry( hass: HomeAssistant, device_registry: DeviceRegistry, - mock_added_config_entry: ConfigEntry, + mock_added_config_entry: MockSchlageConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test lock is added to device registry.""" @@ -117,7 +119,7 @@ async def test_lock_device_registry( async def test_auto_add_device( hass: HomeAssistant, device_registry: DeviceRegistry, - mock_added_config_entry: ConfigEntry, + mock_added_config_entry: MockSchlageConfigEntry, mock_schlage: Mock, mock_lock: Mock, mock_lock_attrs: dict[str, Any], @@ -153,7 +155,7 @@ async def test_auto_add_device( async def test_auto_remove_device( hass: HomeAssistant, device_registry: DeviceRegistry, - mock_added_config_entry: ConfigEntry, + mock_added_config_entry: MockSchlageConfigEntry, mock_schlage: Mock, freezer: FrozenDateTimeFactory, ) -> None: diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 518c723d581..6a3bb799213 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -6,16 +6,17 @@ from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.core import HomeAssistant +from . import MockSchlageConfigEntry + from tests.common import async_fire_time_changed async def test_lock_attributes( hass: HomeAssistant, - mock_added_config_entry: ConfigEntry, + mock_added_config_entry: MockSchlageConfigEntry, mock_schlage: Mock, mock_lock: Mock, freezer: FrozenDateTimeFactory, @@ -38,7 +39,9 @@ async def test_lock_attributes( async def test_lock_services( - hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, ) -> None: """Test lock services.""" await hass.services.async_call( @@ -65,7 +68,7 @@ async def test_lock_services( async def test_changed_by( hass: HomeAssistant, mock_lock: Mock, - mock_added_config_entry: ConfigEntry, + mock_added_config_entry: MockSchlageConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test population of the changed_by attribute.""" diff --git a/tests/components/schlage/test_select.py b/tests/components/schlage/test_select.py index c27fd4c8813..59ff065d449 100644 --- a/tests/components/schlage/test_select.py +++ b/tests/components/schlage/test_select.py @@ -7,13 +7,16 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from . import MockSchlageConfigEntry + async def test_select( - hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, ) -> None: """Test the auto-lock time select entity.""" entity_id = "select.vault_door_auto_lock_time" diff --git a/tests/components/schlage/test_sensor.py b/tests/components/schlage/test_sensor.py index 9fa90edecbb..9a489f6ff73 100644 --- a/tests/components/schlage/test_sensor.py +++ b/tests/components/schlage/test_sensor.py @@ -1,13 +1,14 @@ """Test schlage sensor.""" from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant +from . import MockSchlageConfigEntry + async def test_battery_sensor( - hass: HomeAssistant, mock_added_config_entry: ConfigEntry + hass: HomeAssistant, mock_added_config_entry: MockSchlageConfigEntry ) -> None: """Test the battery sensor.""" battery_sensor = hass.states.get("sensor.vault_door_battery") diff --git a/tests/components/schlage/test_switch.py b/tests/components/schlage/test_switch.py index 52b8da81670..fc5acc4399f 100644 --- a/tests/components/schlage/test_switch.py +++ b/tests/components/schlage/test_switch.py @@ -3,13 +3,16 @@ from unittest.mock import Mock from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from . import MockSchlageConfigEntry + async def test_beeper_services( - hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, ) -> None: """Test BeeperSwitch services.""" await hass.services.async_call( @@ -35,7 +38,9 @@ async def test_beeper_services( async def test_lock_and_leave_services( - hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, ) -> None: """Test LockAndLeaveSwitch services.""" await hass.services.async_call( diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index d1f2a22d036..dc9bf912c2d 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -443,9 +443,9 @@ async def test_scrape_sensor_errors(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("sensor.ha_class") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE state2 = hass.states.get("sensor.ha_class2") - assert state2.state == STATE_UNKNOWN + assert state2.state == STATE_UNAVAILABLE async def test_scrape_sensor_unique_id( diff --git a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json index aa0df6e3df6..04437c12ebf 100644 --- a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json +++ b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json @@ -619,7 +619,7 @@ }, "heat_mode": { "name": "Spa Heat Mode", - "value": 0, + "value": 3, "device_type": "enum", "enum_options": [ "Off", diff --git a/tests/components/screenlogic/test_climate.py b/tests/components/screenlogic/test_climate.py new file mode 100644 index 00000000000..c2e96a35508 --- /dev/null +++ b/tests/components/screenlogic/test_climate.py @@ -0,0 +1,103 @@ +"""Tests for ScreenLogic climate entity.""" + +import logging +from unittest.mock import DEFAULT, patch + +import pytest +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.device_const.heat import HEAT_MODE + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + HVACAction, + HVACMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import slugify + +from . import ( + DATA_MISSING_VALUES_CHEM_CHLOR, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_NAME, + stub_async_connect, +) + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.parametrize( + ( + "tested_dataset", + "expected_entity_states", + ), + [ + ( + DATA_MISSING_VALUES_CHEM_CHLOR, + { + f"{CLIMATE_DOMAIN}.{slugify(MOCK_ADAPTER_NAME)}_pool_heat": { + "state": HVACMode.OFF, + "attributes": { + ATTR_CURRENT_TEMPERATURE: 27.2, + ATTR_TEMPERATURE: 28.3, + ATTR_HVAC_ACTION: HVACAction.OFF, + ATTR_HVAC_MODES: [HVACMode.OFF, HVACMode.HEAT], + ATTR_PRESET_MODE: "heater", + ATTR_PRESET_MODES: [HEAT_MODE.HEATER.name.lower()], + }, + }, + f"{CLIMATE_DOMAIN}.{slugify(MOCK_ADAPTER_NAME)}_spa_heat": { + "state": HVACMode.HEAT, + "attributes": { + ATTR_CURRENT_TEMPERATURE: 28.9, + ATTR_TEMPERATURE: 34.4, + ATTR_HVAC_ACTION: HVACAction.IDLE, + ATTR_HVAC_MODES: [HVACMode.OFF, HVACMode.HEAT], + ATTR_PRESET_MODE: "heater", + ATTR_PRESET_MODES: [HEAT_MODE.HEATER.name.lower()], + }, + }, + }, + ) + ], +) +async def test_climate_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + tested_dataset: dict, + expected_entity_states: dict, +) -> None: + """Test setup for platforms that define expected data.""" + + def stub_connect(*args, **kwargs): + return stub_async_connect(tested_dataset, *args, **kwargs) + + mock_config_entry.add_to_hass(hass) + + with ( + patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), + patch.multiple( + ScreenLogicGateway, + async_connect=stub_connect, + is_connected=True, + _async_connected_request=DEFAULT, + ), + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + for entity_id, state_data in expected_entity_states.items(): + assert (climate_state := hass.states.get(entity_id)) is not None + assert climate_state.state == state_data["state"] + for attribute, value in state_data["attributes"].items(): + assert climate_state.attributes[attribute] == value diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index 8ca6bd4cb90..5ce777a47fa 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -12,7 +12,6 @@ from screenlogicpy.const.common import ( ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.screenlogic.config_flow import ( GATEWAY_MANUAL_ENTRY, GATEWAY_SELECT_KEY, @@ -25,6 +24,7 @@ from homeassistant.components.screenlogic.const import ( from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -135,7 +135,7 @@ async def test_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Pentair: 01-01-01", ip="1.1.1.1", macaddress="aabbccddeeff", diff --git a/tests/components/screenlogic/test_init.py b/tests/components/screenlogic/test_init.py index 6416c93f779..b177f860606 100644 --- a/tests/components/screenlogic/test_init.py +++ b/tests/components/screenlogic/test_init.py @@ -4,12 +4,14 @@ from dataclasses import dataclass from unittest.mock import DEFAULT, patch import pytest -from screenlogicpy import ScreenLogicGateway +from screenlogicpy import ScreenLogicError, ScreenLogicGateway +from screenlogicpy.const.common import ScreenLogicConnectionError from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.screenlogic import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import slugify @@ -41,7 +43,7 @@ TEST_MIGRATING_ENTITIES = [ EntityMigrationData( "Chemistry Alarm", "chem_alarm", - "Active Alert", + "Active alert", "active_alert", BINARY_SENSOR_DOMAIN, ), @@ -284,3 +286,35 @@ async def test_platform_setup( for entity_id in tested_entity_ids: assert hass.states.get(entity_id) is not None + + +@pytest.mark.parametrize( + "exception", + [ScreenLogicConnectionError, ScreenLogicError], +) +async def test_retry_on_connect_exception( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, exception: Exception +) -> None: + """Test setup retries on expected exceptions.""" + + def stub_connect(*args, **kwargs): + raise exception + + mock_config_entry.add_to_hass(hass) + + with ( + patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), + patch.multiple( + ScreenLogicGateway, + async_connect=stub_connect, + is_connected=False, + _async_connected_request=DEFAULT, + ), + ): + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/script/test_blueprint.py b/tests/components/script/test_blueprint.py index 7f03a89c548..f65e5483ae4 100644 --- a/tests/components/script/test_blueprint.py +++ b/tests/components/script/test_blueprint.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, template from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.common import MockConfigEntry, async_mock_service @@ -37,7 +37,7 @@ def patch_blueprint(blueprint_path: str, data_path: str) -> Iterator[None]: return orig_load(self, path) return Blueprint( - yaml.load_yaml(data_path), + yaml_util.load_yaml(data_path), expected_domain=self.domain, path=path, schema=BLUEPRINT_SCHEMA, diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index a5eda3757a9..248ada605cc 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -42,7 +42,7 @@ from homeassistant.helpers.script import ( ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util import homeassistant.util.dt as dt_util from tests.common import ( @@ -1722,7 +1722,7 @@ async def test_blueprint_script_fails_substitution( """Test blueprint script with bad inputs.""" with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): assert await async_setup_component( hass, diff --git a/tests/components/sensibo/conftest.py b/tests/components/sensibo/conftest.py index eaa42e47257..d853a15bfc3 100644 --- a/tests/components/sensibo/conftest.py +++ b/tests/components/sensibo/conftest.py @@ -2,16 +2,15 @@ from __future__ import annotations +from collections.abc import AsyncGenerator, Generator import json from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch -from pysensibo import SensiboClient -from pysensibo.model import SensiboData +from pysensibo import SensiboClient, SensiboData import pytest from homeassistant.components.sensibo.const import DOMAIN, PLATFORMS -from homeassistant.config_entries import SOURCE_USER from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -21,6 +20,15 @@ from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sensibo.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="load_platforms") async def patch_platform_constant() -> list[Platform]: """Return list of platforms to load.""" @@ -30,63 +38,84 @@ async def patch_platform_constant() -> list[Platform]: @pytest.fixture async def load_int( hass: HomeAssistant, - get_data: SensiboData, + mock_client: SensiboClient, load_platforms: list[Platform], ) -> MockConfigEntry: - """Set up the Sensibo integration in Home Assistant.""" + """Set up the Sensibo integration.""" config_entry = MockConfigEntry( domain=DOMAIN, - source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="username", + unique_id="firstnamelastname", version=2, ) config_entry.add_to_hass(hass) - with ( - patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {"username": "username"}}, - ), - ): + with patch("homeassistant.components.sensibo.PLATFORMS", load_platforms): await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done() return config_entry +@pytest.fixture(name="mock_client") +async def get_client( + hass: HomeAssistant, + get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], +) -> AsyncGenerator[MagicMock]: + """Mock SensiboClient.""" + + with ( + patch( + "homeassistant.components.sensibo.coordinator.SensiboClient", + autospec=True, + ) as mock_client, + patch("homeassistant.components.sensibo.util.SensiboClient", new=mock_client), + ): + client = mock_client.return_value + client.async_get_devices_data.return_value = get_data[0] + client.async_get_me.return_value = get_data[1] + client.async_get_devices.return_value = get_data[2] + yield client + + @pytest.fixture(name="get_data") async def get_data_from_library( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, load_json: dict[str, Any] -) -> SensiboData: - """Retrieve data from upstream Sensibo library.""" - - client = SensiboClient("123467890", aioclient_mock.create_session(hass.loop)) - with patch("pysensibo.SensiboClient.async_get_devices", return_value=load_json): - output = await client.async_get_devices_data() + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + load_json: tuple[dict[str, Any], dict[str, Any]], +) -> AsyncGenerator[tuple[SensiboData, dict[str, Any], dict[str, Any]]]: + """Get data from api.""" + client = SensiboClient("1234567890", aioclient_mock.create_session(hass.loop)) + with patch.object( + client, + "async_get_me", + return_value=load_json[1], + ): + me = await client.async_get_me() + with patch.object( + client, + "async_get_devices", + return_value=load_json[0], + ): + data = await client.async_get_devices_data() + raw_data = await client.async_get_devices() + yield (data, me, raw_data) await client._session.close() - return output @pytest.fixture(name="load_json") -def load_json_from_fixture(load_data: str) -> SensiboData: +def load_json_from_fixture( + load_data: tuple[str, str], +) -> tuple[dict[str, Any], dict[str, Any]]: """Load fixture with json data and return.""" - json_data: dict[str, Any] = json.loads(load_data) - return json_data + json_data: dict[str, Any] = json.loads(load_data[0]) + json_me: dict[str, Any] = json.loads(load_data[1]) + return (json_data, json_me) @pytest.fixture(name="load_data", scope="package") -def load_data_from_fixture() -> str: +def load_data_from_fixture() -> tuple[str, str]: """Load fixture with fixture data and return.""" - return load_fixture("data.json", "sensibo") + return (load_fixture("data.json", "sensibo"), load_fixture("me.json", "sensibo")) diff --git a/tests/components/sensibo/fixtures/me.json b/tests/components/sensibo/fixtures/me.json new file mode 100644 index 00000000000..1b06a524966 --- /dev/null +++ b/tests/components/sensibo/fixtures/me.json @@ -0,0 +1,13 @@ +{ + "status": "success", + "result": { + "username": "firstnamelastname", + "email": "first.last@domain.com", + "firstName": "Firstname", + "lastName": "Lastname", + "temperatureUnit": "C", + "appRegistrationSource": "Sensibo", + "organization": null, + "availableOrganizations": [] + } +} diff --git a/tests/components/sensibo/snapshots/test_climate.ambr b/tests/components/sensibo/snapshots/test_climate.ambr index e3b27332932..5bcfae0917e 100644 --- a/tests/components/sensibo/snapshots/test_climate.ambr +++ b/tests/components/sensibo/snapshots/test_climate.ambr @@ -81,6 +81,11 @@ ]), 'max_temp': 20, 'min_temp': 10, + 'swing_horizontal_modes': list([ + 'stopped', + 'fixedleft', + 'fixedcenterleft', + ]), 'swing_modes': list([ 'stopped', 'fixedtop', @@ -109,7 +114,7 @@ 'original_name': None, 'platform': 'sensibo', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'climate_device', 'unique_id': 'ABC999111', 'unit_of_measurement': None, @@ -137,7 +142,13 @@ ]), 'max_temp': 20, 'min_temp': 10, - 'supported_features': , + 'supported_features': , + 'swing_horizontal_mode': 'stopped', + 'swing_horizontal_modes': list([ + 'stopped', + 'fixedleft', + 'fixedcenterleft', + ]), 'swing_mode': 'stopped', 'swing_modes': list([ 'stopped', @@ -194,7 +205,7 @@ 'original_name': None, 'platform': 'sensibo', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'climate_device', 'unique_id': 'AAZZAAZZ', 'unit_of_measurement': None, @@ -216,9 +227,8 @@ ]), 'max_temp': 1, 'min_temp': 0, - 'supported_features': , + 'supported_features': , 'target_temp_step': 1, - 'temperature': None, }), 'context': , 'entity_id': 'climate.kitchen', @@ -228,3 +238,23 @@ 'state': 'off', }) # --- +# name: test_climate_get_device_capabilities + dict({ + 'climate.hallway': dict({ + 'horizontalswing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + }), + }) +# --- diff --git a/tests/components/sensibo/snapshots/test_entity.ambr b/tests/components/sensibo/snapshots/test_entity.ambr new file mode 100644 index 00000000000..23ead2f6d96 --- /dev/null +++ b/tests/components/sensibo/snapshots/test_entity.ambr @@ -0,0 +1,137 @@ +# serializer version: 1 +# name: test_device + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': 'hallway', + 'config_entries': , + 'configuration_url': 'https://home.sensibo.com/', + 'connections': set({ + tuple( + 'mac', + '00:02:00:b6:00:00', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266ex', + 'id': , + 'identifiers': set({ + tuple( + 'sensibo', + 'ABC999111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Sensibo', + 'model': 'skyv2', + 'model_id': None, + 'name': 'Hallway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890', + 'suggested_area': 'Hallway', + 'sw_version': 'SKY30046', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': 'kitchen', + 'config_entries': , + 'configuration_url': 'https://home.sensibo.com/', + 'connections': set({ + tuple( + 'mac', + '00:01:00:01:00:01', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'pure-esp32', + 'id': , + 'identifiers': set({ + tuple( + 'sensibo', + 'AAZZAAZZ', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Sensibo', + 'model': 'pure', + 'model_id': None, + 'name': 'Kitchen', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '0987654321', + 'suggested_area': 'Kitchen', + 'sw_version': 'PUR00111', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': 'bedroom', + 'config_entries': , + 'configuration_url': 'https://home.sensibo.com/', + 'connections': set({ + tuple( + 'mac', + '00:03:00:03:00:03', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'pure-esp32', + 'id': , + 'identifiers': set({ + tuple( + 'sensibo', + 'BBZZBBZZ', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Sensibo', + 'model': 'pure', + 'model_id': None, + 'name': 'Bedroom', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '0987654329', + 'suggested_area': 'Bedroom', + 'sw_version': 'PUR00111', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.sensibo.com/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'nrf52', + 'id': , + 'identifiers': set({ + tuple( + 'sensibo', + 'AABBCC', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Sensibo', + 'model': 'motion_sensor', + 'model_id': None, + 'name': 'Hallway Motion Sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'V17', + 'via_device_id': , + }), + ]) +# --- diff --git a/tests/components/sensibo/snapshots/test_select.ambr b/tests/components/sensibo/snapshots/test_select.ambr index bdafc8654ff..7438fb70140 100644 --- a/tests/components/sensibo/snapshots/test_select.ambr +++ b/tests/components/sensibo/snapshots/test_select.ambr @@ -1,61 +1,4 @@ # serializer version: 1 -# name: test_select[load_platforms0][select.hallway_horizontal_swing-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'stopped', - 'fixedleft', - 'fixedcenterleft', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.hallway_horizontal_swing', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Horizontal swing', - 'platform': 'sensibo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'horizontalswing', - 'unique_id': 'ABC999111-horizontalSwing', - 'unit_of_measurement': None, - }) -# --- -# name: test_select[load_platforms0][select.hallway_horizontal_swing-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Hallway Horizontal swing', - 'options': list([ - 'stopped', - 'fixedleft', - 'fixedcenterleft', - ]), - }), - 'context': , - 'entity_id': 'select.hallway_horizontal_swing', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stopped', - }) -# --- # name: test_select[load_platforms0][select.hallway_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index dbc3e87a236..771b4a0b6b5 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -3,17 +3,16 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock -from pysensibo.model import SensiboData +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, snapshot_platform @@ -26,33 +25,27 @@ from tests.common import async_fire_time_changed, snapshot_platform async def test_binary_sensor( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo binary sensor.""" await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) - monkeypatch.setattr( - get_data.parsed["ABC999111"].motion_sensors["AABBCC"], "alive", False - ) - monkeypatch.setattr( - get_data.parsed["ABC999111"].motion_sensors["AABBCC"], "motion", False - ) + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].motion_sensors[ + "AABBCC" + ].motion = False - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state1 = hass.states.get("binary_sensor.hallway_motion_sensor_connectivity") - state3 = hass.states.get("binary_sensor.hallway_motion_sensor_motion") - assert state1.state == "off" - assert state3.state == "off" + assert ( + hass.states.get("binary_sensor.hallway_motion_sensor_connectivity").state + == STATE_ON + ) + assert ( + hass.states.get("binary_sensor.hallway_motion_sensor_motion").state == STATE_OFF + ) diff --git a/tests/components/sensibo/test_button.py b/tests/components/sensibo/test_button.py index 5c36fe9e94d..2d93fd81ed7 100644 --- a/tests/components/sensibo/test_button.py +++ b/tests/components/sensibo/test_button.py @@ -2,12 +2,11 @@ from __future__ import annotations -from datetime import datetime, timedelta -from unittest.mock import patch +from datetime import timedelta +from unittest.mock import MagicMock from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory -from pysensibo.model import SensiboData import pytest from syrupy.assertion import SnapshotAssertion @@ -45,14 +44,18 @@ async def test_button( await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR]], +) async def test_button_update( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: - """Test the Sensibo button.""" + """Test the Sensibo button press.""" state_button = hass.states.get("button.hallway_reset_filter") state_filter_clean = hass.states.get("binary_sensor.hallway_filter_clean_required") @@ -62,48 +65,32 @@ async def test_button_update( assert state_filter_clean.state is STATE_ON assert state_filter_last_reset.state == "2022-03-12T15:24:26+00:00" - today = datetime(datetime.now().year + 1, 6, 19, 20, 0, 0).replace( - tzinfo=dt_util.UTC - ) - today_str = today.isoformat() + today = dt_util.utcnow() + timedelta(minutes=10) + today = today.replace(microsecond=0) + today_str = today.isoformat(timespec="seconds") freezer.move_to(today) - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_reset_filter", - return_value={"status": "success"}, - ), - ): - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - { - ATTR_ENTITY_ID: state_button.entity_id, - }, - blocking=True, - ) - await hass.async_block_till_done() + mock_client.async_reset_filter.return_value = {"status": "success"} - monkeypatch.setattr(get_data.parsed["ABC999111"], "filter_clean", False) - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "filter_last_reset", - today, + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: state_button.entity_id, + }, + blocking=True, ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].filter_clean = False + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].filter_last_reset = today + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() state_button = hass.states.get("button.hallway_reset_filter") state_filter_clean = hass.states.get("binary_sensor.hallway_filter_clean_required") @@ -116,31 +103,22 @@ async def test_button_update( async def test_button_failure( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, ) -> None: - """Test the Sensibo button fails.""" + """Test the Sensibo button failure.""" - state_button = hass.states.get("button.hallway_reset_filter") + state = hass.states.get("button.hallway_reset_filter") - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_reset_filter", - return_value={"status": "failure"}, - ), - pytest.raises( - HomeAssistantError, - ), + mock_client.async_reset_filter.return_value = {"status": "failure"} + + with pytest.raises( + HomeAssistantError, ): await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: state_button.entity_id, + ATTR_ENTITY_ID: state.entity_id, }, blocking=True, ) diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 8be9f4a60e4..7e848f3870c 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -1,11 +1,11 @@ -"""The test for the sensibo binary sensor platform.""" +"""The test for the sensibo climate platform.""" from __future__ import annotations from datetime import datetime, timedelta -from unittest.mock import patch +from unittest.mock import MagicMock -from pysensibo.model import SensiboData +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from voluptuous import MultipleInvalid @@ -13,14 +13,17 @@ from voluptuous import MultipleInvalid from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + HVACMode, ) from homeassistant.components.sensibo.climate import ( ATTR_AC_INTEGRATION, @@ -42,6 +45,7 @@ from homeassistant.components.sensibo.climate import ( SERVICE_ENABLE_PURE_BOOST, SERVICE_ENABLE_TIMER, SERVICE_FULL_STATE, + SERVICE_GET_DEVICE_CAPABILITIES, _find_valid_target_temp, ) from homeassistant.components.sensibo.const import DOMAIN @@ -53,6 +57,8 @@ from homeassistant.const import ( ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, STATE_UNKNOWN, Platform, ) @@ -86,7 +92,6 @@ async def test_climate_find_valid_targets() -> None: async def test_climate( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - get_data: SensiboData, load_int: ConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, @@ -95,45 +100,39 @@ async def test_climate( await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) - found_log = False logs = caplog.get_records("setup") - for log in logs: - if ( - log.message - == "Device Bedroom not correctly registered with remote on Sensibo cloud." - ): - found_log = True - break - assert found_log + assert "Device Bedroom not correctly registered with remote on Sensibo cloud." in [ + log.message for log in logs + ] async def test_climate_fan( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate fan service.""" - state1 = hass.states.get("climate.hallway") - assert state1.attributes["fan_mode"] == "high" + state = hass.states.get("climate.hallway") + assert state.attributes["fan_mode"] == "high" + + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].fan_modes = [ + "quiet", + "low", + "medium", + "not_in_ha", + ] + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].fan_modes_translated = { + "low": "low", + "medium": "medium", + "quiet": "quiet", + "not_in_ha": "not_in_ha", + } - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "fan_modes", - ["quiet", "low", "medium", "not_in_ha"], - ) - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "fan_modes_translated", - { - "low": "low", - "medium": "medium", - "quiet": "quiet", - "not_in_ha": "not_in_ha", - }, - ) with pytest.raises( HomeAssistantError, match="Climate fan mode not_in_ha is not supported by the integration", @@ -141,107 +140,81 @@ async def test_climate_fan( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_FAN_MODE: "not_in_ha"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_FAN_MODE: "not_in_ha"}, blocking=True, ) - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_FAN_MODE: "low"}, - blocking=True, - ) - await hass.async_block_till_done() + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } - state2 = hass.states.get("climate.hallway") - assert state2.attributes["fan_mode"] == "low" - - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "active_features", - [ - "timestamp", - "on", - "mode", - "swing", - "targetTemperature", - "horizontalSwing", - "light", - ], + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_FAN_MODE: "low"}, + blocking=True, ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), - pytest.raises(HomeAssistantError), - ): + state = hass.states.get("climate.hallway") + assert state.attributes["fan_mode"] == "low" + + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].active_features = [ + "timestamp", + "on", + "mode", + "swing", + "targetTemperature", + "horizontalSwing", + "light", + ] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match="service_not_supported"): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_FAN_MODE: "low"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_FAN_MODE: "low"}, blocking=True, ) - await hass.async_block_till_done() - state3 = hass.states.get("climate.hallway") - assert state3.attributes["fan_mode"] == "low" + state = hass.states.get("climate.hallway") + assert "fan_mode" not in state.attributes async def test_climate_swing( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate swing service.""" - state1 = hass.states.get("climate.hallway") - assert state1.attributes["swing_mode"] == "stopped" + state = hass.states.get("climate.hallway") + assert state.attributes["swing_mode"] == "stopped" - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "swing_modes", - ["stopped", "fixedtop", "fixedmiddletop", "not_in_ha"], - ) - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "swing_modes_translated", - { - "fixedmiddletop": "fixedMiddleTop", - "fixedtop": "fixedTop", - "stopped": "stopped", - "not_in_ha": "not_in_ha", - }, - ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].swing_modes = [ + "stopped", + "fixedtop", + "fixedmiddletop", + "not_in_ha", + ] + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].swing_modes_translated = { + "fixedmiddletop": "fixedMiddleTop", + "fixedtop": "fixedTop", + "stopped": "stopped", + "not_in_ha": "not_in_ha", + } + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() with pytest.raises( HomeAssistantError, @@ -250,582 +223,506 @@ async def test_climate_swing( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_SWING_MODE: "not_in_ha"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_SWING_MODE: "not_in_ha"}, blocking=True, ) - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_SWING_MODE: "fixedtop"}, - blocking=True, - ) - await hass.async_block_till_done() + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } - state2 = hass.states.get("climate.hallway") - assert state2.attributes["swing_mode"] == "fixedtop" - - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "active_features", - [ - "timestamp", - "on", - "mode", - "targetTemperature", - "horizontalSwing", - "light", - ], + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_SWING_MODE: "fixedtop"}, + blocking=True, ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), - pytest.raises(HomeAssistantError), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_SWING_MODE: "fixedtop"}, - blocking=True, - ) + state = hass.states.get("climate.hallway") + assert state.attributes["swing_mode"] == "fixedtop" + + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].active_features = [ + "timestamp", + "on", + "mode", + "targetTemperature", + "horizontalSwing", + "light", + ] + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - state3 = hass.states.get("climate.hallway") - assert state3.attributes["swing_mode"] == "fixedtop" + with pytest.raises(HomeAssistantError, match="service_not_supported"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_SWING_MODE: "fixedtop"}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert "swing_mode" not in state.attributes + + +async def test_climate_horizontal_swing( + hass: HomeAssistant, + load_int: ConfigEntry, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Sensibo climate horizontal swing service.""" + + state = hass.states.get("climate.hallway") + assert state.attributes["swing_horizontal_mode"] == "stopped" + + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].horizontal_swing_modes = [ + "stopped", + "fixedleft", + "fixedcenter", + "fixedright", + "not_in_ha", + ] + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].swing_modes_translated = { + "stopped": "stopped", + "fixedleft": "fixedLeft", + "fixedcenter": "fixedCenter", + "fixedright": "fixedRight", + "not_in_ha": "not_in_ha", + } + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with pytest.raises( + HomeAssistantError, + match="Climate horizontal swing mode not_in_ha is not supported by the integration", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_SWING_HORIZONTAL_MODE: "not_in_ha"}, + blocking=True, + ) + + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_SWING_HORIZONTAL_MODE: "fixedleft"}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert state.attributes["swing_horizontal_mode"] == "fixedleft" + + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].active_features = [ + "timestamp", + "on", + "mode", + "targetTemperature", + "swing", + "light", + ] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match="service_not_supported"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_SWING_HORIZONTAL_MODE: "fixedcenter", + }, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert "swing_horizontal_mode" not in state.attributes async def test_climate_temperatures( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate temperature service.""" - state1 = hass.states.get("climate.hallway") - assert state1.attributes["temperature"] == 25 + state = hass.states.get("climate.hallway") + assert state.attributes["temperature"] == 25 - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_TEMPERATURE: 20}, - blocking=True, - ) - await hass.async_block_till_done() + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } - state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 20 - - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_TEMPERATURE: 15}, - blocking=True, - ) - await hass.async_block_till_done() - - state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 16 - - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_TEMPERATURE: 18.5}, - blocking=True, - ) - await hass.async_block_till_done() - - state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 19 - - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - pytest.raises( - ServiceValidationError, - match="Provided temperature 24.0 is not valid. Accepted range is 10 to 20", - ), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_TEMPERATURE: 24}, - blocking=True, - ) - await hass.async_block_till_done() - - state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 19 - - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_TEMPERATURE: 20}, - blocking=True, - ) - await hass.async_block_till_done() - - state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 20 - - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - pytest.raises(MultipleInvalid), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: state1.entity_id}, - blocking=True, - ) - await hass.async_block_till_done() - - state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 20 - - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "active_features", - [ - "timestamp", - "on", - "mode", - "swing", - "horizontalSwing", - "light", - ], + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_TEMPERATURE: 20}, + blocking=True, ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - pytest.raises(HomeAssistantError), + state = hass.states.get("climate.hallway") + assert state.attributes["temperature"] == 20 + + mock_client.async_set_ac_state_property.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_TEMPERATURE: 20}, + blocking=True, + ) + assert not mock_client.async_set_ac_state_property.called + + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert state.attributes["temperature"] == 16 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_TEMPERATURE: 18.5}, + blocking=True, + ) + + state2 = hass.states.get("climate.hallway") + assert state2.attributes["temperature"] == 19 + + with pytest.raises( + ServiceValidationError, + match="Provided temperature 24.0 is not valid. Accepted range is 10 to 20", ): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_TEMPERATURE: 20}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_TEMPERATURE: 24}, blocking=True, ) + + state = hass.states.get("climate.hallway") + assert state.attributes["temperature"] == 19 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_TEMPERATURE: 20}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert state.attributes["temperature"] == 20 + + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert state.attributes["temperature"] == 20 + + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].active_features = [ + "timestamp", + "on", + "mode", + "swing", + "horizontalSwing", + "light", + ] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 20 + with pytest.raises(HomeAssistantError, match="service_not_supported"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_TEMPERATURE: 20}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert "temperature" not in state.attributes async def test_climate_temperature_is_none( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate temperature service no temperature provided.""" - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "active_features", - [ - "timestamp", - "on", - "mode", - "fanLevel", - "targetTemperature", - "swing", - "horizontalSwing", - "light", - ], - ) - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "target_temp", - 25, - ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].active_features = [ + "timestamp", + "on", + "mode", + "fanLevel", + "targetTemperature", + "swing", + "horizontalSwing", + "light", + ] + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].target_temp = 25 - state1 = hass.states.get("climate.hallway") - assert state1.attributes["temperature"] == 25 + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), - pytest.raises(ServiceValidationError), - ): + state = hass.states.get("climate.hallway") + assert state.attributes["temperature"] == 25 + + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: state1.entity_id, + ATTR_ENTITY_ID: state.entity_id, ATTR_TARGET_TEMP_HIGH: 30, ATTR_TARGET_TEMP_LOW: 20, }, blocking=True, ) - await hass.async_block_till_done() - state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 25 + state = hass.states.get("climate.hallway") + assert state.attributes["temperature"] == 25 async def test_climate_hvac_mode( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate hvac mode service.""" - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "active_features", - [ - "timestamp", - "on", - "mode", - "fanLevel", - "targetTemperature", - "swing", - "horizontalSwing", - "light", - ], - ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].active_features = [ + "timestamp", + "on", + "mode", + "fanLevel", + "targetTemperature", + "swing", + "horizontalSwing", + "light", + ] - state1 = hass.states.get("climate.hallway") - assert state1.state == "heat" - - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_HVAC_MODE: "off"}, - blocking=True, - ) - await hass.async_block_till_done() - - state2 = hass.states.get("climate.hallway") - assert state2.state == "off" - - monkeypatch.setattr(get_data.parsed["ABC999111"], "device_on", False) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_HVAC_MODE: "heat"}, - blocking=True, - ) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - state2 = hass.states.get("climate.hallway") - assert state2.state == "heat" + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.HEAT + + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_HVAC_MODE: "off"}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.OFF + + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].device_on = False + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_HVAC_MODE: "heat"}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.HEAT async def test_climate_on_off( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate on/off service.""" - monkeypatch.setattr(get_data.parsed["ABC999111"], "hvac_mode", "heat") - monkeypatch.setattr(get_data.parsed["ABC999111"], "device_on", True) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].hvac_mode = "heat" + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].device_on = True - state1 = hass.states.get("climate.hallway") - assert state1.state == "heat" - - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: state1.entity_id}, - blocking=True, - ) - await hass.async_block_till_done() - - state2 = hass.states.get("climate.hallway") - assert state2.state == "off" - - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: state1.entity_id}, - blocking=True, - ) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - state2 = hass.states.get("climate.hallway") - assert state2.state == "heat" + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.HEAT + + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.OFF + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.HEAT async def test_climate_service_failed( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate service failed.""" - monkeypatch.setattr(get_data.parsed["ABC999111"], "hvac_mode", "heat") - monkeypatch.setattr(get_data.parsed["ABC999111"], "device_on", True) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].hvac_mode = "heat" + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].device_on = True - state1 = hass.states.get("climate.hallway") - assert state1.state == "heat" + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={ - "result": {"status": "Error", "failureReason": "Did not work"} - }, - ), - pytest.raises(HomeAssistantError), - ): + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.HEAT + + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Error", "failureReason": "Did not work"} + } + + with pytest.raises(HomeAssistantError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: state1.entity_id}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - await hass.async_block_till_done() - state2 = hass.states.get("climate.hallway") - assert state2.state == "heat" + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.HEAT async def test_climate_assumed_state( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate assumed state service.""" - monkeypatch.setattr(get_data.parsed["ABC999111"], "hvac_mode", "heat") - monkeypatch.setattr(get_data.parsed["ABC999111"], "device_on", True) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].hvac_mode = "heat" + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].device_on = True - state1 = hass.states.get("climate.hallway") - assert state1.state == "heat" - - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - DOMAIN, - SERVICE_ASSUME_STATE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_STATE: "off"}, - blocking=True, - ) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - state2 = hass.states.get("climate.hallway") - assert state2.state == "off" + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.HEAT + + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } + + await hass.services.async_call( + DOMAIN, + SERVICE_ASSUME_STATE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_STATE: "off"}, + blocking=True, + ) + + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.OFF async def test_climate_no_fan_no_swing( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: - """Test the Sensibo climate fan service.""" + """Test the Sensibo climate fan.""" state = hass.states.get("climate.hallway") assert state.attributes["fan_mode"] == "high" assert state.attributes["swing_mode"] == "stopped" - monkeypatch.setattr(get_data.parsed["ABC999111"], "fan_mode", None) - monkeypatch.setattr(get_data.parsed["ABC999111"], "swing_mode", None) - monkeypatch.setattr(get_data.parsed["ABC999111"], "fan_modes", None) - monkeypatch.setattr(get_data.parsed["ABC999111"], "swing_modes", None) + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].fan_mode = None + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].swing_mode = None + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].fan_modes = None + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].swing_modes = None - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() state = hass.states.get("climate.hallway") assert state.attributes["fan_mode"] is None @@ -838,110 +735,70 @@ async def test_climate_no_fan_no_swing( async def test_climate_set_timer( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate Set Timer service.""" - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state_climate = hass.states.get("climate.hallway") + state = hass.states.get("climate.hallway") assert hass.states.get("sensor.hallway_timer_end_time").state == STATE_UNKNOWN - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", - return_value={"status": "failure"}, - ), - pytest.raises( - MultipleInvalid, - ), - ): + mock_client.async_set_timer.return_value = {"status": "failure"} + + with pytest.raises(MultipleInvalid): await hass.services.async_call( DOMAIN, SERVICE_ENABLE_TIMER, { - ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_ENTITY_ID: state.entity_id, }, blocking=True, ) - await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", - return_value={"status": "failure"}, - ), - pytest.raises( - HomeAssistantError, - ), - ): + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_ENABLE_TIMER, { - ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_ENTITY_ID: state.entity_id, ATTR_MINUTES: 30, }, blocking=True, ) - await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", - return_value={"status": "success", "result": {"id": "SzTGE4oZ4D"}}, - ), - ): - await hass.services.async_call( - DOMAIN, - SERVICE_ENABLE_TIMER, - { - ATTR_ENTITY_ID: state_climate.entity_id, - ATTR_MINUTES: 30, - }, - blocking=True, - ) - await hass.async_block_till_done() + mock_client.async_set_timer.return_value = { + "status": "success", + "result": {"id": "SzTGE4oZ4D"}, + } - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", True) - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", "SzTGE4oZ4D") - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", False) - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "timer_time", - datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt_util.UTC), + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_TIMER, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_MINUTES: 30, + }, + blocking=True, ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].timer_on = True + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].timer_id = "SzTGE4oZ4D" + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].timer_state_on = False + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].timer_time = datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt_util.UTC) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() assert ( hass.states.get("sensor.hallway_timer_end_time").state @@ -953,144 +810,110 @@ async def test_climate_set_timer( async def test_climate_pure_boost( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: - """Test the Sensibo climate assumed state service.""" + """Test the Sensibo climate pure boost service.""" - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state_climate = hass.states.get("climate.kitchen") + state = hass.states.get("climate.kitchen") state2 = hass.states.get("switch.kitchen_pure_boost") - assert state2.state == "off" + assert state2.state == STATE_OFF - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", - ), - pytest.raises( - MultipleInvalid, - ), + with pytest.raises( + MultipleInvalid, ): await hass.services.async_call( DOMAIN, SERVICE_ENABLE_PURE_BOOST, { - ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_ENTITY_ID: state.entity_id, ATTR_INDOOR_INTEGRATION: True, ATTR_OUTDOOR_INTEGRATION: True, - ATTR_SENSITIVITY: "Sensitive", + ATTR_SENSITIVITY: "sensitive", }, blocking=True, ) + + mock_client.async_set_pureboost.return_value = { + "status": "success", + "result": { + "enabled": True, + "sensitivity": "S", + "measurements_integration": True, + "ac_integration": False, + "geo_integration": False, + "prime_integration": True, + }, + } + + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_PURE_BOOST, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_AC_INTEGRATION: False, + ATTR_GEO_INTEGRATION: False, + ATTR_INDOOR_INTEGRATION: True, + ATTR_OUTDOOR_INTEGRATION: True, + ATTR_SENSITIVITY: "sensitive", + }, + blocking=True, + ) + + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].pure_boost_enabled = True + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].pure_sensitivity = "s" + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].pure_measure_integration = True + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].pure_prime_integration = True + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", - return_value={ - "status": "success", - "result": { - "enabled": True, - "sensitivity": "S", - "measurements_integration": True, - "ac_integration": False, - "geo_integration": False, - "prime_integration": True, - }, - }, - ), - ): - await hass.services.async_call( - DOMAIN, - SERVICE_ENABLE_PURE_BOOST, - { - ATTR_ENTITY_ID: state_climate.entity_id, - ATTR_AC_INTEGRATION: False, - ATTR_GEO_INTEGRATION: False, - ATTR_INDOOR_INTEGRATION: True, - ATTR_OUTDOOR_INTEGRATION: True, - ATTR_SENSITIVITY: "Sensitive", - }, - blocking=True, - ) - await hass.async_block_till_done() - - monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_boost_enabled", True) - monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_sensitivity", "s") - monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_measure_integration", True) - monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_prime_integration", True) - - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - - state1 = hass.states.get("switch.kitchen_pure_boost") - state2 = hass.states.get( - "binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality" + assert hass.states.get("switch.kitchen_pure_boost").state == STATE_ON + assert ( + hass.states.get( + "binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality" + ).state + == STATE_ON ) - state3 = hass.states.get( - "binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality" + assert ( + hass.states.get( + "binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality" + ).state + == STATE_ON ) - state4 = hass.states.get("sensor.kitchen_pure_sensitivity") - assert state1.state == "on" - assert state2.state == "on" - assert state3.state == "on" - assert state4.state == "s" + assert hass.states.get("sensor.kitchen_pure_sensitivity").state == "s" @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate_climate_react( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate react custom service.""" - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() state_climate = hass.states.get("climate.hallway") - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_climate_react", - ), - pytest.raises( - MultipleInvalid, - ), + with pytest.raises( + MultipleInvalid, ): await hass.services.async_call( DOMAIN, @@ -1103,423 +926,386 @@ async def test_climate_climate_react( }, blocking=True, ) - await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_climate_react", - return_value={ - "status": "success", - "result": { - "enabled": True, - "deviceUid": "ABC999111", - "highTemperatureState": { - "on": True, - "targetTemperature": 15, - "temperatureUnit": "C", - "mode": "cool", - "fanLevel": "high", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", - }, - "highTemperatureThreshold": 30.5, - "lowTemperatureState": { - "on": True, - "targetTemperature": 25, - "temperatureUnit": "C", - "mode": "heat", - "fanLevel": "low", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", - }, - "lowTemperatureThreshold": 5.5, - "type": "temperature", - }, + mock_client.async_set_climate_react.return_value = { + "status": "success", + "result": { + "enabled": True, + "deviceUid": "ABC999111", + "highTemperatureState": { + "on": True, + "targetTemperature": 15, + "temperatureUnit": "C", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", }, - ), - ): - await hass.services.async_call( - DOMAIN, - SERVICE_ENABLE_CLIMATE_REACT, - { - ATTR_ENTITY_ID: state_climate.entity_id, - ATTR_LOW_TEMPERATURE_THRESHOLD: 5.5, - ATTR_HIGH_TEMPERATURE_THRESHOLD: 30.5, - ATTR_LOW_TEMPERATURE_STATE: { - "on": True, - "targetTemperature": 25, - "temperatureUnit": "C", - "mode": "heat", - "fanLevel": "low", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", - }, - ATTR_HIGH_TEMPERATURE_STATE: { - "on": True, - "targetTemperature": 15, - "temperatureUnit": "C", - "mode": "cool", - "fanLevel": "high", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", - }, - ATTR_SMART_TYPE: "temperature", + "highTemperatureThreshold": 30.5, + "lowTemperatureState": { + "on": True, + "targetTemperature": 25, + "temperatureUnit": "C", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", }, - blocking=True, - ) - await hass.async_block_till_done() + "lowTemperatureThreshold": 5.5, + "type": "feelsLike", + }, + } - monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_on", True) - monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_type", "temperature") - monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_low_temp_threshold", 5.5) - monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_high_temp_threshold", 30.5) - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "smart_low_state", + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_CLIMATE_REACT, { - "on": True, - "targetTemperature": 25, - "temperatureUnit": "C", - "mode": "heat", - "fanLevel": "low", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", - }, - ) - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "smart_high_state", - { - "on": True, - "targetTemperature": 15, - "temperatureUnit": "C", - "mode": "cool", - "fanLevel": "high", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_LOW_TEMPERATURE_THRESHOLD: 5.5, + ATTR_HIGH_TEMPERATURE_THRESHOLD: 30.5, + ATTR_LOW_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 25, + "temperatureUnit": "C", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_HIGH_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 15, + "temperatureUnit": "C", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_SMART_TYPE: "feelslike", }, + blocking=True, ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].smart_on = True + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].smart_type = "feelsLike" + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].smart_low_temp_threshold = 5.5 + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].smart_high_temp_threshold = 30.5 + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].smart_low_state = { + "on": True, + "targetTemperature": 25, + "temperatureUnit": "C", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + } + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].smart_high_state = { + "on": True, + "targetTemperature": 15, + "temperatureUnit": "C", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + } - state1 = hass.states.get("switch.hallway_climate_react") - state2 = hass.states.get("sensor.hallway_climate_react_low_temperature_threshold") - state3 = hass.states.get("sensor.hallway_climate_react_high_temperature_threshold") - state4 = hass.states.get("sensor.hallway_climate_react_type") - assert state1.state == "on" - assert state2.state == "5.5" - assert state3.state == "30.5" - assert state4.state == "temperature" + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("switch.hallway_climate_react").state == STATE_ON + assert ( + hass.states.get("sensor.hallway_climate_react_low_temperature_threshold").state + == "5.5" + ) + assert ( + hass.states.get("sensor.hallway_climate_react_high_temperature_threshold").state + == "30.5" + ) + assert hass.states.get("sensor.hallway_climate_react_type").state == "feelslike" @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate_climate_react_fahrenheit( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate react custom service with fahrenheit.""" - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - - state_climate = hass.states.get("climate.hallway") - - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_climate_react", - return_value={ - "status": "success", - "result": { - "enabled": True, - "deviceUid": "ABC999111", - "highTemperatureState": { - "on": True, - "targetTemperature": 65, - "temperatureUnit": "F", - "mode": "cool", - "fanLevel": "high", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", - }, - "highTemperatureThreshold": 77, - "lowTemperatureState": { - "on": True, - "targetTemperature": 85, - "temperatureUnit": "F", - "mode": "heat", - "fanLevel": "low", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", - }, - "lowTemperatureThreshold": 32, - "type": "temperature", - }, - }, - ), - ): - await hass.services.async_call( - DOMAIN, - SERVICE_ENABLE_CLIMATE_REACT, - { - ATTR_ENTITY_ID: state_climate.entity_id, - ATTR_LOW_TEMPERATURE_THRESHOLD: 32.0, - ATTR_HIGH_TEMPERATURE_THRESHOLD: 77.0, - ATTR_LOW_TEMPERATURE_STATE: { - "on": True, - "targetTemperature": 85, - "temperatureUnit": "F", - "mode": "heat", - "fanLevel": "low", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", - }, - ATTR_HIGH_TEMPERATURE_STATE: { - "on": True, - "targetTemperature": 65, - "temperatureUnit": "F", - "mode": "cool", - "fanLevel": "high", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", - }, - ATTR_SMART_TYPE: "temperature", - }, - blocking=True, - ) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_on", True) - monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_type", "temperature") - monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_low_temp_threshold", 0) - monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_high_temp_threshold", 25) - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "smart_low_state", - { - "on": True, - "targetTemperature": 85, - "temperatureUnit": "F", - "mode": "heat", - "fanLevel": "low", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", + state = hass.states.get("climate.hallway") + + mock_client.async_set_climate_react.return_value = { + "status": "success", + "result": { + "enabled": True, + "deviceUid": "ABC999111", + "highTemperatureState": { + "on": True, + "targetTemperature": 65, + "temperatureUnit": "F", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + "highTemperatureThreshold": 77, + "lowTemperatureState": { + "on": True, + "targetTemperature": 85, + "temperatureUnit": "F", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + "lowTemperatureThreshold": 32, + "type": "temperature", }, - ) - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "smart_high_state", + } + + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_CLIMATE_REACT, { - "on": True, - "targetTemperature": 65, - "temperatureUnit": "F", - "mode": "cool", - "fanLevel": "high", - "swing": "stopped", - "horizontalSwing": "stopped", - "light": "on", + ATTR_ENTITY_ID: state.entity_id, + ATTR_LOW_TEMPERATURE_THRESHOLD: 32.0, + ATTR_HIGH_TEMPERATURE_THRESHOLD: 77.0, + ATTR_LOW_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 85, + "temperatureUnit": "F", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_HIGH_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 65, + "temperatureUnit": "F", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_SMART_TYPE: "temperature", }, + blocking=True, ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].smart_on = True + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].smart_type = "temperature" + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].smart_low_temp_threshold = 0 + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].smart_high_temp_threshold = 25 + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].smart_low_state = { + "on": True, + "targetTemperature": 85, + "temperatureUnit": "F", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + } + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].smart_high_state = { + "on": True, + "targetTemperature": 65, + "temperatureUnit": "F", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + } - state1 = hass.states.get("switch.hallway_climate_react") - state2 = hass.states.get("sensor.hallway_climate_react_low_temperature_threshold") - state3 = hass.states.get("sensor.hallway_climate_react_high_temperature_threshold") - state4 = hass.states.get("sensor.hallway_climate_react_type") - assert state1.state == "on" - assert state2.state == "0" - assert state3.state == "25" - assert state4.state == "temperature" + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("switch.hallway_climate_react").state == STATE_ON + assert ( + hass.states.get("sensor.hallway_climate_react_low_temperature_threshold").state + == "0" + ) + assert ( + hass.states.get("sensor.hallway_climate_react_high_temperature_threshold").state + == "25" + ) + assert hass.states.get("sensor.hallway_climate_react_type").state == "temperature" @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate_full_ac_state( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate Full AC state service.""" - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state_climate = hass.states.get("climate.hallway") - assert state_climate.state == "heat" + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.HEAT - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_states", - ), - pytest.raises( - MultipleInvalid, - ), + with pytest.raises( + MultipleInvalid, ): await hass.services.async_call( DOMAIN, SERVICE_FULL_STATE, { - ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_ENTITY_ID: state.entity_id, ATTR_TARGET_TEMPERATURE: 22, }, blocking=True, ) - await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_states", - return_value={"result": {"status": "Success"}}, - ), - ): - await hass.services.async_call( - DOMAIN, - SERVICE_FULL_STATE, - { - ATTR_ENTITY_ID: state_climate.entity_id, - ATTR_MODE: "cool", - ATTR_TARGET_TEMPERATURE: 22, - ATTR_FAN_MODE: "high", - ATTR_SWING_MODE: "stopped", - ATTR_HORIZONTAL_SWING_MODE: "stopped", - ATTR_LIGHT: "on", - }, - blocking=True, - ) - await hass.async_block_till_done() + mock_client.async_set_ac_states.return_value = {"result": {"status": "Success"}} - monkeypatch.setattr(get_data.parsed["ABC999111"], "hvac_mode", "cool") - monkeypatch.setattr(get_data.parsed["ABC999111"], "device_on", True) - monkeypatch.setattr(get_data.parsed["ABC999111"], "target_temp", 22) - monkeypatch.setattr(get_data.parsed["ABC999111"], "fan_mode", "high") - monkeypatch.setattr(get_data.parsed["ABC999111"], "swing_mode", "stopped") - monkeypatch.setattr( - get_data.parsed["ABC999111"], "horizontal_swing_mode", "stopped" + await hass.services.async_call( + DOMAIN, + SERVICE_FULL_STATE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_MODE: "cool", + ATTR_TARGET_TEMPERATURE: 22, + ATTR_FAN_MODE: "high", + ATTR_SWING_MODE: "stopped", + ATTR_HORIZONTAL_SWING_MODE: "stopped", + ATTR_LIGHT: "on", + }, + blocking=True, ) - monkeypatch.setattr(get_data.parsed["ABC999111"], "light_mode", "on") - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].hvac_mode = "cool" + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].device_on = True + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].target_temp = 22 + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].fan_mode = "high" + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].swing_mode = "stopped" + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].horizontal_swing_mode = "stopped" + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].light_mode = "on" + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() state = hass.states.get("climate.hallway") - assert state.state == "cool" + assert state.state == HVACMode.COOL assert state.attributes["temperature"] == 22 async def test_climate_fan_mode_and_swing_mode_not_supported( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo climate fan_mode and swing_mode not supported is raising error.""" - state1 = hass.states.get("climate.hallway") - assert state1.attributes["fan_mode"] == "high" - assert state1.attributes["swing_mode"] == "stopped" + state = hass.states.get("climate.hallway") + assert state.attributes["fan_mode"] == "high" + assert state.attributes["swing_mode"] == "stopped" - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), - pytest.raises(ServiceValidationError), - ): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_SWING_MODE: "faulty_swing_mode"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_SWING_MODE: "faulty_swing_mode"}, blocking=True, ) - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), - pytest.raises(ServiceValidationError), - ): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_FAN_MODE: "faulty_fan_mode"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_FAN_MODE: "faulty_fan_mode"}, blocking=True, ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state2 = hass.states.get("climate.hallway") - assert state2.attributes["fan_mode"] == "high" - assert state2.attributes["swing_mode"] == "stopped" + state = hass.states.get("climate.hallway") + assert state.attributes["fan_mode"] == "high" + assert state.attributes["swing_mode"] == "stopped" + + +async def test_climate_get_device_capabilities( + hass: HomeAssistant, + load_int: ConfigEntry, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test the Sensibo climate Get device capabilitites service.""" + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_DEVICE_CAPABILITIES, + {ATTR_ENTITY_ID: "climate.hallway", ATTR_HVAC_MODE: "heat"}, + blocking=True, + return_response=True, + ) + assert response == snapshot + + with pytest.raises( + ServiceValidationError, match="The entity does not support the chosen mode" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_DEVICE_CAPABILITIES, + {ATTR_ENTITY_ID: "climate.hallway", ATTR_HVAC_MODE: "heat_cool"}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/sensibo/test_config_flow.py b/tests/components/sensibo/test_config_flow.py index d6edb1c7ae0..179c57167c8 100644 --- a/tests/components/sensibo/test_config_flow.py +++ b/tests/components/sensibo/test_config_flow.py @@ -3,24 +3,26 @@ from __future__ import annotations from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock -import aiohttp -from pysensibo.exceptions import AuthenticationError, SensiboError +from pysensibo import AuthenticationError, SensiboData, SensiboError import pytest from homeassistant import config_entries +from homeassistant.components.sensibo.const import DOMAIN from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -DOMAIN = "sensibo" +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_basic_setup( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_client: MagicMock +) -> None: + """Test we get and complete the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -29,32 +31,19 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {"username": "username"}}, - ), - patch( - "homeassistant.components.sensibo.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_API_KEY: "1234567890", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + }, + ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["version"] == 2 - assert result2["data"] == { - "api_key": "1234567890", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 2 + assert result["title"] == "firstnamelastname" + assert result["result"].unique_id == "firstnamelastname" + assert result["data"] == { + CONF_API_KEY: "1234567890", } assert len(mock_setup_entry.mock_calls) == 1 @@ -63,14 +52,12 @@ async def test_form(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("error_message", "p_error"), [ - (aiohttp.ClientConnectionError, "cannot_connect"), - (TimeoutError, "cannot_connect"), (AuthenticationError, "invalid_auth"), (SensiboError, "cannot_connect"), ], ) async def test_flow_fails( - hass: HomeAssistant, error_message: Exception, p_error: str + hass: HomeAssistant, mock_client: MagicMock, error_message: Exception, p_error: str ) -> None: """Test config flow errors.""" @@ -81,48 +68,38 @@ async def test_flow_fails( assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - side_effect=error_message, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_KEY: "1234567890", - }, - ) + mock_client.async_get_devices.side_effect = error_message - assert result2["errors"] == {"base": p_error} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + }, + ) - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {"username": "username"}}, - ), - patch( - "homeassistant.components.sensibo.async_setup_entry", - return_value=True, - ), - ): - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_KEY: "1234567891", - }, - ) + assert result["errors"] == {"base": p_error} - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Sensibo" - assert result3["data"] == { - "api_key": "1234567891", + mock_client.async_get_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "firstnamelastname" + assert result["data"] == { + CONF_API_KEY: "1234567890", } -async def test_flow_get_no_devices(hass: HomeAssistant) -> None: +async def test_flow_get_no_devices( + hass: HomeAssistant, + mock_client: MagicMock, + get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], +) -> None: """Test config flow get no devices from api.""" result = await hass.config_entries.flow.async_init( @@ -132,27 +109,38 @@ async def test_flow_get_no_devices(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": []}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {}}, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_KEY: "1234567890", - }, - ) + mock_client.async_get_devices.return_value = {"result": []} - assert result2["errors"] == {"base": "no_devices"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + }, + ) + + assert result["errors"] == {"base": "no_devices"} + + mock_client.async_get_devices.return_value = get_data[2] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "firstnamelastname" + assert result["data"] == { + CONF_API_KEY: "1234567890", + } -async def test_flow_get_no_username(hass: HomeAssistant) -> None: +async def test_flow_get_no_username( + hass: HomeAssistant, + mock_client: MagicMock, + get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], +) -> None: """Test config flow get no username from api.""" result = await hass.config_entries.flow.async_init( @@ -162,33 +150,40 @@ async def test_flow_get_no_username(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {}}, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_KEY: "1234567890", - }, - ) + mock_client.async_get_me.return_value = {"result": {}} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + }, + ) assert result2["errors"] == {"base": "no_username"} + mock_client.async_get_me.return_value = get_data[1] -async def test_reauth_flow(hass: HomeAssistant) -> None: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "firstnamelastname" + assert result["data"] == { + CONF_API_KEY: "1234567890", + } + + +async def test_reauth_flow(hass: HomeAssistant, mock_client: MagicMock) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( version=2, domain=DOMAIN, - unique_id="username", - data={"api_key": "1234567890"}, + unique_id="firstnamelastname", + data={CONF_API_KEY: "1234567890"}, ) entry.add_to_hass(hass) @@ -197,94 +192,58 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {"username": "username"}}, - ) as mock_sensibo, - patch( - "homeassistant.components.sensibo.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "1234567891"}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567890"}, + ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert entry.data == {"api_key": "1234567891"} - - assert len(mock_sensibo.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == {CONF_API_KEY: "1234567890"} @pytest.mark.parametrize( ("sideeffect", "p_error"), [ - (aiohttp.ClientConnectionError, "cannot_connect"), - (TimeoutError, "cannot_connect"), (AuthenticationError, "invalid_auth"), (SensiboError, "cannot_connect"), ], ) async def test_reauth_flow_error( - hass: HomeAssistant, sideeffect: Exception, p_error: str + hass: HomeAssistant, sideeffect: Exception, p_error: str, mock_client: MagicMock ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( version=2, domain=DOMAIN, - unique_id="username", - data={"api_key": "1234567890"}, + unique_id="firstnamelastname", + data={CONF_API_KEY: "1234567890"}, ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) - with patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - side_effect=sideeffect, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "1234567890"}, - ) - await hass.async_block_till_done() + mock_client.async_get_devices.side_effect = sideeffect - assert result2["step_id"] == "reauth_confirm" - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": p_error} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567890"}, + ) - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {"username": "username"}}, - ), - patch( - "homeassistant.components.sensibo.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "1234567891"}, - ) - await hass.async_block_till_done() + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": p_error} - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert entry.data == {"api_key": "1234567891"} + mock_client.async_get_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567890"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == {CONF_API_KEY: "1234567890"} @pytest.mark.parametrize( @@ -297,12 +256,12 @@ async def test_reauth_flow_error( ), ( {"result": []}, - {"result": {"username": "username"}}, + {"result": {"username": "firstnamelastname"}}, "no_devices", ), ( {"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - {"result": {"username": "username2"}}, + {"result": {"username": "firstnamelastname2"}}, "incorrect_api_key", ), ], @@ -312,13 +271,15 @@ async def test_flow_reauth_no_username_or_device( get_devices: dict[str, Any], get_me: dict[str, Any], p_error: str, + mock_client: MagicMock, + get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], ) -> None: - """Test config flow get no username from api.""" + """Test reauth flow with errors from api.""" entry = MockConfigEntry( version=2, domain=DOMAIN, - unique_id="username", - data={"api_key": "1234567890"}, + unique_id="firstnamelastname", + data={CONF_API_KEY: "1234567890"}, ) entry.add_to_hass(hass) @@ -327,36 +288,40 @@ async def test_flow_reauth_no_username_or_device( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value=get_devices, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value=get_me, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_KEY: "1234567890", - }, - ) - await hass.async_block_till_done() + mock_client.async_get_devices.return_value = get_devices + mock_client.async_get_me.return_value = get_me - assert result2["step_id"] == "reauth_confirm" - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": p_error} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + }, + ) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": p_error} + + mock_client.async_get_devices.return_value = get_data[2] + mock_client.async_get_me.return_value = get_data[1] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567890"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == {CONF_API_KEY: "1234567890"} -async def test_reconfigure_flow(hass: HomeAssistant) -> None: +async def test_reconfigure_flow(hass: HomeAssistant, mock_client: MagicMock) -> None: """Test a reconfigure flow.""" entry = MockConfigEntry( version=2, domain=DOMAIN, - unique_id="username", - data={"api_key": "1234567890"}, + unique_id="firstnamelastname", + data={CONF_API_KEY: "1234567890"}, ) entry.add_to_hass(hass) @@ -365,94 +330,61 @@ async def test_reconfigure_flow(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {"username": "username"}}, - ) as mock_sensibo, - patch( - "homeassistant.components.sensibo.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "1234567891"}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567890"}, + ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reconfigure_successful" - assert entry.data == {"api_key": "1234567891"} - - assert len(mock_sensibo.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == {CONF_API_KEY: "1234567890"} @pytest.mark.parametrize( ("sideeffect", "p_error"), [ - (aiohttp.ClientConnectionError, "cannot_connect"), - (TimeoutError, "cannot_connect"), (AuthenticationError, "invalid_auth"), (SensiboError, "cannot_connect"), ], ) async def test_reconfigure_flow_error( - hass: HomeAssistant, sideeffect: Exception, p_error: str + hass: HomeAssistant, + sideeffect: Exception, + p_error: str, + mock_client: MagicMock, ) -> None: """Test a reconfigure flow with error.""" entry = MockConfigEntry( version=2, domain=DOMAIN, - unique_id="username", - data={"api_key": "1234567890"}, + unique_id="firstnamelastname", + data={CONF_API_KEY: "1234567890"}, ) entry.add_to_hass(hass) result = await entry.start_reconfigure_flow(hass) - with patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - side_effect=sideeffect, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "1234567890"}, - ) - await hass.async_block_till_done() + mock_client.async_get_devices.side_effect = sideeffect - assert result2["step_id"] == "reconfigure" - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": p_error} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567890"}, + ) - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {"username": "username"}}, - ), - patch( - "homeassistant.components.sensibo.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "1234567891"}, - ) - await hass.async_block_till_done() + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": p_error} - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reconfigure_successful" - assert entry.data == {"api_key": "1234567891"} + mock_client.async_get_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567890"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == {CONF_API_KEY: "1234567890"} @pytest.mark.parametrize( @@ -465,12 +397,12 @@ async def test_reconfigure_flow_error( ), ( {"result": []}, - {"result": {"username": "username"}}, + {"result": {"username": "firstnamelastname"}}, "no_devices", ), ( {"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - {"result": {"username": "username2"}}, + {"result": {"username": "firstnamelastname2"}}, "incorrect_api_key", ), ], @@ -480,13 +412,15 @@ async def test_flow_reconfigure_no_username_or_device( get_devices: dict[str, Any], get_me: dict[str, Any], p_error: str, + mock_client: MagicMock, + get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], ) -> None: - """Test config flow get no username from api.""" + """Test reconfigure flow with errors from api.""" entry = MockConfigEntry( version=2, domain=DOMAIN, - unique_id="username", - data={"api_key": "1234567890"}, + unique_id="firstnamelastname", + data={CONF_API_KEY: "1234567890"}, ) entry.add_to_hass(hass) @@ -495,24 +429,28 @@ async def test_flow_reconfigure_no_username_or_device( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value=get_devices, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value=get_me, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_API_KEY: "1234567890", - }, - ) - await hass.async_block_till_done() + mock_client.async_get_devices.return_value = get_devices + mock_client.async_get_me.return_value = get_me - assert result2["step_id"] == "reconfigure" - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": p_error} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + }, + ) + + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": p_error} + + mock_client.async_get_devices.return_value = get_data[2] + mock_client.async_get_me.return_value = get_data[1] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567890"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == {CONF_API_KEY: "1234567890"} diff --git a/tests/components/sensibo/test_coordinator.py b/tests/components/sensibo/test_coordinator.py index d81b7fd613c..6cb8e6fe923 100644 --- a/tests/components/sensibo/test_coordinator.py +++ b/tests/components/sensibo/test_coordinator.py @@ -3,17 +3,17 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from pysensibo.exceptions import AuthenticationError, SensiboError from pysensibo.model import SensiboData -import pytest +from homeassistant.components.climate import HVACMode from homeassistant.components.sensibo.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG @@ -21,75 +21,69 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_coordinator( - hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData + hass: HomeAssistant, + mock_client: MagicMock, + get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo coordinator with errors.""" config_entry = MockConfigEntry( domain=DOMAIN, - source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="username", + unique_id="firstnamelastname", version=2, ) config_entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - ) as mock_data, - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {"username": "username"}}, - ), - ): - monkeypatch.setattr(get_data.parsed["ABC999111"], "hvac_mode", "heat") - monkeypatch.setattr(get_data.parsed["ABC999111"], "device_on", True) - mock_data.return_value = get_data - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - mock_data.assert_called_once() - state = hass.states.get("climate.hallway") - assert state.state == "heat" - mock_data.reset_mock() + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].hvac_mode = "heat" + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].device_on = True - mock_data.side_effect = SensiboError("info") - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) - await hass.async_block_till_done() - mock_data.assert_called_once() - state = hass.states.get("climate.hallway") - assert state.state == STATE_UNAVAILABLE - mock_data.reset_mock() + mock_data = mock_client.async_get_devices_data + mock_data.return_value = get_data[0] + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + mock_data.assert_called_once() + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.HEAT + mock_data.reset_mock() - mock_data.return_value = SensiboData(raw={}, parsed={}) - mock_data.side_effect = None - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=3)) - await hass.async_block_till_done() - mock_data.assert_called_once() - state = hass.states.get("climate.hallway") - assert state.state == STATE_UNAVAILABLE - mock_data.reset_mock() + mock_data.side_effect = SensiboError("info") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_data.assert_called_once() + state = hass.states.get("climate.hallway") + assert state.state == STATE_UNAVAILABLE + mock_data.reset_mock() - monkeypatch.setattr(get_data.parsed["ABC999111"], "hvac_mode", "heat") - monkeypatch.setattr(get_data.parsed["ABC999111"], "device_on", True) + mock_data.return_value = SensiboData(raw={}, parsed={}) + mock_data.side_effect = None + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_data.assert_called_once() + state = hass.states.get("climate.hallway") + assert state.state == STATE_UNAVAILABLE + mock_data.reset_mock() - mock_data.return_value = get_data - mock_data.side_effect = None - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() - mock_data.assert_called_once() - state = hass.states.get("climate.hallway") - assert state.state == "heat" - mock_data.reset_mock() + mock_data.return_value = get_data[0] + mock_data.side_effect = None + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_data.assert_called_once() + state = hass.states.get("climate.hallway") + assert state.state == HVACMode.HEAT + mock_data.reset_mock() - mock_data.side_effect = AuthenticationError("info") - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=7)) - await hass.async_block_till_done() - mock_data.assert_called_once() - state = hass.states.get("climate.hallway") - assert state.state == STATE_UNAVAILABLE + mock_data.side_effect = AuthenticationError("info") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_data.assert_called_once() + state = hass.states.get("climate.hallway") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/sensibo/test_entity.py b/tests/components/sensibo/test_entity.py index e17877b63b1..b8818149510 100644 --- a/tests/components/sensibo/test_entity.py +++ b/tests/components/sensibo/test_entity.py @@ -2,10 +2,10 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import MagicMock -from pysensibo.model import SensiboData import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_FAN_MODE, @@ -17,35 +17,24 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr -async def test_entity( +async def test_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, load_int: ConfigEntry, - get_data: SensiboData, + snapshot: SnapshotAssertion, ) -> None: - """Test the Sensibo climate.""" + """Test the Sensibo device.""" state1 = hass.states.get("climate.hallway") assert state1 - dr_entries = dr.async_entries_for_config_entry(device_registry, load_int.entry_id) - dr_entry: dr.DeviceEntry - for dr_entry in dr_entries: - if dr_entry.name == "Hallway": - assert dr_entry.identifiers == {("sensibo", "ABC999111")} - device_id = dr_entry.id - - er_entries = er.async_entries_for_device( - entity_registry, device_id, include_disabled_entities=True + assert ( + dr.async_entries_for_config_entry(device_registry, load_int.entry_id) + == snapshot ) - er_entry: er.RegistryEntry - for er_entry in er_entries: - if er_entry.name == "Hallway": - assert er_entry.unique_id == "Hallway" @pytest.mark.parametrize("p_error", SENSIBO_ERRORS) @@ -53,35 +42,30 @@ async def test_entity_failed_service_calls( hass: HomeAssistant, p_error: Exception, load_int: ConfigEntry, - get_data: SensiboData, + mock_client: MagicMock, ) -> None: """Test the Sensibo send command with error.""" state = hass.states.get("climate.hallway") assert state - with patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: state.entity_id, ATTR_FAN_MODE: "low"}, - blocking=True, - ) - await hass.async_block_till_done() + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_FAN_MODE: "low"}, + blocking=True, + ) state = hass.states.get("climate.hallway") assert state.attributes["fan_mode"] == "low" - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - side_effect=p_error, - ), - pytest.raises(HomeAssistantError), - ): + mock_client.async_set_ac_state_property.side_effect = p_error + + with pytest.raises(HomeAssistantError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, diff --git a/tests/components/sensibo/test_init.py b/tests/components/sensibo/test_init.py index 2938d4ede0e..b4911983fe7 100644 --- a/tests/components/sensibo/test_init.py +++ b/tests/components/sensibo/test_init.py @@ -1,10 +1,14 @@ -"""Test for Sensibo component Init.""" +"""Test for Sensibo integration setup.""" from __future__ import annotations -from unittest.mock import patch +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from pysensibo.model import SensiboData +import pytest from homeassistant.components.sensibo.const import DOMAIN from homeassistant.components.sensibo.util import NoUsernameError @@ -15,141 +19,72 @@ from homeassistant.setup import async_setup_component from . import ENTRY_CONFIG -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator -async def test_setup_entry(hass: HomeAssistant, get_data: SensiboData) -> None: - """Test setup entry.""" +async def test_load_unload_entry(hass: HomeAssistant, mock_client: MagicMock) -> None: + """Test setup and unload config entry.""" entry = MockConfigEntry( domain=DOMAIN, - source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="12", + unique_id="firstnamelastname", version=2, ) entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {"username": "username"}}, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() -async def test_migrate_entry(hass: HomeAssistant, get_data: SensiboData) -> None: + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_migrate_entry(hass: HomeAssistant, mock_client: MagicMock) -> None: """Test migrate entry unique id.""" entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="12", + unique_id="someother", version=1, ) entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {"username": "username"}}, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED assert entry.version == 2 - assert entry.unique_id == "username" + assert entry.unique_id == "firstnamelastname" -async def test_migrate_entry_fails(hass: HomeAssistant, get_data: SensiboData) -> None: - """Test migrate entry unique id.""" +async def test_migrate_entry_fails(hass: HomeAssistant, mock_client: MagicMock) -> None: + """Test migrate entry fails.""" entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="12", + unique_id="someother", version=1, ) entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - side_effect=NoUsernameError("No username returned"), - ), - ): - await hass.config_entries.async_setup(entry.entry_id) + mock_client.async_get_me.side_effect = NoUsernameError("No username returned") + + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.MIGRATION_ERROR assert entry.version == 1 - assert entry.unique_id == "12" - - -async def test_unload_entry(hass: HomeAssistant, get_data: SensiboData) -> None: - """Test unload an entry.""" - entry = MockConfigEntry( - domain=DOMAIN, - source=SOURCE_USER, - data=ENTRY_CONFIG, - entry_id="1", - unique_id="12", - version="2", - ) - entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices", - return_value={"result": [{"id": "xyzxyz"}, {"id": "abcabc"}]}, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_me", - return_value={"result": {"username": "username"}}, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED + assert entry.unique_id == "someother" async def test_device_remove_devices( @@ -174,3 +109,73 @@ async def test_device_remove_devices( ) response = await client.remove_device(dead_device_entry.id, load_int.entry_id) assert response["success"] + + +@pytest.mark.parametrize( + ("entity_id", "device_ids"), + [ + # Device is ABC999111 + ("climate.hallway", ["ABC999111"]), + ("binary_sensor.hallway_filter_clean_required", ["ABC999111"]), + ("number.hallway_temperature_calibration", ["ABC999111"]), + ("sensor.hallway_filter_last_reset", ["ABC999111"]), + ("update.hallway_firmware", ["ABC999111"]), + # Device is AABBCC belonging to device ABC999111 + ("binary_sensor.hallway_motion_sensor_motion", ["ABC999111", "AABBCC"]), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_automatic_device_addition_and_removal( + hass: HomeAssistant, + load_int: ConfigEntry, + mock_client: MagicMock, + get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, + entity_id: str, + device_ids: list[str], +) -> None: + """Test for automatic device addition and removal.""" + + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + for device_id in device_ids: + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Remove one of the devices + new_device_list = [ + device for device in get_data[2]["result"] if device["id"] != device_ids[0] + ] + mock_client.async_get_devices.return_value = { + "status": "success", + "result": new_device_list, + } + new_data = {k: v for k, v in get_data[0].parsed.items() if k != device_ids[0]} + new_raw = mock_client.async_get_devices.return_value["result"] + mock_client.async_get_devices_data.return_value = SensiboData(new_raw, new_data) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert not state + assert not entity_registry.async_get(entity_id) + for device_id in device_ids: + assert not device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Add the device back + mock_client.async_get_devices.return_value = get_data[2] + mock_client.async_get_devices_data.return_value = get_data[0] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + for device_id in device_ids: + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) diff --git a/tests/components/sensibo/test_number.py b/tests/components/sensibo/test_number.py index 95836ba023c..ff5347a02d3 100644 --- a/tests/components/sensibo/test_number.py +++ b/tests/components/sensibo/test_number.py @@ -3,9 +3,9 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock -from pysensibo.model import SensiboData +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -19,7 +19,6 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, snapshot_platform @@ -32,81 +31,57 @@ from tests.common import async_fire_time_changed, snapshot_platform async def test_number( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo number.""" await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) - monkeypatch.setattr(get_data.parsed["ABC999111"], "calibration_temp", 0.2) + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].calibration_temp = 0.2 - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state1 = hass.states.get("number.hallway_temperature_calibration") - assert state1.state == "0.2" + state = hass.states.get("number.hallway_temperature_calibration") + assert state.state == "0.2" @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number_set_value( - hass: HomeAssistant, - load_int: ConfigEntry, - get_data: SensiboData, + hass: HomeAssistant, load_int: ConfigEntry, mock_client: MagicMock ) -> None: """Test the Sensibo number service.""" - state1 = hass.states.get("number.hallway_temperature_calibration") - assert state1.state == "0.1" + state = hass.states.get("number.hallway_temperature_calibration") + assert state.state == "0.1" - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_calibration", - return_value={"status": "failure"}, - ), - ): - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_VALUE: "0.2"}, - blocking=True, - ) - await hass.async_block_till_done() + mock_client.async_set_calibration.return_value = {"status": "failure"} - state2 = hass.states.get("number.hallway_temperature_calibration") - assert state2.state == "0.1" - - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_calibration", - return_value={"status": "success"}, - ), - ): + with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_VALUE: "0.2"}, + {ATTR_ENTITY_ID: state.entity_id, ATTR_VALUE: "0.2"}, blocking=True, ) - await hass.async_block_till_done() - state2 = hass.states.get("number.hallway_temperature_calibration") - assert state2.state == "0.2" + state = hass.states.get("number.hallway_temperature_calibration") + assert state.state == "0.1" + + mock_client.async_set_calibration.return_value = {"status": "success"} + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_VALUE: "0.2"}, + blocking=True, + ) + + state = hass.states.get("number.hallway_temperature_calibration") + assert state.state == "0.2" diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index 2e4a1cb507c..75dbdc88840 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -3,9 +3,9 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch -from pysensibo.model import SensiboData +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -14,14 +14,16 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) +from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util +from homeassistant.helpers import entity_registry as er, issue_registry as ir -from tests.common import async_fire_time_changed, snapshot_platform +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.parametrize( @@ -31,155 +33,208 @@ from tests.common import async_fire_time_changed, snapshot_platform async def test_select( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo select.""" await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) - monkeypatch.setattr( - get_data.parsed["ABC999111"], "horizontal_swing_mode", "fixedleft" - ) + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].light_mode = "dim" - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state1 = hass.states.get("select.hallway_horizontal_swing") - assert state1.state == "fixedleft" + state = hass.states.get("select.kitchen_light") + assert state.state == "dim" async def test_select_set_option( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo select service.""" - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "active_features", - [ - "timestamp", - "on", - "mode", - "targetTemperature", - "light", - ], + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].active_features = [ + "timestamp", + "on", + "mode", + "targetTemperature", + "light", + ] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("select.kitchen_light") + assert state.state == "on" + + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "failed"} + } + + with pytest.raises( + HomeAssistantError, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "dim"}, + blocking=True, + ) + + state = hass.states.get("select.kitchen_light") + assert state.state == "on" + + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].active_features = [ + "timestamp", + "on", + "mode", + "light", + ] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Failed", "failureReason": "No connection"} + } + + with pytest.raises( + HomeAssistantError, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "dim"}, + blocking=True, + ) + + state = hass.states.get("select.kitchen_light") + assert state.state == "on" + + mock_client.async_set_ac_state_property.return_value = { + "result": {"status": "Success"} + } + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: "dim"}, + blocking=True, ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + state = hass.states.get("select.kitchen_light") + assert state.state == "dim" - state1 = hass.states.get("select.hallway_horizontal_swing") - assert state1.state == "stopped" + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].active_features = [ + "timestamp", + "on", + "mode", + ] - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "failed"}}, - ), - pytest.raises( - HomeAssistantError, - ), - ): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_OPTION: "fixedleft"}, - blocking=True, - ) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - state2 = hass.states.get("select.hallway_horizontal_swing") - assert state2.state == "stopped" + state = hass.states.get("select.kitchen_light") + assert state.state == STATE_UNAVAILABLE - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "active_features", - [ - "timestamp", - "on", - "mode", - "targetTemperature", - "horizontalSwing", - "light", - ], + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SELECT]], +) +async def test_deprecated_horizontal_swing_select( + hass: HomeAssistant, + load_platforms: list[Platform], + mock_client: MagicMock, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the deprecated horizontal swing select entity.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="firstnamelastname", + version=2, ) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) + config_entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + SELECT_DOMAIN, + DOMAIN, + "ABC999111-horizontalSwing", + config_entry=config_entry, + disabled_by=None, + has_entity_name=True, + suggested_object_id="hallway_horizontal_swing", + ) + + with patch("homeassistant.components.sensibo.PLATFORMS", load_platforms): + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={ - "result": {"status": "Failed", "failureReason": "No connection"} - }, - ), - pytest.raises( - HomeAssistantError, - ), - ): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_OPTION: "fixedleft"}, - blocking=True, - ) - await hass.async_block_till_done() + state = hass.states.get("select.hallway_horizontal_swing") + assert state.state == "stopped" - state2 = hass.states.get("select.hallway_horizontal_swing") - assert state2.state == "stopped" + # No issue created without automation or script + assert issue_registry.issues == {} with ( + patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - return_value={"result": {"status": "Success"}}, + # Patch check for automation, that one exist + "homeassistant.components.sensibo.select.automations_with_entity", + return_value=["automation.test"], ), ): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_OPTION: "fixedleft"}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done(True) - state2 = hass.states.get("select.hallway_horizontal_swing") - assert state2.state == "fixedleft" + # Issue is created when entity is enabled and automation/script exist + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") + assert issue + assert issue.translation_key == "deprecated_entity_horizontalswing" + assert hass.states.get("select.hallway_horizontal_swing") + assert entity_registry.async_is_registered("select.hallway_horizontal_swing") + + # Disabling the entity should remove the entity and remove the issue + # once the integration is reloaded + entity_registry.async_update_entity( + state.entity_id, disabled_by=er.RegistryEntryDisabler.USER + ) + + with ( + patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), + patch( + "homeassistant.components.sensibo.select.automations_with_entity", + return_value=["automation.test"], + ), + ): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done(True) + + # Disabling the entity and reloading has removed the entity and issue + assert not hass.states.get("select.hallway_horizontal_swing") + assert not entity_registry.async_is_registered("select.hallway_horizontal_swing") + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") + assert not issue diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 32794e266b0..8ea76036123 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -1,12 +1,12 @@ -"""The test for the sensibo select platform.""" +"""The test for the sensibo sensor platform.""" from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from pysensibo.model import PureAQI, SensiboData +from pysensibo.model import PureAQI import pytest from syrupy.assertion import SnapshotAssertion @@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, snapshot_platform @@ -27,8 +26,7 @@ from tests.common import async_fire_time_changed, snapshot_platform async def test_sensor( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, freezer: FrozenDateTimeFactory, @@ -37,17 +35,13 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) - monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pm25_pure", PureAQI(2)) + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].pm25_pure = PureAQI(2) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state1 = hass.states.get("sensor.kitchen_pure_aqi") - assert state1.state == "moderate" + state = hass.states.get("sensor.kitchen_pure_aqi") + assert state.state == "moderate" diff --git a/tests/components/sensibo/test_switch.py b/tests/components/sensibo/test_switch.py index f260af7baaa..43d87c31535 100644 --- a/tests/components/sensibo/test_switch.py +++ b/tests/components/sensibo/test_switch.py @@ -3,9 +3,9 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock -from pysensibo.model import SensiboData +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -22,7 +22,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, snapshot_platform @@ -45,221 +44,159 @@ async def test_switch( async def test_switch_timer( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: - """Test the Sensibo switch.""" + """Test the Sensibo switch timer.""" - state1 = hass.states.get("switch.hallway_timer") - assert state1.state == STATE_OFF - assert state1.attributes["id"] is None - assert state1.attributes["turn_on"] is None + state = hass.states.get("switch.hallway_timer") + assert state.state == STATE_OFF + assert state.attributes["id"] is None + assert state.attributes["turn_on"] is None - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", - return_value={"status": "success", "result": {"id": "SzTGE4oZ4D"}}, - ), - ): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: state1.entity_id, - }, - blocking=True, - ) + mock_client.async_set_timer.return_value = { + "status": "success", + "result": {"id": "SzTGE4oZ4D"}, + } + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].timer_on = True + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].timer_id = "SzTGE4oZ4D" + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].timer_state_on = False + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", True) - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", "SzTGE4oZ4D") - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", False) - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - state1 = hass.states.get("switch.hallway_timer") - assert state1.state == STATE_ON - assert state1.attributes["id"] == "SzTGE4oZ4D" - assert state1.attributes["turn_on"] is False + state = hass.states.get("switch.hallway_timer") + assert state.state == STATE_ON + assert state.attributes["id"] == "SzTGE4oZ4D" + assert state.attributes["turn_on"] is False - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_del_timer", - return_value={"status": "success", "result": {"id": "SzTGE4oZ4D"}}, - ), - ): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: state1.entity_id, - }, - blocking=True, - ) + mock_client.async_del_timer.return_value = { + "status": "success", + "result": {"id": "SzTGE4oZ4D"}, + } + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].timer_on = False + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", False) - - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - - state1 = hass.states.get("switch.hallway_timer") - assert state1.state == STATE_OFF + state = hass.states.get("switch.hallway_timer") + assert state.state == STATE_OFF async def test_switch_pure_boost( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: - """Test the Sensibo switch.""" + """Test the Sensibo switch pure boost.""" - state1 = hass.states.get("switch.kitchen_pure_boost") - assert state1.state == STATE_OFF + state = hass.states.get("switch.kitchen_pure_boost") + assert state.state == STATE_OFF - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", - return_value={"status": "success"}, - ), - ): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: state1.entity_id, - }, - blocking=True, - ) + mock_client.async_set_pureboost.return_value = {"status": "success"} + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].pure_boost_enabled = True + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].pure_measure_integration = None + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_boost_enabled", True) - monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_measure_integration", None) + state = hass.states.get("switch.kitchen_pure_boost") + assert state.state == STATE_ON - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - state1 = hass.states.get("switch.kitchen_pure_boost") - assert state1.state == STATE_ON + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", - return_value={"status": "success"}, - ), - ): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: state1.entity_id, - }, - blocking=True, - ) + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].pure_boost_enabled = False + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_boost_enabled", False) - - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - - state1 = hass.states.get("switch.kitchen_pure_boost") - assert state1.state == STATE_OFF + state = hass.states.get("switch.kitchen_pure_boost") + assert state.state == STATE_OFF async def test_switch_command_failure( - hass: HomeAssistant, - load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + hass: HomeAssistant, load_int: ConfigEntry, mock_client: MagicMock ) -> None: """Test the Sensibo switch fails commands.""" - state1 = hass.states.get("switch.hallway_timer") + state = hass.states.get("switch.hallway_timer") - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", - return_value={"status": "failure"}, - ), - pytest.raises( - HomeAssistantError, - ), + mock_client.async_set_timer.return_value = {"status": "failure"} + + with pytest.raises( + HomeAssistantError, ): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: state1.entity_id, + ATTR_ENTITY_ID: state.entity_id, }, blocking=True, ) - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_del_timer", - return_value={"status": "failure"}, - ), - pytest.raises( - HomeAssistantError, - ), + mock_client.async_del_timer.return_value = {"status": "failure"} + + with pytest.raises( + HomeAssistantError, ): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: state1.entity_id, + ATTR_ENTITY_ID: state.entity_id, }, blocking=True, ) @@ -268,114 +205,78 @@ async def test_switch_command_failure( async def test_switch_climate_react( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Sensibo switch for climate react.""" - state1 = hass.states.get("switch.hallway_climate_react") - assert state1.state == STATE_OFF + state = hass.states.get("switch.hallway_climate_react") + assert state.state == STATE_OFF - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_enable_climate_react", - return_value={"status": "success"}, - ), - ): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: state1.entity_id, - }, - blocking=True, - ) + mock_client.async_enable_climate_react.return_value = {"status": "success"} + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].smart_on = True + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_on", True) + state = hass.states.get("switch.hallway_climate_react") + assert state.state == STATE_ON - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - state1 = hass.states.get("switch.hallway_climate_react") - assert state1.state == STATE_ON + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) - with ( - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), - patch( - "homeassistant.components.sensibo.util.SensiboClient.async_enable_climate_react", - return_value={"status": "success"}, - ), - ): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: state1.entity_id, - }, - blocking=True, - ) + mock_client.async_get_devices_data.return_value.parsed["ABC999111"].smart_on = False + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() - monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_on", False) - - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - - state1 = hass.states.get("switch.hallway_climate_react") - assert state1.state == STATE_OFF + state = hass.states.get("switch.hallway_climate_react") + assert state.state == STATE_OFF async def test_switch_climate_react_no_data( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: - """Test the Sensibo switch for climate react.""" + """Test the Sensibo switch for climate react with no data.""" - monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_type", None) + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].smart_type = None - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state1 = hass.states.get("switch.hallway_climate_react") - assert state1.state == STATE_OFF + state = hass.states.get("switch.hallway_climate_react") + assert state.state == STATE_OFF with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: state1.entity_id, + ATTR_ENTITY_ID: state.entity_id, }, blocking=True, ) - await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_update.py b/tests/components/sensibo/test_update.py index a4eb9751243..08ee68fdb8e 100644 --- a/tests/components/sensibo/test_update.py +++ b/tests/components/sensibo/test_update.py @@ -3,10 +3,9 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from pysensibo.model import SensiboData import pytest from syrupy.assertion import SnapshotAssertion @@ -26,8 +25,7 @@ from tests.common import async_fire_time_changed, snapshot_platform async def test_update( hass: HomeAssistant, load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, + mock_client: MagicMock, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, freezer: FrozenDateTimeFactory, @@ -36,15 +34,13 @@ async def test_update( await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) - monkeypatch.setattr(get_data.parsed["ABC999111"], "fw_ver", "SKY30048") + mock_client.async_get_devices_data.return_value.parsed[ + "ABC999111" + ].fw_ver = "SKY30048" - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - freezer.tick(timedelta(minutes=5)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state1 = hass.states.get("update.hallway_firmware") - assert state1.state == STATE_OFF + state = hass.states.get("update.hallway_firmware") + assert state.state == STATE_OFF diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 3893a089b81..604cd91770e 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -5,8 +5,8 @@ from __future__ import annotations from collections.abc import Generator from datetime import UTC, date, datetime from decimal import Decimal -from types import ModuleType from typing import Any +from unittest.mock import patch import pytest @@ -59,8 +59,6 @@ from tests.common import ( MockModule, MockPlatform, async_mock_restore_state_shutdown_restart, - help_test_all, - import_and_test_deprecated_constant_enum, mock_config_flow, mock_integration, mock_platform, @@ -484,6 +482,108 @@ async def test_restore_sensor_restore_state( assert entity0.native_unit_of_measurement == uom +async def test_translated_unit( + hass: HomeAssistant, +) -> None: + """Test translated unit.""" + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={ + "component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests" + }, + ): + entity0 = MockSensor( + name="Test", + native_value="123", + unique_id="very_unique", + ) + entity0.entity_description = SensorEntityDescription( + "test", + translation_key="test_translation_key", + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": "test"}} + ) + await hass.async_block_till_done() + + entity_id = entity0.entity_id + state = hass.states.get(entity_id) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "Tests" + + +async def test_translated_unit_with_native_unit_raises( + hass: HomeAssistant, +) -> None: + """Test that translated unit.""" + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={ + "component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests" + }, + ): + entity0 = MockSensor( + name="Test", + native_value="123", + unique_id="very_unique", + ) + entity0.entity_description = SensorEntityDescription( + "test", + translation_key="test_translation_key", + native_unit_of_measurement="bad_unit", + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": "test"}} + ) + await hass.async_block_till_done() + # Setup fails so entity_id is None + assert entity0.entity_id is None + + +async def test_unit_translation_key_without_platform_raises( + hass: HomeAssistant, +) -> None: + """Test that unit translation key property raises if the entity has no platform yet.""" + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={ + "component.test.entity.sensor.test_translation_key.unit_of_measurement": "Tests" + }, + ): + entity0 = MockSensor( + name="Test", + native_value="123", + unique_id="very_unique", + ) + entity0.entity_description = SensorEntityDescription( + "test", + translation_key="test_translation_key", + ) + with pytest.raises( + ValueError, + match="cannot have a translation key for unit of measurement before " + "being added to the entity platform", + ): + unit = entity0.unit_of_measurement + + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": "test"}} + ) + await hass.async_block_till_done() + + # Should not raise after being added to the platform + unit = entity0.unit_of_measurement + assert unit == "Tests" + + @pytest.mark.parametrize( ( "device_class", @@ -2044,7 +2144,7 @@ async def test_non_numeric_validation_raise( (13, "13"), (17.50, "17.5"), ("1e-05", "1e-05"), - (Decimal(18.50), "18.5"), + (Decimal("18.50"), "18.50"), ("19.70", "19.70"), (None, STATE_UNKNOWN), ], @@ -2579,71 +2679,6 @@ async def test_entity_category_config_raises_error( assert not hass.states.get("sensor.test") -@pytest.mark.parametrize( - "module", - [sensor, sensor.const], -) -def test_all(module: ModuleType) -> None: - """Test module.__all__ is correctly set.""" - help_test_all(module) - - -@pytest.mark.parametrize(("enum"), list(sensor.SensorStateClass)) -@pytest.mark.parametrize(("module"), [sensor, sensor.const]) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: sensor.SensorStateClass, - module: ModuleType, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, module, enum, "STATE_CLASS_", "2025.1" - ) - - -@pytest.mark.parametrize( - ("enum"), - [ - sensor.SensorDeviceClass.AQI, - sensor.SensorDeviceClass.BATTERY, - sensor.SensorDeviceClass.CO, - sensor.SensorDeviceClass.CO2, - sensor.SensorDeviceClass.CURRENT, - sensor.SensorDeviceClass.DATE, - sensor.SensorDeviceClass.ENERGY, - sensor.SensorDeviceClass.FREQUENCY, - sensor.SensorDeviceClass.GAS, - sensor.SensorDeviceClass.HUMIDITY, - sensor.SensorDeviceClass.ILLUMINANCE, - sensor.SensorDeviceClass.MONETARY, - sensor.SensorDeviceClass.NITROGEN_DIOXIDE, - sensor.SensorDeviceClass.NITROGEN_MONOXIDE, - sensor.SensorDeviceClass.NITROUS_OXIDE, - sensor.SensorDeviceClass.OZONE, - sensor.SensorDeviceClass.PM1, - sensor.SensorDeviceClass.PM10, - sensor.SensorDeviceClass.PM25, - sensor.SensorDeviceClass.POWER_FACTOR, - sensor.SensorDeviceClass.POWER, - sensor.SensorDeviceClass.PRESSURE, - sensor.SensorDeviceClass.SIGNAL_STRENGTH, - sensor.SensorDeviceClass.SULPHUR_DIOXIDE, - sensor.SensorDeviceClass.TEMPERATURE, - sensor.SensorDeviceClass.TIMESTAMP, - sensor.SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, - sensor.SensorDeviceClass.VOLTAGE, - ], -) -def test_deprecated_constants_sensor_device_class( - caplog: pytest.LogCaptureFixture, - enum: sensor.SensorStateClass, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, sensor, enum, "DEVICE_CLASS_", "2025.1" - ) - - @pytest.mark.parametrize( ("device_class", "native_unit"), [ diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 44eaa9fde0d..fcf5a711c46 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -57,7 +57,7 @@ from tests.components.recorder.common import ( ) from tests.typing import ( MockHAClientWebSocket, - RecorderInstanceGenerator, + RecorderInstanceContextManager, WebSocketGenerator, ) @@ -102,7 +102,7 @@ KW_SENSOR_ATTRIBUTES = { @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder patches.""" @@ -121,15 +121,6 @@ def disable_mariadb_issue() -> None: yield -@pytest.fixture(autouse=True) -def disable_sqlite_issue() -> None: - """Disable creating issue about outdated SQLite version.""" - with patch( - "homeassistant.components.recorder.util._async_create_issue_deprecated_version" - ): - yield - - async def async_list_statistic_ids( hass: HomeAssistant, statistic_ids: set[str] | None = None, @@ -4247,8 +4238,14 @@ async def async_record_states( @pytest.mark.parametrize( ("units", "attributes", "unit", "unit2", "supported_unit"), [ - (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), + ( + US_CUSTOMARY_SYSTEM, + POWER_SENSOR_ATTRIBUTES, + "W", + "kW", + "GW, MW, TW, W, kW, mW", + ), + (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW, mW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, @@ -4459,8 +4456,14 @@ async def test_validate_statistics_unit_ignore_device_class( @pytest.mark.parametrize( ("units", "attributes", "unit", "unit2", "supported_unit"), [ - (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), + ( + US_CUSTOMARY_SYSTEM, + POWER_SENSOR_ATTRIBUTES, + "W", + "kW", + "GW, MW, TW, W, kW, mW", + ), + (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW, mW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, diff --git a/tests/components/sensor/test_recorder_missing_stats.py b/tests/components/sensor/test_recorder_missing_stats.py index 43e18b89e72..449ffd55727 100644 --- a/tests/components/sensor/test_recorder_missing_stats.py +++ b/tests/components/sensor/test_recorder_missing_stats.py @@ -24,7 +24,7 @@ from tests.components.recorder.common import ( async_wait_recording_done, do_adhoc_statistics, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager POWER_SENSOR_ATTRIBUTES = { "device_class": "energy", @@ -47,7 +47,7 @@ def disable_db_issue_creation(): @pytest.mark.parametrize("enable_missing_statistics", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_compile_missing_statistics( - async_test_recorder: RecorderInstanceGenerator, freezer: FrozenDateTimeFactory + async_test_recorder: RecorderInstanceContextManager, freezer: FrozenDateTimeFactory ) -> None: """Test compile missing statistics.""" three_days_ago = datetime(2021, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/seventeentrack/test_repairs.py b/tests/components/seventeentrack/test_repairs.py deleted file mode 100644 index 44d1f078432..00000000000 --- a/tests/components/seventeentrack/test_repairs.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Tests for the seventeentrack repair flow.""" - -from unittest.mock import AsyncMock - -from freezegun.api import FrozenDateTimeFactory - -from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN -from homeassistant.components.seventeentrack import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component - -from . import goto_future, init_integration -from .conftest import DEFAULT_SUMMARY_LENGTH, get_package - -from tests.common import MockConfigEntry -from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow -from tests.typing import ClientSessionGenerator - - -async def test_repair( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Ensure everything starts correctly.""" - await init_integration(hass, mock_config_entry) # 2 - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH - assert len(issue_registry.issues) == 1 - - package = get_package() - mock_seventeentrack.return_value.profile.packages.return_value = [package] - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - assert "deprecated" not in mock_config_entry.data - - repair_issue = issue_registry.async_get_issue( - domain=DOMAIN, issue_id=f"deprecate_sensor_{mock_config_entry.entry_id}" - ) - - assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) - - client = await hass_client() - - data = await start_repair_fix_flow(client, DOMAIN, repair_issue.issue_id) - - flow_id = data["flow_id"] - assert data == { - "type": "form", - "flow_id": flow_id, - "handler": DOMAIN, - "step_id": "confirm", - "data_schema": [], - "errors": None, - "description_placeholders": None, - "last_step": None, - "preview": None, - } - - data = await process_repair_fix_flow(client, flow_id) - - flow_id = data["flow_id"] - assert data == { - "type": "create_entry", - "handler": DOMAIN, - "flow_id": flow_id, - "description": None, - "description_placeholders": None, - } - - assert mock_config_entry.data["deprecated"] - - repair_issue = issue_registry.async_get_issue( - domain=DOMAIN, issue_id="deprecate_sensor" - ) - - assert repair_issue is None - - await goto_future(hass, freezer) - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH diff --git a/tests/components/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py index a631996b4eb..5367fabba9e 100644 --- a/tests/components/seventeentrack/test_sensor.py +++ b/tests/components/seventeentrack/test_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from pyseventeentrack.errors import SeventeenTrackError @@ -63,87 +63,6 @@ async def test_login_exception( assert not hass.states.async_entity_ids("sensor") -async def test_add_package( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure package is added correctly when user add a new package.""" - package = get_package() - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - assert hass.states.get("sensor.17track_package_friendly_name_1") - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - package2 = get_package( - tracking_number="789", - friendly_name="friendly name 2", - info_text="info text 2", - location="location 2", - timestamp="2020-08-10 14:25", - ) - mock_seventeentrack.return_value.profile.packages.return_value = [package, package2] - - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 2 - - -async def test_add_package_default_friendly_name( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure package is added correctly with default friendly name when user add a new package without his own friendly name.""" - package = get_package(friendly_name=None) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - state_456 = hass.states.get("sensor.17track_package_456") - assert state_456 is not None - assert state_456.attributes["friendly_name"] == "17Track Package 456" - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - -async def test_remove_package( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure entity is not there anymore if package is not there.""" - package1 = get_package() - package2 = get_package( - tracking_number="789", - friendly_name="friendly name 2", - info_text="info text 2", - location="location 2", - timestamp="2020-08-10 14:25", - ) - - mock_seventeentrack.return_value.profile.packages.return_value = [ - package1, - package2, - ] - - await init_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert hass.states.get("sensor.17track_package_friendly_name_2") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 2 - - mock_seventeentrack.return_value.profile.packages.return_value = [package2] - - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is None - assert hass.states.get("sensor.17track_package_friendly_name_2") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - async def test_package_error( hass: HomeAssistant, mock_seventeentrack: AsyncMock, @@ -159,72 +78,6 @@ async def test_package_error( assert hass.states.get("sensor.17track_package_friendly_name_1") is None -async def test_delivered_not_shown( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry_with_default_options: MockConfigEntry, -) -> None: - """Ensure delivered packages are not shown.""" - package = get_package(status=40) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - with patch( - "homeassistant.components.seventeentrack.sensor.persistent_notification" - ) as persistent_notification_mock: - await init_integration(hass, mock_config_entry_with_default_options) - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is None - persistent_notification_mock.create.assert_called() - - -async def test_delivered_shown( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure delivered packages are show when user choose to show them.""" - package = get_package(status=40) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - with patch( - "homeassistant.components.seventeentrack.sensor.persistent_notification" - ) as persistent_notification_mock: - await init_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - persistent_notification_mock.create.assert_not_called() - - -async def test_becomes_delivered_not_shown_notification( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry_with_default_options: MockConfigEntry, -) -> None: - """Ensure notification is triggered when package becomes delivered.""" - package = get_package() - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry_with_default_options) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - package_delivered = get_package(status=40) - mock_seventeentrack.return_value.profile.packages.return_value = [package_delivered] - - with patch( - "homeassistant.components.seventeentrack.sensor.persistent_notification" - ) as persistent_notification_mock: - await goto_future(hass, freezer) - - persistent_notification_mock.create.assert_called() - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH - - async def test_summary_correctly_updated( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -237,7 +90,7 @@ async def test_summary_correctly_updated( await init_integration(hass, mock_config_entry) - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 + assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH state_ready_picked = hass.states.get("sensor.17track_ready_to_be_picked_up") assert state_ready_picked is not None @@ -278,25 +131,6 @@ async def test_summary_error( ) -async def test_utc_timestamp( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure package timestamp is converted correctly from HA-defined time zone to UTC.""" - - package = get_package(tz="Asia/Jakarta") - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - state_456 = hass.states.get("sensor.17track_package_friendly_name_1") - assert state_456 is not None - assert str(state_456.attributes.get("timestamp")) == "2020-08-10 03:32:00+00:00" - - async def test_non_valid_platform_config( hass: HomeAssistant, mock_seventeentrack: AsyncMock ) -> None: diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index 3748cfd6dc4..bfb2176026b 100644 --- a/tests/components/sharkiq/test_vacuum.py +++ b/tests/components/sharkiq/test_vacuum.py @@ -35,10 +35,7 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.const import ( @@ -160,7 +157,7 @@ async def test_simple_properties( assert entity assert state - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING assert entity.unique_id == "AC000Wxxxxxxxxx" @@ -189,10 +186,10 @@ async def test_initial_attributes( @pytest.mark.parametrize( ("service", "target_state"), [ - (SERVICE_STOP, STATE_IDLE), - (SERVICE_PAUSE, STATE_PAUSED), - (SERVICE_RETURN_TO_BASE, STATE_RETURNING), - (SERVICE_START, STATE_CLEANING), + (SERVICE_STOP, VacuumActivity.IDLE), + (SERVICE_PAUSE, VacuumActivity.PAUSED), + (SERVICE_RETURN_TO_BASE, VacuumActivity.RETURNING), + (SERVICE_START, VacuumActivity.CLEANING), ], ) async def test_cleaning_states( diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 7de45eeee98..7a20560e25f 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -148,6 +148,13 @@ def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: return entity.state +def get_entity_attribute(hass: HomeAssistant, entity_id: str, attribute: str) -> str: + """Return entity attribute.""" + entity = hass.states.get(entity_id) + assert entity + return entity.attributes[attribute] + + def register_device( device_registry: DeviceRegistry, config_entry: ConfigEntry ) -> DeviceEntry: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index d453d25698c..85cd558e918 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -199,8 +199,69 @@ MOCK_CONFIG = { }, "wifi": {"sta": {"enable": True}, "sta1": {"enable": False}}, "ws": {"enable": False, "server": None}, + "voltmeter:100": {"xvoltage": {"unit": "ppm"}}, } + +MOCK_BLU_TRV_REMOTE_CONFIG = { + "components": [ + { + "key": "blutrv:200", + "status": { + "id": 200, + "target_C": 17.1, + "current_C": 17.1, + "pos": 0, + "rssi": -60, + "battery": 100, + "packet_id": 58, + "last_updated_ts": 1734967725, + "paired": True, + "rpc": True, + "rsv": 61, + }, + "config": { + "id": 200, + "addr": "f8:44:77:25:f0:dd", + "name": "TRV-Name", + "key": None, + "trv": "bthomedevice:200", + "temp_sensors": [], + "dw_sensors": [], + "override_delay": 30, + "meta": {}, + }, + }, + ], + "blutrv:200": { + "id": 0, + "enable": True, + "min_valve_position": 0, + "default_boost_duration": 1800, + "default_override_duration": 2147483647, + "default_override_target_C": 8, + "addr": "f8:44:77:25:f0:dd", + "name": "TRV-Name", + "local_name": "SBTR-001AEU", + }, +} + + +MOCK_BLU_TRV_REMOTE_STATUS = { + "blutrv:200": { + "id": 0, + "pos": 0, + "steps": 0, + "current_C": 15.2, + "target_C": 17.1, + "schedule_rev": 0, + "rssi": -60, + "battery": 100, + "errors": [], + }, +} + + MOCK_SHELLY_COAP = { "mac": MOCK_MAC, "auth": False, @@ -280,7 +341,7 @@ MOCK_STATUS_RPC = { }, "relay_in_thermostat": True, }, - "voltmeter": {"voltage": 4.321}, + "voltmeter:100": {"voltage": 4.321, "xvoltage": 12.34}, "wifi": {"rssi": -63}, } @@ -372,6 +433,24 @@ def _mock_rpc_device(version: str | None = None): return device +def _mock_blu_rtv_device(version: str | None = None): + """Mock rpc (Gen2, Websocket) device.""" + device = Mock( + spec=RpcDevice, + config=MOCK_CONFIG | MOCK_BLU_TRV_REMOTE_CONFIG, + event={}, + shelly=MOCK_SHELLY_RPC, + version=version or "1.0.0", + hostname="test-host", + status=MOCK_STATUS_RPC | MOCK_BLU_TRV_REMOTE_STATUS, + firmware_version="some fw string", + initialized=True, + connected=True, + ) + type(device).name = PropertyMock(return_value="Test name") + return device + + @pytest.fixture async def mock_rpc_device(): """Mock rpc (Gen2, Websocket) device with BLE support.""" @@ -419,3 +498,24 @@ async def mock_rpc_device(): @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" + + +@pytest.fixture(autouse=True) +async def mock_blu_trv(): + """Mock BLU TRV.""" + + with ( + patch("aioshelly.rpc_device.RpcDevice.create") as blu_trv_device_mock, + patch("homeassistant.components.shelly.bluetooth.async_start_scanner"), + ): + + def update(): + blu_trv_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.STATUS + ) + + device = _mock_blu_rtv_device() + blu_trv_device_mock.return_value = device + blu_trv_device_mock.return_value.mock_update = Mock(side_effect=update) + + yield blu_trv_device_mock.return_value diff --git a/tests/components/shelly/snapshots/test_binary_sensor.ambr b/tests/components/shelly/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..8dcb7b00a42 --- /dev/null +++ b/tests/components/shelly/snapshots/test_binary_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_blu_trv_binary_sensor_entity[binary_sensor.trv_name_calibration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.trv_name_calibration', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TRV-Name calibration', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-blutrv:200-calibration', + 'unit_of_measurement': None, + }) +# --- +# name: test_blu_trv_binary_sensor_entity[binary_sensor.trv_name_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'TRV-Name calibration', + }), + 'context': , + 'entity_id': 'binary_sensor.trv_name_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/shelly/snapshots/test_number.ambr b/tests/components/shelly/snapshots/test_number.ambr new file mode 100644 index 00000000000..965d44698c2 --- /dev/null +++ b/tests/components/shelly/snapshots/test_number.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_blu_trv_number_entity[number.trv_name_external_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 50, + 'min': -50, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.trv_name_external_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TRV-Name external temperature', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'external_temperature', + 'unique_id': '123456789ABC-blutrv:200-external_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_blu_trv_number_entity[number.trv_name_external_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name external temperature', + 'max': 50, + 'min': -50, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.trv_name_external_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.2', + }) +# --- +# name: test_blu_trv_number_entity[number.trv_name_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.trv_name_valve_position', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TRV-Name valve position', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': '123456789ABC-blutrv:200-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_blu_trv_number_entity[number.trv_name_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name valve position', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.trv_name_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/shelly/snapshots/test_sensor.ambr b/tests/components/shelly/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8ab767ca889 --- /dev/null +++ b/tests/components/shelly/snapshots/test_sensor.ambr @@ -0,0 +1,153 @@ +# serializer version: 1 +# name: test_blu_trv_sensor_entity[sensor.trv_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.trv_name_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TRV-Name battery', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-blutrv:200-blutrv_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'TRV-Name battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.trv_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.trv_name_signal_strength', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TRV-Name signal strength', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-blutrv:200-blutrv_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'TRV-Name signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.trv_name_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.trv_name_valve_position', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TRV-Name valve position', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': '123456789ABC-blutrv:200-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.trv_name_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index fadfe28db3e..bff6d199d0e 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -3,15 +3,15 @@ from copy import deepcopy from unittest.mock import Mock -from aioshelly.const import MODEL_MOTION +from aioshelly.const import MODEL_BLU_GATEWAY_GEN3, MODEL_MOTION from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.shelly.const import UPDATE_PERIOD_MULTIPLIER from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -387,7 +387,7 @@ async def test_rpc_restored_sleeping_binary_sensor_no_last_state( ) async def test_rpc_device_virtual_binary_sensor( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, name: str | None, @@ -422,7 +422,7 @@ async def test_rpc_device_virtual_binary_sensor( async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, @@ -456,7 +456,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( async def test_rpc_remove_virtual_binary_sensor_when_orphaned( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, ) -> None: @@ -477,3 +477,22 @@ async def test_rpc_remove_virtual_binary_sensor_when_orphaned( entry = entity_registry.async_get(entity_id) assert not entry + + +async def test_blu_trv_binary_sensor_entity( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test BLU TRV binary sensor entity.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + for entity in ("calibration",): + entity_id = f"{BINARY_SENSOR_DOMAIN}.trv_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index aeeeca30edd..352bdcb0a7d 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -3,7 +3,12 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock, PropertyMock -from aioshelly.const import MODEL_VALVE, MODEL_WALL_DISPLAY +from aioshelly.const import ( + BLU_TRV_IDENTIFIER, + MODEL_BLU_GATEWAY_GEN3, + MODEL_VALVE, + MODEL_WALL_DISPLAY, +) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest @@ -37,7 +42,13 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import MOCK_MAC, init_integration, register_device, register_entity +from . import ( + MOCK_MAC, + get_entity_attribute, + init_integration, + register_device, + register_entity, +) from .conftest import MOCK_STATUS_COAP from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data @@ -294,13 +305,13 @@ async def test_block_restored_climate( assert hass.states.get(entity_id).attributes.get("temperature") == 22.0 -async def test_block_restored_climate_us_customery( +async def test_block_restored_climate_us_customary( hass: HomeAssistant, mock_block_device: Mock, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test block restored climate with US CUSTOMATY unit system.""" + """Test block restored climate with US CUSTOMARY unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") @@ -759,3 +770,82 @@ async def test_wall_display_thermostat_mode_external_actuator( entry = entity_registry.async_get(climate_entity_id) assert entry assert entry.unique_id == "123456789ABC-thermostat:0" + + +async def test_blu_trv_climate_set_temperature( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV set target temperature.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + + monkeypatch.setitem( + mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "target_C", 28 + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 28}, + blocking=True, + ) + mock_blu_trv.mock_update() + + mock_blu_trv.call_rpc.assert_called_once_with( + "BluTRV.Call", + { + "id": 200, + "method": "Trv.SetTarget", + "params": {"id": 0, "target_C": 28.0}, + }, + ) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 28 + + +async def test_blu_trv_climate_disabled( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV disabled.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + + monkeypatch.setitem( + mock_blu_trv.config[f"{BLU_TRV_IDENTIFIER}:200"], "enable", False + ) + mock_blu_trv.mock_update() + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) is None + + +async def test_blu_trv_climate_hvac_action( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV is heating.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.IDLE + + monkeypatch.setitem(mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "pos", 10) + mock_blu_trv.mock_update() + + assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.HEATING diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index d9945706182..b5f87a874c3 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -15,7 +15,6 @@ from aioshelly.exceptions import ( import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.shelly import MacAddressMismatchError, config_flow from homeassistant.components.shelly.const import ( CONF_BLE_SCANNER_MODE, @@ -25,6 +24,10 @@ from homeassistant.components.shelly.const import ( from homeassistant.components.shelly.coordinator import ENTRY_RELOAD_COOLDOWN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -33,22 +36,22 @@ from . import init_integration from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-12345", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-12345"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-12345"}, type="mock_type", ) -DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WITH_MAC = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-AABBCCDDEEFF", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-AABBCCDDEEFF"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-AABBCCDDEEFF"}, type="mock_type", ) @@ -1459,13 +1462,13 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="shelly1pm-12345", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-12345"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-12345"}, type="mock_type", ), ) diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index b5516485501..270e2163635 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -545,3 +545,22 @@ async def test_sleeping_block_device_wrong_sleep_period( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.data[CONF_SLEEP_PERIOD] == BLOCK_EXPECTED_SLEEP_PERIOD + + +async def test_bluetooth_cleanup_on_remove_entry( + hass: HomeAssistant, + mock_rpc_device: Mock, +) -> None: + """Test bluetooth is cleaned up on entry removal.""" + entry = await init_integration(hass, 2) + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + with patch("homeassistant.components.shelly.async_remove_scanner") as remove_mock: + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + remove_mock.assert_called_once_with(hass, entry.unique_id.upper()) diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 6c1cc394b64..2a64ab839ea 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -3,8 +3,10 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock +from aioshelly.const import MODEL_BLU_GATEWAY_GEN3 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.number import ( ATTR_MAX, @@ -390,3 +392,26 @@ async def test_rpc_remove_virtual_number_when_orphaned( entry = entity_registry.async_get(entity_id) assert not entry + + +async def test_blu_trv_number_entity( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test BLU TRV number entity.""" + # disable automatic temperature control in the device + monkeypatch.setitem(mock_blu_trv.config["blutrv:200"], "enable", False) + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + for entity in ("external_temperature", "valve_position"): + entity_id = f"{NUMBER_DOMAIN}.trv_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 18c3d874c55..0bbb374012f 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -3,8 +3,10 @@ from copy import deepcopy from unittest.mock import Mock +from aioshelly.const import MODEL_BLU_GATEWAY_GEN3 from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, @@ -428,14 +430,16 @@ async def test_rpc_sensor_error( assert hass.states.get(entity_id).state == "4.321" - mutate_rpc_device_status(monkeypatch, mock_rpc_device, "voltmeter", "voltage", None) + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "voltmeter:100", "voltage", None + ) mock_rpc_device.mock_update() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "123456789ABC-voltmeter-voltmeter" + assert entry.unique_id == "123456789ABC-voltmeter:100-voltmeter" async def test_rpc_polling_sensor( @@ -1383,3 +1387,42 @@ async def test_rpc_device_sensor_goes_unavailable_on_disconnect( await hass.async_block_till_done() temp_sensor_state = hass.states.get("sensor.test_name_temperature") assert temp_sensor_state.state != STATE_UNAVAILABLE + + +async def test_rpc_voltmeter_value( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, +) -> None: + """Test RPC voltmeter value sensor.""" + entity_id = f"{SENSOR_DOMAIN}.test_name_voltmeter_value" + + await init_integration(hass, 2) + + state = hass.states.get(entity_id) + + assert state.state == "12.34" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "ppm" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-voltmeter:100-voltmeter_value" + + +async def test_blu_trv_sensor_entity( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test BLU TRV sensor entity.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + for entity in ("battery", "signal_strength", "valve_position"): + entity_id = f"{SENSOR_DOMAIN}.trv_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 5c7933afd7e..5aae9dfffc9 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -25,7 +25,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -444,7 +443,7 @@ async def test_wall_display_relay_mode( ) async def test_rpc_device_virtual_switch( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, name: str | None, @@ -517,7 +516,7 @@ async def test_rpc_device_virtual_binary_sensor( async def test_rpc_remove_virtual_switch_when_mode_label( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, @@ -551,7 +550,7 @@ async def test_rpc_remove_virtual_switch_when_mode_label( async def test_rpc_remove_virtual_switch_when_orphaned( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, ) -> None: @@ -577,7 +576,7 @@ async def test_rpc_remove_virtual_switch_when_orphaned( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_rpc_device_script_switch( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index cd4cdf877a5..9ea66c1acb7 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components.shelly.const import ( DOMAIN, GEN1_RELEASE_URL, + GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, ) from homeassistant.components.update import ( @@ -572,7 +573,6 @@ async def test_rpc_beta_update( assert state.attributes[ATTR_LATEST_VERSION] == "1" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - assert state.attributes[ATTR_RELEASE_URL] is None monkeypatch.setitem( mock_rpc_device.status["sys"], @@ -589,7 +589,7 @@ async def test_rpc_beta_update( assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" assert state.attributes[ATTR_IN_PROGRESS] is False - assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None + assert state.attributes[ATTR_RELEASE_URL] == GEN2_BETA_RELEASE_URL await hass.services.async_call( UPDATE_DOMAIN, diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 17bcd6e3d40..b7c3dff10f6 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -17,7 +17,11 @@ from aioshelly.const import ( ) import pytest -from homeassistant.components.shelly.const import GEN1_RELEASE_URL, GEN2_RELEASE_URL +from homeassistant.components.shelly.const import ( + GEN1_RELEASE_URL, + GEN2_BETA_RELEASE_URL, + GEN2_RELEASE_URL, +) from homeassistant.components.shelly.utils import ( get_block_channel_name, get_block_device_sleep_period, @@ -300,7 +304,7 @@ async def test_get_rpc_input_triggers( (1, MODEL_1, True, None), (2, MODEL_WALL_DISPLAY, False, None), (2, MODEL_PLUS_2PM_V2, False, GEN2_RELEASE_URL), - (2, MODEL_PLUS_2PM_V2, True, None), + (2, MODEL_PLUS_2PM_V2, True, GEN2_BETA_RELEASE_URL), ], ) def test_get_release_url( diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py index b35ce98b664..9dc8597120a 100644 --- a/tests/components/shelly/test_valve.py +++ b/tests/components/shelly/test_valve.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry from . import init_integration @@ -17,7 +17,7 @@ GAS_VALVE_BLOCK_ID = 6 async def test_block_device_gas_valve( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/components/simulated/__init__.py b/tests/components/simulated/__init__.py deleted file mode 100644 index 501fbab603a..00000000000 --- a/tests/components/simulated/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the simulated component.""" diff --git a/tests/components/simulated/test_sensor.py b/tests/components/simulated/test_sensor.py deleted file mode 100644 index b167147367a..00000000000 --- a/tests/components/simulated/test_sensor.py +++ /dev/null @@ -1,50 +0,0 @@ -"""The tests for the simulated sensor.""" - -from homeassistant.components.simulated.sensor import ( - CONF_AMP, - CONF_FWHM, - CONF_MEAN, - CONF_PERIOD, - CONF_PHASE, - CONF_RELATIVE_TO_EPOCH, - CONF_SEED, - CONF_UNIT, - DEFAULT_AMP, - DEFAULT_FWHM, - DEFAULT_MEAN, - DEFAULT_NAME, - DEFAULT_PHASE, - DEFAULT_RELATIVE_TO_EPOCH, - DEFAULT_SEED, - DOMAIN, -) -from homeassistant.const import CONF_FRIENDLY_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component - - -async def test_simulated_sensor_default_config( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test default config.""" - config = {"sensor": {"platform": "simulated"}} - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids()) == 1 - state = hass.states.get("sensor.simulated") - - assert state.attributes.get(CONF_FRIENDLY_NAME) == DEFAULT_NAME - assert state.attributes.get(CONF_AMP) == DEFAULT_AMP - assert state.attributes.get(CONF_UNIT) is None - assert state.attributes.get(CONF_MEAN) == DEFAULT_MEAN - assert state.attributes.get(CONF_PERIOD) == 60.0 - assert state.attributes.get(CONF_PHASE) == DEFAULT_PHASE - assert state.attributes.get(CONF_FWHM) == DEFAULT_FWHM - assert state.attributes.get(CONF_SEED) == DEFAULT_SEED - assert state.attributes.get(CONF_RELATIVE_TO_EPOCH) == DEFAULT_RELATIVE_TO_EPOCH - - issue = issue_registry.async_get_issue(DOMAIN, DOMAIN) - assert issue.issue_id == DOMAIN - assert issue.translation_key == "simulated_deprecation" diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py index 475b32540b4..b78d25366fa 100644 --- a/tests/components/siren/test_init.py +++ b/tests/components/siren/test_init.py @@ -1,11 +1,9 @@ """The tests for the siren component.""" -from types import ModuleType from unittest.mock import MagicMock import pytest -from homeassistant.components import siren from homeassistant.components.siren import ( SirenEntity, SirenEntityDescription, @@ -14,8 +12,6 @@ from homeassistant.components.siren import ( from homeassistant.components.siren.const import SirenEntityFeature from homeassistant.core import HomeAssistant -from tests.common import help_test_all, import_and_test_deprecated_constant_enum - class MockSirenEntity(SirenEntity): """Mock siren device to use in tests.""" @@ -109,40 +105,3 @@ async def test_missing_tones_dict(hass: HomeAssistant) -> None: siren.hass = hass with pytest.raises(ValueError): process_turn_on_params(siren, {"tone": 3}) - - -@pytest.mark.parametrize( - "module", - [siren, siren.const], -) -def test_all(module: ModuleType) -> None: - """Test module.__all__ is correctly set.""" - help_test_all(module) - - -@pytest.mark.parametrize(("enum"), list(SirenEntityFeature)) -@pytest.mark.parametrize(("module"), [siren, siren.const]) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: SirenEntityFeature, - module: ModuleType, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum(caplog, module, enum, "SUPPORT_", "2025.1") - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockSirenEntity(siren.SirenEntity): - _attr_supported_features = 1 - - entity = MockSirenEntity() - assert entity.supported_features is siren.SirenEntityFeature(1) - assert "MockSirenEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "SirenEntityFeature.TURN_ON" in caplog.text - caplog.clear() - assert entity.supported_features is siren.SirenEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/slack/__init__.py b/tests/components/slack/__init__.py index acb52a11a6c..507e96294ff 100644 --- a/tests/components/slack/__init__.py +++ b/tests/components/slack/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -AUTH_URL = "https://www.slack.com/api/auth.test" +AUTH_URL = "https://slack.com/api/auth.test" TOKEN = "abc123" TEAM_NAME = "Test Team" diff --git a/tests/components/slack/test_config_flow.py b/tests/components/slack/test_config_flow.py index 565b5ec2149..6d0953da5e9 100644 --- a/tests/components/slack/test_config_flow.py +++ b/tests/components/slack/test_config_flow.py @@ -81,7 +81,7 @@ async def test_flow_user_cannot_connect( async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: """Test user initialized flow with unreachable server.""" with patch( - "homeassistant.components.slack.config_flow.WebClient.auth_test" + "homeassistant.components.slack.config_flow.AsyncWebClient.auth_test" ) as mock: mock.side_effect = Exception result = await hass.config_entries.flow.async_init( diff --git a/tests/components/slide_local/__init__.py b/tests/components/slide_local/__init__.py new file mode 100644 index 00000000000..cd7bd6cb6d1 --- /dev/null +++ b/tests/components/slide_local/__init__.py @@ -0,0 +1,21 @@ +"""Tests for the slide_local integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_platform( + hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] +) -> MockConfigEntry: + """Set up the slide local integration.""" + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.slide_local.PLATFORMS", platforms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/slide_local/conftest.py b/tests/components/slide_local/conftest.py new file mode 100644 index 00000000000..ad2734bbb64 --- /dev/null +++ b/tests/components/slide_local/conftest.py @@ -0,0 +1,61 @@ +"""Test fixtures for Slide local.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.slide_local.const import CONF_INVERT_POSITION, DOMAIN +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC + +from .const import HOST, SLIDE_INFO_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="slide", + data={ + CONF_HOST: HOST, + CONF_API_VERSION: 2, + CONF_MAC: "12:34:56:78:90:ab", + }, + options={ + CONF_INVERT_POSITION: False, + }, + minor_version=1, + unique_id="12:34:56:78:90:ab", + entry_id="ce5f5431554d101905d31797e1232da8", + ) + + +@pytest.fixture +def mock_slide_api() -> Generator[AsyncMock]: + """Build a fixture for the SlideLocalApi that connects successfully and returns one device.""" + + with ( + patch( + "homeassistant.components.slide_local.coordinator.SlideLocalApi", + autospec=True, + ) as mock_slide_local_api, + patch( + "homeassistant.components.slide_local.config_flow.SlideLocalApi", + new=mock_slide_local_api, + ), + ): + client = mock_slide_local_api.return_value + client.slide_info.return_value = SLIDE_INFO_DATA + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.slide_local.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/slide_local/const.py b/tests/components/slide_local/const.py new file mode 100644 index 00000000000..edf45753407 --- /dev/null +++ b/tests/components/slide_local/const.py @@ -0,0 +1,8 @@ +"""Common const used across tests for slide_local.""" + +from homeassistant.components.slide_local.const import DOMAIN + +from tests.common import load_json_object_fixture + +HOST = "127.0.0.2" +SLIDE_INFO_DATA = load_json_object_fixture("slide_1.json", DOMAIN) diff --git a/tests/components/slide_local/fixtures/slide_1.json b/tests/components/slide_local/fixtures/slide_1.json new file mode 100644 index 00000000000..6367b94f243 --- /dev/null +++ b/tests/components/slide_local/fixtures/slide_1.json @@ -0,0 +1,11 @@ +{ + "slide_id": "slide_1234567890ab", + "mac": "1234567890ab", + "board_rev": 1, + "device_name": "slide bedroom", + "zone_name": "bedroom", + "curtain_type": 0, + "calib_time": 10239, + "pos": 0.0, + "touch_go": true +} diff --git a/tests/components/slide_local/snapshots/test_button.ambr b/tests/components/slide_local/snapshots/test_button.ambr new file mode 100644 index 00000000000..549538f1361 --- /dev/null +++ b/tests/components/slide_local/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_all_entities[button.slide_bedroom_calibrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.slide_bedroom_calibrate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Calibrate', + 'platform': 'slide_local', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibrate', + 'unique_id': '1234567890ab-calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.slide_bedroom_calibrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'slide bedroom Calibrate', + }), + 'context': , + 'entity_id': 'button.slide_bedroom_calibrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/slide_local/snapshots/test_cover.ambr b/tests/components/slide_local/snapshots/test_cover.ambr new file mode 100644 index 00000000000..d9283618a47 --- /dev/null +++ b/tests/components/slide_local/snapshots/test_cover.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_all_entities[cover.slide_bedroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.slide_bedroom', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'slide_local', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1234567890ab', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[cover.slide_bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'current_position': 100, + 'device_class': 'curtain', + 'friendly_name': 'slide bedroom', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.slide_bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/slide_local/snapshots/test_diagnostics.ambr b/tests/components/slide_local/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..63dab3f5a66 --- /dev/null +++ b/tests/components/slide_local/snapshots/test_diagnostics.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'api_version': 2, + 'host': '127.0.0.2', + 'mac': '12:34:56:78:90:ab', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'slide_local', + 'entry_id': 'ce5f5431554d101905d31797e1232da8', + 'minor_version': 1, + 'options': dict({ + 'invert_position': False, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'slide', + 'unique_id': '12:34:56:78:90:ab', + 'version': 1, + }), + 'slide_data': dict({ + 'board_rev': 1, + 'calib_time': 10239, + 'curtain_type': 0, + 'device_name': 'slide bedroom', + 'mac': '1234567890ab', + 'pos': 0, + 'slide_id': 'slide_1234567890ab', + 'state': 'open', + 'touch_go': True, + 'zone_name': 'bedroom', + }), + }) +# --- diff --git a/tests/components/slide_local/snapshots/test_init.ambr b/tests/components/slide_local/snapshots/test_init.ambr new file mode 100644 index 00000000000..d90f72e4b05 --- /dev/null +++ b/tests/components/slide_local/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.2', + 'connections': set({ + tuple( + 'mac', + '12:34:56:78:90:ab', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 1, + 'id': , + 'identifiers': set({ + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Innovation in Motion', + 'model': None, + 'model_id': None, + 'name': 'slide bedroom', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890ab', + 'suggested_area': None, + 'sw_version': 2, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/slide_local/snapshots/test_switch.ambr b/tests/components/slide_local/snapshots/test_switch.ambr new file mode 100644 index 00000000000..e19467c283e --- /dev/null +++ b/tests/components/slide_local/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_all_entities[switch.slide_bedroom_touchgo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.slide_bedroom_touchgo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TouchGo', + 'platform': 'slide_local', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'touchgo', + 'unique_id': '1234567890ab-touchgo', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.slide_bedroom_touchgo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'slide bedroom TouchGo', + }), + 'context': , + 'entity_id': 'switch.slide_bedroom_touchgo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/slide_local/test_button.py b/tests/components/slide_local/test_button.py new file mode 100644 index 00000000000..c232affbb99 --- /dev/null +++ b/tests/components/slide_local/test_button.py @@ -0,0 +1,88 @@ +"""Tests for the Slide Local button platform.""" + +from unittest.mock import AsyncMock + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, +) +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_platform(hass, mock_config_entry, [Platform.BUTTON]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_pressing_button( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing button.""" + await setup_platform(hass, mock_config_entry, [Platform.BUTTON]) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.slide_bedroom_calibrate", + }, + blocking=True, + ) + mock_slide_api.slide_calibrate.assert_called_once() + + +@pytest.mark.parametrize( + ("exception"), + [ + ClientConnectionError, + ClientTimeoutError, + AuthenticationFailed, + DigestAuthCalcError, + ], +) +async def test_pressing_button_exception( + hass: HomeAssistant, + exception: Exception, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing button.""" + await setup_platform(hass, mock_config_entry, [Platform.BUTTON]) + + mock_slide_api.slide_calibrate.side_effect = exception + + with pytest.raises( + HomeAssistantError, + match="Error while sending the calibration request to the device", + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.slide_bedroom_calibrate", + }, + blocking=True, + ) diff --git a/tests/components/slide_local/test_config_flow.py b/tests/components/slide_local/test_config_flow.py new file mode 100644 index 00000000000..b8b69d99fd8 --- /dev/null +++ b/tests/components/slide_local/test_config_flow.py @@ -0,0 +1,428 @@ +"""Test the slide_local config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, +) +import pytest + +from homeassistant.components.slide_local.const import CONF_INVERT_POSITION, DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from . import setup_platform +from .const import HOST, SLIDE_INFO_DATA + +from tests.common import MockConfigEntry + +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], + hostname="Slide-1234567890AB.local.", + name="Slide-1234567890AB._http._tcp.local.", + port=80, + properties={ + "id": "slide-1234567890AB", + "arch": "esp32", + "app": "slide", + "fw_version": "2.0.0-1683059251", + "fw_id": "20230502-202745", + }, + type="mock_type", +) + + +async def test_user( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"][CONF_HOST] == HOST + assert result2["data"][CONF_PASSWORD] == "pwd" + assert result2["data"][CONF_API_VERSION] == 2 + assert result2["result"].unique_id == "12:34:56:78:90:ab" + assert not result2["options"][CONF_INVERT_POSITION] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_api_1( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"][CONF_HOST] == HOST + assert result2["data"][CONF_PASSWORD] == "pwd" + assert result2["data"][CONF_API_VERSION] == 1 + assert result2["result"].unique_id == "12:34:56:78:90:ab" + assert not result2["options"][CONF_INVERT_POSITION] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_api_error( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = [None, None] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "unknown" + + mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"][CONF_HOST] == HOST + assert result2["data"][CONF_PASSWORD] == "pwd" + assert result2["data"][CONF_API_VERSION] == 1 + assert result2["result"].unique_id == "12:34:56:78:90:ab" + assert not result2["options"][CONF_INVERT_POSITION] + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ClientConnectionError, "cannot_connect"), + (ClientTimeoutError, "cannot_connect"), + (AuthenticationFailed, "invalid_auth"), + (DigestAuthCalcError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_api_1_exceptions( + hass: HomeAssistant, + exception: Exception, + error: str, + mock_slide_api: AsyncMock, +) -> None: + """Test we can handle Form exceptions for api 1.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = [None, exception] + + # tests with connection error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == error + + # tests with all provided + mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ClientConnectionError, "cannot_connect"), + (ClientTimeoutError, "cannot_connect"), + (AuthenticationFailed, "invalid_auth"), + (DigestAuthCalcError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_api_2_exceptions( + hass: HomeAssistant, + exception: Exception, + error: str, + mock_slide_api: AsyncMock, +) -> None: + """Test we can handle Form exceptions for api 2.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_slide_api.slide_info.side_effect = exception + + # tests with connection error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == error + + # tests with all provided + mock_slide_api.slide_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_abort_if_already_setup( + hass: HomeAssistant, + mock_slide_api: AsyncMock, +) -> None: + """Test we abort if the device is already setup.""" + + MockConfigEntry(domain=DOMAIN, unique_id="12:34:56:78:90:ab").add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PASSWORD: "pwd", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reconfigure( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_config_entry: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfigure flow options.""" + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "127.0.0.3", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data[CONF_HOST] == "127.0.0.3" + + +async def test_zeroconf( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test starting a flow from discovery.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "127.0.0.2" + assert result["data"][CONF_HOST] == "127.0.0.2" + assert not result["options"][CONF_INVERT_POSITION] + assert result["result"].unique_id == "12:34:56:78:90:ab" + + +async def test_zeroconf_duplicate_entry( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test starting a flow from discovery.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: HOST}, unique_id="12:34:56:78:90:ab" + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].data[CONF_HOST] == HOST + + +async def test_zeroconf_update_duplicate_entry( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test updating an existing entry from discovery.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.3"}, unique_id="12:34:56:78:90:ab" + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].data[CONF_HOST] == HOST + + +@pytest.mark.parametrize( + ("exception"), + [ + (ClientConnectionError), + (ClientTimeoutError), + (AuthenticationFailed), + (DigestAuthCalcError), + (Exception), + ], +) +async def test_zeroconf_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_slide_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test starting a flow from discovery.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "slide_host"}, unique_id="12:34:56:78:90:cd" + ).add_to_hass(hass) + + mock_slide_api.slide_info.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=MOCK_ZEROCONF_DATA + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_connection_failed" + + +async def test_options_flow( + hass: HomeAssistant, mock_slide_api: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow works correctly.""" + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_INVERT_POSITION: True, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config_entry.options == { + CONF_INVERT_POSITION: True, + } diff --git a/tests/components/slide_local/test_cover.py b/tests/components/slide_local/test_cover.py new file mode 100644 index 00000000000..e0e4a0741d8 --- /dev/null +++ b/tests/components/slide_local/test_cover.py @@ -0,0 +1,215 @@ +"""Tests for the Slide Local cover platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from goslideapi.goslideapi import ClientConnectionError +from syrupy import SnapshotAssertion + +from homeassistant.components.cover import ( + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + CoverState, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform +from .const import SLIDE_INFO_DATA + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_connection_error( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + + mock_slide_api.slide_info.side_effect = [ClientConnectionError, SLIDE_INFO_DATA] + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("cover.slide_bedroom").state == STATE_UNAVAILABLE + + freezer.tick(delta=timedelta(minutes=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("cover.slide_bedroom").state == CoverState.OPEN + + +async def test_state_change( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + + mock_slide_api.slide_info.side_effect = [ + dict(SLIDE_INFO_DATA, pos=0.0), + dict(SLIDE_INFO_DATA, pos=0.4), + dict(SLIDE_INFO_DATA, pos=1.0), + dict(SLIDE_INFO_DATA, pos=0.8), + ] + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("cover.slide_bedroom").state == CoverState.OPEN + + freezer.tick(delta=timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("cover.slide_bedroom").state == CoverState.CLOSING + + freezer.tick(delta=timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("cover.slide_bedroom").state == CoverState.CLOSED + + freezer.tick(delta=timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("cover.slide_bedroom").state == CoverState.OPENING + + +async def test_open_cover( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test open cover.""" + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.slide_bedroom", + }, + blocking=True, + ) + mock_slide_api.slide_open.assert_called_once() + + +async def test_close_cover( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test close cover.""" + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.slide_bedroom", + }, + blocking=True, + ) + mock_slide_api.slide_close.assert_called_once() + + +async def test_stop_cover( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test stop cover.""" + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + { + ATTR_ENTITY_ID: "cover.slide_bedroom", + }, + blocking=True, + ) + mock_slide_api.slide_stop.assert_called_once() + + +async def test_set_position( + hass: HomeAssistant, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test set cover position.""" + + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + + mock_slide_api.slide_info.side_effect = [ + dict(SLIDE_INFO_DATA, pos=0.0), + dict(SLIDE_INFO_DATA, pos=1.0), + dict(SLIDE_INFO_DATA, pos=1.0), + dict(SLIDE_INFO_DATA, pos=0.0), + ] + + freezer.tick(delta=timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.slide_bedroom", ATTR_POSITION: 1.0}, + blocking=True, + ) + + freezer.tick(delta=timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("cover.slide_bedroom").state == CoverState.CLOSED + + freezer.tick(delta=timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.slide_bedroom", ATTR_POSITION: 0.0}, + blocking=True, + ) + + freezer.tick(delta=timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("cover.slide_bedroom").state == CoverState.OPEN + + assert len(mock_slide_api.slide_set_position.mock_calls) == 2 diff --git a/tests/components/slide_local/test_diagnostics.py b/tests/components/slide_local/test_diagnostics.py new file mode 100644 index 00000000000..3e11af378c5 --- /dev/null +++ b/tests/components/slide_local/test_diagnostics.py @@ -0,0 +1,34 @@ +"""Test slide_local diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_platform + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_slide_api: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await setup_platform( + hass, mock_config_entry, [Platform.BUTTON, Platform.COVER, Platform.SWITCH] + ) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/slide_local/test_init.py b/tests/components/slide_local/test_init.py new file mode 100644 index 00000000000..ec9a12f9eeb --- /dev/null +++ b/tests/components/slide_local/test_init.py @@ -0,0 +1,65 @@ +"""Tests for the Slide Local integration.""" + +from unittest.mock import AsyncMock + +from goslideapi.goslideapi import ClientConnectionError +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_platform + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + device_entry = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "1234567890ab")} + ) + assert device_entry is not None + assert device_entry == snapshot + + +async def test_raise_config_entry_not_ready_when_offline( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_slide_api: AsyncMock, +) -> None: + """Config entry state is SETUP_RETRY when slide is offline.""" + + mock_slide_api.slide_info.side_effect = [ClientConnectionError, None] + + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_raise_config_entry_not_ready_when_empty_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_slide_api: AsyncMock, +) -> None: + """Config entry state is SETUP_RETRY when slide is offline.""" + + mock_slide_api.slide_info.return_value = None + + await setup_platform(hass, mock_config_entry, [Platform.COVER]) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + assert len(hass.config_entries.flow.async_progress()) == 0 diff --git a/tests/components/slide_local/test_switch.py b/tests/components/slide_local/test_switch.py new file mode 100644 index 00000000000..9d0d8274aa5 --- /dev/null +++ b/tests/components/slide_local/test_switch.py @@ -0,0 +1,103 @@ +"""Tests for the Slide Local switch platform.""" + +from unittest.mock import AsyncMock + +from goslideapi.goslideapi import ( + AuthenticationFailed, + ClientConnectionError, + ClientTimeoutError, + DigestAuthCalcError, +) +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_platform(hass, mock_config_entry, [Platform.SWITCH]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service"), + [ + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_TOGGLE, + ], +) +async def test_services( + hass: HomeAssistant, + service: str, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch.""" + await setup_platform(hass, mock_config_entry, [Platform.SWITCH]) + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + { + ATTR_ENTITY_ID: "switch.slide_bedroom_touchgo", + }, + blocking=True, + ) + mock_slide_api.slide_set_touchgo.assert_called_once() + + +@pytest.mark.parametrize( + ("exception", "service"), + [ + (ClientConnectionError, SERVICE_TURN_OFF), + (ClientTimeoutError, SERVICE_TURN_ON), + (AuthenticationFailed, SERVICE_TURN_OFF), + (DigestAuthCalcError, SERVICE_TURN_ON), + ], +) +async def test_service_exception( + hass: HomeAssistant, + exception: Exception, + service: str, + mock_slide_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing button.""" + await setup_platform(hass, mock_config_entry, [Platform.SWITCH]) + + mock_slide_api.slide_set_touchgo.side_effect = exception + + with pytest.raises( + HomeAssistantError, + match=f"Error while sending the request setting Touch&Go to {service[5:]} to the device", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + service, + { + ATTR_ENTITY_ID: "switch.slide_bedroom_touchgo", + }, + blocking=True, + ) diff --git a/tests/components/sma/snapshots/test_diagnostics.ambr b/tests/components/sma/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c7de3851b5f --- /dev/null +++ b/tests/components/sma/snapshots/test_diagnostics.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'entities': dict({ + }), + 'entry': dict({ + 'data': dict({ + 'group': 'user', + 'host': '1.1.1.1', + 'password': '**REDACTED**', + 'ssl': True, + 'verify_ssl': False, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'sma', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'import', + 'title': 'SMA Device Name', + 'unique_id': '123456789', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/sma/test_diagnostics.py b/tests/components/sma/test_diagnostics.py new file mode 100644 index 00000000000..6c1fe0dc5cb --- /dev/null +++ b/tests/components/sma/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Test the SMA diagnostics.""" + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index c06ab551ef6..205c700a402 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components import zeroconf from homeassistant.components.smappee.const import ( CONF_SERIALNUMBER, DOMAIN, @@ -20,6 +19,7 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -63,7 +63,7 @@ async def test_show_zeroconf_connection_error_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -95,7 +95,7 @@ async def test_show_zeroconf_connection_error_form_next_generation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -179,7 +179,7 @@ async def test_zeroconf_wrong_mdns(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -305,7 +305,7 @@ async def test_zeroconf_device_exists_abort(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -355,7 +355,7 @@ async def test_zeroconf_abort_if_cloud_device_exists(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -377,7 +377,7 @@ async def test_zeroconf_confirm_abort_if_cloud_device_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -504,7 +504,7 @@ async def test_full_zeroconf_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -589,7 +589,7 @@ async def test_full_zeroconf_flow_next_generation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 3621e58bc3d..05ddc3a71de 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -14,6 +14,7 @@ from homeassistant.components.smartthings.const import ( CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, + CONF_REFRESH_TOKEN, DOMAIN, ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET @@ -757,3 +758,56 @@ async def test_no_available_locations_aborts( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_available_locations" + + +async def test_reauth( + hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock +) -> None: + """Test reauth flow.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_APP_ID: app.app_id, + CONF_CLIENT_ID: app_oauth_client.client_id, + CONF_CLIENT_SECRET: app_oauth_client.client_secret, + CONF_LOCATION_ID: location.location_id, + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_ACCESS_TOKEN: token, + CONF_REFRESH_TOKEN: "abc", + }, + unique_id=smartapp.format_unique_id(app.app_id, location.location_id), + ) + entry.add_to_hass(hass) + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + await smartapp.smartapp_update(hass, request, None, app) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "update_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert entry.data[CONF_REFRESH_TOKEN] == refresh_token diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index e518f84aecb..83372b58228 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -23,6 +23,7 @@ from homeassistant.components.smartthings.const import ( PLATFORMS, SIGNAL_SMARTTHINGS_UPDATE, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.exceptions import ConfigEntryNotReady @@ -68,17 +69,10 @@ async def test_unrecoverable_api_errors_create_new_flow( ) # Assert setup returns false - result = await smartthings.async_setup_entry(hass, config_entry) + result = await hass.config_entries.async_setup(config_entry.entry_id) assert not result - # Assert entry was removed and new flow created - await hass.async_block_till_done() - assert not hass.config_entries.async_entries(DOMAIN) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["handler"] == "smartthings" - assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - hass.config_entries.flow.async_abort(flows[0]["flow_id"]) + assert config_entry.state == ConfigEntryState.SETUP_ERROR async def test_recoverable_api_errors_raise_not_ready( diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 22b181a3645..b46188b5b5f 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, @@ -101,8 +101,8 @@ async def test_entity_state(hass: HomeAssistant, light_devices) -> None: assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION assert state.attributes[ATTR_BRIGHTNESS] == 255 assert ATTR_HS_COLOR not in state.attributes[ATTR_HS_COLOR] - assert isinstance(state.attributes[ATTR_COLOR_TEMP], int) - assert state.attributes[ATTR_COLOR_TEMP] == 222 + assert isinstance(state.attributes[ATTR_COLOR_TEMP_KELVIN], int) + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4500 async def test_entity_and_device_attributes( @@ -273,7 +273,7 @@ async def test_turn_on_with_color_temp(hass: HomeAssistant, light_devices) -> No await hass.services.async_call( "light", "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_COLOR_TEMP: 300}, + {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_COLOR_TEMP_KELVIN: 3333}, blocking=True, ) # This test schedules and update right after the call @@ -282,7 +282,7 @@ async def test_turn_on_with_color_temp(hass: HomeAssistant, light_devices) -> No state = hass.states.get("light.color_dimmer_2") assert state is not None assert state.state == "on" - assert state.attributes[ATTR_COLOR_TEMP] == 300 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3333 async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 021ee9cc810..7e6768e4d7d 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -36,9 +36,9 @@ async def test_mapping_integrity() -> None: for sensor_map in maps: assert sensor_map.attribute in ATTRIBUTES, sensor_map.attribute if sensor_map.device_class: - assert ( - sensor_map.device_class in DEVICE_CLASSES - ), sensor_map.device_class + assert sensor_map.device_class in DEVICE_CLASSES, ( + sensor_map.device_class + ) if sensor_map.state_class: assert sensor_map.state_class in STATE_CLASSES, sensor_map.state_class diff --git a/tests/components/smhi/__init__.py b/tests/components/smhi/__init__.py index a0bbf854699..0e65d288737 100644 --- a/tests/components/smhi/__init__.py +++ b/tests/components/smhi/__init__.py @@ -2,7 +2,6 @@ ENTITY_ID = "weather.smhi_test" TEST_CONFIG = { - "name": "test", "location": { "longitude": "17.84197", "latitude": "59.32624", @@ -11,5 +10,5 @@ TEST_CONFIG = { TEST_CONFIG_MIGRATE = { "name": "test", "longitude": "17.84197", - "latitude": "17.84197", + "latitude": "59.32624", } diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py index 4195d1e5d52..362adebe416 100644 --- a/tests/components/smhi/test_config_flow.py +++ b/tests/components/smhi/test_config_flow.py @@ -57,7 +57,6 @@ async def test_form(hass: HomeAssistant) -> None: "latitude": 0.0, "longitude": 0.0, }, - "name": "Home", } assert len(mock_setup_entry.mock_calls) == 1 @@ -93,7 +92,6 @@ async def test_form(hass: HomeAssistant) -> None: "latitude": 1.0, "longitude": 1.0, }, - "name": "Weather", } @@ -150,7 +148,6 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: "latitude": 2.0, "longitude": 2.0, }, - "name": "Weather", } @@ -201,8 +198,8 @@ async def test_reconfigure_flow( domain=DOMAIN, title="Home", unique_id="57.2898-13.6304", - data={"location": {"latitude": 57.2898, "longitude": 13.6304}, "name": "Home"}, - version=2, + data={"location": {"latitude": 57.2898, "longitude": 13.6304}}, + version=3, ) entry.add_to_hass(hass) @@ -269,7 +266,6 @@ async def test_reconfigure_flow( "latitude": 58.2898, "longitude": 14.6304, }, - "name": "Home", } entity = entity_registry.async_get(entity.entity_id) assert entity diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index cfb386c8f6f..d00742d4900 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -1,10 +1,9 @@ """Test SMHI component setup process.""" -from unittest.mock import patch - from smhi.smhi_lib import APIURL_TEMPLATE from homeassistant.components.smhi.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,7 +21,7 @@ async def test_setup_entry( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain=DOMAIN, title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -40,7 +39,7 @@ async def test_remove_entry( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain=DOMAIN, title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -77,7 +76,7 @@ async def test_migrate_entry( original_name="Weather", platform="smhi", supported_features=0, - unique_id="17.84197, 17.84197", + unique_id="59.32624, 17.84197", ) await hass.config_entries.async_setup(entry.entry_id) @@ -86,30 +85,27 @@ async def test_migrate_entry( state = hass.states.get(entity.entity_id) assert state - assert entry.version == 2 - assert entry.unique_id == "17.84197-17.84197" + assert entry.version == 3 + assert entry.unique_id == "59.32624-17.84197" + assert entry.data == TEST_CONFIG entity_get = entity_registry.async_get(entity.entity_id) - assert entity_get.unique_id == "17.84197, 17.84197" + assert entity_get.unique_id == "59.32624, 17.84197" -async def test_migrate_entry_failed( +async def test_migrate_from_future_version( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: - """Test migrate entry data that fails.""" + """Test migrate entry not possible from future version.""" uri = APIURL_TEMPLATE.format( TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE, version=4) entry.add_to_hass(hass) - assert entry.version == 1 + assert entry.version == 4 - with patch( - "homeassistant.config_entries.ConfigEntries.async_update_entry", - return_value=False, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert entry.version == 1 + assert entry.state == ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 1870d7b498a..cc6902710bd 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -49,7 +49,7 @@ async def test_setup_hass( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -80,7 +80,7 @@ async def test_clear_night( ) aioclient_mock.get(uri, text=api_response_night) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -105,7 +105,7 @@ async def test_clear_night( async def test_properties_no_data(hass: HomeAssistant) -> None: """Test properties when no API data available.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) with patch( @@ -193,7 +193,7 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: testdata = [data, data2, data3] - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) with ( @@ -232,7 +232,7 @@ async def test_refresh_weather_forecast_retry( hass: HomeAssistant, error: Exception ) -> None: """Test the refresh weather forecast function.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) now = dt_util.utcnow() @@ -357,7 +357,7 @@ async def test_custom_speed_unit( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -394,7 +394,7 @@ async def test_forecast_services( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -458,7 +458,7 @@ async def test_forecast_services_lack_of_data( ) aioclient_mock.get(uri, text=api_response_lack_data) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -503,7 +503,7 @@ async def test_forecast_service( ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index 2fd39f75704..c4aea195aa7 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -3,21 +3,22 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock +from pysmlight import Info from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.smlight.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME from tests.common import MockConfigEntry -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="slzb-06.local.", @@ -27,7 +28,7 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -DISCOVERY_INFO_LEGACY = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_LEGACY = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="slzb-06.local.", @@ -97,7 +98,7 @@ async def test_zeroconf_flow( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_zeroconf_flow_auth( @@ -151,12 +152,99 @@ async def test_zeroconf_flow_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 3 + + +async def test_zeroconf_unsupported_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test we abort zeroconf flow if device unsupported.""" + mock_smlight_client.get_info.return_value = Info(model="SLZB-X") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unsupported_device" + + +async def test_user_unsupported_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test we abort user flow if unsupported device.""" + mock_smlight_client.get_info.return_value = Info(model="SLZB-X") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unsupported_device" + + +async def test_user_unsupported_abort_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test we abort user flow if unsupported device (with auth).""" + mock_smlight_client.check_auth_needed.return_value = True + mock_smlight_client.authenticate.side_effect = SmlightAuthError + mock_smlight_client.get_info.side_effect = SmlightAuthError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: MOCK_HOST, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info(model="SLZB-X") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unsupported_device" @pytest.mark.usefixtures("mock_smlight_client") async def test_user_device_exists_abort( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test we abort user flow if device already configured.""" mock_config_entry.add_to_hass(hass) @@ -239,7 +327,7 @@ async def test_user_invalid_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 4 async def test_user_cannot_connect( @@ -276,7 +364,7 @@ async def test_user_cannot_connect( assert result2["title"] == "SLZB-06p7" assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 3 async def test_auth_cannot_connect( @@ -378,7 +466,7 @@ async def test_zeroconf_legacy_mac( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 2 + assert len(mock_smlight_client.get_info.mock_calls) == 3 async def test_reauth_flow( diff --git a/tests/components/solarlog/conftest.py b/tests/components/solarlog/conftest.py index 2d4b4e32522..caa3621b9bb 100644 --- a/tests/components/solarlog/conftest.py +++ b/tests/components/solarlog/conftest.py @@ -10,9 +10,9 @@ from homeassistant.components.solarlog.const import ( CONF_HAS_PWD, DOMAIN as SOLARLOG_DOMAIN, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD -from .const import HOST, NAME +from .const import HOST from tests.common import MockConfigEntry, load_json_object_fixture @@ -38,7 +38,6 @@ def mock_config_entry() -> MockConfigEntry: title="solarlog", data={ CONF_HOST: HOST, - CONF_NAME: NAME, CONF_HAS_PWD: True, CONF_PASSWORD: "pwd", }, diff --git a/tests/components/solarlog/const.py b/tests/components/solarlog/const.py index e23633c80ae..1294a376b01 100644 --- a/tests/components/solarlog/const.py +++ b/tests/components/solarlog/const.py @@ -1,4 +1,3 @@ """Common const used across tests for SolarLog.""" -NAME = "Solarlog test 1 2 3" HOST = "http://1.1.1.1" diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index 4b37ea63dce..e0f1bc2623c 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -5,7 +5,6 @@ 'data': dict({ 'has_password': True, 'host': '**REDACTED**', - 'name': 'Solarlog test 1 2 3', 'password': 'pwd', }), 'disabled_by': None, diff --git a/tests/components/solarlog/snapshots/test_sensor.ambr b/tests/components/solarlog/snapshots/test_sensor.ambr index 32be560fc62..06bc01f9d39 100644 --- a/tests/components/solarlog/snapshots/test_sensor.ambr +++ b/tests/components/solarlog/snapshots/test_sensor.ambr @@ -254,7 +254,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'solarlog Alternator loss', + 'friendly_name': 'SolarLog Alternator loss', 'state_class': , 'unit_of_measurement': , }), @@ -308,7 +308,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'solarlog Capacity', + 'friendly_name': 'SolarLog Capacity', 'state_class': , 'unit_of_measurement': '%', }), @@ -359,7 +359,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'solarlog Consumption AC', + 'friendly_name': 'SolarLog Consumption AC', 'state_class': , 'unit_of_measurement': , }), @@ -416,7 +416,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'solarlog Consumption day', + 'friendly_name': 'SolarLog Consumption day', 'state_class': , 'unit_of_measurement': , }), @@ -473,7 +473,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'solarlog Consumption month', + 'friendly_name': 'SolarLog Consumption month', 'state_class': , 'unit_of_measurement': , }), @@ -530,7 +530,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'solarlog Consumption total', + 'friendly_name': 'SolarLog Consumption total', 'state_class': , 'unit_of_measurement': , }), @@ -587,7 +587,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'solarlog Consumption year', + 'friendly_name': 'SolarLog Consumption year', 'state_class': , 'unit_of_measurement': , }), @@ -642,7 +642,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'solarlog Consumption yesterday', + 'friendly_name': 'SolarLog Consumption yesterday', 'unit_of_measurement': , }), 'context': , @@ -695,7 +695,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'solarlog Efficiency', + 'friendly_name': 'SolarLog Efficiency', 'state_class': , 'unit_of_measurement': '%', }), @@ -746,7 +746,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'solarlog Installed peak power', + 'friendly_name': 'SolarLog Installed peak power', 'state_class': , 'unit_of_measurement': , }), @@ -795,7 +795,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'solarlog Last update', + 'friendly_name': 'SolarLog Last update', }), 'context': , 'entity_id': 'sensor.solarlog_last_update', @@ -844,7 +844,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'solarlog Power AC', + 'friendly_name': 'SolarLog Power AC', 'state_class': , 'unit_of_measurement': , }), @@ -895,7 +895,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'solarlog Power available', + 'friendly_name': 'SolarLog Power available', 'state_class': , 'unit_of_measurement': , }), @@ -946,7 +946,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'solarlog Power DC', + 'friendly_name': 'SolarLog Power DC', 'state_class': , 'unit_of_measurement': , }), @@ -997,7 +997,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'solarlog Self-consumption year', + 'friendly_name': 'SolarLog Self-consumption year', 'state_class': , 'unit_of_measurement': , }), @@ -1051,7 +1051,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'solarlog Usage', + 'friendly_name': 'SolarLog Usage', 'state_class': , 'unit_of_measurement': '%', }), @@ -1102,7 +1102,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', - 'friendly_name': 'solarlog Voltage AC', + 'friendly_name': 'SolarLog Voltage AC', 'state_class': , 'unit_of_measurement': , }), @@ -1153,7 +1153,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', - 'friendly_name': 'solarlog Voltage DC', + 'friendly_name': 'SolarLog Voltage DC', 'state_class': , 'unit_of_measurement': , }), @@ -1210,7 +1210,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'solarlog Yield day', + 'friendly_name': 'SolarLog Yield day', 'state_class': , 'unit_of_measurement': , }), @@ -1267,7 +1267,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'solarlog Yield month', + 'friendly_name': 'SolarLog Yield month', 'state_class': , 'unit_of_measurement': , }), @@ -1324,7 +1324,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'solarlog Yield total', + 'friendly_name': 'SolarLog Yield total', 'state_class': , 'unit_of_measurement': , }), @@ -1378,7 +1378,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'solarlog Yield year', + 'friendly_name': 'SolarLog Yield year', 'state_class': , 'unit_of_measurement': , }), @@ -1433,7 +1433,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'solarlog Yield yesterday', + 'friendly_name': 'SolarLog Yield yesterday', 'unit_of_measurement': , }), 'context': , diff --git a/tests/components/solarlog/test_config_flow.py b/tests/components/solarlog/test_config_flow.py index 8a34407ff54..58a5faa0772 100644 --- a/tests/components/solarlog/test_config_flow.py +++ b/tests/components/solarlog/test_config_flow.py @@ -9,14 +9,13 @@ from solarlog_cli.solarlog_exceptions import ( SolarLogError, ) -from homeassistant.components.solarlog import config_flow from homeassistant.components.solarlog.const import CONF_HAS_PWD, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import HOST, NAME +from .const import HOST from tests.common import MockConfigEntry @@ -33,24 +32,16 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: HOST, CONF_NAME: NAME, CONF_HAS_PWD: False}, + {CONF_HOST: HOST, CONF_HAS_PWD: False}, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "solarlog_test_1_2_3" + assert result2["title"] == HOST assert result2["data"][CONF_HOST] == "http://1.1.1.1" assert result2["data"][CONF_HAS_PWD] is False assert len(mock_setup_entry.mock_calls) == 1 -def init_config_flow(hass: HomeAssistant) -> config_flow.SolarLogConfigFlow: - """Init a configuration flow.""" - flow = config_flow.SolarLogConfigFlow() - flow.hass = hass - return flow - - @pytest.mark.usefixtures("test_connect") async def test_user( hass: HomeAssistant, @@ -66,12 +57,11 @@ async def test_user( # tests with all provided result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: HOST, CONF_NAME: NAME, CONF_HAS_PWD: False} + result["flow_id"], {CONF_HOST: HOST, CONF_HAS_PWD: False} ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "solarlog_test_1_2_3" + assert result["title"] == HOST assert result["data"][CONF_HOST] == HOST assert len(mock_setup_entry.mock_calls) == 1 @@ -97,19 +87,19 @@ async def test_form_exceptions( mock_solarlog_connector: AsyncMock, ) -> None: """Test we can handle Form exceptions.""" - flow = init_config_flow(hass) - result = await flow.async_step_user() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["errors"] == {} mock_solarlog_connector.test_connection.side_effect = exception1 # tests with connection error - result = await flow.async_step_user( - {CONF_NAME: NAME, CONF_HOST: HOST, CONF_HAS_PWD: False} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: HOST, CONF_HAS_PWD: False} ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -119,16 +109,16 @@ async def test_form_exceptions( mock_solarlog_connector.test_connection.side_effect = None mock_solarlog_connector.test_extended_data_available.side_effect = exception2 - result = await flow.async_step_user( - {CONF_NAME: NAME, CONF_HOST: HOST, CONF_HAS_PWD: True} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: HOST, CONF_HAS_PWD: True} ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "password" - result = await flow.async_step_password({CONF_PASSWORD: "pwd"}) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "pwd"} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "password" @@ -136,32 +126,20 @@ async def test_form_exceptions( mock_solarlog_connector.test_extended_data_available.side_effect = None - # tests with all provided (no password) - result = await flow.async_step_user( - {CONF_NAME: NAME, CONF_HOST: HOST, CONF_HAS_PWD: False} + # tests with all provided + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "pwd"} ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "solarlog_test_1_2_3" - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_HAS_PWD] is False - - # tests with all provided (password) - result = await flow.async_step_password({CONF_PASSWORD: "pwd"}) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "solarlog_test_1_2_3" + assert result["title"] == HOST assert result["data"][CONF_PASSWORD] == "pwd" async def test_abort_if_already_setup(hass: HomeAssistant, test_connect: None) -> None: """Test we abort if the device is already setup.""" - MockConfigEntry(domain=DOMAIN, data={CONF_NAME: NAME, CONF_HOST: HOST}).add_to_hass( - hass - ) + MockConfigEntry(domain=DOMAIN, data={CONF_HOST: HOST}).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -173,7 +151,7 @@ async def test_abort_if_already_setup(hass: HomeAssistant, test_connect: None) - result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: HOST, CONF_NAME: "solarlog_test_7_8_9", CONF_HAS_PWD: False}, + {CONF_HOST: HOST, CONF_HAS_PWD: False}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -196,7 +174,7 @@ async def test_reconfigure_flow( """Test config flow options.""" entry = MockConfigEntry( domain=DOMAIN, - title="solarlog_test_1_2_3", + title=HOST, data={ CONF_HOST: HOST, CONF_HAS_PWD: False, @@ -213,7 +191,6 @@ async def test_reconfigure_flow( result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HAS_PWD: True, CONF_PASSWORD: password} ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" @@ -221,7 +198,7 @@ async def test_reconfigure_flow( entry = hass.config_entries.async_get_entry(entry.entry_id) assert entry - assert entry.title == "solarlog_test_1_2_3" + assert entry.title == HOST assert entry.data[CONF_HAS_PWD] == has_password assert entry.data[CONF_PASSWORD] == password @@ -244,7 +221,7 @@ async def test_reauth( entry = MockConfigEntry( domain=DOMAIN, - title="solarlog_test_1_2_3", + title=HOST, data={ CONF_HOST: HOST, CONF_HAS_PWD: True, @@ -265,7 +242,6 @@ async def test_reauth( result["flow_id"], {CONF_PASSWORD: "other_pwd"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -278,7 +254,6 @@ async def test_reauth( result["flow_id"], {CONF_PASSWORD: "other_pwd"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" diff --git a/tests/components/solarlog/test_init.py b/tests/components/solarlog/test_init.py index b4ef270e78b..a9a595f8962 100644 --- a/tests/components/solarlog/test_init.py +++ b/tests/components/solarlog/test_init.py @@ -19,7 +19,7 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from . import setup_platform -from .const import HOST, NAME +from .const import HOST from tests.common import MockConfigEntry @@ -140,7 +140,7 @@ async def test_migrate_config_entry( """Test successful migration of entry data.""" entry = MockConfigEntry( domain=DOMAIN, - title=NAME, + title=HOST, data={ CONF_HOST: HOST, }, diff --git a/tests/components/somfy_mylink/test_config_flow.py b/tests/components/somfy_mylink/test_config_flow.py index 9084d988ec9..b7007f27fa9 100644 --- a/tests/components/somfy_mylink/test_config_flow.py +++ b/tests/components/somfy_mylink/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.somfy_mylink.const import ( CONF_REVERSED_TARGET_IDS, CONF_SYSTEM_ID, @@ -14,6 +13,7 @@ from homeassistant.components.somfy_mylink.const import ( from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -263,7 +263,7 @@ async def test_form_user_already_configured_from_dhcp(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", @@ -287,7 +287,7 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", @@ -302,7 +302,7 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", diff --git a/tests/components/songpal/test_config_flow.py b/tests/components/songpal/test_config_flow.py index 5215e9b3c0e..0ae2ab596db 100644 --- a/tests/components/songpal/test_config_flow.py +++ b/tests/components/songpal/test_config_flow.py @@ -4,7 +4,6 @@ import copy import dataclasses from unittest.mock import patch -from homeassistant.components import ssdp from homeassistant.components.songpal.const import CONF_ENDPOINT, DOMAIN from homeassistant.config_entries import ( SOURCE_IMPORT, @@ -15,6 +14,11 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from . import ( CONF_DATA, @@ -30,13 +34,13 @@ from tests.common import MockConfigEntry UDN = "uuid:1234" -SSDP_DATA = ssdp.SsdpServiceInfo( +SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{HOST}:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: UDN, - ssdp.ATTR_UPNP_FRIENDLY_NAME: FRIENDLY_NAME, + ATTR_UPNP_UDN: UDN, + ATTR_UPNP_FRIENDLY_NAME: FRIENDLY_NAME, "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_BaseURL": ENDPOINT, "X_ScalarWebAPI_ServiceList": { diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 04b35e2c021..0f56794b9f2 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -18,11 +18,13 @@ from soco.data_structures import ( ) from soco.events_base import Event as SonosEvent -from homeassistant.components import ssdp, zeroconf +from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos import DOMAIN from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture @@ -108,7 +110,7 @@ class SonosMockEvent: @pytest.fixture def zeroconf_payload(): """Return a default zeroconf payload.""" - return zeroconf.ZeroconfServiceInfo( + return ZeroconfServiceInfo( ip_address=ip_address("192.168.4.2"), ip_addresses=[ip_address("192.168.4.2")], hostname="Sonos-aaa", @@ -335,17 +337,17 @@ def discover_fixture(soco): def do_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_location=f"http://{soco.ip_address}/", ssdp_st="urn:schemas-upnp-org:device:ZonePlayer:1", ssdp_usn=f"uuid:{soco.uid}_MR::urn:schemas-upnp-org:service:GroupRenderingControl:1", upnp={ - ssdp.ATTR_UPNP_UDN: f"uuid:{soco.uid}", + ATTR_UPNP_UDN: f"uuid:{soco.uid}", }, ), ssdp.SsdpChange.ALIVE, diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 141013dec20..70605092da1 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -6,17 +6,18 @@ from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component async def test_user_form( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we get the user initiated form.""" @@ -84,7 +85,7 @@ async def test_user_form_already_created(hass: HomeAssistant) -> None: async def test_zeroconf_form( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we pass Zeroconf discoveries to the manager.""" @@ -128,12 +129,12 @@ async def test_ssdp_discovery(hass: HomeAssistant, soco) -> None: await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_location=f"http://{soco.ip_address}/", ssdp_st="urn:schemas-upnp-org:device:ZonePlayer:1", ssdp_usn=f"uuid:{soco.uid}_MR::urn:schemas-upnp-org:service:GroupRenderingControl:1", upnp={ - ssdp.ATTR_UPNP_UDN: f"uuid:{soco.uid}", + ATTR_UPNP_UDN: f"uuid:{soco.uid}", }, ), ) @@ -173,7 +174,7 @@ async def test_zeroconf_sonos_v1(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.107"), ip_addresses=[ip_address("192.168.1.107")], port=1443, @@ -221,7 +222,7 @@ async def test_zeroconf_sonos_v1(hass: HomeAssistant) -> None: async def test_zeroconf_form_not_sonos( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we abort on non-sonos devices.""" mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock() diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 36a6571f3b0..3fc8da6a952 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -8,7 +8,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components import sonos, zeroconf +from homeassistant.components import sonos from homeassistant.components.sonos import SonosDiscoveryManager from homeassistant.components.sonos.const import ( DATA_SONOS_DISCOVERY_MANAGER, @@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -28,7 +29,7 @@ from tests.common import async_fire_time_changed async def test_creating_entry_sets_up_media_player( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test setting up Sonos loads the media player.""" diff --git a/tests/components/soundtouch/test_config_flow.py b/tests/components/soundtouch/test_config_flow.py index 264049ab5fc..fe524da5603 100644 --- a/tests/components/soundtouch/test_config_flow.py +++ b/tests/components/soundtouch/test_config_flow.py @@ -8,11 +8,11 @@ import requests_mock from requests_mock import ANY, Mocker from homeassistant.components.soundtouch.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import DEVICE_1_ID, DEVICE_1_IP, DEVICE_1_NAME diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index 0de96d05605..8c0e897947a 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -6,7 +6,12 @@ from unittest.mock import patch from aiohttp.test_utils import TestClient import pytest -from homeassistant.components.spaceapi import DOMAIN, SPACEAPI_VERSION, URL_API_SPACEAPI +from homeassistant.components.spaceapi import ( + ATTR_SENSOR_LOCATION, + DOMAIN, + SPACEAPI_VERSION, + URL_API_SPACEAPI, +) from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -27,7 +32,7 @@ CONFIG = { "icon_closed": "https://home-assistant.io/close.png", }, "sensors": { - "temperature": ["test.temp1", "test.temp2"], + "temperature": ["test.temp1", "test.temp2", "test.temp3"], "humidity": ["test.hum1"], }, "spacefed": {"spacenet": True, "spacesaml": False, "spacephone": True}, @@ -67,17 +72,23 @@ SENSOR_OUTPUT = { "location": "Home", "name": "temp1", "unit": UnitOfTemperature.CELSIUS, - "value": "25", + "value": 25.0, + }, + { + "location": "outside", + "name": "temp2", + "unit": UnitOfTemperature.CELSIUS, + "value": 23.0, }, { "location": "Home", - "name": "temp2", + "name": "temp3", "unit": UnitOfTemperature.CELSIUS, - "value": "23", + "value": None, }, ], "humidity": [ - {"location": "Home", "name": "hum1", "unit": PERCENTAGE, "value": "88"} + {"location": "Home", "name": "hum1", "unit": PERCENTAGE, "value": 88.0} ], } @@ -96,6 +107,19 @@ def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> Tes hass.states.async_set( "test.temp2", 23, + attributes={ + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_SENSOR_LOCATION: "outside", + }, + ) + hass.states.async_set( + "test.temp3", + "foo", + attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + hass.states.async_set( + "test.temp3", + "foo", attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) hass.states.async_set( diff --git a/tests/components/spotify/conftest.py b/tests/components/spotify/conftest.py index d3fc418f1cd..67d4eac3960 100644 --- a/tests/components/spotify/conftest.py +++ b/tests/components/spotify/conftest.py @@ -9,12 +9,7 @@ from spotifyaio.models import ( Album, Artist, ArtistResponse, - AudioFeatures, - CategoriesResponse, - Category, - CategoryPlaylistResponse, Devices, - FeaturedPlaylistResponse, NewReleasesResponse, NewReleasesResponseInner, PlaybackState, @@ -135,12 +130,10 @@ def mock_spotify() -> Generator[AsyncMock]: PlaybackState, ), ("current_user.json", "get_current_user", UserProfile), - ("category.json", "get_category", Category), ("playlist.json", "get_playlist", Playlist), ("album.json", "get_album", Album), ("artist.json", "get_artist", Artist), ("show.json", "get_show", Show), - ("audio_features.json", "get_audio_features", AudioFeatures), ): getattr(client, method).return_value = obj.from_json( load_fixture(fixture, DOMAIN) @@ -148,15 +141,6 @@ def mock_spotify() -> Generator[AsyncMock]: client.get_followed_artists.return_value = ArtistResponse.from_json( load_fixture("followed_artists.json", DOMAIN) ).artists.items - client.get_featured_playlists.return_value = FeaturedPlaylistResponse.from_json( - load_fixture("featured_playlists.json", DOMAIN) - ).playlists.items - client.get_categories.return_value = CategoriesResponse.from_json( - load_fixture("categories.json", DOMAIN) - ).categories.items - client.get_category_playlists.return_value = CategoryPlaylistResponse.from_json( - load_fixture("category_playlists.json", DOMAIN) - ).playlists.items client.get_new_releases.return_value = NewReleasesResponse.from_json( load_fixture("new_releases.json", DOMAIN) ).albums.items diff --git a/tests/components/spotify/fixtures/audio_features.json b/tests/components/spotify/fixtures/audio_features.json deleted file mode 100644 index 52dfee060f7..00000000000 --- a/tests/components/spotify/fixtures/audio_features.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "danceability": 0.696, - "energy": 0.905, - "key": 3, - "loudness": -2.743, - "mode": 1, - "speechiness": 0.103, - "acousticness": 0.011, - "instrumentalness": 0.000905, - "liveness": 0.302, - "valence": 0.625, - "tempo": 114.944, - "type": "audio_features", - "id": "11dFghVXANMlKmJXsNCbNl", - "uri": "spotify:track:11dFghVXANMlKmJXsNCbNl", - "track_href": "https://api.spotify.com/v1/tracks/11dFghVXANMlKmJXsNCbNl", - "analysis_url": "https://api.spotify.com/v1/audio-analysis/11dFghVXANMlKmJXsNCbNl", - "duration_ms": 207960, - "time_signature": 4 -} diff --git a/tests/components/spotify/fixtures/categories.json b/tests/components/spotify/fixtures/categories.json deleted file mode 100644 index ed873c95c30..00000000000 --- a/tests/components/spotify/fixtures/categories.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "categories": { - "href": "https://api.spotify.com/v1/browse/categories?offset=0&limit=20&locale=en-US,en;q%3D0.5", - "items": [ - { - "href": "https://api.spotify.com/v1/browse/categories/0JQ5DAt0tbjZptfcdMSKl3", - "id": "0JQ5DAt0tbjZptfcdMSKl3", - "icons": [ - { - "height": 274, - "url": "https://t.scdn.co/images/728ed47fc1674feb95f7ac20236eb6d7.jpeg", - "width": 274 - } - ], - "name": "Made For You" - }, - { - "href": "https://api.spotify.com/v1/browse/categories/0JQ5DAqbMKFz6FAsUtgAab", - "id": "0JQ5DAqbMKFz6FAsUtgAab", - "icons": [ - { - "height": 274, - "url": "https://t.scdn.co/images/728ed47fc1674feb95f7ac20236eb6d7.jpeg", - "width": 274 - } - ], - "name": "New Releases" - } - ], - "limit": 20, - "next": "https://api.spotify.com/v1/browse/categories?offset=20&limit=20&locale=en-US,en;q%3D0.5", - "offset": 0, - "previous": null, - "total": 56 - } -} diff --git a/tests/components/spotify/fixtures/category.json b/tests/components/spotify/fixtures/category.json deleted file mode 100644 index d60605cf94f..00000000000 --- a/tests/components/spotify/fixtures/category.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "href": "https://api.spotify.com/v1/browse/categories/0JQ5DAqbMKFRY5ok2pxXJ0", - "id": "0JQ5DAqbMKFRY5ok2pxXJ0", - "icons": [ - { - "height": 274, - "url": "https://t.scdn.co/media/original/dinner_1b6506abba0ba52c54e6d695c8571078_274x274.jpg", - "width": 274 - } - ], - "name": "Cooking & Dining" -} diff --git a/tests/components/spotify/fixtures/category_playlists.json b/tests/components/spotify/fixtures/category_playlists.json deleted file mode 100644 index c2262708d5a..00000000000 --- a/tests/components/spotify/fixtures/category_playlists.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "playlists": { - "href": "https://api.spotify.com/v1/browse/categories/0JQ5DAqbMKFRY5ok2pxXJ0/playlists?country=NL&offset=0&limit=20", - "items": [ - { - "collaborative": false, - "description": "Lekker eten en lang natafelen? Daar hoort muziek bij.", - "external_urls": { - "spotify": "https://open.spotify.com/playlist/37i9dQZF1DX7yhuKT9G4qk" - }, - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DX7yhuKT9G4qk", - "id": "37i9dQZF1DX7yhuKT9G4qk", - "images": [ - { - "height": null, - "url": "https://i.scdn.co/image/ab67706f0000000343319faa9428405f3312b588", - "width": null - } - ], - "name": "eten met vrienden", - "owner": { - "display_name": "Spotify", - "external_urls": { - "spotify": "https://open.spotify.com/user/spotify" - }, - "href": "https://api.spotify.com/v1/users/spotify", - "id": "spotify", - "type": "user", - "uri": "spotify:user:spotify" - }, - "primary_color": null, - "public": null, - "snapshot_id": "MTcwMTY5Njk3NywwMDAwMDAwMDkyY2JjZDA1MjA2YTBmNzMxMmFlNGI0YzRhMjg0ZWZl", - "tracks": { - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DX7yhuKT9G4qk/tracks", - "total": 313 - }, - "type": "playlist", - "uri": "spotify:playlist:37i9dQZF1DX7yhuKT9G4qk" - }, - { - "collaborative": false, - "description": "From new retro to classic country blues, honky tonk, rockabilly, and more.", - "external_urls": { - "spotify": "https://open.spotify.com/playlist/37i9dQZF1DXbvE0SE0Cczh" - }, - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DXbvE0SE0Cczh", - "id": "37i9dQZF1DXbvE0SE0Cczh", - "images": [ - { - "height": null, - "url": "https://i.scdn.co/image/ab67706f00000003b93c270883619dde61725fc8", - "width": null - } - ], - "name": "Jukebox Joint", - "owner": { - "display_name": "Spotify", - "external_urls": { - "spotify": "https://open.spotify.com/user/spotify" - }, - "href": "https://api.spotify.com/v1/users/spotify", - "id": "spotify", - "type": "user", - "uri": "spotify:user:spotify" - }, - "primary_color": null, - "public": null, - "snapshot_id": "MTY4NjkxODgwMiwwMDAwMDAwMGUwNWRkNjY5N2UzM2Q4NzI4NzRiZmNhMGVmMzAyZTA5", - "tracks": { - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DXbvE0SE0Cczh/tracks", - "total": 60 - }, - "type": "playlist", - "uri": "spotify:playlist:37i9dQZF1DXbvE0SE0Cczh" - } - ], - "limit": 20, - "next": "https://api.spotify.com/v1/browse/categories/0JQ5DAqbMKFRY5ok2pxXJ0/playlists?country=NL&offset=20&limit=20", - "offset": 0, - "previous": null, - "total": 46 - } -} diff --git a/tests/components/spotify/fixtures/featured_playlists.json b/tests/components/spotify/fixtures/featured_playlists.json deleted file mode 100644 index 5e6e53a7ee1..00000000000 --- a/tests/components/spotify/fixtures/featured_playlists.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "message": "Popular Playlists", - "playlists": { - "href": "https://api.spotify.com/v1/browse/featured-playlists?country=NL×tamp=2023-12-18T18%3A35%3A35&offset=0&limit=20", - "items": [ - { - "collaborative": false, - "description": "De ideale playlist voor het fijne kerstgevoel bij de boom!", - "external_urls": { - "spotify": "https://open.spotify.com/playlist/37i9dQZF1DX4dopZ9vOp1t" - }, - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DX4dopZ9vOp1t", - "id": "37i9dQZF1DX4dopZ9vOp1t", - "images": [ - { - "height": null, - "url": "https://i.scdn.co/image/ab67706f000000037d14c267b8ee5fea2246a8fe", - "width": null - } - ], - "name": "Kerst Hits 2023", - "owner": { - "display_name": "Spotify", - "external_urls": { - "spotify": "https://open.spotify.com/user/spotify" - }, - "href": "https://api.spotify.com/v1/users/spotify", - "id": "spotify", - "type": "user", - "uri": "spotify:user:spotify" - }, - "primary_color": null, - "public": null, - "snapshot_id": "MTcwMjU2ODI4MSwwMDAwMDAwMDE1ZGRiNzI3OGY4OGU2MzA1MWNkZGMyNTdmNDUwMTc1", - "tracks": { - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DX4dopZ9vOp1t/tracks", - "total": 298 - }, - "type": "playlist", - "uri": "spotify:playlist:37i9dQZF1DX4dopZ9vOp1t" - }, - { - "collaborative": false, - "description": "De 50 populairste hits van Nederland. Cover: Jack Harlow", - "external_urls": { - "spotify": "https://open.spotify.com/playlist/37i9dQZF1DWSBi5svWQ9Nk" - }, - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DWSBi5svWQ9Nk", - "id": "37i9dQZF1DWSBi5svWQ9Nk", - "images": [ - { - "height": null, - "url": "https://i.scdn.co/image/ab67706f00000003f7b99051789611a49101c1cf", - "width": null - } - ], - "name": "Top Hits NL", - "owner": { - "display_name": "Spotify", - "external_urls": { - "spotify": "https://open.spotify.com/user/spotify" - }, - "href": "https://api.spotify.com/v1/users/spotify", - "id": "spotify", - "type": "user", - "uri": "spotify:user:spotify" - }, - "primary_color": null, - "public": null, - "snapshot_id": "MTcwMjU5NDgwMCwwMDAwMDAwMDU4NWY2MTE4NmU4NmIwMDdlMGE4ZGRkOTZkN2U2MzAx", - "tracks": { - "href": "https://api.spotify.com/v1/playlists/37i9dQZF1DWSBi5svWQ9Nk/tracks", - "total": 50 - }, - "type": "playlist", - "uri": "spotify:playlist:37i9dQZF1DWSBi5svWQ9Nk" - } - ], - "limit": 20, - "next": "https://api.spotify.com/v1/browse/featured-playlists?country=NL×tamp=2023-12-18T18%3A35%3A35&offset=20&limit=20", - "offset": 0, - "previous": null, - "total": 24 - } -} diff --git a/tests/components/spotify/fixtures/playlist.json b/tests/components/spotify/fixtures/playlist.json index 36c28cc814b..5680ac9109c 100644 --- a/tests/components/spotify/fixtures/playlist.json +++ b/tests/components/spotify/fixtures/playlist.json @@ -514,6 +514,472 @@ "uri": "spotify:track:2E2znCPaS8anQe21GLxcvJ", "is_local": false } + }, + { + "added_at": "2024-11-28T11:20:58Z", + "added_by": { + "external_urls": { + "spotify": "https://open.spotify.com/user/1112264649" + }, + "href": "https://api.spotify.com/v1/users/1112264649", + "id": "1112264649", + "type": "user", + "uri": "spotify:user:1112264649" + }, + "is_local": false, + "primary_color": null, + "track": { + "explicit": false, + "audio_preview_url": "https://podz-content.spotifycdn.com/audio/clips/06lRxUmh8UNVTByuyxLYqh/clip_132296_192296.mp3", + "description": "Patreon: https://www.patreon.com/safetythirdMerch: https://safetythird.shopYouTube: https://www.youtube.com/@safetythird/Advertising Inquiries: https://redcircle.com/brandsPrivacy & Opt-Out: https://redcircle.com/privacy", + "duration_ms": 3690161, + "episode": true, + "external_urls": { + "spotify": "https://open.spotify.com/episode/3o0RYoo5iOMKSmEbunsbvW" + }, + "href": "https://api.spotify.com/v1/episodes/3o0RYoo5iOMKSmEbunsbvW", + "html_description": "

Patreon: https://www.patreon.com/safetythird

Merch: https://safetythird.shop

YouTube: https://www.youtube.com/@safetythird/



Advertising Inquiries: https://redcircle.com/brands

Privacy & Opt-Out: https://redcircle.com/privacy", + "id": "3o0RYoo5iOMKSmEbunsbvW", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a", + "width": 64 + } + ], + "is_externally_hosted": false, + "language": "en-US", + "languages": ["en-US"], + "name": "My Squirrel Has Brain Damage - Safety Third 119", + "release_date": "2024-07-26", + "release_date_precision": "day", + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "PR", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "show": { + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "PR", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "copyrights": [], + "description": "Safety Third is a weekly show hosted by William Osman, NileRed, The Backyard Scientist, Allen Pan, and a couple other YouTube \"Scientists\". Sometimes we have guests, sometimes it's just us, but always: safety is our number three priority.", + "explicit": true, + "external_urls": { + "spotify": "https://open.spotify.com/show/1Y9ExMgMxoBVrgrfU7u0nD" + }, + "href": "https://api.spotify.com/v1/shows/1Y9ExMgMxoBVrgrfU7u0nD", + "html_description": "

Safety Third is a weekly show hosted by William Osman, NileRed, The Backyard Scientist, Allen Pan, and a couple other YouTube "Scientists". Sometimes we have guests, sometimes it's just us, but always: safety is our number three priority.

", + "id": "1Y9ExMgMxoBVrgrfU7u0nD", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a", + "width": 64 + } + ], + "is_externally_hosted": false, + "languages": ["en-US"], + "media_type": "audio", + "name": "Safety Third", + "publisher": "Safety Third ", + "total_episodes": 120, + "type": "show", + "uri": "spotify:show:1Y9ExMgMxoBVrgrfU7u0nD" + }, + "track": false, + "type": "episode", + "uri": "spotify:episode:3o0RYoo5iOMKSmEbunsbvW" + }, + "video_thumbnail": { + "url": null + } } ] } diff --git a/tests/components/spotify/snapshots/test_diagnostics.ambr b/tests/components/spotify/snapshots/test_diagnostics.ambr index 161b6025ff3..0ac375d18e3 100644 --- a/tests/components/spotify/snapshots/test_diagnostics.ambr +++ b/tests/components/spotify/snapshots/test_diagnostics.ambr @@ -14,20 +14,6 @@ }), ]), 'playback': dict({ - 'audio_features': dict({ - 'acousticness': 0.011, - 'danceability': 0.696, - 'energy': 0.905, - 'instrumentalness': 0.000905, - 'key': 3, - 'liveness': 0.302, - 'loudness': -2.743, - 'mode': 1, - 'speechiness': 0.103, - 'tempo': 114.944, - 'time_signature': 4, - 'valence': 0.625, - }), 'current_playback': dict({ 'context': dict({ 'context_type': 'playlist', @@ -423,6 +409,69 @@ 'uri': 'spotify:track:2E2znCPaS8anQe21GLxcvJ', }), }), + dict({ + 'track': dict({ + 'description': 'Patreon: https://www.patreon.com/safetythirdMerch: https://safetythird.shopYouTube: https://www.youtube.com/@safetythird/Advertising Inquiries: https://redcircle.com/brandsPrivacy & Opt-Out: https://redcircle.com/privacy', + 'duration_ms': 3690161, + 'episode_id': '3o0RYoo5iOMKSmEbunsbvW', + 'explicit': False, + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/episode/3o0RYoo5iOMKSmEbunsbvW', + }), + 'href': 'https://api.spotify.com/v1/episodes/3o0RYoo5iOMKSmEbunsbvW', + 'images': list([ + dict({ + 'height': 640, + 'url': 'https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a', + 'width': 640, + }), + dict({ + 'height': 300, + 'url': 'https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a', + 'width': 300, + }), + dict({ + 'height': 64, + 'url': 'https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a', + 'width': 64, + }), + ]), + 'name': 'My Squirrel Has Brain Damage - Safety Third 119', + 'release_date': '2024-07-26', + 'release_date_precision': 'day', + 'show': dict({ + 'description': 'Safety Third is a weekly show hosted by William Osman, NileRed, The Backyard Scientist, Allen Pan, and a couple other YouTube "Scientists". Sometimes we have guests, sometimes it\'s just us, but always: safety is our number three priority.', + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/show/1Y9ExMgMxoBVrgrfU7u0nD', + }), + 'href': 'https://api.spotify.com/v1/shows/1Y9ExMgMxoBVrgrfU7u0nD', + 'images': list([ + dict({ + 'height': 640, + 'url': 'https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a', + 'width': 640, + }), + dict({ + 'height': 300, + 'url': 'https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a', + 'width': 300, + }), + dict({ + 'height': 64, + 'url': 'https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a', + 'width': 64, + }), + ]), + 'name': 'Safety Third', + 'publisher': 'Safety Third ', + 'show_id': '1Y9ExMgMxoBVrgrfU7u0nD', + 'total_episodes': 120, + 'uri': 'spotify:show:1Y9ExMgMxoBVrgrfU7u0nD', + }), + 'type': 'episode', + 'uri': 'spotify:episode:3o0RYoo5iOMKSmEbunsbvW', + }), + }), ]), }), 'uri': 'spotify:playlist:3cEYpjA9oz9GiPac4AsH4n', diff --git a/tests/components/spotify/snapshots/test_media_browser.ambr b/tests/components/spotify/snapshots/test_media_browser.ambr index e1ff42cb7c8..6b217977227 100644 --- a/tests/components/spotify/snapshots/test_media_browser.ambr +++ b/tests/components/spotify/snapshots/test_media_browser.ambr @@ -84,26 +84,6 @@ 'thumbnail': None, 'title': 'Top Tracks', }), - dict({ - 'can_expand': True, - 'can_play': False, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/categories', - 'media_content_type': 'spotify://categories', - 'thumbnail': None, - 'title': 'Categories', - }), - dict({ - 'can_expand': True, - 'can_play': False, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/featured_playlists', - 'media_content_type': 'spotify://featured_playlists', - 'thumbnail': None, - 'title': 'Featured Playlists', - }), dict({ 'can_expand': True, 'can_play': False, @@ -299,76 +279,6 @@ 'title': 'Pitbull', }) # --- -# name: test_browsing[categories-categories] - dict({ - 'can_expand': True, - 'can_play': False, - 'children': list([ - dict({ - 'can_expand': True, - 'can_play': False, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/0JQ5DAt0tbjZptfcdMSKl3', - 'media_content_type': 'spotify://category_playlists', - 'thumbnail': 'https://t.scdn.co/images/728ed47fc1674feb95f7ac20236eb6d7.jpeg', - 'title': 'Made For You', - }), - dict({ - 'can_expand': True, - 'can_play': False, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/0JQ5DAqbMKFz6FAsUtgAab', - 'media_content_type': 'spotify://category_playlists', - 'thumbnail': 'https://t.scdn.co/images/728ed47fc1674feb95f7ac20236eb6d7.jpeg', - 'title': 'New Releases', - }), - ]), - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/categories', - 'media_content_type': 'spotify://categories', - 'not_shown': 0, - 'thumbnail': None, - 'title': 'Categories', - }) -# --- -# name: test_browsing[category_playlists-dinner] - dict({ - 'can_expand': True, - 'can_play': False, - 'children': list([ - dict({ - 'can_expand': True, - 'can_play': True, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:37i9dQZF1DX7yhuKT9G4qk', - 'media_content_type': 'spotify://playlist', - 'thumbnail': 'https://i.scdn.co/image/ab67706f0000000343319faa9428405f3312b588', - 'title': 'eten met vrienden', - }), - dict({ - 'can_expand': True, - 'can_play': True, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:37i9dQZF1DXbvE0SE0Cczh', - 'media_content_type': 'spotify://playlist', - 'thumbnail': 'https://i.scdn.co/image/ab67706f00000003b93c270883619dde61725fc8', - 'title': 'Jukebox Joint', - }), - ]), - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/dinner', - 'media_content_type': 'spotify://category_playlists', - 'not_shown': 0, - 'thumbnail': 'https://t.scdn.co/media/original/dinner_1b6506abba0ba52c54e6d695c8571078_274x274.jpg', - 'title': 'Cooking & Dining', - }) -# --- # name: test_browsing[current_user_followed_artists-current_user_followed_artists] dict({ 'can_expand': True, @@ -649,41 +559,6 @@ 'title': 'Top Tracks', }) # --- -# name: test_browsing[featured_playlists-featured_playlists] - dict({ - 'can_expand': True, - 'can_play': False, - 'children': list([ - dict({ - 'can_expand': True, - 'can_play': True, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:37i9dQZF1DX4dopZ9vOp1t', - 'media_content_type': 'spotify://playlist', - 'thumbnail': 'https://i.scdn.co/image/ab67706f000000037d14c267b8ee5fea2246a8fe', - 'title': 'Kerst Hits 2023', - }), - dict({ - 'can_expand': True, - 'can_play': True, - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:37i9dQZF1DWSBi5svWQ9Nk', - 'media_content_type': 'spotify://playlist', - 'thumbnail': 'https://i.scdn.co/image/ab67706f00000003f7b99051789611a49101c1cf', - 'title': 'Top Hits NL', - }), - ]), - 'children_media_class': , - 'media_class': , - 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/featured_playlists', - 'media_content_type': 'spotify://featured_playlists', - 'not_shown': 0, - 'thumbnail': None, - 'title': 'Featured Playlists', - }) -# --- # name: test_browsing[new_releases-new_releases] dict({ 'can_expand': True, @@ -774,6 +649,16 @@ 'thumbnail': 'https://i.scdn.co/image/ab67616d0000b27304e57d181ff062f8339d6c71', 'title': 'You Are So Beautiful', }), + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': , + 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:episode:3o0RYoo5iOMKSmEbunsbvW', + 'media_content_type': 'spotify://episode', + 'thumbnail': 'https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a', + 'title': 'My Squirrel Has Brain Damage - Safety Third 119', + }), ]), 'children_media_class': , 'media_class': , diff --git a/tests/components/spotify/snapshots/test_sensor.ambr b/tests/components/spotify/snapshots/test_sensor.ambr deleted file mode 100644 index ce77dda479f..00000000000 --- a/tests/components/spotify/snapshots/test_sensor.ambr +++ /dev/null @@ -1,595 +0,0 @@ -# serializer version: 1 -# name: test_entities[sensor.spotify_spotify_1_song_acousticness-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_acousticness', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Song acousticness', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'acousticness', - 'unique_id': '1112264111_acousticness', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_acousticness-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song acousticness', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_acousticness', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1.1', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_danceability-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_danceability', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Song danceability', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'danceability', - 'unique_id': '1112264111_danceability', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_danceability-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song danceability', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_danceability', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '69.6', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_energy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_energy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Song energy', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'energy', - 'unique_id': '1112264111_energy', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song energy', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '90.5', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_instrumentalness-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_instrumentalness', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Song instrumentalness', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'instrumentalness', - 'unique_id': '1112264111_instrumentalness', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_instrumentalness-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song instrumentalness', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_instrumentalness', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0905', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_key-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'C', - 'C♯/D♭', - 'D', - 'D♯/E♭', - 'E', - 'F', - 'F♯/G♭', - 'G', - 'G♯/A♭', - 'A', - 'A♯/B♭', - 'B', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_key', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Song key', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'key', - 'unique_id': '1112264111_key', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_key-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Spotify spotify_1 Song key', - 'options': list([ - 'C', - 'C♯/D♭', - 'D', - 'D♯/E♭', - 'E', - 'F', - 'F♯/G♭', - 'G', - 'G♯/A♭', - 'A', - 'A♯/B♭', - 'B', - ]), - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_key', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'D♯/E♭', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_liveness-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_liveness', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Song liveness', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'liveness', - 'unique_id': '1112264111_liveness', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_liveness-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song liveness', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_liveness', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30.2', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'major', - 'minor', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Song mode', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mode', - 'unique_id': '1112264111_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Spotify spotify_1 Song mode', - 'options': list([ - 'major', - 'minor', - ]), - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'major', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_speechiness-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_speechiness', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Song speechiness', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'speechiness', - 'unique_id': '1112264111_speechiness', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_speechiness-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song speechiness', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_speechiness', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10.3', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_tempo-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_tempo', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Song tempo', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'song_tempo', - 'unique_id': '1112264111_bpm', - 'unit_of_measurement': 'bpm', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_tempo-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song tempo', - 'unit_of_measurement': 'bpm', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_tempo', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '114.944', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_time_signature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '3/4', - '4/4', - '5/4', - '6/4', - '7/4', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_time_signature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Song time signature', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'time_signature', - 'unique_id': '1112264111_time_signature', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_time_signature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Spotify spotify_1 Song time signature', - 'options': list([ - '3/4', - '4/4', - '5/4', - '6/4', - '7/4', - ]), - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_time_signature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4/4', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_valence-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.spotify_spotify_1_song_valence', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Song valence', - 'platform': 'spotify', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'valence', - 'unique_id': '1112264111_valence', - 'unit_of_measurement': '%', - }) -# --- -# name: test_entities[sensor.spotify_spotify_1_song_valence-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Spotify spotify_1 Song valence', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.spotify_spotify_1_song_valence', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '62.5', - }) -# --- diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index cb942a63568..24c0e1d41d9 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -7,18 +7,18 @@ from unittest.mock import MagicMock, patch import pytest from spotifyaio import SpotifyConnectionError -from homeassistant.components import zeroconf from homeassistant.components.spotify.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -BLANK_ZEROCONF_INFO = zeroconf.ZeroconfServiceInfo( +BLANK_ZEROCONF_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", diff --git a/tests/components/spotify/test_media_browser.py b/tests/components/spotify/test_media_browser.py index dcacc23bbee..ff3404dcfe9 100644 --- a/tests/components/spotify/test_media_browser.py +++ b/tests/components/spotify/test_media_browser.py @@ -112,9 +112,6 @@ async def test_browse_media_playlists( ("current_user_recently_played", "current_user_recently_played"), ("current_user_top_artists", "current_user_top_artists"), ("current_user_top_tracks", "current_user_top_tracks"), - ("featured_playlists", "featured_playlists"), - ("categories", "categories"), - ("category_playlists", "dinner"), ("new_releases", "new_releases"), ("playlist", "spotify:playlist:3cEYpjA9oz9GiPac4AsH4n"), ("album", "spotify:album:3IqzqH6ShrRtie9Yd2ODyG"), diff --git a/tests/components/spotify/test_media_player.py b/tests/components/spotify/test_media_player.py index b03424f8459..456af43d411 100644 --- a/tests/components/spotify/test_media_player.py +++ b/tests/components/spotify/test_media_player.py @@ -10,6 +10,7 @@ from spotifyaio import ( ProductType, RepeatMode as SpotifyRepeatMode, SpotifyConnectionError, + SpotifyNotFoundError, ) from syrupy import SnapshotAssertion @@ -142,6 +143,7 @@ async def test_spotify_dj_list( hass: HomeAssistant, mock_spotify: MagicMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test the Spotify entities with a Spotify DJ playlist.""" mock_spotify.return_value.get_playback.return_value.context.uri = ( @@ -152,12 +154,67 @@ async def test_spotify_dj_list( assert state assert state.attributes["media_playlist"] == "DJ" + mock_spotify.return_value.get_playlist.assert_not_called() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("media_player.spotify_spotify_1") + assert state + assert state.attributes["media_playlist"] == "DJ" + + mock_spotify.return_value.get_playlist.assert_not_called() + + +@pytest.mark.usefixtures("setup_credentials") +async def test_normal_playlist( + hass: HomeAssistant, + mock_spotify: MagicMock, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, +) -> None: + """Test normal playlist switching.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("media_player.spotify_spotify_1") + assert state + assert state.attributes["media_playlist"] == "Spotify Web API Testing playlist" + + mock_spotify.return_value.get_playlist.assert_called_once_with( + "spotify:user:rushofficial:playlist:2r35vbe6hHl6yDSMfjKgmm" + ) + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("media_player.spotify_spotify_1") + assert state + assert state.attributes["media_playlist"] == "Spotify Web API Testing playlist" + + mock_spotify.return_value.get_playlist.assert_called_once_with( + "spotify:user:rushofficial:playlist:2r35vbe6hHl6yDSMfjKgmm" + ) + + mock_spotify.return_value.get_playback.return_value.context.uri = ( + "spotify:playlist:123123123123123" + ) + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playlist.assert_called_with( + "spotify:playlist:123123123123123" + ) + @pytest.mark.usefixtures("setup_credentials") async def test_fetching_playlist_does_not_fail( hass: HomeAssistant, mock_spotify: MagicMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test failing fetching playlist does not fail update.""" mock_spotify.return_value.get_playlist.side_effect = SpotifyConnectionError @@ -166,6 +223,42 @@ async def test_fetching_playlist_does_not_fail( assert state assert "media_playlist" not in state.attributes + mock_spotify.return_value.get_playlist.assert_called_once() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_spotify.return_value.get_playlist.call_count == 2 + + +@pytest.mark.usefixtures("setup_credentials") +async def test_fetching_playlist_once( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that not being able to find a playlist doesn't retry.""" + mock_spotify.return_value.get_playlist.side_effect = SpotifyNotFoundError + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("media_player.spotify_spotify_1") + assert state + assert "media_playlist" not in state.attributes + + mock_spotify.return_value.get_playlist.assert_called_once() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("media_player.spotify_spotify_1") + assert state + assert "media_playlist" not in state.attributes + + mock_spotify.return_value.get_playlist.assert_called_once() + @pytest.mark.usefixtures("setup_credentials") async def test_idle( @@ -548,3 +641,147 @@ async def test_no_album_images( state = hass.states.get("media_player.spotify_spotify_1") assert state assert ATTR_ENTITY_PICTURE not in state.attributes + + +@pytest.mark.usefixtures("setup_credentials") +async def test_normal_polling_interval( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + await setup_integration(hass, mock_config_entry) + + assert mock_spotify.return_value.get_playback.return_value.is_playing is True + assert ( + mock_spotify.return_value.get_playback.return_value.progress_ms + - mock_spotify.return_value.get_playback.return_value.item.duration_ms + < 30000 + ) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + + +@pytest.mark.usefixtures("setup_credentials") +async def test_smart_polling_interval( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + freezer.move_to("2023-10-21") + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 30000 + + await setup_integration(hass, mock_config_entry) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 50000 + + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + freezer.tick(timedelta(seconds=9)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + +@pytest.mark.usefixtures("setup_credentials") +async def test_smart_polling_interval_handles_errors( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 30000 + + await setup_integration(hass, mock_config_entry) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + mock_spotify.return_value.get_playback.side_effect = SpotifyConnectionError + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + freezer.tick(timedelta(seconds=9)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + +@pytest.mark.usefixtures("setup_credentials") +async def test_smart_polling_interval_handles_paused( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 30000 + mock_spotify.return_value.get_playback.return_value.is_playing = False + + await setup_integration(hass, mock_config_entry) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + freezer.tick(timedelta(seconds=9)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() diff --git a/tests/components/spotify/test_sensor.py b/tests/components/spotify/test_sensor.py deleted file mode 100644 index 11ce361034a..00000000000 --- a/tests/components/spotify/test_sensor.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Tests for the Spotify sensor platform.""" - -from unittest.mock import MagicMock, patch - -import pytest -from spotifyaio import PlaybackState -from syrupy import SnapshotAssertion - -from homeassistant.components.spotify import DOMAIN -from homeassistant.const import STATE_UNKNOWN, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import setup_integration - -from tests.common import MockConfigEntry, load_fixture, snapshot_platform - - -@pytest.mark.usefixtures("setup_credentials") -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_entities( - hass: HomeAssistant, - mock_spotify: MagicMock, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test the Spotify entities.""" - with patch("homeassistant.components.spotify.PLATFORMS", [Platform.SENSOR]): - await setup_integration(hass, mock_config_entry) - - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -@pytest.mark.usefixtures("setup_credentials") -async def test_audio_features_unavailable( - hass: HomeAssistant, - mock_spotify: MagicMock, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test the Spotify entities.""" - mock_spotify.return_value.get_audio_features.return_value = None - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.spotify_spotify_1_song_tempo").state == STATE_UNKNOWN - - -@pytest.mark.usefixtures("setup_credentials") -async def test_audio_features_unknown_during_podcast( - hass: HomeAssistant, - mock_spotify: MagicMock, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test the Spotify audio features sensor during a podcast.""" - mock_spotify.return_value.get_playback.return_value = PlaybackState.from_json( - load_fixture("playback_episode.json", DOMAIN) - ) - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.spotify_spotify_1_song_tempo").state == STATE_UNKNOWN diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index cb990e454b7..3f2400c0a32 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pathlib import Path from unittest.mock import patch from sqlalchemy.exc import SQLAlchemyError @@ -597,9 +598,6 @@ async def test_options_flow_db_url_empty( "homeassistant.components.sql.async_setup_entry", return_value=True, ), - patch( - "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", - ), ): result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -621,7 +619,9 @@ async def test_options_flow_db_url_empty( async def test_full_flow_not_recorder_db( - recorder_mock: Recorder, hass: HomeAssistant + recorder_mock: Recorder, + hass: HomeAssistant, + tmp_path: Path, ) -> None: """Test full config flow with not using recorder db.""" result = await hass.config_entries.flow.async_init( @@ -629,20 +629,19 @@ async def test_full_flow_not_recorder_db( ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} + db_path = tmp_path / "db.db" + db_path_str = f"sqlite:///{db_path}" with ( patch( "homeassistant.components.sql.async_setup_entry", return_value=True, ), - patch( - "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", - ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "name": "Get Value", "query": "SELECT 5 as value", "column": "value", @@ -654,7 +653,7 @@ async def test_full_flow_not_recorder_db( assert result2["title"] == "Get Value" assert result2["options"] == { "name": "Get Value", - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "query": "SELECT 5 as value", "column": "value", } @@ -671,15 +670,12 @@ async def test_full_flow_not_recorder_db( "homeassistant.components.sql.async_setup_entry", return_value=True, ), - patch( - "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", - ), ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ "query": "SELECT 5 as value", - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "column": "value", "unit_of_measurement": "MiB", }, @@ -689,7 +685,7 @@ async def test_full_flow_not_recorder_db( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "Get Value", - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", @@ -697,24 +693,22 @@ async def test_full_flow_not_recorder_db( # Need to test same again to mitigate issue with db_url removal result = await hass.config_entries.options.async_init(entry.entry_id) - with patch( - "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as value", - "db_url": "sqlite://path/to/db.db", - "column": "value", - "unit_of_measurement": "MB", - }, - ) - await hass.async_block_till_done() + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "query": "SELECT 5 as value", + "db_url": db_path_str, + "column": "value", + "unit_of_measurement": "MB", + }, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "Get Value", - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MB", @@ -722,7 +716,7 @@ async def test_full_flow_not_recorder_db( assert entry.options == { "name": "Get Value", - "db_url": "sqlite://path/to/db.db", + "db_url": db_path_str, "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MB", diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index b219ad47f3a..6b4032323d0 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -3,12 +3,13 @@ from __future__ import annotations from datetime import timedelta +from pathlib import Path +import sqlite3 from typing import Any from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest -from sqlalchemy import text as sql_text from sqlalchemy.exc import SQLAlchemyError from homeassistant.components.recorder import Recorder @@ -143,29 +144,37 @@ async def test_query_no_value( assert text in caplog.text -async def test_query_mssql_no_result( - recorder_mock: Recorder, hass: HomeAssistant, caplog: pytest.LogCaptureFixture +async def test_query_on_disk_sqlite_no_result( + recorder_mock: Recorder, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + tmp_path: Path, ) -> None: """Test the SQL sensor with a query that returns no value.""" - config = { - "db_url": "mssql://", - "query": "SELECT 5 as value where 1=2", - "column": "value", - "name": "count_tables", - } - with ( - patch("homeassistant.components.sql.sensor.sqlalchemy"), - patch( - "homeassistant.components.sql.sensor.sqlalchemy.text", - return_value=sql_text("SELECT TOP 1 5 as value where 1=2"), - ), - ): - await init_integration(hass, config) + db_path = tmp_path / "test.db" + db_path_str = f"sqlite:///{db_path}" - state = hass.states.get("sensor.count_tables") + def make_test_db(): + """Create a test database.""" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE users (value INTEGER)") + conn.commit() + conn.close() + + await hass.async_add_executor_job(make_test_db) + + config = { + "db_url": db_path_str, + "query": "SELECT value from users", + "column": "value", + "name": "count_users", + } + await init_integration(hass, config) + + state = hass.states.get("sensor.count_users") assert state.state == STATE_UNKNOWN - text = "SELECT TOP 1 5 AS VALUE WHERE 1=2 returned no results" + text = "SELECT value from users LIMIT 1; returned no results" assert text in caplog.text diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 2dc0cabeaa6..7b007114420 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -137,6 +137,7 @@ async def mock_async_browse( "title": "Fake Item 1", "id": FAKE_VALID_ITEM_ID, "hasitems": False, + "isaudio": True, "item_type": child_types[media_type], "artwork_track_id": "b35bb9e9", "url": "file:///var/lib/squeezeboxserver/music/track_1.mp3", @@ -145,6 +146,7 @@ async def mock_async_browse( "title": "Fake Item 2", "id": FAKE_VALID_ITEM_ID + "_2", "hasitems": media_type == "favorites", + "isaudio": True, "item_type": child_types[media_type], "image_url": "http://lms.internal:9000/html/images/favorites.png", "url": "file:///var/lib/squeezeboxserver/music/track_2.mp3", @@ -153,6 +155,7 @@ async def mock_async_browse( "title": "Fake Item 3", "id": FAKE_VALID_ITEM_ID + "_3", "hasitems": media_type == "favorites", + "isaudio": True, "album_id": FAKE_VALID_ITEM_ID if media_type == "favorites" else None, "url": "file:///var/lib/squeezeboxserver/music/track_3.mp3", }, diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index 0a03bcc291c..c5efe66152f 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -6,11 +6,11 @@ from unittest.mock import patch from pysqueezebox import Server from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.squeezebox.const import CONF_HTTPS, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -143,27 +143,128 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "edit"} - ) async def patch_async_query(self, *args): self.http_status = HTTPStatus.UNAUTHORIZED return False - with patch("pysqueezebox.Server.async_query", new=patch_async_query): + with ( + patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ), + patch( + "homeassistant.components.squeezebox.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.squeezebox.config_flow.async_discover", + mock_discover, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + + with patch( + "homeassistant.components.squeezebox.config_flow.Server.async_query", + new=patch_async_query, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_HOST: HOST, CONF_PORT: PORT, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} + +async def test_form_validate_exception(hass: HomeAssistant) -> None: + """Test we handle exception.""" + + with ( + patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ), + patch( + "homeassistant.components.squeezebox.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.squeezebox.config_flow.async_discover", + mock_discover, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + + with patch( + "homeassistant.components.squeezebox.config_flow.Server.async_query", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -232,7 +333,7 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", @@ -254,7 +355,7 @@ async def test_dhcp_discovery_no_server_found(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", @@ -273,7 +374,7 @@ async def test_dhcp_discovery_existing_player(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 7dc0f0095d4..839509e756b 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -2,6 +2,7 @@ from datetime import datetime from ipaddress import IPv4Address +from typing import Any from unittest.mock import ANY, AsyncMock, patch from async_upnp_client.server import UpnpServer @@ -19,6 +20,24 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.ssdp import ( + ATTR_NT, + ATTR_ST, + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_MODEL_URL, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + ATTR_UPNP_UPC, + SsdpServiceInfo, +) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -26,6 +45,7 @@ from tests.common import ( MockConfigEntry, MockModule, async_fire_time_changed, + import_and_test_deprecated_constant, mock_integration, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -74,7 +94,7 @@ async def test_ssdp_flow_dispatched_on_st( "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), "source": config_entries.SOURCE_SSDP, } - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_usn == "uuid:mock-udn::mock-st" @@ -83,7 +103,7 @@ async def test_ssdp_flow_dispatched_on_st( assert mock_call_data.ssdp_udn == ANY assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == {"mock-domain"} - assert mock_call_data.upnp == {ssdp.ATTR_UPNP_UDN: "uuid:mock-udn"} + assert mock_call_data.upnp == {ATTR_UPNP_UDN: "uuid:mock-udn"} assert "Failed to fetch ssdp data" not in caplog.text @@ -118,7 +138,7 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), "source": config_entries.SOURCE_SSDP, } - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_usn == "uuid:mock-udn::mock-st" @@ -127,7 +147,7 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( assert mock_call_data.ssdp_udn == ANY assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == {"mock-domain"} - assert mock_call_data.upnp == {ssdp.ATTR_UPNP_UDN: "uuid:mock-udn"} + assert mock_call_data.upnp == {ATTR_UPNP_UDN: "uuid:mock-udn"} assert "Failed to fetch ssdp data" not in caplog.text @@ -227,8 +247,8 @@ async def test_scan_match_upnp_devicedesc_devicetype( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_MANUFACTURER: "Paulus", } ] }, @@ -270,8 +290,8 @@ async def test_scan_not_all_present( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Not-Paulus", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_MANUFACTURER: "Not-Paulus", } ] }, @@ -455,8 +475,8 @@ async def test_discovery_from_advertisement_sets_ssdp_st( assert discovery_info.ssdp_headers["nts"] == "ssdp:alive" assert discovery_info.ssdp_headers["_timestamp"] == ANY assert discovery_info.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:mock-udn", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:mock-udn", } @@ -555,7 +575,7 @@ async def test_scan_with_registered_callback( assert async_match_any_callback.call_count == 1 assert async_not_matching_integration_callback.call_count == 0 assert async_integration_callback.call_args[0][1] == ssdp.SsdpChange.ALIVE - mock_call_data: ssdp.SsdpServiceInfo = async_integration_callback.call_args[0][0] + mock_call_data: SsdpServiceInfo = async_integration_callback.call_args[0][0] assert mock_call_data.ssdp_ext == "" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_server == "mock-server" @@ -568,8 +588,8 @@ async def test_scan_with_registered_callback( assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == set() assert mock_call_data.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } assert "Exception in SSDP callback" in caplog.text @@ -627,8 +647,8 @@ async def test_getting_existing_headers( assert discovery_info_by_st.ssdp_udn == ANY assert discovery_info_by_st.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_st.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } discovery_info_by_udn = await ssdp.async_get_discovery_info_by_udn( @@ -646,8 +666,8 @@ async def test_getting_existing_headers( assert discovery_info_by_udn.ssdp_udn == ANY assert discovery_info_by_udn.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_udn.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } discovery_info_by_udn_st = await ssdp.async_get_discovery_info_by_udn_st( @@ -664,8 +684,8 @@ async def test_getting_existing_headers( assert discovery_info_by_udn_st.ssdp_udn == ANY assert discovery_info_by_udn_st.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_udn_st.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } assert ( @@ -713,7 +733,7 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -738,7 +758,7 @@ async def test_async_detect_interfaces_setting_empty_route( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -787,7 +807,7 @@ async def test_bind_failure_skips_adapter( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -999,7 +1019,7 @@ async def test_ssdp_rediscover( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == expected_context - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" @@ -1086,7 +1106,7 @@ async def test_ssdp_rediscover_no_match( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == expected_context - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" @@ -1094,3 +1114,105 @@ async def test_ssdp_rediscover_no_match( await hass.async_block_till_done() assert len(mock_flow_init.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "SsdpServiceInfo", + "homeassistant.helpers.service_info.ssdp.SsdpServiceInfo", + SsdpServiceInfo, + ), + ( + "ATTR_ST", + "homeassistant.helpers.service_info.ssdp.ATTR_ST", + ATTR_ST, + ), + ( + "ATTR_NT", + "homeassistant.helpers.service_info.ssdp.ATTR_NT", + ATTR_NT, + ), + ( + "ATTR_UPNP_DEVICE_TYPE", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_DEVICE_TYPE", + ATTR_UPNP_DEVICE_TYPE, + ), + ( + "ATTR_UPNP_FRIENDLY_NAME", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_FRIENDLY_NAME", + ATTR_UPNP_FRIENDLY_NAME, + ), + ( + "ATTR_UPNP_MANUFACTURER", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER", + ATTR_UPNP_MANUFACTURER, + ), + ( + "ATTR_UPNP_MANUFACTURER_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER_URL", + ATTR_UPNP_MANUFACTURER_URL, + ), + ( + "ATTR_UPNP_MODEL_DESCRIPTION", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_DESCRIPTION", + ATTR_UPNP_MODEL_DESCRIPTION, + ), + ( + "ATTR_UPNP_MODEL_NAME", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NAME", + ATTR_UPNP_MODEL_NAME, + ), + ( + "ATTR_UPNP_MODEL_NUMBER", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NUMBER", + ATTR_UPNP_MODEL_NUMBER, + ), + ( + "ATTR_UPNP_MODEL_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_URL", + ATTR_UPNP_MODEL_URL, + ), + ( + "ATTR_UPNP_SERIAL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERIAL", + ATTR_UPNP_SERIAL, + ), + ( + "ATTR_UPNP_SERVICE_LIST", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERVICE_LIST", + ATTR_UPNP_SERVICE_LIST, + ), + ( + "ATTR_UPNP_UDN", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UDN", + ATTR_UPNP_UDN, + ), + ( + "ATTR_UPNP_UPC", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UPC", + ATTR_UPNP_UPC, + ), + ( + "ATTR_UPNP_PRESENTATION_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_PRESENTATION_URL", + ATTR_UPNP_PRESENTATION_URL, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + ssdp, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/starlink/fixtures/history_stats_success.json b/tests/components/starlink/fixtures/history_stats_success.json new file mode 100644 index 00000000000..5a228dd34af --- /dev/null +++ b/tests/components/starlink/fixtures/history_stats_success.json @@ -0,0 +1,112 @@ +[ + { + "samples": 900, + "end_counter": 119395 + }, + { + "total_ping_drop": 2.4592087380588055, + "count_full_ping_drop": 0, + "count_obstructed": 0, + "total_obstructed_ping_drop": 0, + "count_full_obstructed_ping_drop": 0, + "count_unscheduled": 0, + "total_unscheduled_ping_drop": 0, + "count_full_unscheduled_ping_drop": 0 + }, + { + "init_run_fragment": 0, + "final_run_fragment": 0, + "run_seconds[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, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ], + "run_minutes[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, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + }, + { + "mean_all_ping_latency": 31.55747121333472, + "deciles_all_ping_latency[]": [ + 21.005102157592773, 22.67989158630371, 25.310760498046875, + 26.85667610168457, 27.947458267211914, 29.192155838012695, + 31.26323890686035, 34.226768493652344, 38.54373550415039, + 42.308048248291016, 60.11151885986328 + ], + "mean_full_ping_latency": 31.526783029284427, + "deciles_full_ping_latency[]": [ + 21.070240020751953, 22.841461181640625, 25.34041976928711, + 26.908039093017578, 27.947458267211914, 29.135879516601562, + 31.122955322265625, 34.1280403137207, 38.49388122558594, + 42.308048248291016, 60.11151885986328 + ], + "stdev_full_ping_latency": 7.8141330200011785 + }, + { + "load_bucket_samples[]": [738, 24, 39, 55, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "load_bucket_min_latency[]": [ + 21.070240020751953, + 21.35713768005371, + 21.156545639038086, + 24.763751983642578, + 24.7109317779541, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "load_bucket_median_latency[]": [ + 29.2450590133667, + 27.031108856201172, + 25.726211547851562, + 31.845806121826172, + 28.919479370117188, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "load_bucket_max_latency[]": [ + 60.11151885986328, + 40.572628021240234, + 48.063961029052734, + 53.505126953125, + 38.7435302734375, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "download_usage": 72504227, + "upload_usage": 5719755 + }, + { + "latest_power": 27.54502296447754, + "mean_power": 31.449254739549424, + "min_power": 21.826229095458984, + "max_power": 41.71160888671875, + "total_energy": 0.007862313684887356 + } +] diff --git a/tests/components/starlink/patchers.py b/tests/components/starlink/patchers.py index f8179f07bed..08e82548ef8 100644 --- a/tests/components/starlink/patchers.py +++ b/tests/components/starlink/patchers.py @@ -24,6 +24,11 @@ SLEEP_DATA_SUCCESS_PATCHER = patch( return_value=json.loads(load_fixture("sleep_data_success.json", "starlink")), ) +HISTORY_STATS_SUCCESS_PATCHER = patch( + "homeassistant.components.starlink.coordinator.history_stats", + return_value=json.loads(load_fixture("history_stats_success.json", "starlink")), +) + DEVICE_FOUND_PATCHER = patch( "homeassistant.components.starlink.config_flow.get_id", return_value="some-valid-id" ) diff --git a/tests/components/starlink/snapshots/test_diagnostics.ambr b/tests/components/starlink/snapshots/test_diagnostics.ambr index 4c85ad84ca7..c54e0b2df6d 100644 --- a/tests/components/starlink/snapshots/test_diagnostics.ambr +++ b/tests/components/starlink/snapshots/test_diagnostics.ambr @@ -16,6 +16,13 @@ 'alert_thermal_throttle': False, 'alert_unexpected_location': False, }), + 'consumption': dict({ + 'latest_power': 27.54502296447754, + 'max_power': 41.71160888671875, + 'mean_power': 31.449254739549424, + 'min_power': 21.826229095458984, + 'total_energy': 0.007862313684887356, + }), 'location': dict({ 'altitude': '**REDACTED**', 'latitude': '**REDACTED**', @@ -79,5 +86,9 @@ 'uplink_throughput_bps': 11802.771484375, 'uptime': 804138, }), + 'usage': dict({ + 'download_usage': 72504227, + 'upload_usage': 5719755, + }), }) # --- diff --git a/tests/components/starlink/test_diagnostics.py b/tests/components/starlink/test_diagnostics.py index c5876e5e9f2..cd36dd0367e 100644 --- a/tests/components/starlink/test_diagnostics.py +++ b/tests/components/starlink/test_diagnostics.py @@ -7,6 +7,7 @@ from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from .patchers import ( + HISTORY_STATS_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER, STATUS_DATA_SUCCESS_PATCHER, @@ -32,6 +33,7 @@ async def test_diagnostics( STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER, + HISTORY_STATS_SUCCESS_PATCHER, ): entry.add_to_hass(hass) diff --git a/tests/components/starlink/test_init.py b/tests/components/starlink/test_init.py index 62a1ee41236..7e04c21562a 100644 --- a/tests/components/starlink/test_init.py +++ b/tests/components/starlink/test_init.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from .patchers import ( + HISTORY_STATS_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER, STATUS_DATA_SUCCESS_PATCHER, @@ -25,6 +26,7 @@ async def test_successful_entry(hass: HomeAssistant) -> None: STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER, + HISTORY_STATS_SUCCESS_PATCHER, ): entry.add_to_hass(hass) @@ -46,6 +48,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER, SLEEP_DATA_SUCCESS_PATCHER, + HISTORY_STATS_SUCCESS_PATCHER, ): entry.add_to_hass(hass) diff --git a/tests/components/steamist/test_config_flow.py b/tests/components/steamist/test_config_flow.py index 40578113bb3..5e963f77a2b 100644 --- a/tests/components/steamist/test_config_flow.py +++ b/tests/components/steamist/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.steamist.const import DOMAIN from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( DEFAULT_ENTRY_DATA, @@ -30,7 +30,7 @@ from tests.common import MockConfigEntry MODULE = "homeassistant.components.steamist" -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=DEVICE_HOSTNAME, ip=DEVICE_IP_ADDRESS, macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""), @@ -238,7 +238,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=DEVICE_IP_ADDRESS, macaddress="000000000000", diff --git a/tests/components/stookalert/__init__.py b/tests/components/stookalert/__init__.py deleted file mode 100644 index 3785c76639a..00000000000 --- a/tests/components/stookalert/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Stookalert integration.""" diff --git a/tests/components/stookalert/test_config_flow.py b/tests/components/stookalert/test_config_flow.py deleted file mode 100644 index 3664527cbcf..00000000000 --- a/tests/components/stookalert/test_config_flow.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Tests for the Stookalert config flow.""" - -from unittest.mock import patch - -from homeassistant.components.stookalert.const import CONF_PROVINCE, DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry - - -async def test_full_user_flow(hass: HomeAssistant) -> None: - """Test the full user configuration flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - - with patch( - "homeassistant.components.stookalert.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PROVINCE: "Overijssel", - }, - ) - - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == "Overijssel" - assert result2.get("data") == { - CONF_PROVINCE: "Overijssel", - } - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_already_configured(hass: HomeAssistant) -> None: - """Test we abort if the Stookalert province is already configured.""" - MockConfigEntry( - domain=DOMAIN, data={CONF_PROVINCE: "Overijssel"}, unique_id="Overijssel" - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PROVINCE: "Overijssel", - }, - ) - - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "already_configured" diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py index 0df9b55d1a9..ddefb6be772 100644 --- a/tests/components/stookwijzer/test_init.py +++ b/tests/components/stookwijzer/test_init.py @@ -100,6 +100,7 @@ async def test_entity_entry_migration( entity_registry: er.EntityRegistry, ) -> None: """Test successful migration of entry data.""" + mock_config_entry.add_to_hass(hass) entity = entity_registry.async_get_or_create( suggested_object_id="advice", disabled_by=None, diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index babd7c0b748..cd48fd94c24 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -119,13 +119,16 @@ def make_playlist( response.extend( [ f"#EXT-X-PART-INF:PART-TARGET={part_target_duration:.3f}", - f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*part_target_duration:.3f}", - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*part_target_duration:.3f},PRECISE=YES", + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=" + f"{2 * part_target_duration:.3f}", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_LL_HLS * part_target_duration:.3f},PRECISE=YES", ] ) else: response.append( - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*segment_duration:.3f},PRECISE=YES", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_NON_LL_HLS * segment_duration:.3f},PRECISE=YES", ) if segments: response.extend(segments) @@ -278,8 +281,19 @@ async def test_stream_timeout_after_stop( await hass.async_block_till_done() +@pytest.mark.parametrize( + ("exception"), + [ + # pylint: disable-next=c-extension-no-member + (av.error.InvalidDataError(-2, "error")), + (av.HTTPBadRequestError(500, "error")), + ], +) async def test_stream_retries( - hass: HomeAssistant, setup_component, should_retry + hass: HomeAssistant, + setup_component, + should_retry, + exception, ) -> None: """Test hls stream is retried on failure.""" # Setup demo HLS track @@ -309,8 +323,7 @@ async def test_stream_retries( def av_open_side_effect(*args, **kwargs): hass.loop.call_soon_threadsafe(futures.pop().set_result, None) - # pylint: disable-next=c-extension-no-member - raise av.error.InvalidDataError(-2, "error") + raise exception with ( patch("av.open") as av_open, diff --git a/tests/components/stream/test_init.py b/tests/components/stream/test_init.py index 1ae6f9e8931..ba5f8d2e70f 100644 --- a/tests/components/stream/test_init.py +++ b/tests/components/stream/test_init.py @@ -1,15 +1,41 @@ """Test stream init.""" import logging +from typing import Any +from unittest.mock import MagicMock, patch import av import pytest -from homeassistant.components.stream import __name__ as stream_name +from homeassistant.components.stream import ( + SOURCE_TIMEOUT, + StreamClientError, + StreamOpenClientError, + __name__ as stream_name, + async_check_stream_client_error, + create_stream, +) +from homeassistant.components.stream.const import ATTR_PREFER_TCP from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from .common import dynamic_stream_settings + + +async def test_stream_not_setup(hass: HomeAssistant, h264_video) -> None: + """Test hls stream. + + Purposefully not mocking anything here to test full + integration with the stream component. + """ + with pytest.raises(HomeAssistantError, match="Stream integration is not set up"): + create_stream(hass, "rtsp://foobar", {}, dynamic_stream_settings()) + + with pytest.raises(HomeAssistantError, match="Stream integration is not set up"): + await async_check_stream_client_error(hass, "rtsp://foobar") + async def test_log_levels( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -53,3 +79,111 @@ async def test_log_levels( assert "SHOULD PASS" in caplog.text assert "SHOULD NOT PASS" not in caplog.text + + +async def test_check_open_stream_params(hass: HomeAssistant) -> None: + """Test check open stream params.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + container_mock = MagicMock() + source = "rtsp://foobar" + + with patch("av.open", return_value=container_mock) as open_mock: + await async_check_stream_client_error(hass, source) + + options = { + "rtsp_flags": ATTR_PREFER_TCP, + "stimeout": "5000000", + } + open_mock.assert_called_once_with(source, options=options, timeout=SOURCE_TIMEOUT) + container_mock.close.assert_called_once() + + container_mock.reset_mock() + with ( + patch("av.open", return_value=container_mock) as open_mock, + pytest.raises(HomeAssistantError, match="Invalid stream options"), + ): + await async_check_stream_client_error(hass, source, {"foo": "bar"}) + + +@pytest.mark.parametrize( + ("error", "enum_result"), + [ + pytest.param( + av.HTTPBadRequestError(400, ""), + StreamClientError.BadRequest, + id="BadRequest", + ), + pytest.param( + av.HTTPUnauthorizedError(401, ""), + StreamClientError.Unauthorized, + id="Unauthorized", + ), + pytest.param( + av.HTTPForbiddenError(403, ""), StreamClientError.Forbidden, id="Forbidden" + ), + pytest.param( + av.HTTPNotFoundError(404, ""), StreamClientError.NotFound, id="NotFound" + ), + pytest.param( + av.HTTPOtherClientError(408, ""), StreamClientError.Other, id="Other" + ), + ], +) +async def test_try_open_stream_error( + hass: HomeAssistant, error: av.HTTPClientError, enum_result: StreamClientError +) -> None: + """Test trying to open a stream.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + with ( + patch("av.open", side_effect=error), + pytest.raises(StreamOpenClientError) as ex, + ): + await async_check_stream_client_error(hass, "rtsp://foobar") + assert ex.value.error_code is enum_result + + +@pytest.mark.parametrize( + ("options", "expected_pyav_options"), + [ + ( + {}, + {"rtsp_flags": "prefer_tcp", "stimeout": "5000000"}, + ), + ( + {"rtsp_transport": "udp"}, + { + "rtsp_flags": "prefer_tcp", + "rtsp_transport": "udp", + "stimeout": "5000000", + }, + ), + ( + {"use_wallclock_as_timestamps": True}, + { + "rtsp_flags": "prefer_tcp", + "stimeout": "5000000", + "use_wallclock_as_timestamps": "1", + }, + ), + ], +) +async def test_convert_stream_options( + hass: HomeAssistant, + options: dict[str, Any], + expected_pyav_options: dict[str, Any], +) -> None: + """Test stream options.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + container_mock = MagicMock() + source = "rtsp://foobar" + + with patch("av.open", return_value=container_mock) as open_mock: + await async_check_stream_client_error(hass, source, options) + + open_mock.assert_called_once_with( + source, options=expected_pyav_options, timeout=SOURCE_TIMEOUT + ) + container_mock.close.assert_called_once() diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 5577076830b..443103fdf92 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -99,18 +99,17 @@ def make_segment_with_parts( if discontinuity: response.append("#EXT-X-DISCONTINUITY") response.extend( - f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},URI="./segment/{segment}.{i}.m4s"{",INDEPENDENT=YES" if i%independent_period==0 else ""}' + f"#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f}," + f'URI="./segment/{segment}.{i}.m4s"' + f"{',INDEPENDENT=YES' if i % independent_period == 0 else ''}" for i in range(num_parts) ) - response.extend( - [ - "#EXT-X-PROGRAM-DATE-TIME:" - + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", - f"#EXTINF:{math.ceil(SEGMENT_DURATION/TEST_PART_DURATION)*TEST_PART_DURATION:.3f},", - f"./segment/{segment}.m4s", - ] + response.append( + f"#EXT-X-PROGRAM-DATE-TIME:{FAKE_TIME.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3]}Z" ) + duration = math.ceil(SEGMENT_DURATION / TEST_PART_DURATION) * TEST_PART_DURATION + response.append(f"#EXTINF:{duration:.3f},") + response.append(f"./segment/{segment}.m4s") return "\n".join(response) @@ -166,7 +165,7 @@ async def test_ll_hls_stream( # Fetch playlist playlist_url = "/" + master_playlist.splitlines()[-1] playlist_response = await hls_client.get( - playlist_url + f"?_HLS_msn={num_playlist_segments-1}" + playlist_url + f"?_HLS_msn={num_playlist_segments - 1}" ) assert playlist_response.status == HTTPStatus.OK @@ -465,7 +464,8 @@ async def test_ll_hls_playlist_bad_msn_part( ).status == HTTPStatus.BAD_REQUEST assert ( await hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={num_completed_parts-1+hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit}" + "/playlist.m3u8?_HLS_msn=1&_HLS_part=" + f"{num_completed_parts - 1 + hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit}" ) ).status == HTTPStatus.BAD_REQUEST stream_worker_sync.resume() @@ -515,13 +515,13 @@ async def test_ll_hls_playlist_rollover_part( *( [ hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)-1}" + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts) - 1}" ), hls_client.get( f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)}" ), hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)+1}" + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts) + 1}" ), hls_client.get("/playlist.m3u8?_HLS_msn=2&_HLS_part=0"), ] diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 73c51087ef1..2be972cc6a2 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -41,6 +41,7 @@ from homeassistant.components.stream.const import ( TARGET_SEGMENT_DURATION_NON_LL_HLS, ) from homeassistant.components.stream.core import Orientation, StreamSettings +from homeassistant.components.stream.exceptions import StreamClientError from homeassistant.components.stream.worker import ( StreamEndedError, StreamState, @@ -341,7 +342,18 @@ async def async_decode_stream( return py_av.capture_buffer -async def test_stream_open_fails(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exception", "error_code"), + [ + # pylint: disable-next=c-extension-no-member + (av.error.InvalidDataError(-2, "error"), StreamClientError.Other), + (av.HTTPBadRequestError(400, ""), StreamClientError.BadRequest), + (av.HTTPUnauthorizedError(401, ""), StreamClientError.Unauthorized), + ], +) +async def test_stream_open_fails( + hass: HomeAssistant, exception: Exception, error_code: StreamClientError +) -> None: """Test failure on stream open.""" stream = Stream( hass, @@ -352,12 +364,11 @@ async def test_stream_open_fails(hass: HomeAssistant) -> None: ) stream.add_provider(HLS_PROVIDER) with patch("av.open") as av_open: - # pylint: disable-next=c-extension-no-member - av_open.side_effect = av.error.InvalidDataError(-2, "error") - with pytest.raises(StreamWorkerError): + av_open.side_effect = exception + with pytest.raises(StreamWorkerError) as err: run_worker(hass, stream, STREAM_SOURCE) - await hass.async_block_till_done() av_open.assert_called_once() + assert err.value.error_code == error_code async def test_stream_worker_success(hass: HomeAssistant) -> None: diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 92225123995..3d5daab2bec 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -34,6 +34,7 @@ from tests.common import ( mock_integration, mock_platform, mock_restore_cache, + reset_translation_cache, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -518,6 +519,9 @@ async def test_default_engine_prefer_cloud_entity( assert provider_engine.name == "test" assert async_default_engine(hass) == "stt.cloud_stt_entity" + # Reset the `cloud` translations cache to avoid flaky translation checks + reset_translation_cache(hass, ["cloud"]) + async def test_get_engine_legacy( hass: HomeAssistant, tmp_path: Path, mock_provider: MockSTTProvider diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index f634a053c65..73557fd3bde 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -1,20 +1,21 @@ """Common fixtures for the Suez Water tests.""" from collections.abc import Generator +from datetime import date from unittest.mock import AsyncMock, patch from pysuez import AggregatedData, PriceResult from pysuez.const import ATTRIBUTION import pytest -from homeassistant.components.suez_water.const import DOMAIN +from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN from tests.common import MockConfigEntry MOCK_DATA = { "username": "test-username", "password": "test-password", - "counter_id": "test-counter", + CONF_COUNTER_ID: "test-counter", } @@ -22,10 +23,11 @@ MOCK_DATA = { def mock_config_entry() -> MockConfigEntry: """Create mock config_entry needed by suez_water integration.""" return MockConfigEntry( - unique_id=MOCK_DATA["username"], + unique_id=MOCK_DATA[CONF_COUNTER_ID], domain=DOMAIN, title="Suez mock device", data=MOCK_DATA, + version=2, ) @@ -56,22 +58,22 @@ def mock_suez_client() -> Generator[AsyncMock]: result = AggregatedData( value=160, current_month={ - "2024-01-01": 130, - "2024-01-02": 145, + date.fromisoformat("2024-01-01"): 130, + date.fromisoformat("2024-01-02"): 145, }, previous_month={ - "2024-12-01": 154, - "2024-12-02": 166, + date.fromisoformat("2024-12-01"): 154, + date.fromisoformat("2024-12-02"): 166, }, current_year=1500, previous_year=1000, attribution=ATTRIBUTION, highest_monthly_consumption=2558, history={ - "2024-01-01": 130, - "2024-01-02": 145, - "2024-12-01": 154, - "2024-12-02": 166, + date.fromisoformat("2024-01-01"): 130, + date.fromisoformat("2024-01-02"): 145, + date.fromisoformat("2024-12-01"): 154, + date.fromisoformat("2024-12-02"): 166, }, ) diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py index 6779b4c7d02..bebb4fd72ac 100644 --- a/tests/components/suez_water/test_config_flow.py +++ b/tests/components/suez_water/test_config_flow.py @@ -32,8 +32,8 @@ async def test_form( await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["result"].unique_id == "test-username" + assert result["title"] == MOCK_DATA[CONF_COUNTER_ID] + assert result["result"].unique_id == MOCK_DATA[CONF_COUNTER_ID] assert result["data"] == MOCK_DATA assert len(mock_setup_entry.mock_calls) == 1 @@ -63,18 +63,20 @@ async def test_form_invalid_auth( await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["result"].unique_id == "test-username" + assert result["title"] == MOCK_DATA[CONF_COUNTER_ID] + assert result["result"].unique_id == MOCK_DATA[CONF_COUNTER_ID] assert result["data"] == MOCK_DATA assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_already_configured(hass: HomeAssistant) -> None: +async def test_form_already_configured( + hass: HomeAssistant, suez_client: AsyncMock +) -> None: """Test we abort when entry is already configured.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id="test-username", + unique_id=MOCK_DATA[CONF_COUNTER_ID], data=MOCK_DATA, ) entry.add_to_hass(hass) @@ -124,7 +126,7 @@ async def test_form_error( ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" + assert result["title"] == MOCK_DATA[CONF_COUNTER_ID] assert result["data"] == MOCK_DATA assert len(mock_setup_entry.mock_calls) == 1 @@ -139,7 +141,7 @@ async def test_form_auto_counter( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - partial_form = {**MOCK_DATA} + partial_form = MOCK_DATA.copy() partial_form.pop(CONF_COUNTER_ID) suez_client.find_counter.side_effect = PySuezError("test counter not found") @@ -160,7 +162,7 @@ async def test_form_auto_counter( await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["result"].unique_id == "test-username" + assert result["title"] == MOCK_DATA[CONF_COUNTER_ID] + assert result["result"].unique_id == MOCK_DATA[CONF_COUNTER_ID] assert result["data"] == MOCK_DATA assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/suez_water/test_init.py b/tests/components/suez_water/test_init.py index 78d086af38f..16d32b61dee 100644 --- a/tests/components/suez_water/test_init.py +++ b/tests/components/suez_water/test_init.py @@ -2,11 +2,14 @@ from unittest.mock import AsyncMock +from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN from homeassistant.components.suez_water.coordinator import PySuezError from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from . import setup_integration +from .conftest import MOCK_DATA from tests.common import MockConfigEntry @@ -35,3 +38,56 @@ async def test_initialization_setup_api_error( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_migration_version_rollback( + hass: HomeAssistant, + suez_client: AsyncMock, +) -> None: + """Test that downgrading from a future version is not possible.""" + future_entry = MockConfigEntry( + unique_id=MOCK_DATA[CONF_COUNTER_ID], + domain=DOMAIN, + title="Suez mock device", + data=MOCK_DATA, + version=3, + ) + await setup_integration(hass, future_entry) + assert future_entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_no_migration_current_version( + hass: HomeAssistant, + suez_client: AsyncMock, +) -> None: + """Test that a current version does not migrate.""" + current_entry = MockConfigEntry( + unique_id=MOCK_DATA[CONF_COUNTER_ID], + domain=DOMAIN, + title="Suez mock device", + data=MOCK_DATA, + version=2, + ) + await setup_integration(hass, current_entry) + assert current_entry.state is ConfigEntryState.LOADED + assert current_entry.unique_id == MOCK_DATA[CONF_COUNTER_ID] + + +async def test_migration_version_1_to_2( + hass: HomeAssistant, + suez_client: AsyncMock, +) -> None: + """Test that a migration from 1 to 2 changes the unique_id.""" + past_entry = MockConfigEntry( + unique_id=MOCK_DATA[CONF_USERNAME], + domain=DOMAIN, + title=MOCK_DATA[CONF_USERNAME], + data=MOCK_DATA, + version=1, + ) + + await setup_integration(hass, past_entry) + assert past_entry.state is ConfigEntryState.LOADED + assert past_entry.unique_id == MOCK_DATA[CONF_COUNTER_ID] + assert past_entry.title == MOCK_DATA[CONF_USERNAME] + assert past_entry.version == 2 diff --git a/tests/components/suez_water/test_sensor.py b/tests/components/suez_water/test_sensor.py index cb578432f62..950d5d8393d 100644 --- a/tests/components/suez_water/test_sensor.py +++ b/tests/components/suez_water/test_sensor.py @@ -1,5 +1,6 @@ """Test Suez_water sensor platform.""" +from datetime import date from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory @@ -32,6 +33,13 @@ async def test_sensors_valid_state( assert mock_config_entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + state = hass.states.get("sensor.suez_mock_device_water_usage_yesterday") + assert state + previous: dict = state.attributes["previous_month_consumption"] + assert previous + assert previous.get(date.fromisoformat("2024-12-01")) is None + assert previous.get(str(date.fromisoformat("2024-12-01"))) == 154 + @pytest.mark.parametrize("method", [("fetch_aggregated_data"), ("get_price")]) async def test_sensors_failed_update( diff --git a/tests/components/swiss_public_transport/conftest.py b/tests/components/swiss_public_transport/conftest.py index 88bd233765b..03924ba4e2f 100644 --- a/tests/components/swiss_public_transport/conftest.py +++ b/tests/components/swiss_public_transport/conftest.py @@ -1,10 +1,57 @@ """Common fixtures for the swiss_public_transport tests.""" from collections.abc import Generator +import json from unittest.mock import AsyncMock, patch import pytest +from homeassistant.components.swiss_public_transport.const import ( + CONF_DESTINATION, + CONF_START, + DOMAIN, +) + +from tests.common import MockConfigEntry, load_fixture + +START = "Zürich" +DESTINATION = "Bern" + + +@pytest.fixture +def mock_opendata_client() -> Generator[AsyncMock]: + """Mock a Opendata client.""" + with ( + patch( + "homeassistant.components.swiss_public_transport.OpendataTransport", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport", + new=mock_client, + ), + ): + client = mock_client.return_value + client.async_get_data.return_value = None + client.from_name = START + client.to_name = DESTINATION + client.connections = json.loads(load_fixture("connections.json", DOMAIN))[0:3] + yield client + + +@pytest.fixture(name="swiss_public_transport_config_entry") +def mock_swiss_public_transport_config_entry() -> MockConfigEntry: + """Mock cookidoo configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_START: START, + CONF_DESTINATION: DESTINATION, + }, + title=f"{START} {DESTINATION}", + entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + ) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: diff --git a/tests/components/swiss_public_transport/fixtures/connections.json b/tests/components/swiss_public_transport/fixtures/connections.json index 7e61206c366..1e8e5022bdf 100644 --- a/tests/components/swiss_public_transport/fixtures/connections.json +++ b/tests/components/swiss_public_transport/fixtures/connections.json @@ -23,8 +23,7 @@ "platform": 2, "transfers": 0, "duration": "10", - "delay": 0, - "line": "T10" + "delay": 0 }, { "departure": "2024-01-06T18:06:00+0100", diff --git a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..dbd689fc8f6 --- /dev/null +++ b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr @@ -0,0 +1,384 @@ +# serializer version: 1 +# name: test_all_entities[sensor.zurich_bern_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Delay', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'delay', + 'unique_id': 'Zürich Bern_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.zurich_bern_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'device_class': 'duration', + 'friendly_name': 'Zürich Bern Delay', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.zurich_bern_departure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_departure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Departure', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'departure0', + 'unique_id': 'Zürich Bern_departure', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zurich_bern_departure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'device_class': 'timestamp', + 'friendly_name': 'Zürich Bern Departure', + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_departure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-06T17:03:00+00:00', + }) +# --- +# name: test_all_entities[sensor.zurich_bern_departure_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_departure_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Departure +1', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'departure1', + 'unique_id': 'Zürich Bern_departure1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zurich_bern_departure_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'device_class': 'timestamp', + 'friendly_name': 'Zürich Bern Departure +1', + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_departure_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-06T17:04:00+00:00', + }) +# --- +# name: test_all_entities[sensor.zurich_bern_departure_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_departure_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Departure +2', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'departure2', + 'unique_id': 'Zürich Bern_departure2', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zurich_bern_departure_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'device_class': 'timestamp', + 'friendly_name': 'Zürich Bern Departure +2', + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_departure_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-01-06T17:05:00+00:00', + }) +# --- +# name: test_all_entities[sensor.zurich_bern_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Duration', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Zürich Bern_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.zurich_bern_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'device_class': 'duration', + 'friendly_name': 'Zürich Bern Duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[sensor.zurich_bern_line-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_line', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Line', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'line', + 'unique_id': 'Zürich Bern_line', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zurich_bern_line-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'friendly_name': 'Zürich Bern Line', + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_line', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'T10', + }) +# --- +# name: test_all_entities[sensor.zurich_bern_platform-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_platform', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Platform', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'platform', + 'unique_id': 'Zürich Bern_platform', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zurich_bern_platform-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'friendly_name': 'Zürich Bern Platform', + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_platform', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.zurich_bern_transfers-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_transfers', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Transfers', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'transfers', + 'unique_id': 'Zürich Bern_transfers', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zurich_bern_transfers-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'friendly_name': 'Zürich Bern Transfers', + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_transfers', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/swiss_public_transport/test_config_flow.py b/tests/components/swiss_public_transport/test_config_flow.py index 027336e28a6..7c17b0d4c30 100644 --- a/tests/components/swiss_public_transport/test_config_flow.py +++ b/tests/components/swiss_public_transport/test_config_flow.py @@ -12,6 +12,10 @@ from homeassistant.components.swiss_public_transport import config_flow from homeassistant.components.swiss_public_transport.const import ( CONF_DESTINATION, CONF_START, + CONF_TIME_FIXED, + CONF_TIME_MODE, + CONF_TIME_OFFSET, + CONF_TIME_STATION, CONF_VIA, MAX_VIA, ) @@ -23,40 +27,86 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -MOCK_DATA_STEP = { +MOCK_USER_DATA_STEP = { CONF_START: "test_start", CONF_DESTINATION: "test_destination", + CONF_TIME_STATION: "departure", + CONF_TIME_MODE: "now", } -MOCK_DATA_STEP_ONE_VIA = { - **MOCK_DATA_STEP, +MOCK_USER_DATA_STEP_ONE_VIA = { + **MOCK_USER_DATA_STEP, CONF_VIA: ["via_station"], } -MOCK_DATA_STEP_MANY_VIA = { - **MOCK_DATA_STEP, +MOCK_USER_DATA_STEP_MANY_VIA = { + **MOCK_USER_DATA_STEP, CONF_VIA: ["via_station_1", "via_station_2", "via_station_3"], } -MOCK_DATA_STEP_TOO_MANY_STATIONS = { - **MOCK_DATA_STEP, - CONF_VIA: MOCK_DATA_STEP_ONE_VIA[CONF_VIA] * (MAX_VIA + 1), +MOCK_USER_DATA_STEP_TOO_MANY_STATIONS = { + **MOCK_USER_DATA_STEP, + CONF_VIA: MOCK_USER_DATA_STEP_ONE_VIA[CONF_VIA] * (MAX_VIA + 1), +} + +MOCK_USER_DATA_STEP_ARRIVAL = { + **MOCK_USER_DATA_STEP, + CONF_TIME_STATION: "arrival", +} + +MOCK_USER_DATA_STEP_TIME_FIXED = { + **MOCK_USER_DATA_STEP, + CONF_TIME_MODE: "fixed", +} + +MOCK_USER_DATA_STEP_TIME_FIXED_OFFSET = { + **MOCK_USER_DATA_STEP, + CONF_TIME_MODE: "offset", +} + +MOCK_USER_DATA_STEP_BAD = { + **MOCK_USER_DATA_STEP, + CONF_TIME_MODE: "bad", +} + +MOCK_ADVANCED_DATA_STEP_TIME = { + CONF_TIME_FIXED: "18:03:00", +} + +MOCK_ADVANCED_DATA_STEP_TIME_OFFSET = { + CONF_TIME_OFFSET: {"hours": 0, "minutes": 10, "seconds": 0}, } @pytest.mark.parametrize( - ("user_input", "config_title"), + ("user_input", "time_mode_input", "config_title"), [ - (MOCK_DATA_STEP, "test_start test_destination"), - (MOCK_DATA_STEP_ONE_VIA, "test_start test_destination via via_station"), + (MOCK_USER_DATA_STEP, None, "test_start test_destination"), ( - MOCK_DATA_STEP_MANY_VIA, + MOCK_USER_DATA_STEP_ONE_VIA, + None, + "test_start test_destination via via_station", + ), + ( + MOCK_USER_DATA_STEP_MANY_VIA, + None, "test_start test_destination via via_station_1, via_station_2, via_station_3", ), + (MOCK_USER_DATA_STEP_ARRIVAL, None, "test_start test_destination arrival"), + ( + MOCK_USER_DATA_STEP_TIME_FIXED, + MOCK_ADVANCED_DATA_STEP_TIME, + "test_start test_destination at 18:03:00", + ), + ( + MOCK_USER_DATA_STEP_TIME_FIXED_OFFSET, + MOCK_ADVANCED_DATA_STEP_TIME_OFFSET, + "test_start test_destination in 00:10:00", + ), ], ) async def test_flow_user_init_data_success( - hass: HomeAssistant, user_input, config_title + hass: HomeAssistant, user_input, time_mode_input, config_title ) -> None: """Test success response.""" result = await hass.config_entries.flow.async_init( @@ -66,48 +116,56 @@ async def test_flow_user_init_data_success( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["handler"] == "swiss_public_transport" - assert result["data_schema"] == config_flow.DATA_SCHEMA + assert result["data_schema"] == config_flow.USER_DATA_SCHEMA with patch( "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", autospec=True, return_value=True, ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=user_input, ) + if time_mode_input: + assert result["type"] == FlowResultType.FORM + if CONF_TIME_FIXED in time_mode_input: + assert result["step_id"] == "time_fixed" + if CONF_TIME_OFFSET in time_mode_input: + assert result["step_id"] == "time_offset" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=time_mode_input, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["result"].title == config_title - assert result["data"] == user_input + assert result["data"] == {**user_input, **(time_mode_input or {})} @pytest.mark.parametrize( ("raise_error", "text_error", "user_input_error"), [ - (OpendataTransportConnectionError(), "cannot_connect", MOCK_DATA_STEP), - (OpendataTransportError(), "bad_config", MOCK_DATA_STEP), - (None, "too_many_via_stations", MOCK_DATA_STEP_TOO_MANY_STATIONS), - (IndexError(), "unknown", MOCK_DATA_STEP), + (OpendataTransportConnectionError(), "cannot_connect", MOCK_USER_DATA_STEP), + (OpendataTransportError(), "bad_config", MOCK_USER_DATA_STEP), + (None, "too_many_via_stations", MOCK_USER_DATA_STEP_TOO_MANY_STATIONS), + (IndexError(), "unknown", MOCK_USER_DATA_STEP), ], ) -async def test_flow_user_init_data_error_and_recover( +async def test_flow_user_init_data_error_and_recover_on_step_1( hass: HomeAssistant, raise_error, text_error, user_input_error ) -> None: - """Test unknown errors.""" + """Test errors in user step.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) with patch( "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", autospec=True, side_effect=raise_error, ) as mock_OpendataTransport: - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=user_input_error, @@ -121,13 +179,75 @@ async def test_flow_user_init_data_error_and_recover( mock_OpendataTransport.return_value = True result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=MOCK_DATA_STEP, + user_input=MOCK_USER_DATA_STEP, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["result"].title == "test_start test_destination" - assert result["data"] == MOCK_DATA_STEP + assert result["data"] == MOCK_USER_DATA_STEP + + +@pytest.mark.parametrize( + ("raise_error", "text_error", "user_input"), + [ + ( + OpendataTransportConnectionError(), + "cannot_connect", + MOCK_ADVANCED_DATA_STEP_TIME, + ), + (OpendataTransportError(), "bad_config", MOCK_ADVANCED_DATA_STEP_TIME), + (IndexError(), "unknown", MOCK_ADVANCED_DATA_STEP_TIME), + ], +) +async def test_flow_user_init_data_error_and_recover_on_step_2( + hass: HomeAssistant, raise_error, text_error, user_input +) -> None: + """Test errors in time mode step.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["handler"] == "swiss_public_transport" + assert result["data_schema"] == config_flow.USER_DATA_SCHEMA + + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_DATA_STEP_TIME_FIXED, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "time_fixed" + + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + side_effect=raise_error, + ) as mock_OpendataTransport: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == text_error + + # Recover + mock_OpendataTransport.side_effect = None + mock_OpendataTransport.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].title == "test_start test_destination at 18:03:00" async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> None: @@ -135,8 +255,8 @@ async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> No entry = MockConfigEntry( domain=config_flow.DOMAIN, - data=MOCK_DATA_STEP, - unique_id=unique_id_from_config(MOCK_DATA_STEP), + data=MOCK_USER_DATA_STEP, + unique_id=unique_id_from_config(MOCK_USER_DATA_STEP), ) entry.add_to_hass(hass) @@ -151,7 +271,7 @@ async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=MOCK_DATA_STEP, + user_input=MOCK_USER_DATA_STEP, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/swiss_public_transport/test_init.py b/tests/components/swiss_public_transport/test_init.py index 9ad4a8d50b0..963f5e6fa40 100644 --- a/tests/components/swiss_public_transport/test_init.py +++ b/tests/components/swiss_public_transport/test_init.py @@ -7,6 +7,9 @@ import pytest from homeassistant.components.swiss_public_transport.const import ( CONF_DESTINATION, CONF_START, + CONF_TIME_FIXED, + CONF_TIME_OFFSET, + CONF_TIME_STATION, CONF_VIA, DOMAIN, ) @@ -28,6 +31,17 @@ MOCK_DATA_STEP_VIA = { CONF_VIA: ["via_station"], } +MOCK_DATA_STEP_TIME_FIXED = { + **MOCK_DATA_STEP_VIA, + CONF_TIME_FIXED: "18:03:00", +} + +MOCK_DATA_STEP_TIME_OFFSET = { + **MOCK_DATA_STEP_VIA, + CONF_TIME_OFFSET: {"hours": 0, "minutes": 10, "seconds": 0}, + CONF_TIME_STATION: "arrival", +} + CONNECTIONS = [ { "departure": "2024-01-06T18:03:00+0100", @@ -70,6 +84,8 @@ CONNECTIONS = [ (1, 1, MOCK_DATA_STEP_BASE, "None_departure"), (1, 2, MOCK_DATA_STEP_BASE, None), (2, 1, MOCK_DATA_STEP_VIA, None), + (3, 1, MOCK_DATA_STEP_TIME_FIXED, None), + (3, 1, MOCK_DATA_STEP_TIME_OFFSET, None), ], ) async def test_migration_from( @@ -113,7 +129,7 @@ async def test_migration_from( ) # Check change in config entry and verify most recent version - assert config_entry.version == 2 + assert config_entry.version == 3 assert config_entry.minor_version == 1 assert config_entry.unique_id == unique_id @@ -130,7 +146,7 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None: mock_entry = MockConfigEntry( domain=DOMAIN, - version=3, + version=4, minor_version=1, unique_id="some_crazy_future_unique_id", data=MOCK_DATA_STEP_BASE, diff --git a/tests/components/swiss_public_transport/test_sensor.py b/tests/components/swiss_public_transport/test_sensor.py new file mode 100644 index 00000000000..4afdd88c9de --- /dev/null +++ b/tests/components/swiss_public_transport/test_sensor.py @@ -0,0 +1,145 @@ +"""Tests for the swiss_public_transport sensor platform.""" + +import json +from unittest.mock import AsyncMock, patch + +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.swiss_public_transport.const import ( + DEFAULT_UPDATE_TIME, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_fixture, + snapshot_platform, +) +from tests.test_config_entries import FrozenDateTimeFactory + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_opendata_client: AsyncMock, + swiss_public_transport_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, swiss_public_transport_config_entry) + + assert swiss_public_transport_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, swiss_public_transport_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("raise_error"), + [OpendataTransportConnectionError, OpendataTransportError], +) +async def test_fetching_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_opendata_client: AsyncMock, + swiss_public_transport_config_entry: MockConfigEntry, + raise_error: Exception, +) -> None: + """Test fetching data.""" + await setup_integration(hass, swiss_public_transport_config_entry) + + assert swiss_public_transport_config_entry.state is ConfigEntryState.LOADED + + mock_opendata_client.async_get_data.assert_called() + + assert mock_opendata_client.async_get_data.call_count == 2 + + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 8 + + assert ( + hass.states.get("sensor.zurich_bern_departure").state + == "2024-01-06T17:03:00+00:00" + ) + assert ( + hass.states.get("sensor.zurich_bern_departure_1").state + == "2024-01-06T17:04:00+00:00" + ) + assert ( + hass.states.get("sensor.zurich_bern_departure_2").state + == "2024-01-06T17:05:00+00:00" + ) + assert hass.states.get("sensor.zurich_bern_duration").state == "10" + assert hass.states.get("sensor.zurich_bern_platform").state == "0" + assert hass.states.get("sensor.zurich_bern_transfers").state == "0" + assert hass.states.get("sensor.zurich_bern_delay").state == "0" + assert hass.states.get("sensor.zurich_bern_line").state == "T10" + + # Set new data and verify it + mock_opendata_client.connections = json.loads( + load_fixture("connections.json", DOMAIN) + )[3:6] + freezer.tick(DEFAULT_UPDATE_TIME) + async_fire_time_changed(hass) + assert mock_opendata_client.async_get_data.call_count == 3 + assert ( + hass.states.get("sensor.zurich_bern_departure").state + == "2024-01-06T17:06:00+00:00" + ) + + # Simulate fetch exception + mock_opendata_client.async_get_data.side_effect = raise_error + freezer.tick(DEFAULT_UPDATE_TIME) + async_fire_time_changed(hass) + assert mock_opendata_client.async_get_data.call_count == 4 + assert hass.states.get("sensor.zurich_bern_departure").state == "unavailable" + + # Recover and fetch new data again + mock_opendata_client.async_get_data.side_effect = None + mock_opendata_client.connections = json.loads( + load_fixture("connections.json", DOMAIN) + )[6:9] + freezer.tick(DEFAULT_UPDATE_TIME) + async_fire_time_changed(hass) + assert mock_opendata_client.async_get_data.call_count == 5 + assert ( + hass.states.get("sensor.zurich_bern_departure").state + == "2024-01-06T17:09:00+00:00" + ) + + +@pytest.mark.parametrize( + ("raise_error", "state"), + [ + (OpendataTransportConnectionError, ConfigEntryState.SETUP_RETRY), + (OpendataTransportError, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_fetching_data_setup_exception( + hass: HomeAssistant, + mock_opendata_client: AsyncMock, + swiss_public_transport_config_entry: MockConfigEntry, + raise_error: Exception, + state: ConfigEntryState, +) -> None: + """Test fetching data with setup exception.""" + + mock_opendata_client.async_get_data.side_effect = raise_error + + await setup_integration(hass, swiss_public_transport_config_entry) + + assert swiss_public_transport_config_entry.state is state diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index 989b10c11d6..f52c455dabd 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -11,12 +11,7 @@ from homeassistant.setup import async_setup_component from . import common from .common import MockSwitch -from tests.common import ( - MockUser, - help_test_all, - import_and_test_deprecated_constant_enum, - setup_test_component_platform, -) +from tests.common import MockUser, setup_test_component_platform @pytest.fixture(autouse=True) @@ -87,19 +82,3 @@ async def test_switch_context( assert state2 is not None assert state.state != state2.state assert state2.context.user_id == hass_admin_user.id - - -def test_all() -> None: - """Test module.__all__ is correctly set.""" - help_test_all(switch) - - -@pytest.mark.parametrize(("enum"), list(switch.SwitchDeviceClass)) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: switch.SwitchDeviceClass, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, switch, enum, "DEVICE_CLASS_", "2025.1" - ) diff --git a/tests/components/switch_as_x/test_light.py b/tests/components/switch_as_x/test_light.py index 5e48b7db965..5f724a2d7e7 100644 --- a/tests/components/switch_as_x/test_light.py +++ b/tests/components/switch_as_x/test_light.py @@ -3,7 +3,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, @@ -57,7 +57,7 @@ async def test_default_state(hass: HomeAssistant) -> None: assert state.attributes["supported_features"] == 0 assert state.attributes.get(ATTR_BRIGHTNESS) is None assert state.attributes.get(ATTR_HS_COLOR) is None - assert state.attributes.get(ATTR_COLOR_TEMP) is None + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None assert state.attributes.get(ATTR_EFFECT_LIST) is None assert state.attributes.get(ATTR_EFFECT) is None assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ColorMode.ONOFF] diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index bd3985ff062..9ecffd395a3 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -230,3 +230,47 @@ WOMETERTHPC_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + +WORELAY_SWITCH_1PM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="W1080000", + manufacturer_data={2409: b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"<\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="W1080000", + manufacturer_data={2409: b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"<\x00\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "W1080000"), + time=0, + connectable=True, + tx_power=-127, +) + +LEAK_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Any", + manufacturer_data={ + 2409: b"\xd6407D1\x02V\x90\x00\x00\x00\x00\x1e\x05\x00\x00\x00\x00" + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"&\\x00V"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Any", + manufacturer_data={ + 2409: b"\xd6407D1\x02V\x90\x00\x00\x00\x00\x1e\x05\x00\x00\x00\x00" + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"&\\x00V"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Any"), + time=0, + connectable=False, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index b0fba2a5f18..3caa2a1f0df 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -30,6 +30,7 @@ from . import ( WOHAND_SERVICE_INFO, WOHAND_SERVICE_INFO_NOT_CONNECTABLE, WOLOCK_SERVICE_INFO, + WORELAY_SWITCH_1PM_SERVICE_INFO, WOSENSORTH_SERVICE_INFO, init_integration, patch_async_setup_entry, @@ -95,7 +96,7 @@ async def test_bluetooth_discovery_requires_password(hass: HomeAssistant) -> Non assert len(mock_setup_entry.mock_calls) == 1 -async def test_bluetooth_discovery_lock_key(hass: HomeAssistant) -> None: +async def test_bluetooth_discovery_encrypted_key(hass: HomeAssistant) -> None: """Test discovery via bluetooth with a lock.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -103,18 +104,18 @@ async def test_bluetooth_discovery_lock_key(hass: HomeAssistant) -> None: data=WOLOCK_SERVICE_INFO, ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "lock_choose_method" + assert result["step_id"] == "encrypted_choose_method" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"next_step_id": "lock_key"} + result["flow_id"], user_input={"next_step_id": "encrypted_key"} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "lock_key" + assert result["step_id"] == "encrypted_key" assert result["errors"] == {} with patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.verify_encryption_key", + "switchbot.SwitchbotLock.verify_encryption_key", return_value=False, ): result = await hass.config_entries.flow.async_configure( @@ -127,13 +128,13 @@ async def test_bluetooth_discovery_lock_key(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "lock_key" + assert result["step_id"] == "encrypted_key" assert result["errors"] == {"base": "encryption_key_invalid"} with ( patch_async_setup_entry() as mock_setup_entry, patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.verify_encryption_key", + "switchbot.SwitchbotLock.verify_encryption_key", return_value=True, ), ): @@ -158,6 +159,51 @@ async def test_bluetooth_discovery_lock_key(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_bluetooth_discovery_key(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a encrypted device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WORELAY_SWITCH_1PM_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_key"} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_key" + assert result["errors"] == {} + + with ( + patch_async_setup_entry() as mock_setup_entry, + patch( + "switchbot.SwitchbotRelaySwitch.verify_encryption_key", return_value=True + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Relay Switch 1PM EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + CONF_SENSOR_TYPE: "relay_switch_1pm", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_bluetooth_discovery_already_setup(hass: HomeAssistant) -> None: """Test discovery via bluetooth with a valid device when already setup.""" entry = MockConfigEntry( @@ -400,7 +446,7 @@ async def test_user_setup_single_bot_with_password(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_setup_wolock_key(hass: HomeAssistant) -> None: +async def test_user_setup_woencrypted_key(hass: HomeAssistant) -> None: """Test the user initiated form for a lock.""" with patch( @@ -411,18 +457,18 @@ async def test_user_setup_wolock_key(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "lock_choose_method" + assert result["step_id"] == "encrypted_choose_method" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"next_step_id": "lock_key"} + result["flow_id"], user_input={"next_step_id": "encrypted_key"} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "lock_key" + assert result["step_id"] == "encrypted_key" assert result["errors"] == {} with patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.verify_encryption_key", + "switchbot.SwitchbotLock.verify_encryption_key", return_value=False, ): result = await hass.config_entries.flow.async_configure( @@ -435,13 +481,13 @@ async def test_user_setup_wolock_key(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "lock_key" + assert result["step_id"] == "encrypted_key" assert result["errors"] == {"base": "encryption_key_invalid"} with ( patch_async_setup_entry() as mock_setup_entry, patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.verify_encryption_key", + "switchbot.SwitchbotLock.verify_encryption_key", return_value=True, ), ): @@ -466,7 +512,7 @@ async def test_user_setup_wolock_key(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None: +async def test_user_setup_woencrypted_auth(hass: HomeAssistant) -> None: """Test the user initiated form for a lock.""" with patch( @@ -477,18 +523,18 @@ async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "lock_choose_method" + assert result["step_id"] == "encrypted_choose_method" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"next_step_id": "lock_auth"} + result["flow_id"], user_input={"next_step_id": "encrypted_auth"} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "lock_auth" + assert result["step_id"] == "encrypted_auth" assert result["errors"] == {} with patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", + "switchbot.SwitchbotLock.async_retrieve_encryption_key", side_effect=SwitchbotAuthenticationError("error from api"), ): result = await hass.config_entries.flow.async_configure( @@ -500,18 +546,18 @@ async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "lock_auth" + assert result["step_id"] == "encrypted_auth" assert result["errors"] == {"base": "auth_failed"} assert "error from api" in result["description_placeholders"]["error_detail"] with ( patch_async_setup_entry() as mock_setup_entry, patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.verify_encryption_key", + "switchbot.SwitchbotLock.verify_encryption_key", return_value=True, ), patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", + "switchbot.SwitchbotLock.async_retrieve_encryption_key", return_value={ CONF_KEY_ID: "ff", CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", @@ -539,7 +585,9 @@ async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_setup_wolock_auth_switchbot_api_down(hass: HomeAssistant) -> None: +async def test_user_setup_woencrypted_auth_switchbot_api_down( + hass: HomeAssistant, +) -> None: """Test the user initiated form for a lock when the switchbot api is down.""" with patch( @@ -550,18 +598,18 @@ async def test_user_setup_wolock_auth_switchbot_api_down(hass: HomeAssistant) -> DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "lock_choose_method" + assert result["step_id"] == "encrypted_choose_method" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"next_step_id": "lock_auth"} + result["flow_id"], user_input={"next_step_id": "encrypted_auth"} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "lock_auth" + assert result["step_id"] == "encrypted_auth" assert result["errors"] == {} with patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", + "switchbot.SwitchbotLock.async_retrieve_encryption_key", side_effect=SwitchbotAccountConnectionError("Switchbot API down"), ): result = await hass.config_entries.flow.async_configure( @@ -600,20 +648,20 @@ async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "lock_choose_method" + assert result["step_id"] == "encrypted_choose_method" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"next_step_id": "lock_key"} + result["flow_id"], user_input={"next_step_id": "encrypted_key"} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "lock_key" + assert result["step_id"] == "encrypted_key" assert result["errors"] == {} with ( patch_async_setup_entry() as mock_setup_entry, patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.verify_encryption_key", + "switchbot.SwitchbotLock.verify_encryption_key", return_value=True, ), ): @@ -845,3 +893,163 @@ async def test_options_flow_lock_pro(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 0 assert entry.options[CONF_LOCK_NIGHTLATCH] is True + + +async def test_user_setup_worelay_switch_1pm_key(hass: HomeAssistant) -> None: + """Test the user initiated form for a relay switch 1pm.""" + + with patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_key"} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_key" + assert result["errors"] == {} + + with ( + patch_async_setup_entry() as mock_setup_entry, + patch( + "switchbot.SwitchbotRelaySwitch.verify_encryption_key", return_value=True + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Relay Switch 1PM EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + CONF_SENSOR_TYPE: "relay_switch_1pm", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_setup_worelay_switch_1pm_auth(hass: HomeAssistant) -> None: + """Test the user initiated form for a relay switch 1pm.""" + + with patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_auth"} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_auth" + assert result["errors"] == {} + + with patch( + "switchbot.SwitchbotRelaySwitch.async_retrieve_encryption_key", + side_effect=SwitchbotAuthenticationError("error from api"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "", + CONF_PASSWORD: "", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_auth" + assert result["errors"] == {"base": "auth_failed"} + assert "error from api" in result["description_placeholders"]["error_detail"] + + with ( + patch_async_setup_entry() as mock_setup_entry, + patch( + "switchbot.SwitchbotRelaySwitch.async_retrieve_encryption_key", + return_value={ + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + ), + patch( + "switchbot.SwitchbotRelaySwitch.verify_encryption_key", return_value=True + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Relay Switch 1PM EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + CONF_SENSOR_TYPE: "relay_switch_1pm", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_setup_worelay_switch_1pm_auth_switchbot_api_down( + hass: HomeAssistant, +) -> None: + """Test the user initiated form for a relay switch 1pm when the switchbot api is down.""" + + with patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_auth"} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_auth" + assert result["errors"] == {} + + with patch( + "switchbot.SwitchbotRelaySwitch.async_retrieve_encryption_key", + side_effect=SwitchbotAccountConnectionError("Switchbot API down"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "", + CONF_PASSWORD: "", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_error" + assert result["description_placeholders"] == {"error_detail": "Switchbot API down"} diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 3adeaef936c..acf1bacc054 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -1,9 +1,15 @@ """Test the switchbot sensors.""" +from unittest.mock import patch + import pytest from homeassistant.components.sensor import ATTR_STATE_CLASS -from homeassistant.components.switchbot.const import DOMAIN +from homeassistant.components.switchbot.const import ( + CONF_ENCRYPTION_KEY, + CONF_KEY_ID, + DOMAIN, +) from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, @@ -15,7 +21,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import WOHAND_SERVICE_INFO, WOMETERTHPC_SERVICE_INFO +from . import ( + LEAK_SERVICE_INFO, + WOHAND_SERVICE_INFO, + WOMETERTHPC_SERVICE_INFO, + WORELAY_SWITCH_1PM_SERVICE_INFO, +) from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -105,3 +116,81 @@ async def test_co2_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_relay_switch_1pm_power_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the power sensor.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, WORELAY_SWITCH_1PM_SERVICE_INFO) + + with patch( + "switchbot.SwitchbotRelaySwitch.update", + return_value=None, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "relay_switch_1pm", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + unique_id="aabbccddeeaa", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + power_sensor = hass.states.get("sensor.test_name_power") + power_sensor_attrs = power_sensor.attributes + assert power_sensor.state == "4.9" + assert power_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Power" + assert power_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "W" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_leak_sensor(hass: HomeAssistant) -> None: + """Test setting up the leak detector.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, LEAK_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "leak", + }, + unique_id="aabbccddeeaa", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + battery_sensor = hass.states.get("sensor.test_name_battery") + battery_sensor_attrs = battery_sensor.attributes + assert battery_sensor.state == "86" + assert battery_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Battery" + assert battery_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + leak_sensor = hass.states.get("binary_sensor.test_name") + leak_sensor_attrs = leak_sensor.attributes + assert leak_sensor.state == "off" + assert leak_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py new file mode 100644 index 00000000000..df5b7569100 --- /dev/null +++ b/tests/components/switchbot_cloud/test_button.py @@ -0,0 +1,71 @@ +"""Test for the switchbot_cloud bot as a button.""" + +from unittest.mock import patch + +from switchbot_api import BotCommands, Device + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_pressmode_bot( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test press.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "pressMode"} + + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + entity_id = "button.bot_1" + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once_with( + "bot-id-1", BotCommands.PRESS, "command", "default" + ) + + assert hass.states.get(entity_id).state != STATE_UNKNOWN + + +async def test_switchmode_bot_no_button_entity( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test a switchMode bot isn't added as a button.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "switchMode"} + + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert not hass.states.async_entity_ids(BUTTON_DOMAIN) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 43431ae04c0..d5728faf369 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -116,7 +116,7 @@ async def test_setup_entry_fails_when_refreshing( mock_get_status.side_effect = CannotConnect entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.SETUP_RETRY hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py new file mode 100644 index 00000000000..b1c6fb81b96 --- /dev/null +++ b/tests/components/switchbot_cloud/test_switch.py @@ -0,0 +1,116 @@ +"""Test for the switchbot_cloud relay switch & bot.""" + +from unittest.mock import patch + +from switchbot_api import Device + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_relay_switch( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test turn on and turn off.""" + mock_list_devices.return_value = [ + Device( + deviceId="relay-switch-id-1", + deviceName="relay-switch-1", + deviceType="Relay Switch 1", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"switchStatus": 0} + + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + entity_id = "switch.relay_switch_1" + assert hass.states.get(entity_id).state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_switchmode_bot( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test turn on and turn off.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "switchMode", "power": "off"} + + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + entity_id = "switch.bot_1" + assert hass.states.get(entity_id).state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_pressmode_bot_no_switch_entity( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test a pressMode bot isn't added as a switch.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "pressMode"} + + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert not hass.states.async_entity_ids(SWITCH_DOMAIN) diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index 518c36616ee..58172a6962d 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -60,19 +60,11 @@ def mock_api(): patchers = [ patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.connect", + "homeassistant.components.switcher_kis.entity.SwitcherApi.connect", new=api_mock, ), patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.disconnect", - new=api_mock, - ), - patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.connect", - new=api_mock, - ), - patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.disconnect", + "homeassistant.components.switcher_kis.entity.SwitcherApi.disconnect", new=api_mock, ), ] diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index e9d96673e24..57454e38062 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -3,6 +3,7 @@ from aioswitcher.device import ( DeviceState, DeviceType, + ShutterChildLock, ShutterDirection, SwitcherDualShutterSingleLight, SwitcherLight, @@ -90,6 +91,8 @@ DUMMY_POSITION = [54] DUMMY_POSITION_2 = [54, 54] DUMMY_DIRECTION = [ShutterDirection.SHUTTER_STOP] DUMMY_DIRECTION_2 = [ShutterDirection.SHUTTER_STOP, ShutterDirection.SHUTTER_STOP] +DUMMY_CHILD_LOCK = [ShutterChildLock.ON] +DUMMY_CHILD_LOCK_2 = [ShutterChildLock.ON, ShutterChildLock.ON] DUMMY_USERNAME = "email" DUMMY_TOKEN = "zvVvd7JxtN7CgvkD1Psujw==" DUMMY_LIGHT = [DeviceState.ON] @@ -135,6 +138,7 @@ DUMMY_SHUTTER_DEVICE = SwitcherShutter( DUMMY_TOKEN_NEEDED4, DUMMY_POSITION, DUMMY_DIRECTION, + DUMMY_CHILD_LOCK, ) DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE = SwitcherSingleShutterDualLight( @@ -148,6 +152,7 @@ DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE = SwitcherSingleShutterDualLight( DUMMY_TOKEN_NEEDED5, DUMMY_POSITION, DUMMY_DIRECTION, + DUMMY_CHILD_LOCK, DUMMY_LIGHT_2, ) @@ -162,6 +167,7 @@ DUMMY_DUAL_SHUTTER_SINGLE_LIGHT_DEVICE = SwitcherDualShutterSingleLight( DUMMY_TOKEN_NEEDED6, DUMMY_POSITION_2, DUMMY_DIRECTION_2, + DUMMY_CHILD_LOCK_2, DUMMY_LIGHT, ) diff --git a/tests/components/switcher_kis/test_button.py b/tests/components/switcher_kis/test_button.py index 50c015b4024..6ebd82363e4 100644 --- a/tests/components/switcher_kis/test_button.py +++ b/tests/components/switcher_kis/test_button.py @@ -42,7 +42,7 @@ async def test_assume_button( assert hass.states.get(SWING_OFF_EID) is None with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( BUTTON_DOMAIN, @@ -79,7 +79,7 @@ async def test_swing_button( assert hass.states.get(SWING_OFF_EID) is not None with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( BUTTON_DOMAIN, @@ -103,7 +103,7 @@ async def test_control_device_fail( # Test exception during set hvac mode with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -130,7 +130,7 @@ async def test_control_device_fail( # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_climate.py b/tests/components/switcher_kis/test_climate.py index 72e1a93d1c3..72a25d20d04 100644 --- a/tests/components/switcher_kis/test_climate.py +++ b/tests/components/switcher_kis/test_climate.py @@ -49,7 +49,7 @@ async def test_climate_hvac_mode( # Test set hvac mode heat with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -71,7 +71,7 @@ async def test_climate_hvac_mode( # Test set hvac mode off with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -108,7 +108,7 @@ async def test_climate_temperature( # Test set target temperature with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -128,7 +128,7 @@ async def test_climate_temperature( # Test set target temperature - incorrect params with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: with pytest.raises(ServiceValidationError): await hass.services.async_call( @@ -160,7 +160,7 @@ async def test_climate_fan_level( # Test set fan level to high with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -195,7 +195,7 @@ async def test_climate_swing( # Test set swing mode on with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -218,7 +218,7 @@ async def test_climate_swing( # Test set swing mode off with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", ) as mock_control_device: await hass.services.async_call( CLIMATE_DOMAIN, @@ -249,7 +249,7 @@ async def test_control_device_fail(hass: HomeAssistant, mock_bridge, mock_api) - # Test exception during set hvac mode with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -276,7 +276,7 @@ async def test_control_device_fail(hass: HomeAssistant, mock_bridge, mock_api) - # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.climate.SwitcherApi.control_breeze_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_breeze_device", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_cover.py b/tests/components/switcher_kis/test_cover.py index 2936cafdd53..5829d6345ef 100644 --- a/tests/components/switcher_kis/test_cover.py +++ b/tests/components/switcher_kis/test_cover.py @@ -115,7 +115,7 @@ async def test_cover( # Test set position with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position" + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_position" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -136,7 +136,7 @@ async def test_cover( # Test open with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position" + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_position" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -156,7 +156,7 @@ async def test_cover( # Test close with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position" + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_position" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -176,7 +176,7 @@ async def test_cover( # Test stop with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.stop_shutter" + "homeassistant.components.switcher_kis.entity.SwitcherApi.stop_shutter" ) as mock_control_device: await hass.services.async_call( COVER_DOMAIN, @@ -232,7 +232,7 @@ async def test_cover_control_fail( # Test exception during set position with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_position", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -257,7 +257,7 @@ async def test_cover_control_fail( # Test error response during set position with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_position", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_position", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_light.py b/tests/components/switcher_kis/test_light.py index aa7d6551d75..51d0eb6332f 100644 --- a/tests/components/switcher_kis/test_light.py +++ b/tests/components/switcher_kis/test_light.py @@ -86,7 +86,7 @@ async def test_light( # Test turning on light with patch( - "homeassistant.components.switcher_kis.light.SwitcherApi.set_light", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_light", ) as mock_set_light: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -99,7 +99,7 @@ async def test_light( # Test turning off light with patch( - "homeassistant.components.switcher_kis.light.SwitcherApi.set_light" + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_light" ) as mock_set_light: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -153,7 +153,7 @@ async def test_light_control_fail( # Test exception during turn on with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_light", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_light", side_effect=RuntimeError("fake error"), ) as mock_control_device: with pytest.raises(HomeAssistantError): @@ -178,7 +178,7 @@ async def test_light_control_fail( # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.cover.SwitcherApi.set_light", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_light", return_value=SwitcherBaseResponse(None), ) as mock_control_device: with pytest.raises(HomeAssistantError): diff --git a/tests/components/switcher_kis/test_services.py b/tests/components/switcher_kis/test_services.py index 65e1967cbac..b4a8168419f 100644 --- a/tests/components/switcher_kis/test_services.py +++ b/tests/components/switcher_kis/test_services.py @@ -16,6 +16,7 @@ from homeassistant.components.switcher_kis.const import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import time_period_str from homeassistant.util import slugify @@ -48,7 +49,7 @@ async def test_turn_on_with_timer_service( assert state.state == STATE_OFF with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_device" ) as mock_control_device: await hass.services.async_call( DOMAIN, @@ -78,7 +79,7 @@ async def test_set_auto_off_service(hass: HomeAssistant, mock_bridge, mock_api) entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown" + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_auto_shutdown" ) as mock_set_auto_shutdown: await hass.services.async_call( DOMAIN, @@ -95,7 +96,7 @@ async def test_set_auto_off_service(hass: HomeAssistant, mock_bridge, mock_api) @pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) async def test_set_auto_off_service_fail( - hass: HomeAssistant, mock_bridge, mock_api, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, mock_bridge, mock_api ) -> None: """Test set auto off service failed.""" await init_integration(hass) @@ -105,24 +106,21 @@ async def test_set_auto_off_service_fail( entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown", + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_auto_shutdown", return_value=None, ) as mock_set_auto_shutdown: - await hass.services.async_call( - DOMAIN, - SERVICE_SET_AUTO_OFF_NAME, - {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_AUTO_OFF_NAME, + {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, + ) assert mock_api.call_count == 2 mock_set_auto_shutdown.assert_called_once_with( time_period_str(DUMMY_AUTO_OFF_SET) ) - assert ( - f"Call api for {device.name} failed, api: 'set_auto_shutdown'" - in caplog.text - ) state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index 443c7bc930d..c20149de074 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioswitcher.api import Command, SwitcherBaseResponse +from aioswitcher.api import Command, ShutterChildLock, SwitcherBaseResponse from aioswitcher.device import DeviceState import pytest @@ -16,10 +16,24 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import slugify from . import init_integration -from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE +from .consts import ( + DUMMY_DUAL_SHUTTER_SINGLE_LIGHT_DEVICE as DEVICE3, + DUMMY_PLUG_DEVICE, + DUMMY_SHUTTER_DEVICE as DEVICE, + DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE as DEVICE2, + DUMMY_TOKEN as TOKEN, + DUMMY_USERNAME as USERNAME, + DUMMY_WATER_HEATER_DEVICE, +) + +ENTITY_ID = f"{SWITCH_DOMAIN}.{slugify(DEVICE.name)}_child_lock" +ENTITY_ID2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE2.name)}_child_lock" +ENTITY_ID3 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_1" +ENTITY_ID3_2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_2" @pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) @@ -47,7 +61,7 @@ async def test_switch( # Test turning on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_device", ) as mock_control_device: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -60,7 +74,7 @@ async def test_switch( # Test turning off with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_device" ) as mock_control_device: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -78,7 +92,6 @@ async def test_switch_control_fail( mock_bridge, mock_api, monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, ) -> None: """Test switch control fail.""" await init_integration(hass) @@ -97,18 +110,19 @@ async def test_switch_control_fail( # Test exception during turn on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_device", side_effect=RuntimeError("fake error"), ) as mock_control_device: - await hass.services.async_call( - SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) assert mock_api.call_count == 2 mock_control_device.assert_called_once_with(Command.ON) - assert ( - f"Call api for {device.name} failed, api: 'control_device'" in caplog.text - ) state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE @@ -121,17 +135,207 @@ async def test_switch_control_fail( # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.entity.SwitcherApi.control_device", return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(Command.ON) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_switch( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test the switch.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test state change on --> off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test turning on child lock + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", ) as mock_control_device: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert mock_api.call_count == 4 - mock_control_device.assert_called_once_with(Command.ON) - assert ( - f"Call api for {device.name} failed, api: 'control_device'" in caplog.text + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock" + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.OFF, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_control_fail( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test switch control fail.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test exception during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test error response during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index b79e63e1ce7..727b95563cc 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -6,12 +6,19 @@ from unittest.mock import patch from pysyncthru import SyncThruAPINotSupported from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.syncthru.config_flow import SyncThru from homeassistant.components.syncthru.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -138,16 +145,16 @@ async def test_ssdp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.2:5200/Printer.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Printer:1", - ssdp.ATTR_UPNP_MANUFACTURER: "Samsung Electronics", - ssdp.ATTR_UPNP_PRESENTATION_URL: url, - ssdp.ATTR_UPNP_SERIAL: "00000000", - ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Printer:1", + ATTR_UPNP_MANUFACTURER: "Samsung Electronics", + ATTR_UPNP_PRESENTATION_URL: url, + ATTR_UPNP_SERIAL: "00000000", + ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", }, ), ) diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index e5494b7179f..3ef47292a9b 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -13,7 +13,6 @@ from synology_dsm.exceptions import ( ) from syrupy import SnapshotAssertion -from homeassistant.components import ssdp, zeroconf from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( CONF_SNAPSHOT_QUALITY, @@ -34,6 +33,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .consts import ( DEVICE_TOKEN, @@ -418,13 +423,13 @@ async def test_form_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX99", # MAC address, but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX99", # MAC address, but SSDP does not have `-` }, ), ) @@ -465,13 +470,13 @@ async def test_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -508,13 +513,13 @@ async def test_skip_reconfig_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{new_host}:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -541,13 +546,13 @@ async def test_existing_ssdp(hass: HomeAssistant, service: MagicMock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -606,7 +611,7 @@ async def test_discovered_via_zeroconf( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], port=5000, @@ -645,7 +650,7 @@ async def test_discovered_via_zeroconf_missing_mac( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], port=5000, diff --git a/tests/components/system_bridge/__init__.py b/tests/components/system_bridge/__init__.py index 0606ce8e258..89bd1b652ba 100644 --- a/tests/components/system_bridge/__init__.py +++ b/tests/components/system_bridge/__init__.py @@ -15,9 +15,9 @@ from systembridgemodels.fixtures.modules.processes import FIXTURE_PROCESSES from systembridgemodels.fixtures.modules.system import FIXTURE_SYSTEM from systembridgemodels.modules import Module, ModulesData -from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -44,7 +44,7 @@ FIXTURE_ZEROCONF_INPUT = { CONF_PORT: "9170", } -FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( +FIXTURE_ZEROCONF = ZeroconfServiceInfo( ip_address=ip_address(FIXTURE_USER_INPUT[CONF_HOST]), ip_addresses=[ip_address(FIXTURE_USER_INPUT[CONF_HOST])], port=9170, @@ -62,7 +62,7 @@ FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( }, ) -FIXTURE_ZEROCONF_BAD = zeroconf.ZeroconfServiceInfo( +FIXTURE_ZEROCONF_BAD = ZeroconfServiceInfo( ip_address=ip_address(FIXTURE_USER_INPUT[CONF_HOST]), ip_addresses=[ip_address(FIXTURE_USER_INPUT[CONF_HOST])], port=9170, diff --git a/tests/components/systemmonitor/test_config_flow.py b/tests/components/systemmonitor/test_config_flow.py index f5cc46da096..60a70da27a2 100644 --- a/tests/components/systemmonitor/test_config_flow.py +++ b/tests/components/systemmonitor/test_config_flow.py @@ -48,17 +48,9 @@ async def test_form_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["step_id"] == "user" - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" async def test_add_and_remove_processes( diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index 6d22c5354a4..a5f5e7623e9 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -5,7 +5,7 @@ import socket from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory -from psutil._common import sdiskusage, shwtemp, snetio, snicaddr +from psutil._common import sdiskpart, sdiskusage, shwtemp, snetio, snicaddr import pytest from syrupy.assertion import SnapshotAssertion @@ -504,3 +504,43 @@ async def test_remove_obsolete_entities( entity_registry.async_get("sensor.systemmonitor_network_out_veth54321") is not None ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_duplicate_disk_entities( + hass: HomeAssistant, + mock_psutil: Mock, + mock_os: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the sensor.""" + mock_psutil.disk_usage.return_value = sdiskusage( + 500 * 1024**3, 300 * 1024**3, 200 * 1024**3, 60.0 + ) + mock_psutil.disk_partitions.return_value = [ + sdiskpart("test", "/", "ext4", ""), + sdiskpart("test2", "/media/share", "ext4", ""), + sdiskpart("test3", "/incorrect", "", ""), + sdiskpart("test4", "/media/frigate", "ext4", ""), + sdiskpart("test4", "/media/FRIGATE", "ext4", ""), + sdiskpart("hosts", "/etc/hosts", "bind", ""), + sdiskpart("proc", "/proc/run", "proc", ""), + ] + + mock_config_entry = MockConfigEntry( + title="System Monitor", + domain=DOMAIN, + data={}, + options={ + "binary_sensor": {"process": ["python3", "pip"]}, + }, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + disk_sensor = hass.states.get("sensor.system_monitor_disk_usage_media_frigate") + assert disk_sensor is not None + assert disk_sensor.state == "60.0" + + assert "Platform systemmonitor does not generate unique IDs." not in caplog.text diff --git a/tests/components/tado/snapshots/test_climate.ambr b/tests/components/tado/snapshots/test_climate.ambr new file mode 100644 index 00000000000..6ba35b6f6f2 --- /dev/null +++ b/tests/components/tado/snapshots/test_climate.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_aircon_set_hvac_mode[cool-COOL] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + 24.76, + None, + 'AIR_CONDITIONING', + 'ON', + 'COOL', + 'AUTO', + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[dry-DRY] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + 24.76, + None, + 'AIR_CONDITIONING', + 'ON', + 'DRY', + None, + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[fan_only-FAN] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + None, + None, + 'AIR_CONDITIONING', + 'ON', + 'FAN', + None, + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[heat-HEAT] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + 24.76, + None, + 'AIR_CONDITIONING', + 'ON', + 'HEAT', + 'AUTO', + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[off-OFF] + _Call( + tuple( + 3, + 'MANUAL', + None, + None, + 'AIR_CONDITIONING', + 'OFF', + ), + dict({ + }), + ) +# --- +# name: test_heater_set_temperature + _Call( + tuple( + 1, + 'NEXT_TIME_BLOCK', + 22.0, + None, + 'HEATING', + 'ON', + 'HEAT', + None, + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py index 5a43c728b6e..0699551c9c0 100644 --- a/tests/components/tado/test_climate.py +++ b/tests/components/tado/test_climate.py @@ -1,5 +1,19 @@ """The sensor tests for the tado platform.""" +from unittest.mock import patch + +from PyTado.interface.api.my_tado import TadoZone +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from .util import async_init_integration @@ -121,3 +135,104 @@ async def test_smartac_with_fanlevel_vertical_and_horizontal_swing( # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all(item in state.attributes.items() for item in expected_attributes.items()) + + +async def test_heater_set_temperature( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test the set temperature of the heater.""" + + await async_init_integration(hass) + + with ( + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_overlay" + ) as mock_set_state, + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.get_zone_state", + return_value={"setting": {"temperature": {"celsius": 22.0}}}, + ), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.baseboard_heater", ATTR_TEMPERATURE: 22.0}, + blocking=True, + ) + + mock_set_state.assert_called_once() + snapshot.assert_match(mock_set_state.call_args) + + +@pytest.mark.parametrize( + ("hvac_mode", "set_hvac_mode"), + [ + (HVACMode.HEAT, "HEAT"), + (HVACMode.DRY, "DRY"), + (HVACMode.FAN_ONLY, "FAN"), + (HVACMode.COOL, "COOL"), + (HVACMode.OFF, "OFF"), + ], +) +async def test_aircon_set_hvac_mode( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hvac_mode: HVACMode, + set_hvac_mode: str, +) -> None: + """Test the set hvac mode of the air conditioning.""" + + await async_init_integration(hass) + + with ( + patch( + "homeassistant.components.tado.__init__.PyTado.interface.api.Tado.set_zone_overlay" + ) as mock_set_state, + patch( + "homeassistant.components.tado.__init__.PyTado.interface.api.Tado.get_zone_state", + return_value=TadoZone( + zone_id=1, + current_temp=18.7, + connection=None, + current_temp_timestamp="2025-01-02T12:51:52.802Z", + current_humidity=45.1, + current_humidity_timestamp="2025-01-02T12:51:52.802Z", + is_away=False, + current_hvac_action="IDLE", + current_fan_speed=None, + current_fan_level=None, + current_hvac_mode=set_hvac_mode, + current_swing_mode="OFF", + current_vertical_swing_mode="OFF", + current_horizontal_swing_mode="OFF", + target_temp=16.0, + available=True, + power="ON", + link="ONLINE", + ac_power_timestamp=None, + heating_power_timestamp="2025-01-02T13:01:11.758Z", + ac_power=None, + heating_power=None, + heating_power_percentage=0.0, + tado_mode="HOME", + overlay_termination_type="MANUAL", + overlay_termination_timestamp=None, + default_overlay_termination_type="MANUAL", + default_overlay_termination_duration=None, + preparation=False, + open_window=False, + open_window_detected=False, + open_window_attr={}, + precision=0.1, + ), + ), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.air_conditioning", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + mock_set_state.assert_called_once() + snapshot.assert_match(mock_set_state.call_args) diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 63b17dad13e..19acb0aecbd 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -9,7 +9,6 @@ import pytest import requests from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.tado.config_flow import NoHomes from homeassistant.components.tado.const import ( CONF_FALLBACK, @@ -19,16 +18,20 @@ from homeassistant.components.tado.const import ( from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry -def _get_mock_tado_api(getMe=None) -> MagicMock: +def _get_mock_tado_api(get_me=None) -> MagicMock: mock_tado = MagicMock() - if isinstance(getMe, Exception): - type(mock_tado).getMe = MagicMock(side_effect=getMe) + if isinstance(get_me, Exception): + type(mock_tado).get_me = MagicMock(side_effect=get_me) else: - type(mock_tado).getMe = MagicMock(return_value=getMe) + type(mock_tado).get_me = MagicMock(return_value=get_me) return mock_tado @@ -61,7 +64,7 @@ async def test_form_exceptions( assert result["errors"] == {"base": error} # Test a retry to recover, upon failure - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( @@ -131,7 +134,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( @@ -166,7 +169,9 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: response_mock = MagicMock() type(response_mock).status_code = HTTPStatus.UNAUTHORIZED - mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock)) + mock_tado_api = _get_mock_tado_api( + get_me=requests.HTTPError(response=response_mock) + ) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -189,7 +194,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: response_mock = MagicMock() type(response_mock).status_code = HTTPStatus.INTERNAL_SERVER_ERROR - mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock)) + mock_tado_api = _get_mock_tado_api( + get_me=requests.HTTPError(response=response_mock) + ) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -210,7 +217,7 @@ async def test_no_homes(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_tado_api = _get_mock_tado_api(getMe={"homes": []}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": []}) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -231,13 +238,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -258,13 +265,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -314,7 +321,7 @@ async def test_reconfigure_flow( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( "homeassistant.components.tado.config_flow.Tado", diff --git a/tests/components/tado/test_helper.py b/tests/components/tado/test_helper.py index bdd7977f858..da959c2124a 100644 --- a/tests/components/tado/test_helper.py +++ b/tests/components/tado/test_helper.py @@ -1,45 +1,94 @@ """Helper method tests.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch -from homeassistant.components.tado import TadoConnector +from PyTado.interface import Tado +import pytest + +from homeassistant.components.tado import TadoDataUpdateCoordinator from homeassistant.components.tado.const import ( CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TIMER, + DOMAIN, ) from homeassistant.components.tado.helper import decide_duration, decide_overlay_mode +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry -def dummy_tado_connector(hass: HomeAssistant, fallback) -> TadoConnector: + +@pytest.fixture +def entry(request: pytest.FixtureRequest) -> MockConfigEntry: + """Fixture for ConfigEntry with optional fallback.""" + fallback = ( + request.param if hasattr(request, "param") else CONST_OVERLAY_TADO_DEFAULT + ) + return MockConfigEntry( + version=1, + minor_version=1, + domain=DOMAIN, + title="Tado", + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + options={ + "fallback": fallback, + }, + ) + + +@pytest.fixture +def tado() -> Tado: + """Fixture for Tado instance.""" + with patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_overlay" + ) as mock_set_zone_overlay: + instance = MagicMock(spec=Tado) + instance.set_zone_overlay = mock_set_zone_overlay + yield instance + + +def dummy_tado_connector( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> TadoDataUpdateCoordinator: """Return dummy tado connector.""" - return TadoConnector(hass, username="dummy", password="dummy", fallback=fallback) + return TadoDataUpdateCoordinator(hass, entry, tado) -async def test_overlay_mode_duration_set(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_MODE], indirect=True) +async def test_overlay_mode_duration_set( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when duration is set.""" - tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_TADO_MODE) - overlay_mode = decide_overlay_mode(tado=tado, duration=3600, zone_id=1) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) + overlay_mode = decide_overlay_mode(coordinator=tado, duration=3600, zone_id=1) # Must select TIMER overlay assert overlay_mode == CONST_OVERLAY_TIMER -async def test_overlay_mode_next_time_block_fallback(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_MODE], indirect=True) +async def test_overlay_mode_next_time_block_fallback( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when duration is not set.""" - integration_fallback = CONST_OVERLAY_TADO_MODE - tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) - overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=1) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) + overlay_mode = decide_overlay_mode(coordinator=tado, duration=None, zone_id=1) # Must fallback to integration wide setting - assert overlay_mode == integration_fallback + assert overlay_mode == CONST_OVERLAY_TADO_MODE -async def test_overlay_mode_tado_default_fallback(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_DEFAULT], indirect=True) +async def test_overlay_mode_tado_default_fallback( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when tado default is selected.""" - integration_fallback = CONST_OVERLAY_TADO_DEFAULT zone_fallback = CONST_OVERLAY_MANUAL - tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) class MockZoneData: def __init__(self) -> None: @@ -49,28 +98,40 @@ async def test_overlay_mode_tado_default_fallback(hass: HomeAssistant) -> None: zone_data = {"zone": {zone_id: MockZoneData()}} with patch.dict(tado.data, zone_data): - overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=zone_id) + overlay_mode = decide_overlay_mode( + coordinator=tado, duration=None, zone_id=zone_id + ) # Must fallback to zone setting assert overlay_mode == zone_fallback -async def test_duration_enabled_without_tado_default(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_MANUAL], indirect=True) +async def test_duration_enabled_without_tado_default( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test duration decide method when overlay is timer and duration is set.""" overlay = CONST_OVERLAY_TIMER expected_duration = 600 - tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_MANUAL) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) duration = decide_duration( - tado=tado, duration=expected_duration, overlay_mode=overlay, zone_id=0 + coordinator=tado, duration=expected_duration, overlay_mode=overlay, zone_id=0 ) # Should return the same duration value assert duration == expected_duration -async def test_duration_enabled_with_tado_default(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TIMER], indirect=True) +async def test_duration_enabled_with_tado_default( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when ended up with timer overlay and None duration.""" zone_fallback = CONST_OVERLAY_TIMER expected_duration = 45000 - tado = dummy_tado_connector(hass=hass, fallback=zone_fallback) + tado = dummy_tado_connector( + hass=hass, + entry=entry, + tado=tado, + ) class MockZoneData: def __init__(self) -> None: @@ -81,7 +142,7 @@ async def test_duration_enabled_with_tado_default(hass: HomeAssistant) -> None: zone_data = {"zone": {zone_id: MockZoneData()}} with patch.dict(tado.data, zone_data): duration = decide_duration( - tado=tado, duration=None, zone_id=zone_id, overlay_mode=zone_fallback + coordinator=tado, duration=None, zone_id=zone_id, overlay_mode=zone_fallback ) # Must fallback to zone timer setting assert duration == expected_duration diff --git a/tests/components/tado/test_service.py b/tests/components/tado/test_service.py index 994f135199f..336bef55ea1 100644 --- a/tests/components/tado/test_service.py +++ b/tests/components/tado/test_service.py @@ -40,7 +40,7 @@ async def test_add_meter_readings( config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] fixture: str = load_fixture("tado/add_readings_success.json") with patch( - "PyTado.interface.Tado.set_eiq_meter_readings", + "PyTado.interface.api.Tado.set_eiq_meter_readings", return_value=json.loads(fixture), ): response: None = await hass.services.async_call( @@ -65,7 +65,7 @@ async def test_add_meter_readings_exception( config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] with ( patch( - "PyTado.interface.Tado.set_eiq_meter_readings", + "PyTado.interface.api.Tado.set_eiq_meter_readings", side_effect=RequestException("Error"), ), pytest.raises(HomeAssistantError) as exc, @@ -80,7 +80,7 @@ async def test_add_meter_readings_exception( blocking=True, ) - assert "Could not set meter reading" in str(exc) + assert "Error setting Tado meter reading: Error" in str(exc.value) async def test_add_meter_readings_invalid( @@ -94,7 +94,7 @@ async def test_add_meter_readings_invalid( fixture: str = load_fixture("tado/add_readings_invalid_meter_reading.json") with ( patch( - "PyTado.interface.Tado.set_eiq_meter_readings", + "PyTado.interface.api.Tado.set_eiq_meter_readings", return_value=json.loads(fixture), ), pytest.raises(HomeAssistantError) as exc, @@ -123,7 +123,7 @@ async def test_add_meter_readings_duplicate( fixture: str = load_fixture("tado/add_readings_duplicated_meter_reading.json") with ( patch( - "PyTado.interface.Tado.set_eiq_meter_readings", + "PyTado.interface.api.Tado.set_eiq_meter_readings", return_value=json.loads(fixture), ), pytest.raises(HomeAssistantError) as exc, diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index a76858ab98e..5bf87dbed33 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -188,3 +188,8 @@ async def async_init_integration( if not skip_setup: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + + # For a first refresh + await entry.runtime_data.coordinator.async_refresh() + await entry.runtime_data.mobile_coordinator.async_refresh() + await hass.async_block_till_done() diff --git a/tests/components/tag/snapshots/test_init.ambr b/tests/components/tag/snapshots/test_init.ambr index 29a9a2665b8..caa88b8ca9a 100644 --- a/tests/components/tag/snapshots/test_init.ambr +++ b/tests/components/tag/snapshots/test_init.ambr @@ -5,8 +5,6 @@ 'items': list([ dict({ 'id': 'test tag id', - 'migrated': True, - 'name': 'test tag name', }), dict({ 'device_id': 'some_scanner', @@ -23,3 +21,24 @@ 'version': 1, }) # --- +# name: test_tag_scanned + dict({ + 'data': dict({ + 'items': list([ + dict({ + 'id': 'test tag id', + }), + dict({ + 'id': 'test tag id 2', + }), + dict({ + 'device_id': 'some_scanner', + 'id': 'new tag', + }), + ]), + }), + 'key': 'tag', + 'minor_version': 3, + 'version': 1, + }) +# --- diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index 5c1e80c2d8b..ac862e59f2d 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -6,6 +6,7 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion +from syrupy.filters import props from homeassistant.components.tag import DOMAIN, _create_entry, async_scan_tag from homeassistant.const import CONF_NAME, STATE_UNKNOWN @@ -165,7 +166,9 @@ async def test_tag_scanned( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], storage_setup, + snapshot: SnapshotAssertion, ) -> None: """Test scanning tags.""" assert await storage_setup() @@ -205,6 +208,12 @@ async def test_tag_scanned( }, ] + # Trigger store + freezer.tick(11) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass_storage[DOMAIN] == snapshot(exclude=props("last_scanned")) + def track_changes(coll: collection.ObservableCollection): """Create helper to track changes in a collection.""" diff --git a/tests/components/tailwind/snapshots/test_config_flow.ambr b/tests/components/tailwind/snapshots/test_config_flow.ambr deleted file mode 100644 index 09bf25cb96e..00000000000 --- a/tests/components/tailwind/snapshots/test_config_flow.ambr +++ /dev/null @@ -1,89 +0,0 @@ -# serializer version: 1 -# name: test_user_flow - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': '3c:e9:0e:6d:21:84', - }), - 'data': dict({ - 'host': '127.0.0.1', - 'token': '987654', - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'tailwind', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'host': '127.0.0.1', - 'token': '987654', - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'tailwind', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': 'Tailwind iQ3', - 'unique_id': '3c:e9:0e:6d:21:84', - 'version': 1, - }), - 'title': 'Tailwind iQ3', - 'type': , - 'version': 1, - }) -# --- -# name: test_zeroconf_flow - FlowResultSnapshot({ - 'context': dict({ - 'configuration_url': 'https://web.gotailwind.com/client/integration/local-control-key', - 'source': 'zeroconf', - 'title_placeholders': dict({ - 'name': 'Tailwind iQ3', - }), - 'unique_id': '3c:e9:0e:6d:21:84', - }), - 'data': dict({ - 'host': '127.0.0.1', - 'token': '987654', - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'tailwind', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'host': '127.0.0.1', - 'token': '987654', - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'tailwind', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'zeroconf', - 'title': 'Tailwind iQ3', - 'unique_id': '3c:e9:0e:6d:21:84', - 'version': 1, - }), - 'title': 'Tailwind iQ3', - 'type': , - 'version': 1, - }) -# --- diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index d2d15172718..2e8a8e7a727 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -11,13 +11,13 @@ from gotailwind import ( import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.tailwind.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -25,20 +25,17 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.usefixtures("mock_tailwind") -async def test_user_flow( - hass: HomeAssistant, - snapshot: SnapshotAssertion, -) -> None: +async def test_user_flow(hass: HomeAssistant) -> None: """Test the full happy path user flow from start to finish.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_HOST: "127.0.0.1", @@ -46,8 +43,15 @@ async def test_user_flow( }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "3c:e9:0e:6d:21:84" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + } + assert not config_entry.options @pytest.mark.parametrize( @@ -76,19 +80,27 @@ async def test_user_flow_errors( }, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == expected_error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == expected_error mock_tailwind.status.side_effect = None - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_HOST: "127.0.0.2", CONF_TOKEN: "123456", }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "3c:e9:0e:6d:21:84" + assert config_entry.data == { + CONF_HOST: "127.0.0.2", + CONF_TOKEN: "123456", + } + assert not config_entry.options async def test_user_flow_unsupported_firmware_version( @@ -105,8 +117,8 @@ async def test_user_flow_unsupported_firmware_version( }, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "unsupported_firmware" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_firmware" @pytest.mark.usefixtures("mock_tailwind") @@ -129,8 +141,8 @@ async def test_user_flow_already_configured( }, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" assert mock_config_entry.data[CONF_TOKEN] == "987654" @@ -144,7 +156,7 @@ async def test_zeroconf_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -160,19 +172,26 @@ async def test_zeroconf_flow( ), ) - assert result.get("step_id") == "zeroconf_confirm" - assert result.get("type") is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] is FlowResultType.FORM progress = hass.config_entries.flow.async_progress() assert len(progress) == 1 assert progress[0].get("flow_id") == result["flow_id"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_TOKEN: "987654"} ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "3c:e9:0e:6d:21:84" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + } + assert not config_entry.options @pytest.mark.parametrize( @@ -189,7 +208,7 @@ async def test_zeroconf_flow_abort_incompatible_properties( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -200,8 +219,8 @@ async def test_zeroconf_flow_abort_incompatible_properties( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == expected_reason + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason @pytest.mark.parametrize( @@ -224,7 +243,7 @@ async def test_zeroconf_flow_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -240,25 +259,33 @@ async def test_zeroconf_flow_errors( ), ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "zeroconf_confirm" - assert result2.get("errors") == expected_error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["errors"] == expected_error mock_tailwind.status.side_effect = None - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "3c:e9:0e:6d:21:84" + assert config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "123456", + } + assert not config_entry.options @pytest.mark.usefixtures("mock_tailwind") @@ -276,7 +303,7 @@ async def test_zeroconf_flow_not_discovered_again( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -292,8 +319,8 @@ async def test_zeroconf_flow_not_discovered_again( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" @@ -307,17 +334,17 @@ async def test_reauth_flow( assert mock_config_entry.data[CONF_TOKEN] == "123456" result = await mock_config_entry.start_reauth_flow(hass) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_TOKEN: "987654"}, ) await hass.async_block_till_done() - assert result2.get("type") is FlowResultType.ABORT - assert result2.get("reason") == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert mock_config_entry.data[CONF_TOKEN] == "987654" @@ -343,27 +370,27 @@ async def test_reauth_flow_errors( result = await mock_config_entry.start_reauth_flow(hass) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "reauth_confirm" - assert result2.get("errors") == expected_error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == expected_error mock_tailwind.status.side_effect = None - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) - assert result3.get("type") is FlowResultType.ABORT - assert result3.get("reason") == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_dhcp_discovery_updates_entry( @@ -384,8 +411,8 @@ async def test_dhcp_discovery_updates_entry( ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" @@ -404,5 +431,5 @@ async def test_dhcp_discovery_ignores_unknown(hass: HomeAssistant) -> None: ), ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "unknown" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" diff --git a/tests/components/tailwind/test_init.py b/tests/components/tailwind/test_init.py index 8ea5f1108f4..8e075a26279 100644 --- a/tests/components/tailwind/test_init.py +++ b/tests/components/tailwind/test_init.py @@ -66,8 +66,8 @@ async def test_config_entry_authentication_failed( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" - assert flow.get("handler") == DOMAIN + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index f5802c509bf..6a2b7699840 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -1108,7 +1108,7 @@ async def test_sending_mqtt_commands_rgbww( ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.tasmota_test", color_temp=200) + await common.async_turn_on(hass, "light.tasmota_test", color_temp_kelvin=5000) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;CT 200", @@ -1350,7 +1350,9 @@ async def test_transition( assert state.attributes.get("color_temp") == 153 # Set color_temp of the light from 153 to 500 @ 50%: Speed should be 6*2*2=24 - await common.async_turn_on(hass, "light.tasmota_test", color_temp=500, transition=6) + await common.async_turn_on( + hass, "light.tasmota_test", color_temp_kelvin=2000, transition=6 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 24;NoDelay;Power1 ON;NoDelay;CT 500", @@ -1369,7 +1371,9 @@ async def test_transition( assert state.attributes.get("color_temp") == 500 # Set color_temp of the light from 500 to 326 @ 50%: Speed should be 6*2*2*2=48->40 - await common.async_turn_on(hass, "light.tasmota_test", color_temp=326, transition=6) + await common.async_turn_on( + hass, "light.tasmota_test", color_temp_kelvin=3067, transition=6 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 40;NoDelay;Power1 ON;NoDelay;CT 326", @@ -1510,7 +1514,7 @@ async def _test_split_light( await common.async_turn_on(hass, entity) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Power{idx+num_switches+1} ON", + f"NoDelay;Power{idx + num_switches + 1} ON", 0, False, ) @@ -1520,7 +1524,7 @@ async def _test_split_light( await common.async_turn_on(hass, entity, brightness=(idx + 1) * 25.5) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Channel{idx+num_switches+1} {(idx+1)*10}", + f"NoDelay;Channel{idx + num_switches + 1} {(idx + 1) * 10}", 0, False, ) @@ -1591,7 +1595,7 @@ async def _test_unlinked_light( await common.async_turn_on(hass, entity) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Power{idx+num_switches+1} ON", + f"NoDelay;Power{idx + num_switches + 1} ON", 0, False, ) @@ -1601,7 +1605,7 @@ async def _test_unlinked_light( await common.async_turn_on(hass, entity, brightness=(idx + 1) * 25.5) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Dimmer{idx+1} {(idx+1)*10}", + f"NoDelay;Dimmer{idx + 1} {(idx + 1) * 10}", 0, False, ) diff --git a/tests/components/technove/snapshots/test_binary_sensor.ambr b/tests/components/technove/snapshots/test_binary_sensor.ambr index cc2dcf4a04a..f08dd6970fe 100644 --- a/tests/components/technove/snapshots/test_binary_sensor.ambr +++ b/tests/components/technove/snapshots/test_binary_sensor.ambr @@ -45,53 +45,6 @@ 'state': 'off', }) # --- -# name: test_sensors[binary_sensor.technove_station_charging-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.technove_station_charging', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging', - 'platform': 'technove', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'AA:AA:AA:AA:AA:BB_is_session_active', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[binary_sensor.technove_station_charging-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'TechnoVE Station Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.technove_station_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_sensors[binary_sensor.technove_station_conflict_with_power_sharing_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/technove/test_binary_sensor.py b/tests/components/technove/test_binary_sensor.py index 0a90093779e..93d4805cecb 100644 --- a/tests/components/technove/test_binary_sensor.py +++ b/tests/components/technove/test_binary_sensor.py @@ -45,7 +45,6 @@ async def test_sensors( "entity_id", [ "binary_sensor.technove_station_static_ip", - "binary_sensor.technove_station_charging", ], ) @pytest.mark.usefixtures("init_integration") diff --git a/tests/components/technove/test_config_flow.py b/tests/components/technove/test_config_flow.py index 81e0b32b55b..99a8e231f73 100644 --- a/tests/components/technove/test_config_flow.py +++ b/tests/components/technove/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, MagicMock import pytest from technove import TechnoVEConnectionError -from homeassistant.components import zeroconf from homeassistant.components.technove.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -112,7 +112,7 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -153,7 +153,7 @@ async def test_zeroconf_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -184,7 +184,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -225,7 +225,7 @@ async def test_zeroconf_without_mac_station_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -250,7 +250,7 @@ async def test_zeroconf_with_mac_station_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/tedee/__init__.py b/tests/components/tedee/__init__.py index a72b1fbdd6a..0bff030d2df 100644 --- a/tests/components/tedee/__init__.py +++ b/tests/components/tedee/__init__.py @@ -1 +1,14 @@ """Add tests for Tedee components.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the acaia integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py index 8e028cb5300..d659560ee61 100644 --- a/tests/components/tedee/conftest.py +++ b/tests/components/tedee/conftest.py @@ -14,6 +14,8 @@ from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry, load_fixture WEBHOOK_ID = "bq33efxmdi3vxy55q2wbnudbra7iv8mjrq9x0gea33g4zqtd87093pwveg8xcb33" @@ -84,8 +86,6 @@ async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock ) -> MockConfigEntry: """Set up the Tedee integration for testing.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) return mock_config_entry diff --git a/tests/components/tedee/snapshots/test_binary_sensor.ambr b/tests/components/tedee/snapshots/test_binary_sensor.ambr index 385e4ac9bc1..e3238dacda1 100644 --- a/tests/components/tedee/snapshots/test_binary_sensor.ambr +++ b/tests/components/tedee/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_sensors[entry-charging] +# name: test_binary_sensors[binary_sensor.lock_1a2b_charging-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -32,7 +32,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[entry-lock_uncalibrated] +# name: test_binary_sensors[binary_sensor.lock_1a2b_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Lock-1A2B Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_1a2b_lock_uncalibrated-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -65,7 +79,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[entry-pullspring_enabled] +# name: test_binary_sensors[binary_sensor.lock_1a2b_lock_uncalibrated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Lock-1A2B Lock uncalibrated', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_lock_uncalibrated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_1a2b_pullspring_enabled-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -98,7 +126,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[entry-semi_locked] +# name: test_binary_sensors[binary_sensor.lock_1a2b_pullspring_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-1A2B Pullspring enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_pullspring_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_1a2b_semi_locked-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -131,48 +172,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[state-charging] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'Lock-1A2B Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.lock_1a2b_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[state-lock_uncalibrated] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Lock-1A2B Lock uncalibrated', - }), - 'context': , - 'entity_id': 'binary_sensor.lock_1a2b_lock_uncalibrated', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[state-pullspring_enabled] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Lock-1A2B Pullspring enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.lock_1a2b_pullspring_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_binary_sensors[state-semi_locked] +# name: test_binary_sensors[binary_sensor.lock_1a2b_semi_locked-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Lock-1A2B Semi locked', @@ -185,3 +185,189 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_2c3d_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765-charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Lock-2C3D Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_2c3d_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_lock_uncalibrated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_2c3d_lock_uncalibrated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock uncalibrated', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uncalibrated', + 'unique_id': '98765-uncalibrated', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_lock_uncalibrated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Lock-2C3D Lock uncalibrated', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_2c3d_lock_uncalibrated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_pullspring_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_2c3d_pullspring_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pullspring enabled', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pullspring_enabled', + 'unique_id': '98765-pullspring_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_pullspring_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-2C3D Pullspring enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_2c3d_pullspring_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_semi_locked-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_2c3d_semi_locked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Semi locked', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'semi_locked', + 'unique_id': '98765-semi_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_semi_locked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-2C3D Semi locked', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_2c3d_semi_locked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr index 20d6bfcdc2a..af559f561b2 100644 --- a/tests/components/tedee/snapshots/test_init.ambr +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -31,3 +31,35 @@ 'via_device_id': None, }) # --- +# name: test_lock_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tedee', + '12345', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Tedee', + 'model': 'Tedee PRO', + 'model_id': 'Tedee PRO', + 'name': 'Lock-1A2B', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index 3eba6f3f0af..cca988663d2 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -1,83 +1,4 @@ # serializer version: 1 -# name: test_lock - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Lock-1A2B', - 'supported_features': , - }), - 'context': , - 'entity_id': 'lock.lock_1a2b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unlocked', - }) -# --- -# name: test_lock.1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'lock', - 'entity_category': None, - 'entity_id': 'lock.lock_1a2b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'tedee', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': '12345-lock', - 'unit_of_measurement': None, - }) -# --- -# name: test_lock.2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'tedee', - '12345', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Tedee', - 'model': 'Tedee PRO', - 'model_id': 'Tedee PRO', - 'name': 'Lock-1A2B', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }) -# --- # name: test_lock_without_pullspring StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -157,3 +78,97 @@ 'via_device_id': , }) # --- +# name: test_locks[lock.lock_1a2b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.lock_1a2b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_locks[lock.lock_1a2b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-1A2B', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.lock_1a2b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_locks[lock.lock_2c3d-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.lock_2c3d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_locks[lock.lock_2c3d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-2C3D', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.lock_2c3d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- diff --git a/tests/components/tedee/snapshots/test_sensor.ambr b/tests/components/tedee/snapshots/test_sensor.ambr index d5f4c8361c3..297fe9b0d37 100644 --- a/tests/components/tedee/snapshots/test_sensor.ambr +++ b/tests/components/tedee/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[entry-battery] +# name: test_sensors[sensor.lock_1a2b_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,23 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[entry-pullspring_duration] +# name: test_sensors[sensor.lock_1a2b_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Lock-1A2B Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.lock_1a2b_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_sensors[sensor.lock_1a2b_pullspring_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -69,23 +85,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[state-battery] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Lock-1A2B Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.lock_1a2b_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '70', - }) -# --- -# name: test_sensors[state-pullspring_duration] +# name: test_sensors[sensor.lock_1a2b_pullspring_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -101,3 +101,105 @@ 'state': '2', }) # --- +# name: test_sensors[sensor.lock_2c3d_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.lock_2c3d_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765-battery_sensor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.lock_2c3d_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Lock-2C3D Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.lock_2c3d_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_sensors[sensor.lock_2c3d_pullspring_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.lock_2c3d_pullspring_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pullspring duration', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pullspring_duration', + 'unique_id': '98765-pullspring_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.lock_2c3d_pullspring_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Lock-2C3D Pullspring duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lock_2c3d_pullspring_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/tedee/test_binary_sensor.py b/tests/components/tedee/test_binary_sensor.py index dfe70e7a2ea..ccfd12440ea 100644 --- a/tests/components/tedee/test_binary_sensor.py +++ b/tests/components/tedee/test_binary_sensor.py @@ -1,19 +1,20 @@ """Tests for the Tedee Binary Sensors.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import async_fire_time_changed +from . import setup_integration -pytestmark = pytest.mark.usefixtures("init_integration") +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform BINARY_SENSORS = ("charging", "semi_locked", "pullspring_enabled", "lock_uncalibrated") @@ -22,21 +23,19 @@ BINARY_SENSORS = ("charging", "semi_locked", "pullspring_enabled", "lock_uncalib async def test_binary_sensors( hass: HomeAssistant, mock_tedee: MagicMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test tedee binary sensor.""" - for key in BINARY_SENSORS: - state = hass.states.get(f"binary_sensor.lock_1a2b_{key}") - assert state - assert state == snapshot(name=f"state-{key}") + with patch("homeassistant.components.tedee.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot(name=f"entry-{key}") + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("init_integration") async def test_new_binary_sensors( hass: HomeAssistant, mock_tedee: MagicMock, diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py index 63701bb1788..71bf5262f00 100644 --- a/tests/components/tedee/test_init.py +++ b/tests/components/tedee/test_init.py @@ -20,6 +20,7 @@ from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from . import setup_integration from .conftest import WEBHOOK_ID from tests.common import MockConfigEntry @@ -32,9 +33,7 @@ async def test_load_unload_config_entry( mock_tedee: MagicMock, ) -> None: """Test loading and unloading the integration.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -56,9 +55,7 @@ async def test_config_entry_not_ready( """Test the Tedee configuration entry not ready.""" mock_tedee.get_locks.side_effect = side_effect - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert len(mock_tedee.get_locks.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -70,9 +67,7 @@ async def test_cleanup_on_shutdown( mock_tedee: MagicMock, ) -> None: """Test the webhook is cleaned up on shutdown.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -88,9 +83,7 @@ async def test_webhook_cleanup_errors( caplog: pytest.LogCaptureFixture, ) -> None: """Test the webhook is cleaned up on shutdown.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -110,9 +103,7 @@ async def test_webhook_registration_errors( ) -> None: """Test the webhook is cleaned up on shutdown.""" mock_tedee.register_webhook.side_effect = TedeeWebhookException("") - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -128,9 +119,7 @@ async def test_webhook_registration_cleanup_errors( ) -> None: """Test the errors during webhook cleanup during registration.""" mock_tedee.cleanup_webhooks_by_host.side_effect = TedeeWebhookException("") - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -138,6 +127,21 @@ async def test_webhook_registration_cleanup_errors( assert "Failed to cleanup Tedee webhooks by host:" in caplog.text +async def test_lock_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Ensure the lock device is registered.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device({(mock_config_entry.domain, "12345")}) + assert device + assert device == snapshot + + async def test_bridge_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -146,9 +150,7 @@ async def test_bridge_device( snapshot: SnapshotAssertion, ) -> None: """Ensure the bridge device is registered.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) device = device_registry.async_get_device( {(mock_config_entry.domain, mock_tedee.get_local_bridge.return_value.serial)} @@ -192,9 +194,7 @@ async def test_webhook_post( ) -> None: """Test webhook callback.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) @@ -241,9 +241,7 @@ async def test_migration( "homeassistant.components.tedee.webhook_generate_id", return_value=WEBHOOK_ID, ): - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) assert mock_config_entry.version == 1 assert mock_config_entry.minor_version == 2 diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py index 45eae6e22d9..e0fe9673a46 100644 --- a/tests/components/tedee/test_lock.py +++ b/tests/components/tedee/test_lock.py @@ -1,7 +1,7 @@ """Tests for tedee lock.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from urllib.parse import urlparse from aiotedee import TedeeLock, TedeeLockState @@ -22,42 +22,44 @@ from homeassistant.components.lock import ( LockState, ) from homeassistant.components.webhook import async_generate_url -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component +from . import setup_integration from .conftest import WEBHOOK_ID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.typing import ClientSessionGenerator -pytestmark = pytest.mark.usefixtures("init_integration") - -async def test_lock( +async def test_locks( hass: HomeAssistant, mock_tedee: MagicMock, - device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, +) -> None: + """Test tedee locks.""" + with patch("homeassistant.components.tedee.PLATFORMS", [Platform.LOCK]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_lock_service_calls( + hass: HomeAssistant, + mock_tedee: MagicMock, ) -> None: """Test the tedee lock.""" - mock_tedee.lock.return_value = None - mock_tedee.unlock.return_value = None - mock_tedee.open.return_value = None - - state = hass.states.get("lock.lock_1a2b") - assert state - assert state == snapshot - - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot - assert entry.device_id - - device = device_registry.async_get(entry.device_id) - assert device == snapshot await hass.services.async_call( LOCK_DOMAIN, @@ -105,6 +107,7 @@ async def test_lock( assert state.state == LockState.UNLOCKING +@pytest.mark.usefixtures("init_integration") async def test_lock_without_pullspring( hass: HomeAssistant, mock_tedee: MagicMock, @@ -113,9 +116,8 @@ async def test_lock_without_pullspring( snapshot: SnapshotAssertion, ) -> None: """Test the tedee lock without pullspring.""" - mock_tedee.lock.return_value = None - mock_tedee.unlock.return_value = None - mock_tedee.open.return_value = None + # Fetch translations + await async_setup_component(hass, "homeassistant", {}) state = hass.states.get("lock.lock_2c3d") assert state @@ -131,8 +133,8 @@ async def test_lock_without_pullspring( assert device == snapshot with pytest.raises( - HomeAssistantError, - match="Entity lock.lock_2c3d does not support this service.", + ServiceNotSupported, + match=f"Entity lock.lock_2c3d does not support action {LOCK_DOMAIN}.{SERVICE_OPEN}", ): await hass.services.async_call( LOCK_DOMAIN, @@ -146,6 +148,7 @@ async def test_lock_without_pullspring( assert len(mock_tedee.open.mock_calls) == 0 +@pytest.mark.usefixtures("init_integration") async def test_lock_errors( hass: HomeAssistant, mock_tedee: MagicMock, @@ -188,6 +191,7 @@ async def test_lock_errors( assert exc_info.value.translation_key == "open_failed" +@pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "side_effect", [ @@ -214,6 +218,7 @@ async def test_update_failed( assert state.state == STATE_UNAVAILABLE +@pytest.mark.usefixtures("init_integration") async def test_cleanup_removed_locks( hass: HomeAssistant, mock_tedee: MagicMock, @@ -244,6 +249,7 @@ async def test_cleanup_removed_locks( assert "Lock-1A2B" not in locks +@pytest.mark.usefixtures("init_integration") async def test_new_lock( hass: HomeAssistant, mock_tedee: MagicMock, @@ -272,6 +278,7 @@ async def test_new_lock( assert state +@pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( ("lib_state", "expected_state"), [ diff --git a/tests/components/tedee/test_sensor.py b/tests/components/tedee/test_sensor.py index ddbcd5086af..3c03d340100 100644 --- a/tests/components/tedee/test_sensor.py +++ b/tests/components/tedee/test_sensor.py @@ -1,20 +1,20 @@ """Tests for the Tedee Sensors.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import async_fire_time_changed - -pytestmark = pytest.mark.usefixtures("init_integration") +from . import setup_integration +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform SENSORS = ( "battery", @@ -25,21 +25,18 @@ SENSORS = ( async def test_sensors( hass: HomeAssistant, mock_tedee: MagicMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test tedee sensors.""" - for key in SENSORS: - state = hass.states.get(f"sensor.lock_1a2b_{key}") - assert state - assert state == snapshot(name=f"state-{key}") + with patch("homeassistant.components.tedee.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry.device_id - assert entry == snapshot(name=f"entry-{key}") + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("init_integration") async def test_new_sensors( hass: HomeAssistant, mock_tedee: MagicMock, diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index 93137c3815e..f15db7eba2b 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -105,6 +105,14 @@ def mock_external_calls() -> Generator[None]: patch.object(BotMock, "get_me", return_value=test_user), patch.object(BotMock, "bot", test_user), patch.object(BotMock, "send_message", return_value=message), + patch.object(BotMock, "send_photo", return_value=message), + patch.object(BotMock, "send_sticker", return_value=message), + patch.object(BotMock, "send_video", return_value=message), + patch.object(BotMock, "send_document", return_value=message), + patch.object(BotMock, "send_voice", return_value=message), + patch.object(BotMock, "send_animation", return_value=message), + patch.object(BotMock, "send_location", return_value=message), + patch.object(BotMock, "send_poll", return_value=message), patch("telegram.ext.Updater._bootstrap"), ): yield diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index bdf6ba72fcc..be6b5b31325 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -1,17 +1,33 @@ """Tests for the telegram_bot component.""" +import base64 +import io from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest from telegram import Update from telegram.error import NetworkError, RetryAfter, TelegramError, TimedOut from homeassistant.components.telegram_bot import ( + ATTR_FILE, + ATTR_LATITUDE, + ATTR_LONGITUDE, ATTR_MESSAGE, ATTR_MESSAGE_THREAD_ID, + ATTR_OPTIONS, + ATTR_QUESTION, + ATTR_STICKER_ID, DOMAIN, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_DOCUMENT, + SERVICE_SEND_LOCATION, SERVICE_SEND_MESSAGE, + SERVICE_SEND_PHOTO, + SERVICE_SEND_POLL, + SERVICE_SEND_STICKER, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, ) from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.const import EVENT_HOMEASSISTANT_START @@ -32,23 +48,125 @@ async def test_polling_platform_init(hass: HomeAssistant, polling_platform) -> N assert hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE) is True -async def test_send_message(hass: HomeAssistant, webhook_platform) -> None: - """Test the send_message service.""" +@pytest.mark.parametrize( + ("service", "input"), + [ + ( + SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, + ), + ( + SERVICE_SEND_STICKER, + { + ATTR_STICKER_ID: "1", + ATTR_MESSAGE_THREAD_ID: "123", + }, + ), + ( + SERVICE_SEND_POLL, + { + ATTR_QUESTION: "Question", + ATTR_OPTIONS: ["Yes", "No"], + }, + ), + ( + SERVICE_SEND_LOCATION, + { + ATTR_MESSAGE: "test_message", + ATTR_MESSAGE_THREAD_ID: "123", + ATTR_LONGITUDE: "1.123", + ATTR_LATITUDE: "1.123", + }, + ), + ], +) +async def test_send_message( + hass: HomeAssistant, webhook_platform, service: str, input: dict[str] +) -> None: + """Test the send_message service. Tests any service that does not require files to be sent.""" context = Context() events = async_capture_events(hass, "telegram_sent") - await hass.services.async_call( + response = await hass.services.async_call( DOMAIN, - SERVICE_SEND_MESSAGE, - {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, + service, + input, blocking=True, context=context, + return_response=True, ) await hass.async_block_till_done() assert len(events) == 1 assert events[0].context == context + assert len(response["chats"]) == 1 + assert (response["chats"][0]["message_id"]) == 12345 + + +@patch( + "builtins.open", + mock_open( + read_data=base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" + ) + ), + create=True, +) +def _read_file_as_bytesio_mock(file_path): + """Convert file to BytesIO for testing.""" + _file = None + + with open(file_path, encoding="utf8") as file_handler: + _file = io.BytesIO(file_handler.read()) + + _file.name = "dummy" + _file.seek(0) + + return _file + + +@pytest.mark.parametrize( + "service", + [ + SERVICE_SEND_PHOTO, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, + SERVICE_SEND_DOCUMENT, + ], +) +async def test_send_file(hass: HomeAssistant, webhook_platform, service: str) -> None: + """Test the send_file service (photo, animation, video, document...).""" + context = Context() + events = async_capture_events(hass, "telegram_sent") + + hass.config.allowlist_external_dirs.add("/media/") + + # Mock the file handler read with our base64 encoded dummy file + with patch( + "homeassistant.components.telegram_bot._read_file_as_bytesio", + _read_file_as_bytesio_mock, + ): + response = await hass.services.async_call( + DOMAIN, + service, + { + ATTR_FILE: "/media/dummy", + ATTR_MESSAGE_THREAD_ID: "123", + }, + blocking=True, + context=context, + return_response=True, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].context == context + + assert len(response["chats"]) == 1 + assert (response["chats"][0]["message_id"]) == 12345 + async def test_send_message_thread(hass: HomeAssistant, webhook_platform) -> None: """Test the send_message service for threads.""" diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index 1df9e738b06..cb4e83d934c 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -19,7 +19,7 @@ from homeassistant.components.template import DOMAIN, SERVICE_RELOAD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.common import async_mock_service @@ -40,7 +40,7 @@ def patch_blueprint( return orig_load(self, path) return Blueprint( - yaml.load_yaml(data_path), + yaml_util.load_yaml(data_path), expected_domain=self.domain, path=path, schema=BLUEPRINT_SCHEMA, diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 18b55d05672..2c9b81e7c91 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -273,11 +273,21 @@ async def test_config_flow( "min": "0", "max": "100", "step": "0.1", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, { "min": 0, "max": 100, "step": 0.1, + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, ), ( @@ -794,7 +804,7 @@ EARLY_END_ERROR = "invalid template (TemplateSyntaxError: unexpected 'end of tem ), "unit_of_measurement": ( "'None' is not a valid unit for device class 'energy'; " - "expected one of 'cal', 'Gcal', 'GJ', 'GWh', 'J', 'kcal', 'kJ', 'kWh', 'Mcal', 'MJ', 'MWh', 'TWh', 'Wh'" + "expected one of 'cal', 'Gcal', 'GJ', 'GWh', 'J', 'kcal', 'kJ', 'kWh', 'Mcal', 'MJ', 'MWh', 'mWh', 'TWh', 'Wh'" ), }, ), @@ -1263,11 +1273,21 @@ async def test_option_flow_sensor_preview_config_entry_removed( "min": 0, "max": 100, "step": 0.1, + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, { "min": 0, "max": 100, "step": 0.1, + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, ), ( diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 065a1488dc9..b5ba93a4bd0 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components import light from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_RGB_COLOR, @@ -773,7 +773,7 @@ async def test_temperature_action_no_template( await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP: 345}, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP_KELVIN: 2898}, blocking=True, ) @@ -1395,7 +1395,7 @@ async def test_all_colors_mode_no_template( await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP: 123}, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP_KELVIN: 8130}, blocking=True, ) @@ -1531,7 +1531,7 @@ async def test_all_colors_mode_no_template( await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP: 234}, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_COLOR_TEMP_KELVIN: 4273}, blocking=True, ) diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 186a84d5365..d9cb294c41f 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -10,6 +10,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, STATE_OFF, STATE_ON, + STATE_OPEN, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, ServiceCall @@ -30,6 +31,13 @@ OPTIMISTIC_LOCK_CONFIG = { "caller": "{{ this.entity_id }}", }, }, + "open": { + "service": "test.automation", + "data_template": { + "action": "open", + "caller": "{{ this.entity_id }}", + }, + }, } OPTIMISTIC_CODED_LOCK_CONFIG = { @@ -81,6 +89,53 @@ async def test_template_state(hass: HomeAssistant) -> None: state = hass.states.get("lock.test_template_lock") assert state.state == LockState.UNLOCKED + hass.states.async_set("switch.test_state", STATE_OPEN) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.state == LockState.OPEN + + +@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + **OPTIMISTIC_LOCK_CONFIG, + "name": "Test lock", + "optimistic": True, + "value_template": "{{ states.switch.test_state.state }}", + } + }, + ], +) +@pytest.mark.usefixtures("start_ha") +async def test_open_lock_optimistic( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test optimistic open.""" + await setup.async_setup_component(hass, "switch", {}) + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_lock") + assert state.state == LockState.LOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_OPEN, + {ATTR_ENTITY_ID: "lock.test_lock"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["action"] == "open" + assert calls[0].data["caller"] == "lock.test_lock" + + state = hass.states.get("lock.test_lock") + assert state.state == LockState.OPEN + @pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( @@ -282,6 +337,40 @@ async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> N assert calls[0].data["caller"] == "lock.template_lock" +@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + **OPTIMISTIC_LOCK_CONFIG, + "value_template": "{{ states.switch.test_state.state }}", + } + }, + ], +) +@pytest.mark.usefixtures("start_ha") +async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: + """Test open action.""" + await setup.async_setup_component(hass, "switch", {}) + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == LockState.LOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_OPEN, + {ATTR_ENTITY_ID: "lock.template_lock"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["action"] == "open" + assert calls[0].data["caller"] == "lock.template_lock" + + @pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( "config", diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index ff428c5d4b4..6053a2bd9ec 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -3,14 +3,7 @@ import pytest from homeassistant import setup -from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, -) +from homeassistant.components.vacuum import ATTR_BATTERY_LEVEL, VacuumActivity from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError @@ -44,7 +37,7 @@ _BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" }, ), ( - STATE_CLEANING, + VacuumActivity.CLEANING, 100, { "vacuum": { @@ -149,10 +142,10 @@ async def test_templates_with_entities(hass: HomeAssistant) -> None: """Test templates with values from other entities.""" _verify(hass, STATE_UNKNOWN, None) - hass.states.async_set(_STATE_INPUT_SELECT, STATE_CLEANING) + hass.states.async_set(_STATE_INPUT_SELECT, VacuumActivity.CLEANING) hass.states.async_set(_BATTERY_LEVEL_INPUT_NUMBER, 100) await hass.async_block_till_done() - _verify(hass, STATE_CLEANING, 100) + _verify(hass, VacuumActivity.CLEANING, 100) @pytest.mark.parametrize( @@ -370,8 +363,8 @@ async def test_state_services(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.async_block_till_done() # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == STATE_CLEANING - _verify(hass, STATE_CLEANING, None) + assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.CLEANING + _verify(hass, VacuumActivity.CLEANING, None) assert len(calls) == 1 assert calls[-1].data["action"] == "start" assert calls[-1].data["caller"] == _TEST_VACUUM @@ -381,8 +374,8 @@ async def test_state_services(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.async_block_till_done() # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == STATE_PAUSED - _verify(hass, STATE_PAUSED, None) + assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.PAUSED + _verify(hass, VacuumActivity.PAUSED, None) assert len(calls) == 2 assert calls[-1].data["action"] == "pause" assert calls[-1].data["caller"] == _TEST_VACUUM @@ -392,8 +385,8 @@ async def test_state_services(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.async_block_till_done() # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == STATE_IDLE - _verify(hass, STATE_IDLE, None) + assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.IDLE + _verify(hass, VacuumActivity.IDLE, None) assert len(calls) == 3 assert calls[-1].data["action"] == "stop" assert calls[-1].data["caller"] == _TEST_VACUUM @@ -403,8 +396,8 @@ async def test_state_services(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.async_block_till_done() # verify - assert hass.states.get(_STATE_INPUT_SELECT).state == STATE_RETURNING - _verify(hass, STATE_RETURNING, None) + assert hass.states.get(_STATE_INPUT_SELECT).state == VacuumActivity.RETURNING + _verify(hass, VacuumActivity.RETURNING, None) assert len(calls) == 4 assert calls[-1].data["action"] == "return_to_base" assert calls[-1].data["caller"] == _TEST_VACUUM @@ -506,7 +499,11 @@ async def _register_basic_vacuum(hass: HomeAssistant) -> None: assert await setup.async_setup_component( hass, "input_select", - {"input_select": {"state": {"name": "State", "options": [STATE_CLEANING]}}}, + { + "input_select": { + "state": {"name": "State", "options": [VacuumActivity.CLEANING]} + } + }, ) with assert_setup_component(1, "vacuum"): @@ -522,7 +519,7 @@ async def _register_basic_vacuum(hass: HomeAssistant) -> None: "service": "input_select.select_option", "data": { "entity_id": _STATE_INPUT_SELECT, - "option": STATE_CLEANING, + "option": VacuumActivity.CLEANING, }, } } @@ -554,11 +551,11 @@ async def _register_components(hass: HomeAssistant) -> None: "state": { "name": "State", "options": [ - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, + VacuumActivity.CLEANING, + VacuumActivity.DOCKED, + VacuumActivity.IDLE, + VacuumActivity.PAUSED, + VacuumActivity.RETURNING, ], }, "fan_speed": { @@ -578,7 +575,7 @@ async def _register_components(hass: HomeAssistant) -> None: "service": "input_select.select_option", "data": { "entity_id": _STATE_INPUT_SELECT, - "option": STATE_CLEANING, + "option": VacuumActivity.CLEANING, }, }, { @@ -592,7 +589,10 @@ async def _register_components(hass: HomeAssistant) -> None: "pause": [ { "service": "input_select.select_option", - "data": {"entity_id": _STATE_INPUT_SELECT, "option": STATE_PAUSED}, + "data": { + "entity_id": _STATE_INPUT_SELECT, + "option": VacuumActivity.PAUSED, + }, }, { "service": "test.automation", @@ -605,7 +605,10 @@ async def _register_components(hass: HomeAssistant) -> None: "stop": [ { "service": "input_select.select_option", - "data": {"entity_id": _STATE_INPUT_SELECT, "option": STATE_IDLE}, + "data": { + "entity_id": _STATE_INPUT_SELECT, + "option": VacuumActivity.IDLE, + }, }, { "service": "test.automation", @@ -620,7 +623,7 @@ async def _register_components(hass: HomeAssistant) -> None: "service": "input_select.select_option", "data": { "entity_id": _STATE_INPUT_SELECT, - "option": STATE_RETURNING, + "option": VacuumActivity.RETURNING, }, }, { diff --git a/tests/components/tesla_fleet/conftest.py b/tests/components/tesla_fleet/conftest.py index 0dc5d87984f..06d2b54c936 100644 --- a/tests/components/tesla_fleet/conftest.py +++ b/tests/components/tesla_fleet/conftest.py @@ -15,6 +15,7 @@ from homeassistant.components.tesla_fleet.const import DOMAIN, SCOPES from .const import ( COMMAND_OK, + ENERGY_HISTORY, LIVE_STATUS, PRODUCTS, SITE_INFO, @@ -33,7 +34,9 @@ def mock_expires_at() -> int: return time.time() + 3600 -def create_config_entry(expires_at: int, scopes: list[Scope]) -> MockConfigEntry: +def create_config_entry( + expires_at: int, scopes: list[Scope], implementation: str = DOMAIN +) -> MockConfigEntry: """Create Tesla Fleet entry in Home Assistant.""" access_token = jwt.encode( { @@ -51,7 +54,7 @@ def create_config_entry(expires_at: int, scopes: list[Scope]) -> MockConfigEntry title=UID, unique_id=UID, data={ - "auth_implementation": DOMAIN, + "auth_implementation": implementation, "token": { "status": 0, "userid": UID, @@ -90,6 +93,12 @@ def readonly_config_entry(expires_at: int) -> MockConfigEntry: ) +@pytest.fixture +def bad_config_entry(expires_at: int) -> MockConfigEntry: + """Create Tesla Fleet entry in Home Assistant.""" + return create_config_entry(expires_at, SCOPES, "bad") + + @pytest.fixture(autouse=True) def mock_products() -> Generator[AsyncMock]: """Mock Tesla Fleet Api products method.""" @@ -169,6 +178,16 @@ def mock_request(): yield mock_request +@pytest.fixture(autouse=True) +def mock_energy_history(): + """Mock Teslemetry Energy Specific site_info method.""" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.energy_history", + return_value=ENERGY_HISTORY, + ) as mock_live_status: + yield mock_live_status + + @pytest.fixture(autouse=True) def mock_signed_command() -> Generator[AsyncMock]: """Mock Tesla Fleet Api signed_command method.""" diff --git a/tests/components/tesla_fleet/const.py b/tests/components/tesla_fleet/const.py index 76b4ae20092..d584e7b93d5 100644 --- a/tests/components/tesla_fleet/const.py +++ b/tests/components/tesla_fleet/const.py @@ -11,6 +11,7 @@ PRODUCTS = load_json_object_fixture("products.json", DOMAIN) VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) +ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} diff --git a/tests/components/tesla_fleet/fixtures/energy_history.json b/tests/components/tesla_fleet/fixtures/energy_history.json new file mode 100644 index 00000000000..befe12cc903 --- /dev/null +++ b/tests/components/tesla_fleet/fixtures/energy_history.json @@ -0,0 +1,45 @@ +{ + "response": { + "period": "day", + "time_series": [ + { + "timestamp": "2023-06-01T01:00:00-07:00", + "solar_energy_exported": 70940, + "generator_energy_exported": 0, + "grid_energy_imported": 521, + "grid_services_energy_imported": 17.53125, + "grid_services_energy_exported": 3.80859375, + "grid_energy_exported_from_solar": 43660, + "grid_energy_exported_from_generator": 0, + "grid_energy_exported_from_battery": 19, + "battery_energy_exported": 10030, + "battery_energy_imported_from_grid": 80, + "battery_energy_imported_from_solar": 16800, + "battery_energy_imported_from_generator": 0, + "consumer_energy_imported_from_grid": 441, + "consumer_energy_imported_from_solar": 10480, + "consumer_energy_imported_from_battery": 10011, + "consumer_energy_imported_from_generator": 0 + }, + { + "timestamp": "2023-06-01T01:05:00-07:00", + "solar_energy_exported": 140940, + "generator_energy_exported": 1, + "grid_energy_imported": 1021, + "grid_services_energy_imported": 27.53125, + "grid_services_energy_exported": 6.80859375, + "grid_energy_exported_from_solar": 83660, + "grid_energy_exported_from_generator": 0, + "grid_energy_exported_from_battery": 29, + "battery_energy_exported": 20030, + "battery_energy_imported_from_grid": 0, + "battery_energy_imported_from_solar": 26800, + "battery_energy_imported_from_generator": 0, + "consumer_energy_imported_from_grid": 841, + "consumer_energy_imported_from_solar": 20480, + "consumer_energy_imported_from_battery": 20011, + "consumer_energy_imported_from_generator": 0 + } + ] + } +} diff --git a/tests/components/tesla_fleet/snapshots/test_binary_sensors.ambr b/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr similarity index 100% rename from tests/components/tesla_fleet/snapshots/test_binary_sensors.ambr rename to tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr diff --git a/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr b/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr index eb8c57910a4..cdb24b1d2b5 100644 --- a/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr +++ b/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr @@ -165,6 +165,7 @@ 'openid', 'offline_access', 'vehicle_device_data', + 'vehicle_location', 'vehicle_cmds', 'vehicle_charging_cmds', 'energy_device_data', diff --git a/tests/components/tesla_fleet/snapshots/test_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_sensor.ambr index 2c3780749ca..d6b646d7794 100644 --- a/tests/components/tesla_fleet/snapshots/test_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_sensor.ambr @@ -1,4 +1,442 @@ # serializer version: 1 +# name: test_sensors[sensor.energy_site_battery_charged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_charged', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery charged', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_battery_charge', + 'unique_id': '123456-total_battery_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_charged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery charged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_charged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_charged-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery charged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_charged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_discharged', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery discharged', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_battery_discharge', + 'unique_id': '123456-total_battery_discharge', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery discharged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_discharged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery discharged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_discharged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_exported', + 'unique_id': '123456-battery_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.06', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.06', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from generator', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_generator', + 'unique_id': '123456-battery_energy_imported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from grid', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_grid', + 'unique_id': '123456-battery_energy_imported_from_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.08', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.08', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_solar', + 'unique_id': '123456-battery_energy_imported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.6', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.6', + }) +# --- # name: test_sensors[sensor.energy_site_battery_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -72,6 +510,298 @@ 'state': '5.06', }) # --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from battery', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_battery', + 'unique_id': '123456-consumer_energy_imported_from_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.022', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.022', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from generator', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_generator', + 'unique_id': '123456-consumer_energy_imported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from grid', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_grid', + 'unique_id': '123456-consumer_energy_imported_from_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.282', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.282', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_solar', + 'unique_id': '123456-consumer_energy_imported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.96', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.96', + }) +# --- # name: test_sensors[sensor.energy_site_energy_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -145,6 +875,79 @@ 'state': '38.8964736842105', }) # --- +# name: test_sensors[sensor.energy_site_generator_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_generator_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Generator exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_energy_exported', + 'unique_id': '123456-generator_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_generator_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Generator exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- +# name: test_sensors[sensor.energy_site_generator_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Generator exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- # name: test_sensors[sensor.energy_site_generator_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -218,6 +1021,371 @@ 'state': '0.0', }) # --- +# name: test_sensors[sensor.energy_site_grid_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_grid_energy_exported', + 'unique_id': '123456-total_grid_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported from battery', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_battery', + 'unique_id': '123456-grid_energy_exported_from_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.048', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.048', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported from generator', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_generator', + 'unique_id': '123456-grid_energy_exported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_solar', + 'unique_id': '123456-grid_energy_exported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.32', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.32', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_imported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid imported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_imported', + 'unique_id': '123456-grid_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.542', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.542', + }) +# --- # name: test_sensors[sensor.energy_site_grid_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -291,6 +1459,152 @@ 'state': '0.0', }) # --- +# name: test_sensors[sensor.energy_site_grid_services_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_services_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid services exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_services_energy_exported', + 'unique_id': '123456-grid_services_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0106171875', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0106171875', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_services_imported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid services imported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_services_energy_imported', + 'unique_id': '123456-grid_services_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0450625', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0450625', + }) +# --- # name: test_sensors[sensor.energy_site_grid_services_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -447,6 +1761,79 @@ 'state': 'on_grid', }) # --- +# name: test_sensors[sensor.energy_site_home_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_home_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Home usage', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_home_usage', + 'unique_id': '123456-total_home_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_home_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Home usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_home_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_home_usage-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Home usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_home_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[sensor.energy_site_load_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -590,6 +1977,152 @@ 'state': '95.5053740373966', }) # --- +# name: test_sensors[sensor.energy_site_solar_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_solar_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_exported', + 'unique_id': '123456-solar_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_solar_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '211.88', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '211.88', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_solar_generated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar generated', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_solar_generation', + 'unique_id': '123456-total_solar_generation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[sensor.energy_site_solar_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tesla_fleet/test_binary_sensors.py b/tests/components/tesla_fleet/test_binary_sensor.py similarity index 100% rename from tests/components/tesla_fleet/test_binary_sensors.py rename to tests/components/tesla_fleet/test_binary_sensor.py diff --git a/tests/components/tesla_fleet/test_climate.py b/tests/components/tesla_fleet/test_climate.py index b8cb7f1269b..b45e5259a5c 100644 --- a/tests/components/tesla_fleet/test_climate.py +++ b/tests/components/tesla_fleet/test_climate.py @@ -24,8 +24,13 @@ from homeassistant.components.climate import ( from homeassistant.components.tesla_fleet.coordinator import VEHICLE_INTERVAL from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceNotSupported, + ServiceValidationError, +) from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from . import assert_entities, setup_platform from .const import ( @@ -391,6 +396,7 @@ async def test_climate_noscope( snapshot: SnapshotAssertion, ) -> None: """Tests with no command scopes.""" + await async_setup_component(hass, "homeassistant", {}) await setup_platform(hass, readonly_config_entry, [Platform.CLIMATE]) entity_id = "climate.test_climate" @@ -405,8 +411,9 @@ async def test_climate_noscope( ) with pytest.raises( - HomeAssistantError, - match="Entity climate.test_climate does not support this service.", + ServiceNotSupported, + match="Entity climate.test_climate does not " + "support action climate.set_temperature", ): await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/tesla_fleet/test_config_flow.py b/tests/components/tesla_fleet/test_config_flow.py index b49e090cd5d..6cb8c60ac0c 100644 --- a/tests/components/tesla_fleet/test_config_flow.py +++ b/tests/components/tesla_fleet/test_config_flow.py @@ -11,7 +11,6 @@ from homeassistant.components.application_credentials import ( ) from homeassistant.components.tesla_fleet.const import ( AUTHORIZE_URL, - CLIENT_ID, DOMAIN, SCOPES, TOKEN_URL, @@ -52,69 +51,18 @@ async def access_token(hass: HomeAssistant) -> str: ) -@pytest.mark.usefixtures("current_request_with_host") -async def test_full_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - access_token: str, -) -> None: - """Check full flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - state = config_entry_oauth2_flow._encode_jwt( +@pytest.fixture(autouse=True) +async def create_credential(hass: HomeAssistant) -> None: + """Create a user credential.""" + # Create user application credential + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( hass, - { - "flow_id": result["flow_id"], - "redirect_uri": REDIRECT, - }, + DOMAIN, + ClientCredential("user_client_id", "user_client_secret"), + "user_cred", ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - - assert result["url"].startswith(AUTHORIZE_URL) - parsed_url = urlparse(result["url"]) - parsed_query = parse_qs(parsed_url.query) - assert parsed_query["response_type"][0] == "code" - assert parsed_query["client_id"][0] == CLIENT_ID - assert parsed_query["redirect_uri"][0] == REDIRECT - assert parsed_query["state"][0] == state - assert parsed_query["scope"][0] == " ".join(SCOPES) - assert parsed_query["code_challenge"][0] is not None - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == 200 - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.clear_requests() - aioclient_mock.post( - TOKEN_URL, - json={ - "refresh_token": "mock-refresh-token", - "access_token": access_token, - "type": "Bearer", - "expires_in": 60, - }, - ) - with patch( - "homeassistant.components.tesla_fleet.async_setup_entry", return_value=True - ) as mock_setup: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == UNIQUE_ID - assert "result" in result - assert result["result"].unique_id == UNIQUE_ID - assert "token" in result["result"].data - assert result["result"].data["token"]["access_token"] == access_token - assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" - @pytest.mark.usefixtures("current_request_with_host") async def test_full_flow_user_cred( @@ -125,24 +73,10 @@ async def test_full_flow_user_cred( ) -> None: """Check full flow.""" - # Create user application credential - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential("user_client_id", "user_client_secret"), - "user_cred", - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"implementation": "user_cred"} - ) assert result["type"] is FlowResultType.EXTERNAL_STEP state = config_entry_oauth2_flow._encode_jwt( diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 7c17f986663..2162226efb0 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -21,6 +21,7 @@ from tesla_fleet_api.exceptions import ( from homeassistant.components.tesla_fleet.const import AUTHORIZE_URL from homeassistant.components.tesla_fleet.coordinator import ( + ENERGY_HISTORY_INTERVAL, ENERGY_INTERVAL, ENERGY_INTERVAL_SECONDS, VEHICLE_INTERVAL, @@ -30,6 +31,7 @@ from homeassistant.components.tesla_fleet.coordinator import ( from homeassistant.components.tesla_fleet.models import TeslaFleetData from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr from . import setup_platform @@ -316,6 +318,21 @@ async def test_energy_site_refresh_error( assert normal_config_entry.state is state +# Test Energy History Coordinator +@pytest.mark.parametrize(("side_effect", "state"), ERRORS) +async def test_energy_history_refresh_error( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_energy_history: AsyncMock, + side_effect: TeslaFleetError, + state: ConfigEntryState, +) -> None: + """Test coordinator refresh with an error.""" + mock_energy_history.side_effect = side_effect + await setup_platform(hass, normal_config_entry) + assert normal_config_entry.state is state + + async def test_energy_live_refresh_ratelimited( hass: HomeAssistant, normal_config_entry: MockConfigEntry, @@ -378,6 +395,39 @@ async def test_energy_info_refresh_ratelimited( assert mock_site_info.call_count == 3 +async def test_energy_history_refresh_ratelimited( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_energy_history: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator refresh handles 429.""" + + await setup_platform(hass, normal_config_entry) + + mock_energy_history.side_effect = RateLimited( + {"after": int(ENERGY_HISTORY_INTERVAL.total_seconds() + 10)} + ) + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 2 + + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should not call for another 10 seconds + assert mock_energy_history.call_count == 2 + + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 3 + + async def test_init_region_issue( hass: HomeAssistant, normal_config_entry: MockConfigEntry, @@ -424,3 +474,20 @@ async def test_signing( ) as mock_get_private_key: await setup_platform(hass, normal_config_entry) mock_get_private_key.assert_called_once() + + +async def test_bad_implementation( + hass: HomeAssistant, + bad_config_entry: MockConfigEntry, +) -> None: + """Test handling of a bad authentication implementation.""" + + await setup_platform(hass, bad_config_entry) + assert bad_config_entry.state is ConfigEntryState.SETUP_ERROR + + # Ensure reauth flow starts + assert any(bad_config_entry.async_get_active_flows(hass, {"reauth"})) + result = await bad_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert not result["errors"] diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index e10ae190a59..9533b7e691e 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -124,9 +124,9 @@ async def _test_sensors( for entity in entities_and_expected_values: state = hass.states.get(entity.entity_id) assert state, f"Unable to get state of {entity.entity_id}" - assert ( - state.state == entity.first_value - ), f"First update: {entity.entity_id} is expected to have state {entity.first_value} but has {state.state}" + assert state.state == entity.first_value, ( + f"First update: {entity.entity_id} is expected to have state {entity.first_value} but has {state.state}" + ) # Simulate second data update with ( @@ -147,6 +147,6 @@ async def _test_sensors( # Verify expected vs actual values of second update for entity in entities_and_expected_values: state = hass.states.get(entity.entity_id) - assert ( - state.state == entity.second_value - ), f"Second update: {entity.entity_id} is expected to have state {entity.second_value} but has {state.state}" + assert state.state == entity.second_value, ( + f"Second update: {entity.entity_id} is expected to have state {entity.second_value} but has {state.state}" + ) diff --git a/tests/components/tesla_wall_connector/test_config_flow.py b/tests/components/tesla_wall_connector/test_config_flow.py index a0c28262658..fc1f4199515 100644 --- a/tests/components/tesla_wall_connector/test_config_flow.py +++ b/tests/components/tesla_wall_connector/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import patch from tesla_wall_connector.exceptions import WallConnectorConnectionError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.tesla_wall_connector.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -113,7 +113,7 @@ async def test_dhcp_can_finish( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_abc", ip="1.2.3.4", macaddress="aadc44271212", @@ -146,7 +146,7 @@ async def test_dhcp_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_aabbcc", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -170,7 +170,7 @@ async def test_dhcp_error_from_wall_connector( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_aabbcc", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 256428aa703..e89bab9eff1 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -7,6 +7,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, patch import pytest +from teslemetry_stream.stream import recursive_match from .const import ( COMMAND_OK, @@ -48,6 +49,15 @@ def mock_vehicle_data() -> Generator[AsyncMock]: yield mock_vehicle_data +@pytest.fixture +def mock_legacy(): + """Mock Tesla Fleet Api products method.""" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True + ) as mock_pre2021: + yield mock_pre2021 + + @pytest.fixture(autouse=True) def mock_wake_up(): """Mock Tesla Fleet API Vehicle Specific wake_up method.""" @@ -109,9 +119,53 @@ def mock_energy_history(): @pytest.fixture(autouse=True) -def mock_listen(): +def mock_add_listener(): """Mock Teslemetry Stream listen method.""" with patch( - "homeassistant.components.teslemetry.TeslemetryStream.listen", - ) as mock_listen: - yield mock_listen + "homeassistant.components.teslemetry.TeslemetryStream.async_add_listener", + ) as mock_add_listener: + mock_add_listener.listeners = [] + + def unsubscribe() -> None: + return + + def side_effect(callback, filters): + mock_add_listener.listeners.append((callback, filters)) + return unsubscribe + + def send(event) -> None: + for listener, filters in mock_add_listener.listeners: + if recursive_match(filters, event): + listener(event) + + mock_add_listener.send = send + mock_add_listener.side_effect = side_effect + yield mock_add_listener + + +@pytest.fixture(autouse=True) +def mock_stream_get_config(): + """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStreamVehicle.get_config", + ) as mock_stream_get_config: + yield mock_stream_get_config + + +@pytest.fixture(autouse=True) +def mock_stream_update_config(): + """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStreamVehicle.update_config", + ) as mock_stream_update_config: + yield mock_stream_update_config + + +@pytest.fixture(autouse=True) +def mock_stream_connected(): + """Mock Teslemetry Stream listen method.""" + with patch( + "homeassistant.components.teslemetry.TeslemetryStream.connected", + return_value=True, + ) as mock_stream_connected: + yield mock_stream_connected diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index e459379ccf7..40d55dab71f 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -12,10 +12,13 @@ WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None} PRODUCTS = load_json_object_fixture("products.json", DOMAIN) VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) +VEHICLE_DATA_ASLEEP = load_json_object_fixture("vehicle_data.json", DOMAIN) +VEHICLE_DATA_ASLEEP["response"]["state"] = TeslemetryState.OFFLINE VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) +METADATA = load_json_object_fixture("metadata.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} @@ -44,9 +47,25 @@ METADATA = { "energy_device_data", "energy_cmds", ], + "vehicles": { + "LRW3F7EK4NC700000": { + "proxy": False, + "access": True, + "polling": True, + "firmware": "2024.44.25", + } + }, } METADATA_NOSCOPE = { "uid": "abc-123", "region": "NA", "scopes": ["openid", "offline_access", "vehicle_device_data"], + "vehicles": { + "LRW3F7EK4NC700000": { + "proxy": False, + "access": True, + "polling": True, + "firmware": "2024.44.25", + } + }, } diff --git a/tests/components/teslemetry/fixtures/config.json b/tests/components/teslemetry/fixtures/config.json new file mode 100644 index 00000000000..0a6d2b11ab0 --- /dev/null +++ b/tests/components/teslemetry/fixtures/config.json @@ -0,0 +1,10 @@ +{ + "exp": 1749261108, + "hostname": "na.teslemetry.com", + "port": 4431, + "prefer_typed": true, + "pending": false, + "fields": { + "ChargeAmps": { "interval_seconds": 60 } + } +} diff --git a/tests/components/teslemetry/fixtures/metadata.json b/tests/components/teslemetry/fixtures/metadata.json new file mode 100644 index 00000000000..60282afc934 --- /dev/null +++ b/tests/components/teslemetry/fixtures/metadata.json @@ -0,0 +1,22 @@ +{ + "uid": "abc-123", + "region": "NA", + "scopes": [ + "openid", + "offline_access", + "user_data", + "vehicle_device_data", + "vehicle_cmds", + "vehicle_charging_cmds", + "energy_device_data", + "energy_cmds" + ], + "vehicles": { + "LRW3F7EK4NC700000": { + "access": true, + "polling": true, + "proxy": true, + "firmware": "2024.44.25" + } + } +} diff --git a/tests/components/teslemetry/fixtures/products.json b/tests/components/teslemetry/fixtures/products.json index 8da921a33f4..56497a6d936 100644 --- a/tests/components/teslemetry/fixtures/products.json +++ b/tests/components/teslemetry/fixtures/products.json @@ -4,7 +4,7 @@ "id": 1234, "user_id": 1234, "vehicle_id": 1234, - "vin": "LRWXF7EK4KC700000", + "vin": "LRW3F7EK4NC700000", "color": null, "access_type": "OWNER", "display_name": "Test", diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index d99bc8de5a8..0cd238c4e52 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -3,7 +3,7 @@ "id": 1234, "user_id": 1234, "vehicle_id": 1234, - "vin": "LRWXF7EK4KC700000", + "vin": "LRW3F7EK4NC700000", "color": null, "access_type": "OWNER", "granular_access": { @@ -192,7 +192,7 @@ "api_version": 71, "autopark_state_v2": "unavailable", "calendar_supported": true, - "car_version": "2023.44.30.8 06f534d46010", + "car_version": "2024.44.25 06f534d46010", "center_display_state": 0, "dashcam_clip_save_available": true, "dashcam_state": "Recording", diff --git a/tests/components/teslemetry/fixtures/vehicle_data_alt.json b/tests/components/teslemetry/fixtures/vehicle_data_alt.json index 76416982eba..25b3878f4dd 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data_alt.json +++ b/tests/components/teslemetry/fixtures/vehicle_data_alt.json @@ -3,7 +3,7 @@ "id": 1234, "user_id": 1234, "vehicle_id": 1234, - "vin": "LRWXF7EK4KC700000", + "vin": "LRW3F7EK4NC700000", "color": null, "access_type": "OWNER", "granular_access": { @@ -24,7 +24,6 @@ "battery_range": 266.87, "charge_amps": 16, "charge_current_request": 16, - "charge_current_request_max": 16, "charge_enable_request": true, "charge_energy_added": 0, "charge_limit_soc": 80, @@ -72,16 +71,16 @@ "user_charge_enable_request": true }, "climate_state": { - "allow_cabin_overheat_protection": true, + "allow_cabin_overheat_protection": null, "auto_seat_climate_left": false, "auto_seat_climate_right": false, "auto_steering_wheel_heat": false, "battery_heater": true, "battery_heater_no_power": null, - "cabin_overheat_protection": "Off", + "cabin_overheat_protection": null, "cabin_overheat_protection_actively_cooling": false, "climate_keeper_mode": "off", - "cop_activation_temperature": "Low", + "cop_activation_temperature": null, "defrost_mode": 0, "driver_temp_setting": 22, "fan_status": 0, @@ -106,7 +105,7 @@ "seat_heater_right": 0, "side_mirror_heaters": false, "steering_wheel_heat_level": 0, - "steering_wheel_heater": false, + "steering_wheel_heater": true, "supports_fan_only_cabin_overheat_protection": true, "timestamp": 1705707520649, "wiper_blade_heater": false @@ -191,7 +190,7 @@ "api_version": 71, "autopark_state_v2": "unavailable", "calendar_supported": true, - "car_version": "2023.44.30.8 06f534d46010", + "car_version": "2024.44.25 06f534d46010", "center_display_state": 0, "dashcam_clip_save_available": true, "dashcam_state": "Recording", @@ -204,9 +203,9 @@ "is_user_present": true, "locked": false, "media_info": { - "audio_volume": 2.6667, - "audio_volume_increment": 0.333333, - "audio_volume_max": 10.333333, + "audio_volume": null, + "audio_volume_increment": null, + "audio_volume_max": null, "media_playback_status": "Stopped", "now_playing_album": "", "now_playing_artist": "", diff --git a/tests/components/teslemetry/snapshots/test_binary_sensors.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr similarity index 51% rename from tests/components/teslemetry/snapshots/test_binary_sensors.ambr rename to tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 383db58b336..e90cc9ced55 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensors.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -183,6 +183,98 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Automatic blind spot camera', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'automatic_blind_spot_camera', + 'unique_id': 'LRW3F7EK4NC700000-automatic_blind_spot_camera', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic blind spot camera', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Automatic emergency braking off', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'automatic_emergency_braking_off', + 'unique_id': 'LRW3F7EK4NC700000-automatic_emergency_braking_off', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic emergency braking off', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_battery_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -212,7 +304,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_heater_on', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_heater_on', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_battery_heater_on', 'unit_of_measurement': None, }) # --- @@ -227,10 +319,148 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_actively_cooling-entry] +# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Blind spot collision warning chime', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'blind_spot_collision_warning_chime', + 'unique_id': 'LRW3F7EK4NC700000-blind_spot_collision_warning_chime', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Blind spot collision warning chime', + }), + 'context': , + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_bms_full_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_bms_full_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'BMS full charge', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bms_full_charge_complete', + 'unique_id': 'LRW3F7EK4NC700000-bms_full_charge_complete', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_bms_full_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test BMS full charge', + }), + 'context': , + 'entity_id': 'binary_sensor.test_bms_full_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_brake_pedal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_brake_pedal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brake pedal', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brake_pedal', + 'unique_id': 'LRW3F7EK4NC700000-brake_pedal', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_brake_pedal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Brake pedal', + }), + 'context': , + 'entity_id': 'binary_sensor.test_brake_pedal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -242,7 +472,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -254,23 +484,23 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Cabin overheat protection actively cooling', + 'original_name': 'Cabin overheat protection active', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection_actively_cooling', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection_actively_cooling', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_actively_cooling-state] +# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'heat', - 'friendly_name': 'Test Cabin overheat protection actively cooling', + 'friendly_name': 'Test Cabin overheat protection active', }), 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'last_changed': , 'last_reported': , 'last_updated': , @@ -306,7 +536,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_conn_charge_cable', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_conn_charge_cable', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_conn_charge_cable', 'unit_of_measurement': None, }) # --- @@ -324,6 +554,52 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge port cold weather mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_port_cold_weather_mode', + 'unique_id': 'LRW3F7EK4NC700000-charge_port_cold_weather_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge port cold weather mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -353,7 +629,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_phases', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_phases', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_phases', 'unit_of_measurement': None, }) # --- @@ -367,7 +643,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_dashcam-entry] @@ -399,7 +675,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_dashcam_state', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_dashcam_state', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_dashcam_state', 'unit_of_measurement': None, }) # --- @@ -417,6 +693,328 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DC to DC converter', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dc_dc_enable', + 'unique_id': 'LRW3F7EK4NC700000-dc_dc_enable', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test DC to DC converter', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_drive_rail-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_drive_rail', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Drive rail', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_rail', + 'unique_id': 'LRW3F7EK4NC700000-drive_rail', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_drive_rail-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Drive rail', + }), + 'context': , + 'entity_id': 'binary_sensor.test_drive_rail', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_driver_seat_belt', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Driver seat belt', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'driver_seat_belt', + 'unique_id': 'LRW3F7EK4NC700000-driver_seat_belt', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_driver_seat_occupied', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Driver seat occupied', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'driver_seat_occupied', + 'unique_id': 'LRW3F7EK4NC700000-driver_seat_occupied', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat occupied', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_occupied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Emergency lane departure avoidance', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'emergency_lane_departure_avoidance', + 'unique_id': 'LRW3F7EK4NC700000-emergency_lane_departure_avoidance', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Emergency lane departure avoidance', + }), + 'context': , + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_european_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_european_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'European vehicle', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'europe_vehicle', + 'unique_id': 'LRW3F7EK4NC700000-europe_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_european_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test European vehicle', + }), + 'context': , + 'entity_id': 'binary_sensor.test_european_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_fast_charger_present-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_fast_charger_present', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fast charger present', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fast_charger_present', + 'unique_id': 'LRW3F7EK4NC700000-fast_charger_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_fast_charger_present-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fast charger present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_fast_charger_present', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_front_driver_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -446,7 +1044,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_df', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_df', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_df', 'unit_of_measurement': None, }) # --- @@ -461,7 +1059,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_front_driver_window-entry] @@ -493,7 +1091,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_fd_window', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_fd_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_fd_window', 'unit_of_measurement': None, }) # --- @@ -508,7 +1106,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_door-entry] @@ -540,7 +1138,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_pf', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_pf', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_pf', 'unit_of_measurement': None, }) # --- @@ -555,7 +1153,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_window-entry] @@ -587,7 +1185,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_fp_window', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_fp_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_fp_window', 'unit_of_measurement': None, }) # --- @@ -602,7 +1200,284 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_gps_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_gps_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GPS state', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gps_state', + 'unique_id': 'LRW3F7EK4NC700000-gps_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_gps_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test GPS state', + }), + 'context': , + 'entity_id': 'binary_sensor.test_gps_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_guest_mode_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Guest mode enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'guest_mode_enabled', + 'unique_id': 'LRW3F7EK4NC700000-guest_mode_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Guest mode enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_guest_mode_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_homelink_nearby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_homelink_nearby', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Homelink nearby', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'homelink_nearby', + 'unique_id': 'LRW3F7EK4NC700000-homelink_nearby', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_homelink_nearby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink nearby', + }), + 'context': , + 'entity_id': 'binary_sensor.test_homelink_nearby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_offroad_lightbar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Offroad lightbar', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'offroad_lightbar_present', + 'unique_id': 'LRW3F7EK4NC700000-offroad_lightbar_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Offroad lightbar', + }), + 'context': , + 'entity_id': 'binary_sensor.test_offroad_lightbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Passenger seat belt', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'passenger_seat_belt', + 'unique_id': 'LRW3F7EK4NC700000-passenger_seat_belt', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Passenger seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pin to drive enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pin_to_drive_enabled', + 'unique_id': 'LRW3F7EK4NC700000-pin_to_drive_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pin to drive enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_preconditioning-entry] @@ -634,7 +1509,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_is_preconditioning', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_is_preconditioning', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_is_preconditioning', 'unit_of_measurement': None, }) # --- @@ -680,7 +1555,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_preconditioning_enabled', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_preconditioning_enabled', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_preconditioning_enabled', 'unit_of_measurement': None, }) # --- @@ -694,7 +1569,53 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_rear_display_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rear display HVAC', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_display_hvac_enabled', + 'unique_id': 'LRW3F7EK4NC700000-rear_display_hvac_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Rear display HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_display_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] @@ -726,7 +1647,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_dr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_dr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_dr', 'unit_of_measurement': None, }) # --- @@ -741,7 +1662,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_window-entry] @@ -773,7 +1694,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rd_window', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rd_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rd_window', 'unit_of_measurement': None, }) # --- @@ -788,7 +1709,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_door-entry] @@ -820,7 +1741,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_pr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_pr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_pr', 'unit_of_measurement': None, }) # --- @@ -835,7 +1756,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_window-entry] @@ -867,7 +1788,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rp_window', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rp_window', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rp_window', 'unit_of_measurement': None, }) # --- @@ -882,7 +1803,53 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_right_hand_drive-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_right_hand_drive', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Right hand drive', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'right_hand_drive', + 'unique_id': 'LRW3F7EK4NC700000-right_hand_drive', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_right_hand_drive-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Right hand drive', + }), + 'context': , + 'entity_id': 'binary_sensor.test_right_hand_drive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-entry] @@ -914,7 +1881,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_scheduled_charging_pending', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_scheduled_charging_pending', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_scheduled_charging_pending', 'unit_of_measurement': None, }) # --- @@ -928,7 +1895,53 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_service_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_service_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Service mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'service_mode', + 'unique_id': 'LRW3F7EK4NC700000-service_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_service_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Service mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_service_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_status-entry] @@ -960,7 +1973,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'state', - 'unique_id': 'LRWXF7EK4KC700000-state', + 'unique_id': 'LRW3F7EK4NC700000-state', 'unit_of_measurement': None, }) # --- @@ -978,6 +1991,52 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Supercharger session trip planner', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supercharger_session_trip_planner', + 'unique_id': 'LRW3F7EK4NC700000-supercharger_session_trip_planner', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Supercharger session trip planner', + }), + 'context': , + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1007,7 +2066,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_fl', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_fl', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_fl', 'unit_of_measurement': None, }) # --- @@ -1054,7 +2113,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_fr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_fr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_fr', 'unit_of_measurement': None, }) # --- @@ -1101,7 +2160,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_rl', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_rl', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_rl', 'unit_of_measurement': None, }) # --- @@ -1148,7 +2207,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_soft_warning_rr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_rr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_soft_warning_rr', 'unit_of_measurement': None, }) # --- @@ -1195,7 +2254,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_trip_charging', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_trip_charging', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_trip_charging', 'unit_of_measurement': None, }) # --- @@ -1241,7 +2300,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_is_user_present', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_is_user_present', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_is_user_present', 'unit_of_measurement': None, }) # --- @@ -1259,6 +2318,52 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_wiper_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_wiper_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wiper heat', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wiper_heat_enabled', + 'unique_id': 'LRW3F7EK4NC700000-wiper_heat_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_wiper_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wiper heat', + }), + 'context': , + 'entity_id': 'binary_sensor.test_wiper_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.energy_site_backup_capable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1311,6 +2416,32 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_automatic_blind_spot_camera-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic blind spot camera', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_automatic_emergency_braking_off-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic emergency braking off', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_battery_heater-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1322,17 +2453,56 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_actively_cooling-statealt] +# name: test_binary_sensor_refresh[binary_sensor.test_blind_spot_collision_warning_chime-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Blind spot collision warning chime', + }), + 'context': , + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_bms_full_charge-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test BMS full charge', + }), + 'context': , + 'entity_id': 'binary_sensor.test_bms_full_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_brake_pedal-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Brake pedal', + }), + 'context': , + 'entity_id': 'binary_sensor.test_brake_pedal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_active-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'heat', - 'friendly_name': 'Test Cabin overheat protection actively cooling', + 'friendly_name': 'Test Cabin overheat protection active', }), 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1353,6 +2523,19 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_charge_port_cold_weather_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge port cold weather mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_charger_has_multiple_phases-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1363,7 +2546,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_dashcam-statealt] @@ -1380,6 +2563,97 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_dc_to_dc_converter-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test DC to DC converter', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_drive_rail-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Drive rail', + }), + 'context': , + 'entity_id': 'binary_sensor.test_drive_rail', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_belt-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_occupied-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat occupied', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_occupied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_emergency_lane_departure_avoidance-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Emergency lane departure avoidance', + }), + 'context': , + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_european_vehicle-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test European vehicle', + }), + 'context': , + 'entity_id': 'binary_sensor.test_european_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_fast_charger_present-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fast charger present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_fast_charger_present', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_door-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1391,7 +2665,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_window-statealt] @@ -1405,7 +2679,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_door-statealt] @@ -1419,7 +2693,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_window-statealt] @@ -1433,7 +2707,86 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_gps_state-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test GPS state', + }), + 'context': , + 'entity_id': 'binary_sensor.test_gps_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_guest_mode_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Guest mode enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_guest_mode_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_homelink_nearby-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink nearby', + }), + 'context': , + 'entity_id': 'binary_sensor.test_homelink_nearby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_offroad_lightbar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Offroad lightbar', + }), + 'context': , + 'entity_id': 'binary_sensor.test_offroad_lightbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_passenger_seat_belt-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Passenger seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_pin_to_drive_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pin to drive enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_preconditioning-statealt] @@ -1459,7 +2812,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_rear_display_hvac-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Rear display HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_display_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_door-statealt] @@ -1473,7 +2839,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_window-statealt] @@ -1487,7 +2853,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_door-statealt] @@ -1501,7 +2867,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_window-statealt] @@ -1515,7 +2881,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_right_hand_drive-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Right hand drive', + }), + 'context': , + 'entity_id': 'binary_sensor.test_right_hand_drive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_scheduled_charging_pending-statealt] @@ -1528,7 +2907,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_service_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Service mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_service_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] @@ -1545,6 +2937,19 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_supercharger_session_trip_planner-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Supercharger session trip planner', + }), + 'context': , + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_front_left-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1628,3 +3033,31 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_wiper_heat-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wiper heat', + }), + 'context': , + 'entity_id': 'binary_sensor.test_wiper_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_driver_seat_belt-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_driver_door-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_driver_window-state] + 'on' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_passenger_door-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_passenger_window-state] + 'on' +# --- diff --git a/tests/components/teslemetry/snapshots/test_button.ambr b/tests/components/teslemetry/snapshots/test_button.ambr index 84cf4c21078..6d3016186ae 100644 --- a/tests/components/teslemetry/snapshots/test_button.ambr +++ b/tests/components/teslemetry/snapshots/test_button.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'flash_lights', - 'unique_id': 'LRWXF7EK4KC700000-flash_lights', + 'unique_id': 'LRW3F7EK4NC700000-flash_lights', 'unit_of_measurement': None, }) # --- @@ -74,7 +74,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'homelink', - 'unique_id': 'LRWXF7EK4KC700000-homelink', + 'unique_id': 'LRW3F7EK4NC700000-homelink', 'unit_of_measurement': None, }) # --- @@ -120,7 +120,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'honk', - 'unique_id': 'LRWXF7EK4KC700000-honk', + 'unique_id': 'LRW3F7EK4NC700000-honk', 'unit_of_measurement': None, }) # --- @@ -166,7 +166,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'enable_keyless_driving', - 'unique_id': 'LRWXF7EK4KC700000-enable_keyless_driving', + 'unique_id': 'LRW3F7EK4NC700000-enable_keyless_driving', 'unit_of_measurement': None, }) # --- @@ -212,7 +212,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'boombox', - 'unique_id': 'LRWXF7EK4KC700000-boombox', + 'unique_id': 'LRW3F7EK4NC700000-boombox', 'unit_of_measurement': None, }) # --- @@ -258,7 +258,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wake', - 'unique_id': 'LRWXF7EK4KC700000-wake', + 'unique_id': 'LRW3F7EK4NC700000-wake', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 9d5e3827ffc..7064309e98b 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -43,7 +43,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'climate_state_cabin_overheat_protection', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection', 'unit_of_measurement': None, }) # --- @@ -113,7 +113,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': , - 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, }) # --- @@ -184,7 +184,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'climate_state_cabin_overheat_protection', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection', 'unit_of_measurement': None, }) # --- @@ -208,7 +208,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_climate_alt[climate.test_climate-entry] @@ -253,7 +253,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': , - 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, }) # --- @@ -322,7 +322,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_cabin_overheat_protection', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_cabin_overheat_protection', 'unit_of_measurement': None, }) # --- @@ -361,150 +361,10 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, }) # --- -# name: test_climate_offline[climate.test_cabin_overheat_protection-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'hvac_modes': list([ - , - , - , - ]), - 'max_temp': 40, - 'min_temp': 30, - 'target_temp_step': 5, - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'climate', - 'entity_category': None, - 'entity_id': 'climate.test_cabin_overheat_protection', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Cabin overheat protection', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': 'climate_state_cabin_overheat_protection', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate_offline[climate.test_cabin_overheat_protection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_temperature': None, - 'friendly_name': 'Test Cabin overheat protection', - 'hvac_modes': list([ - , - , - , - ]), - 'max_temp': 40, - 'min_temp': 30, - 'supported_features': , - 'target_temp_step': 5, - }), - 'context': , - 'entity_id': 'climate.test_cabin_overheat_protection', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_climate_offline[climate.test_climate-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'hvac_modes': list([ - , - , - ]), - 'max_temp': 28.0, - 'min_temp': 15.0, - 'preset_modes': list([ - 'off', - 'keep', - 'dog', - 'camp', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'climate', - 'entity_category': None, - 'entity_id': 'climate.test_climate', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Climate', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': , - 'unique_id': 'LRWXF7EK4KC700000-driver_temp', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate_offline[climate.test_climate-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_temperature': None, - 'friendly_name': 'Test Climate', - 'hvac_modes': list([ - , - , - ]), - 'max_temp': 28.0, - 'min_temp': 15.0, - 'preset_mode': None, - 'preset_modes': list([ - 'off', - 'keep', - 'dog', - 'camp', - ]), - 'supported_features': , - 'temperature': None, - }), - 'context': , - 'entity_id': 'climate.test_climate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_invalid_error[error] 'Command returned exception: The data request or command is unknown.' # --- diff --git a/tests/components/teslemetry/snapshots/test_cover.ambr b/tests/components/teslemetry/snapshots/test_cover.ambr index 7ffb9c4a1f9..8364f2a6a6e 100644 --- a/tests/components/teslemetry/snapshots/test_cover.ambr +++ b/tests/components/teslemetry/snapshots/test_cover.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'charge_state_charge_port_door_open', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_door_open', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_ft', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_ft', 'unit_of_measurement': None, }) # --- @@ -124,7 +124,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_sun_roof_state', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', 'unit_of_measurement': None, }) # --- @@ -172,7 +172,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_rt', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rt', 'unit_of_measurement': None, }) # --- @@ -220,7 +220,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'windows', - 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unique_id': 'LRW3F7EK4NC700000-windows', 'unit_of_measurement': None, }) # --- @@ -268,7 +268,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'charge_state_charge_port_door_open', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_door_open', 'unit_of_measurement': None, }) # --- @@ -316,7 +316,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_ft', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_ft', 'unit_of_measurement': None, }) # --- @@ -335,54 +335,6 @@ 'state': 'open', }) # --- -# name: test_cover_alt[cover.test_sunroof-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'cover', - 'entity_category': None, - 'entity_id': 'cover.test_sunroof', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Sunroof', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': 'vehicle_state_sun_roof_state', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_cover_alt[cover.test_sunroof-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Sunroof', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.test_sunroof', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_cover_alt[cover.test_trunk-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -412,7 +364,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_rt', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rt', 'unit_of_measurement': None, }) # --- @@ -460,7 +412,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'windows', - 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unique_id': 'LRW3F7EK4NC700000-windows', 'unit_of_measurement': None, }) # --- @@ -508,7 +460,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_port_door_open', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_door_open', 'unit_of_measurement': None, }) # --- @@ -556,7 +508,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_ft', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_ft', 'unit_of_measurement': None, }) # --- @@ -604,7 +556,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_sun_roof_state', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', 'unit_of_measurement': None, }) # --- @@ -652,7 +604,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_rt', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rt', 'unit_of_measurement': None, }) # --- @@ -700,7 +652,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'windows', - 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unique_id': 'LRW3F7EK4NC700000-windows', 'unit_of_measurement': None, }) # --- @@ -719,3 +671,39 @@ 'state': 'closed', }) # --- +# name: test_cover_streaming[cover.test_charge_port_door-closed] + 'closed' +# --- +# name: test_cover_streaming[cover.test_charge_port_door-open] + 'closed' +# --- +# name: test_cover_streaming[cover.test_charge_port_door-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-unknown] + 'unknown' +# --- diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index 6c18cdf75c6..0bc371b2d2d 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', - 'unique_id': 'LRWXF7EK4KC700000-location', + 'unique_id': 'LRW3F7EK4NC700000-location', 'unit_of_measurement': None, }) # --- @@ -78,7 +78,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'route', - 'unique_id': 'LRWXF7EK4KC700000-route', + 'unique_id': 'LRW3F7EK4NC700000-route', 'unit_of_measurement': None, }) # --- @@ -99,3 +99,55 @@ 'state': 'home', }) # --- +# name: test_device_tracker_alt[device_tracker.test_location-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Location', + 'gps_accuracy': 0, + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.test_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker_alt[device_tracker.test_route-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Route', + 'gps_accuracy': 0, + 'latitude': 30.2226265, + 'longitude': -97.6236871, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.test_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker_streaming[device_tracker.test_location-restore] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_location-state] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_origin-restore] + 'unknown' +# --- +# name: test_device_tracker_streaming[device_tracker.test_origin-state] + 'unknown' +# --- +# name: test_device_tracker_streaming[device_tracker.test_route-restore] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_route-state] + 'home' +# --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 3b96d6f70c0..16cabfddd09 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -352,7 +352,7 @@ 'vehicle_state_api_version': 71, 'vehicle_state_autopark_state_v2': 'unavailable', 'vehicle_state_calendar_supported': True, - 'vehicle_state_car_version': '2023.44.30.8 06f534d46010', + 'vehicle_state_car_version': '2024.44.25 06f534d46010', 'vehicle_state_center_display_state': 0, 'vehicle_state_dashcam_clip_save_available': True, 'vehicle_state_dashcam_state': 'Recording', diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr index e07f075b7d8..7d60ed82859 100644 --- a/tests/components/teslemetry/snapshots/test_init.ambr +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -31,7 +31,7 @@ 'via_device_id': None, }) # --- -# name: test_devices[{('teslemetry', 'LRWXF7EK4KC700000')}] +# name: test_devices[{('teslemetry', 'LRW3F7EK4NC700000')}] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -45,19 +45,19 @@ 'identifiers': set({ tuple( 'teslemetry', - 'LRWXF7EK4KC700000', + 'LRW3F7EK4NC700000', ), }), 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', - 'model': 'Model X', + 'model': 'Model 3', 'model_id': None, 'name': 'Test', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': 'LRWXF7EK4KC700000', + 'serial_number': 'LRW3F7EK4NC700000', 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/teslemetry/snapshots/test_lock.ambr b/tests/components/teslemetry/snapshots/test_lock.ambr index deaabbae904..2130c4d9574 100644 --- a/tests/components/teslemetry/snapshots/test_lock.ambr +++ b/tests/components/teslemetry/snapshots/test_lock.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_port_latch', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_latch', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_latch', 'unit_of_measurement': None, }) # --- @@ -75,7 +75,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_locked', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_locked', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_locked', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/teslemetry/snapshots/test_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr index 06500437701..a9d2569c637 100644 --- a/tests/components/teslemetry/snapshots/test_media_player.ambr +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'media', - 'unique_id': 'LRWXF7EK4KC700000-media', + 'unique_id': 'LRW3F7EK4NC700000-media', 'unit_of_measurement': None, }) # --- @@ -67,7 +67,6 @@ 'media_title': '', 'source': 'Spotify', 'supported_features': , - 'volume_level': 0.25806775026025003, }), 'context': , 'entity_id': 'media_player.test_media_player', @@ -107,7 +106,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media', - 'unique_id': 'LRWXF7EK4KC700000-media', + 'unique_id': 'LRW3F7EK4NC700000-media', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index f33b5e15d30..0f30daf635e 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -149,7 +149,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_current_request', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_current_request', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_current_request', 'unit_of_measurement': , }) # --- @@ -206,7 +206,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_limit_soc', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_limit_soc', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_limit_soc', 'unit_of_measurement': '%', }) # --- diff --git a/tests/components/teslemetry/snapshots/test_select.ambr b/tests/components/teslemetry/snapshots/test_select.ambr index 4e6feda7e5d..0c2547f309d 100644 --- a/tests/components/teslemetry/snapshots/test_select.ambr +++ b/tests/components/teslemetry/snapshots/test_select.ambr @@ -149,7 +149,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_left', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_left', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_left', 'unit_of_measurement': None, }) # --- @@ -208,7 +208,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_right', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_right', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_right', 'unit_of_measurement': None, }) # --- @@ -267,7 +267,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_center', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_center', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_rear_center', 'unit_of_measurement': None, }) # --- @@ -326,7 +326,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_left', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_left', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_rear_left', 'unit_of_measurement': None, }) # --- @@ -385,7 +385,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_seat_heater_rear_right', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_right', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_seat_heater_rear_right', 'unit_of_measurement': None, }) # --- @@ -408,178 +408,3 @@ 'state': 'off', }) # --- -# name: test_select[select.test_seat_heater_third_row_left-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'off', - 'low', - 'medium', - 'high', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.test_seat_heater_third_row_left', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Seat heater third row left', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_seat_heater_third_row_left', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_third_row_left', - 'unit_of_measurement': None, - }) -# --- -# name: test_select[select.test_seat_heater_third_row_left-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat heater third row left', - 'options': list([ - 'off', - 'low', - 'medium', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.test_seat_heater_third_row_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_select[select.test_seat_heater_third_row_right-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'off', - 'low', - 'medium', - 'high', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.test_seat_heater_third_row_right', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Seat heater third row right', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_seat_heater_third_row_right', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_third_row_right', - 'unit_of_measurement': None, - }) -# --- -# name: test_select[select.test_seat_heater_third_row_right-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat heater third row right', - 'options': list([ - 'off', - 'low', - 'medium', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.test_seat_heater_third_row_right', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_select[select.test_steering_wheel_heater-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'off', - 'low', - 'high', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.test_steering_wheel_heater', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Steering wheel heater', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_steering_wheel_heat_level', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_steering_wheel_heat_level', - 'unit_of_measurement': None, - }) -# --- -# name: test_select[select.test_steering_wheel_heater-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Steering wheel heater', - 'options': list([ - 'off', - 'low', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.test_steering_wheel_heater', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index 96cebc2b01f..6439e74eecc 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -2414,6 +2414,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -2422,7 +2425,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_level', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_level', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_battery_level', 'unit_of_measurement': '%', }) # --- @@ -2495,7 +2498,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_range', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_range', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_battery_range', 'unit_of_measurement': , }) # --- @@ -2560,7 +2563,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_conn_charge_cable', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_conn_charge_cable', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_conn_charge_cable', 'unit_of_measurement': None, }) # --- @@ -2624,7 +2627,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_energy_added', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_energy_added', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_energy_added', 'unit_of_measurement': , }) # --- @@ -2694,7 +2697,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_rate', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_rate', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_rate', 'unit_of_measurement': , }) # --- @@ -2761,7 +2764,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_actual_current', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_actual_current', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_actual_current', 'unit_of_measurement': , }) # --- @@ -2828,7 +2831,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_power', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_power', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_power', 'unit_of_measurement': , }) # --- @@ -2895,7 +2898,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_voltage', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_voltage', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charger_voltage', 'unit_of_measurement': , }) # --- @@ -2969,7 +2972,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charging_state', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_charging_state', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charging_state', 'unit_of_measurement': None, }) # --- @@ -3051,7 +3054,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_miles_to_arrival', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_miles_to_arrival', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_miles_to_arrival', 'unit_of_measurement': , }) # --- @@ -3121,7 +3124,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_driver_temp_setting', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_driver_temp_setting', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_driver_temp_setting', 'unit_of_measurement': , }) # --- @@ -3194,7 +3197,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_est_battery_range', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_est_battery_range', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_est_battery_range', 'unit_of_measurement': , }) # --- @@ -3259,7 +3262,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_fast_charger_type', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_fast_charger_type', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_fast_charger_type', 'unit_of_measurement': None, }) # --- @@ -3326,7 +3329,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_ideal_battery_range', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_ideal_battery_range', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_ideal_battery_range', 'unit_of_measurement': , }) # --- @@ -3396,7 +3399,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_inside_temp', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_inside_temp', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_inside_temp', 'unit_of_measurement': , }) # --- @@ -3469,7 +3472,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_odometer', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_odometer', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_odometer', 'unit_of_measurement': , }) # --- @@ -3539,7 +3542,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_outside_temp', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_outside_temp', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_outside_temp', 'unit_of_measurement': , }) # --- @@ -3609,7 +3612,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_passenger_temp_setting', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_passenger_temp_setting', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_passenger_temp_setting', 'unit_of_measurement': , }) # --- @@ -3676,7 +3679,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_power', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_power', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_power', 'unit_of_measurement': , }) # --- @@ -3748,7 +3751,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_shift_state', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_shift_state', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_shift_state', 'unit_of_measurement': None, }) # --- @@ -3826,7 +3829,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_speed', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_speed', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_speed', 'unit_of_measurement': , }) # --- @@ -3843,7 +3846,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_speed-statealt] @@ -3859,7 +3862,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_state_of_charge_at_arrival-entry] @@ -3893,7 +3896,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_energy_at_arrival', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_energy_at_arrival', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_energy_at_arrival', 'unit_of_measurement': '%', }) # --- @@ -3910,7 +3913,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_state_of_charge_at_arrival-statealt] @@ -3926,7 +3929,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_time_to_arrival-entry] @@ -3958,7 +3961,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_minutes_to_arrival', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_minutes_to_arrival', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_minutes_to_arrival', 'unit_of_measurement': None, }) # --- @@ -4019,7 +4022,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_minutes_to_full_charge', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_minutes_to_full_charge', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_minutes_to_full_charge', 'unit_of_measurement': None, }) # --- @@ -4088,7 +4091,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fl', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_fl', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_fl', 'unit_of_measurement': , }) # --- @@ -4161,7 +4164,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_fr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_fr', 'unit_of_measurement': , }) # --- @@ -4234,7 +4237,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rl', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_rl', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_rl', 'unit_of_measurement': , }) # --- @@ -4307,7 +4310,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rr', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_rr', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_tpms_pressure_rr', 'unit_of_measurement': , }) # --- @@ -4374,7 +4377,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_traffic_minutes_delay', - 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_traffic_minutes_delay', + 'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_traffic_minutes_delay', 'unit_of_measurement': , }) # --- @@ -4441,7 +4444,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_usable_battery_level', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_usable_battery_level', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_usable_battery_level', 'unit_of_measurement': '%', }) # --- @@ -4977,3 +4980,24 @@ 'state': 'unknown', }) # --- +# name: test_sensors_streaming[sensor.test_battery_level-state] + '90' +# --- +# name: test_sensors_streaming[sensor.test_charge_cable-state] + 'unknown' +# --- +# name: test_sensors_streaming[sensor.test_charge_energy_added-state] + '10' +# --- +# name: test_sensors_streaming[sensor.test_charger_power-state] + '2' +# --- +# name: test_sensors_streaming[sensor.test_charging-state] + 'charging' +# --- +# name: test_sensors_streaming[sensor.test_time_to_arrival-state] + 'unknown' +# --- +# name: test_sensors_streaming[sensor.test_time_to_full_charge-state] + 'unknown' +# --- diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr index f55cbae6a54..5693d4bdd5e 100644 --- a/tests/components/teslemetry/snapshots/test_switch.ambr +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -122,7 +122,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_seat_climate_left', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_seat_climate_left', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_auto_seat_climate_left', 'unit_of_measurement': None, }) # --- @@ -169,7 +169,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_seat_climate_right', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_seat_climate_right', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_auto_seat_climate_right', 'unit_of_measurement': None, }) # --- @@ -216,7 +216,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_auto_steering_wheel_heat', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_steering_wheel_heat', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_auto_steering_wheel_heat', 'unit_of_measurement': None, }) # --- @@ -263,7 +263,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_user_charge_enable_request', - 'unique_id': 'LRWXF7EK4KC700000-charge_state_user_charge_enable_request', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_user_charge_enable_request', 'unit_of_measurement': None, }) # --- @@ -310,7 +310,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_defrost_mode', - 'unique_id': 'LRWXF7EK4KC700000-climate_state_defrost_mode', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_defrost_mode', 'unit_of_measurement': None, }) # --- @@ -357,7 +357,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_sentry_mode', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sentry_mode', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sentry_mode', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index a1213f3d94b..2411d047135 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_software_update_status', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_software_update_status', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_software_update_status', 'unit_of_measurement': None, }) # --- @@ -40,7 +40,7 @@ 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', 'friendly_name': 'Test Update', 'in_progress': False, - 'installed_version': '2023.44.30.8', + 'installed_version': '2024.44.25', 'latest_version': '2024.12.0.0', 'release_summary': None, 'release_url': None, @@ -54,7 +54,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_update_alt[update.test_update-entry] @@ -86,7 +86,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'vehicle_state_software_update_status', - 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_software_update_status', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_software_update_status', 'unit_of_measurement': None, }) # --- @@ -98,8 +98,8 @@ 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', 'friendly_name': 'Test Update', 'in_progress': False, - 'installed_version': '2023.44.30.8', - 'latest_version': '2023.44.30.8', + 'installed_version': '2024.44.25', + 'latest_version': '2024.44.25', 'release_summary': None, 'release_url': None, 'skipped_version': None, diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py new file mode 100644 index 00000000000..5a7126afe1b --- /dev/null +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -0,0 +1,106 @@ +"""Test the Teslemetry binary sensor platform.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal + +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, assert_entities_alt, setup_platform +from .const import VEHICLE_DATA_ALT + +from tests.common import async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the binary sensor entities are correct.""" + + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor_refresh( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Tests that the binary sensor entities are correct.""" + + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) + + # Refresh + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the binary sensor entities with streaming are correct.""" + + freezer.move_to("2024-01-01 00:00:00+00:00") + + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateOpened", + Signal.FP_WINDOW: "INVALID_VALUE", + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": True, + "DriverRear": False, + "PassengerFront": False, + "PassengerRear": False, + "TrunkFront": False, + "TrunkRear": False, + } + }, + Signal.DRIVER_SEAT_BELT: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "binary_sensor.test_front_driver_window", + "binary_sensor.test_front_passenger_window", + "binary_sensor.test_front_driver_door", + "binary_sensor.test_front_passenger_door", + "binary_sensor.test_driver_seat_belt", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/teslemetry/test_binary_sensors.py b/tests/components/teslemetry/test_binary_sensors.py deleted file mode 100644 index 95fccde5f25..00000000000 --- a/tests/components/teslemetry/test_binary_sensors.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Test the Teslemetry binary sensor platform.""" - -from unittest.mock import AsyncMock - -from freezegun.api import FrozenDateTimeFactory -import pytest -from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline - -from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL -from homeassistant.const import STATE_UNKNOWN, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import assert_entities, assert_entities_alt, setup_platform -from .const import VEHICLE_DATA_ALT - -from tests.common import async_fire_time_changed - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_binary_sensor( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, -) -> None: - """Tests that the binary sensor entities are correct.""" - - entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) - assert_entities(hass, entry.entry_id, entity_registry, snapshot) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_binary_sensor_refresh( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, - mock_vehicle_data: AsyncMock, - freezer: FrozenDateTimeFactory, -) -> None: - """Tests that the binary sensor entities are correct.""" - - entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) - - # Refresh - mock_vehicle_data.return_value = VEHICLE_DATA_ALT - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) - - -async def test_binary_sensor_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the binary sensor entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.BINARY_SENSOR]) - state = hass.states.get("binary_sensor.test_status") - assert state.state == STATE_UNKNOWN diff --git a/tests/components/teslemetry/test_button.py b/tests/components/teslemetry/test_button.py index 04edf668765..75f94342f1e 100644 --- a/tests/components/teslemetry/test_button.py +++ b/tests/components/teslemetry/test_button.py @@ -29,6 +29,7 @@ async def test_button( @pytest.mark.parametrize( ("name", "func"), [ + ("wake", "wake_up"), ("flash_lights", "flash_lights"), ("honk_horn", "honk_horn"), ("keyless_driving", "remote_start_drive"), diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 55f99caa13c..33f2e134806 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import InvalidCommand, VehicleOffline +from tesla_fleet_api.exceptions import InvalidCommand from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -19,7 +19,6 @@ from homeassistant.components.climate import ( SERVICE_TURN_ON, HVACMode, ) -from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -31,12 +30,11 @@ from .const import ( COMMAND_IGNORED_REASON, METADATA_NOSCOPE, VEHICLE_DATA_ALT, + VEHICLE_DATA_ASLEEP, WAKE_UP_ASLEEP, WAKE_UP_ONLINE, ) -from tests.common import async_fire_time_changed - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate( @@ -205,20 +203,6 @@ async def test_climate_alt( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_climate_offline( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the climate entity is correct.""" - - mock_vehicle_data.side_effect = VehicleOffline - entry = await setup_platform(hass, [Platform.CLIMATE]) - assert_entities(hass, entry.entry_id, entity_registry, snapshot) - - async def test_invalid_error(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Tests service error is handled.""" @@ -296,18 +280,9 @@ async def test_asleep_or_offline( ) -> None: """Tests asleep is handled.""" + mock_vehicle_data.return_value = VEHICLE_DATA_ASLEEP await setup_platform(hass, [Platform.CLIMATE]) entity_id = "climate.test_climate" - mock_vehicle_data.assert_called_once() - - # Put the vehicle alseep - mock_vehicle_data.reset_mock() - mock_vehicle_data.side_effect = VehicleOffline - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - mock_vehicle_data.assert_called_once() - mock_wake_up.reset_mock() # Run a command but fail trying to wake up the vehicle mock_wake_up.side_effect = InvalidCommand diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 5801a356ac5..14af1e732fe 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline +from teslemetry_stream import Signal from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, @@ -13,7 +13,7 @@ from homeassistant.components.cover import ( SERVICE_STOP_COVER, CoverState, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -26,6 +26,7 @@ async def test_cover( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct.""" @@ -39,6 +40,7 @@ async def test_cover_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct with alternate values.""" @@ -53,6 +55,7 @@ async def test_cover_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct without scopes.""" @@ -61,18 +64,6 @@ async def test_cover_noscope( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_cover_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the cover entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.COVER]) - state = hass.states.get("cover.test_windows") - assert state.state == STATE_UNKNOWN - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover_services( hass: HomeAssistant, @@ -228,3 +219,127 @@ async def test_cover_services( state = hass.states.get(entity_id) assert state assert state.state == CoverState.CLOSED + + +async def test_cover_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the binary sensor entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.COVER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateClosed", + Signal.FP_WINDOW: "WindowStateClosed", + Signal.RD_WINDOW: "WindowStateClosed", + Signal.RP_WINDOW: "WindowStateClosed", + Signal.CHARGE_PORT_DOOR_OPEN: False, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": False, + "DriverRear": False, + "PassengerFront": False, + "PassengerRear": False, + "TrunkFront": False, + "TrunkRear": False, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-closed") + + # Send some alternative data with everything open + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateOpened", + Signal.FP_WINDOW: "WindowStateOpened", + Signal.RD_WINDOW: "WindowStateOpened", + Signal.RP_WINDOW: "WindowStateOpened", + Signal.CHARGE_PORT_DOOR_OPEN: False, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": True, + "DriverRear": True, + "PassengerFront": True, + "PassengerRear": True, + "TrunkFront": True, + "TrunkRear": True, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities get new values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-open") + + # Send some alternative data with everything unknown + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateUnknown", + Signal.FP_WINDOW: "WindowStateUnknown", + Signal.RD_WINDOW: "WindowStateUnknown", + Signal.RP_WINDOW: "WindowStateUnknown", + Signal.CHARGE_PORT_DOOR_OPEN: None, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": None, + "DriverRear": None, + "PassengerFront": None, + "PassengerRear": None, + "TrunkFront": None, + "TrunkRear": None, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities get UNKNOWN values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-unknown") diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py index a3fcd428c66..38a28092d33 100644 --- a/tests/components/teslemetry/test_device_tracker.py +++ b/tests/components/teslemetry/test_device_tracker.py @@ -1,19 +1,25 @@ """Test the Teslemetry device tracker platform.""" -from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline +from unittest.mock import AsyncMock -from homeassistant.const import STATE_UNKNOWN, Platform +import pytest +from syrupy.assertion import SnapshotAssertion +from teslemetry_stream.const import Signal + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, assert_entities_alt, setup_platform +from .const import VEHICLE_DATA_ALT +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_tracker( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the device tracker entities are correct.""" @@ -21,13 +27,71 @@ async def test_device_tracker( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_device_tracker_offline( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_device_tracker_alt( hass: HomeAssistant, - mock_vehicle_data, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: - """Tests that the device tracker entities are correct when offline.""" + """Tests that the device tracker entities are correct.""" - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.DEVICE_TRACKER]) - state = hass.states.get("device_tracker.test_location") - assert state.state == STATE_UNKNOWN + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) + assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_device_tracker_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the device tracker entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCATION: { + "latitude": 1.0, + "longitude": 2.0, + }, + Signal.DESTINATION_LOCATION: { + "latitude": 3.0, + "longitude": 4.0, + }, + Signal.DESTINATION_NAME: "Home", + Signal.ORIGIN_LOCATION: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "device_tracker.test_location", + "device_tracker.test_route", + "device_tracker.test_origin", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "device_tracker.test_location", + "device_tracker.test_route", + "device_tracker.test_origin", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-restore") diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 2a33e1def66..5481e6cc034 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -12,10 +12,7 @@ from tesla_fleet_api.exceptions import ( VehicleOffline, ) -from homeassistant.components.teslemetry.coordinator import ( - VEHICLE_INTERVAL, - VEHICLE_WAIT, -) +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, Platform @@ -23,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_platform -from .const import VEHICLE_DATA_ALT, WAKE_UP_ASLEEP +from .const import VEHICLE_DATA_ALT from tests.common import async_fire_time_changed @@ -72,22 +69,6 @@ async def test_devices( assert device == snapshot(name=f"{device.identifiers}") -# Vehicle Coordinator -async def test_vehicle_refresh_asleep( - hass: HomeAssistant, - mock_vehicle: AsyncMock, - mock_vehicle_data: AsyncMock, - freezer: FrozenDateTimeFactory, -) -> None: - """Test coordinator refresh with an error.""" - - mock_vehicle.return_value = WAKE_UP_ASLEEP - entry = await setup_platform(hass, [Platform.CLIMATE]) - assert entry.state is ConfigEntryState.LOADED - mock_vehicle.assert_called_once() - mock_vehicle_data.assert_not_called() - - async def test_vehicle_refresh_offline( hass: HomeAssistant, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory ) -> None: @@ -117,63 +98,6 @@ async def test_vehicle_refresh_error( assert entry.state is state -async def test_vehicle_sleep( - hass: HomeAssistant, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory -) -> None: - """Test coordinator refresh with an error.""" - await setup_platform(hass, [Platform.CLIMATE]) - assert mock_vehicle_data.call_count == 1 - - freezer.tick(VEHICLE_WAIT + VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Let vehicle sleep, no updates for 15 minutes - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 - - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # No polling, call_count should not increase - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 - - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # No polling, call_count should not increase - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 - - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Vehicle didn't sleep, go back to normal - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 3 - - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Regular polling - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 4 - - mock_vehicle_data.return_value = VEHICLE_DATA_ALT - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Vehicle active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 5 - - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Dont let sleep when active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 6 - - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Dont let sleep when active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 7 - - # Test Energy Live Coordinator @pytest.mark.parametrize(("side_effect", "state"), ERRORS) async def test_energy_live_refresh_error( @@ -218,13 +142,13 @@ async def test_energy_history_refresh_error( async def test_vehicle_stream( hass: HomeAssistant, - mock_listen: AsyncMock, + mock_add_listener: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test vehicle stream events.""" - entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) - mock_listen.assert_called_once() + await setup_platform(hass, [Platform.BINARY_SENSOR]) + mock_add_listener.assert_called() state = hass.states.get("binary_sensor.test_status") assert state.state == STATE_ON @@ -232,29 +156,37 @@ async def test_vehicle_stream( state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_OFF - runtime_data: TeslemetryData = entry.runtime_data - for listener, _ in runtime_data.vehicles[0].stream._listeners.values(): - listener( - { - "vin": VEHICLE_DATA_ALT["response"]["vin"], - "vehicle_data": VEHICLE_DATA_ALT["response"], - "createdAt": "2024-10-04T10:45:17.537Z", - } - ) + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "vehicle_data": VEHICLE_DATA_ALT["response"], + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_ON - for listener, _ in runtime_data.vehicles[0].stream._listeners.values(): - listener( - { - "vin": VEHICLE_DATA_ALT["response"]["vin"], - "state": "offline", - "createdAt": "2024-10-04T10:45:17.537Z", - } - ) + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "state": "offline", + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_status") assert state.state == STATE_OFF + + +async def test_no_live_status( + hass: HomeAssistant, + mock_live_status: AsyncMock, +) -> None: + """Test coordinator refresh with an error.""" + mock_live_status.side_effect = AsyncMock({"response": ""}) + await setup_platform(hass) + + assert hass.states.get("sensor.energy_site_grid_power") is None diff --git a/tests/components/teslemetry/test_lock.py b/tests/components/teslemetry/test_lock.py index b1460e870f0..f7c9fea1400 100644 --- a/tests/components/teslemetry/test_lock.py +++ b/tests/components/teslemetry/test_lock.py @@ -1,10 +1,9 @@ """Test the Teslemetry lock platform.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, @@ -12,7 +11,7 @@ from homeassistant.components.lock import ( SERVICE_UNLOCK, LockState, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -32,18 +31,6 @@ async def test_lock( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_lock_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the lock entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.LOCK]) - state = hass.states.get("lock.test_lock") - assert state.state == STATE_UNKNOWN - - async def test_lock_services( hass: HomeAssistant, ) -> None: diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py index 0d30750d10d..ae462bfd026 100644 --- a/tests/components/teslemetry/test_media_player.py +++ b/tests/components/teslemetry/test_media_player.py @@ -3,7 +3,6 @@ from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_LEVEL, @@ -47,18 +46,6 @@ async def test_media_player_alt( assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) -async def test_media_player_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the media player entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.MEDIA_PLAYER]) - state = hass.states.get("media_player.test_media_player") - assert state.state == MediaPlayerState.OFF - - async def test_media_player_noscope( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/teslemetry/test_number.py b/tests/components/teslemetry/test_number.py index 5df948b475c..65c03514d22 100644 --- a/tests/components/teslemetry/test_number.py +++ b/tests/components/teslemetry/test_number.py @@ -4,14 +4,13 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -31,18 +30,6 @@ async def test_number( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_number_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the number entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.NUMBER]) - state = hass.states.get("number.test_charge_current") - assert state.state == STATE_UNKNOWN - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number_services( hass: HomeAssistant, mock_vehicle_data: AsyncMock diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index caf0b9c1deb..005a6a2004e 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.select import ( ATTR_OPTION, @@ -33,18 +32,6 @@ async def test_select( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_select_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the select entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.SELECT]) - state = hass.states.get("select.test_seat_heater_front_left") - assert state.state == STATE_UNKNOWN - - async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: """Tests that the select services work.""" mock_vehicle_data.return_value = VEHICLE_DATA_ALT @@ -112,3 +99,23 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: state = hass.states.get(entity_id) assert state.state == EnergyExportMode.BATTERY_OK.value call.assert_called_once() + + +async def test_select_invalid_data( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, +) -> None: + """Tests that the select entities handle invalid data.""" + + broken_data = VEHICLE_DATA_ALT.copy() + broken_data["response"]["climate_state"]["seat_heater_left"] = "green" + broken_data["response"]["climate_state"]["steering_wheel_heat_level"] = "yellow" + + mock_vehicle_data.return_value = broken_data + await setup_platform(hass, [Platform.SELECT]) + state = hass.states.get("select.test_seat_heater_front_left") + assert state.state == STATE_UNKNOWN + state = hass.states.get("select.test_steering_wheel_heater") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index f0b472a7183..a488ebc8a06 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -1,10 +1,11 @@ """Test the Teslemetry sensor platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.const import Platform @@ -25,11 +26,15 @@ async def test_sensors( freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, ) -> None: - """Tests that the sensor entities are correct.""" + """Tests that the sensor entities with the legacy polling are correct.""" freezer.move_to("2024-01-01 00:00:00+00:00") - entry = await setup_platform(hass, [Platform.SENSOR]) + # Force the vehicle to use polling + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True + ): + entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) @@ -40,3 +45,54 @@ async def test_sensors( await hass.async_block_till_done() assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the sensor entities with streaming are correct.""" + + freezer.move_to("2024-01-01 00:00:00+00:00") + + entry = await setup_platform(hass, [Platform.SENSOR]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.DETAILED_CHARGE_STATE: "DetailedChargeStateCharging", + Signal.BATTERY_LEVEL: 90, + Signal.AC_CHARGING_ENERGY_IN: 10, + Signal.AC_CHARGING_POWER: 2, + Signal.CHARGING_CABLE_TYPE: None, + Signal.TIME_TO_FULL_CHARGE: 10, + Signal.MINUTES_TO_ARRIVAL: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "sensor.test_charging", + "sensor.test_battery_level", + "sensor.test_charge_energy_added", + "sensor.test_charger_power", + "sensor.test_charge_cable", + "sensor.test_time_to_full_charge", + "sensor.test_time_to_arrival", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/teslemetry/test_switch.py b/tests/components/teslemetry/test_switch.py index dae3ce6fbf8..6a1ddb430ce 100644 --- a/tests/components/teslemetry/test_switch.py +++ b/tests/components/teslemetry/test_switch.py @@ -4,20 +4,13 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - STATE_OFF, - STATE_ON, - STATE_UNKNOWN, - Platform, -) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -49,18 +42,6 @@ async def test_switch_alt( assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) -async def test_switch_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the switch entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.SWITCH]) - state = hass.states.get("switch.test_auto_seat_climate_left") - assert state.state == STATE_UNKNOWN - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("name", "on", "off"), diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py index f02f09cd19a..448f31afd67 100644 --- a/tests/components/teslemetry/test_update.py +++ b/tests/components/teslemetry/test_update.py @@ -5,12 +5,11 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion -from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.update import INSTALLING from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -44,18 +43,6 @@ async def test_update_alt( assert_entities(hass, entry.entry_id, entity_registry, snapshot) -async def test_update_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, -) -> None: - """Tests that the update entities are correct when offline.""" - - mock_vehicle_data.side_effect = VehicleOffline - await setup_platform(hass, [Platform.UPDATE]) - state = hass.states.get("update.test_update") - assert state.state == STATE_UNKNOWN - - async def test_update_services( hass: HomeAssistant, mock_vehicle_data: AsyncMock, diff --git a/tests/components/tessie/snapshots/test_binary_sensors.ambr b/tests/components/tessie/snapshots/test_binary_sensor.ambr similarity index 100% rename from tests/components/tessie/snapshots/test_binary_sensors.ambr rename to tests/components/tessie/snapshots/test_binary_sensor.ambr diff --git a/tests/components/tessie/test_binary_sensors.py b/tests/components/tessie/test_binary_sensor.py similarity index 100% rename from tests/components/tessie/test_binary_sensors.py rename to tests/components/tessie/test_binary_sensor.py diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index 49a53fd327c..02a8f22b6ea 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -112,4 +112,4 @@ async def test_errors(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() - assert str(error.value) == f"Command failed, {TEST_RESPONSE_ERROR["reason"]}" + assert str(error.value) == f"Command failed, {TEST_RESPONSE_ERROR['reason']}" diff --git a/tests/components/text/test_init.py b/tests/components/text/test_init.py index 8e20af6cb7a..3764d481928 100644 --- a/tests/components/text/test_init.py +++ b/tests/components/text/test_init.py @@ -64,21 +64,22 @@ async def test_text_set_value(hass: HomeAssistant) -> None: with pytest.raises(ValueError): await _async_set_value( - text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: ""}) + text, ServiceCall(hass, DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: ""}) ) with pytest.raises(ValueError): await _async_set_value( - text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "hello world!"}) + text, + ServiceCall(hass, DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "hello world!"}), ) with pytest.raises(ValueError): await _async_set_value( - text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "HELLO"}) + text, ServiceCall(hass, DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "HELLO"}) ) await _async_set_value( - text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "test2"}) + text, ServiceCall(hass, DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "test2"}) ) assert text.state == "test2" diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index c31a1937d45..7feefdafedf 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -3,11 +3,12 @@ from ipaddress import ip_address from unittest.mock import patch -from homeassistant.components import thread, zeroconf +from homeassistant.components import thread from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -TEST_ZEROCONF_RECORD = zeroconf.ZeroconfServiceInfo( +TEST_ZEROCONF_RECORD = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="HomeAssistant OpenThreadBorderRouter #0BBF", diff --git a/tests/components/tile/__init__.py b/tests/components/tile/__init__.py index 5f26eb01ce0..d459dbad187 100644 --- a/tests/components/tile/__init__.py +++ b/tests/components/tile/__init__.py @@ -1 +1,13 @@ -"""Define tests for the Tile component.""" +"""Tests for the Tile integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/tile/conftest.py b/tests/components/tile/conftest.py index 01a711d9261..4391853c878 100644 --- a/tests/components/tile/conftest.py +++ b/tests/components/tile/conftest.py @@ -1,77 +1,88 @@ """Define test fixtures for Tile.""" from collections.abc import Generator -import json -from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from datetime import datetime +from unittest.mock import AsyncMock, patch import pytest +from pytile.api import API from pytile.tile import Tile from homeassistant.components.tile.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from .const import TEST_PASSWORD, TEST_USERNAME -TEST_PASSWORD = "123abc" -TEST_USERNAME = "user@host.com" +from tests.common import MockConfigEntry -@pytest.fixture(name="api") -def api_fixture(data_tile_details: dict[str, Any]) -> Mock: - """Define a pytile API object.""" - tile = Tile(None, data_tile_details) - tile.async_update = AsyncMock() +@pytest.fixture +def tile() -> AsyncMock: + """Define a Tile object.""" + mock = AsyncMock(spec=Tile) + mock.uuid = "19264d2dffdbca32" + mock.name = "Wallet" + mock.dead = False + mock.latitude = 1 + mock.longitude = 1 + mock.altitude = 0 + mock.lost = False + mock.last_timestamp = datetime(2020, 8, 12, 17, 55, 26) + mock.lost_timestamp = datetime(1969, 12, 31, 19, 0, 0) + mock.ring_state = "STOPPED" + mock.voip_state = "OFFLINE" + mock.hardware_version = "02.09" + mock.firmware_version = "01.12.14.0" + mock.as_dict.return_value = { + "accuracy": 13.496111, + "altitude": 0, + "archetype": "WALLET", + "dead": False, + "firmware_version": "01.12.14.0", + "hardware_version": "02.09", + "kind": "TILE", + "last_timestamp": datetime(2020, 8, 12, 17, 55, 26), + "latitude": 0, + "longitude": 0, + "lost": False, + "lost_timestamp": datetime(1969, 12, 31, 19, 0, 0), + "name": "Wallet", + "ring_state": "STOPPED", + "uuid": "19264d2dffdbca32", + "visible": True, + "voip_state": "OFFLINE", + } + return mock - return Mock( - async_get_tiles=AsyncMock( - return_value={data_tile_details["result"]["tile_uuid"]: tile} - ) + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Define a config entry fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USERNAME, + data={CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD}, ) -@pytest.fixture(name="config_entry") -def config_entry_fixture( - hass: HomeAssistant, config: dict[str, Any] -) -> MockConfigEntry: - """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_USERNAME], data=config) - entry.add_to_hass(hass) - return entry - - -@pytest.fixture(name="config") -def config_fixture() -> dict[str, Any]: - """Define a config entry data fixture.""" - return { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - } - - -@pytest.fixture(name="data_tile_details", scope="package") -def data_tile_details_fixture(): - """Define a Tile details data payload.""" - return json.loads(load_fixture("tile_details_data.json", "tile")) - - -@pytest.fixture(name="mock_pytile") -def mock_pytile_fixture(api: Mock) -> Generator[None]: +@pytest.fixture +def mock_pytile(tile: AsyncMock) -> Generator[None]: """Define a fixture to patch pytile.""" + client = AsyncMock(spec=API) + client.async_get_tiles = AsyncMock(return_value={"19264d2dffdbca32": tile}) with ( patch( - "homeassistant.components.tile.config_flow.async_login", return_value=api + "homeassistant.components.tile.config_flow.async_login", return_value=client ), - patch("homeassistant.components.tile.async_login", return_value=api), + patch("homeassistant.components.tile.async_login", return_value=client), ): yield -@pytest.fixture(name="setup_config_entry") -async def setup_config_entry_fixture( - hass: HomeAssistant, config_entry: MockConfigEntry, mock_pytile: None -) -> None: - """Define a fixture to set up tile.""" - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.fixture +def mock_setup_entry(): + """Mock async_setup_entry.""" + with patch( + "homeassistant.components.tile.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/tile/const.py b/tests/components/tile/const.py new file mode 100644 index 00000000000..c48a0e73dab --- /dev/null +++ b/tests/components/tile/const.py @@ -0,0 +1,4 @@ +"""Constants for the Tile component tests.""" + +TEST_PASSWORD = "123abc" +TEST_USERNAME = "user@host.com" diff --git a/tests/components/tile/snapshots/test_binary_sensor.ambr b/tests/components/tile/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..5f72f53fa1e --- /dev/null +++ b/tests/components/tile/snapshots/test_binary_sensor.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.wallet_lost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.wallet_lost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lost', + 'platform': 'tile', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lost', + 'unique_id': 'user@host.com_19264d2dffdbca32_lost', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.wallet_lost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Wallet Lost', + }), + 'context': , + 'entity_id': 'binary_sensor.wallet_lost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tile/snapshots/test_device_tracker.ambr b/tests/components/tile/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..15108331e66 --- /dev/null +++ b/tests/components/tile/snapshots/test_device_tracker.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_all_entities[device_tracker.wallet-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.wallet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tile', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tile', + 'unique_id': 'user@host.com_19264d2dffdbca32', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_tracker.wallet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'altitude': 0, + 'friendly_name': 'Wallet', + 'gps_accuracy': 1, + 'is_lost': False, + 'last_lost_timestamp': datetime.datetime(1970, 1, 1, 3, 0, tzinfo=datetime.timezone.utc), + 'last_timestamp': datetime.datetime(2020, 8, 13, 0, 55, 26, tzinfo=datetime.timezone.utc), + 'latitude': 1, + 'longitude': 1, + 'ring_state': 'STOPPED', + 'source_type': , + 'voip_state': 'OFFLINE', + }), + 'context': , + 'entity_id': 'device_tracker.wallet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/tile/snapshots/test_diagnostics.ambr b/tests/components/tile/snapshots/test_diagnostics.ambr index c04bd93315f..2876b058408 100644 --- a/tests/components/tile/snapshots/test_diagnostics.ambr +++ b/tests/components/tile/snapshots/test_diagnostics.ambr @@ -14,7 +14,7 @@ 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', 'lost': False, - 'lost_timestamp': '1969-12-31T23:59:59.999000', + 'lost_timestamp': '1969-12-31T19:00:00', 'name': 'Wallet', 'ring_state': 'STOPPED', 'uuid': '**REDACTED**', diff --git a/tests/components/tile/snapshots/test_init.ambr b/tests/components/tile/snapshots/test_init.ambr new file mode 100644 index 00000000000..90f165d1e6e --- /dev/null +++ b/tests/components/tile/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '02.09', + 'id': , + 'identifiers': set({ + tuple( + 'tile', + '19264d2dffdbca32', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Tile Inc.', + 'model': None, + 'model_id': None, + 'name': 'Wallet', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '01.12.14.0', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tile/test_binary_sensor.py b/tests/components/tile/test_binary_sensor.py new file mode 100644 index 00000000000..c8b4b9b8376 --- /dev/null +++ b/tests/components/tile/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the Tile Binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_pytile: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.tile.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tile/test_config_flow.py b/tests/components/tile/test_config_flow.py index 849be41d560..2ebfaa5c583 100644 --- a/tests/components/tile/test_config_flow.py +++ b/tests/components/tile/test_config_flow.py @@ -5,8 +5,8 @@ from unittest.mock import AsyncMock, patch import pytest from pytile.errors import InvalidAuthError, TileError -from homeassistant.components.tile import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.components.tile.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -16,15 +16,45 @@ from .conftest import TEST_PASSWORD, TEST_USERNAME from tests.common import MockConfigEntry +async def test_full_flow( + hass: HomeAssistant, mock_pytile: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test a full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_USERNAME + assert result["data"] == { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + } + assert result["result"].unique_id == TEST_USERNAME + + @pytest.mark.parametrize( - ("mock_login_response", "errors"), + ("exception", "errors"), [ - (AsyncMock(side_effect=InvalidAuthError), {"base": "invalid_auth"}), - (AsyncMock(side_effect=TileError), {"base": "unknown"}), + (InvalidAuthError, {"base": "invalid_auth"}), + (TileError, {"base": "unknown"}), ], ) async def test_create_entry( - hass: HomeAssistant, api, config, errors, mock_login_response, mock_pytile + hass: HomeAssistant, + mock_pytile: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + errors: dict[str, str], ) -> None: """Test creating an entry.""" result = await hass.config_entries.flow.async_init( @@ -33,59 +63,61 @@ async def test_create_entry( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - # Test errors that can arise: with patch( - "homeassistant.components.tile.config_flow.async_login", mock_login_response + "homeassistant.components.tile.config_flow.async_login", side_effect=exception ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=config + result["flow_id"], + user_input={ + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == errors - # Test that we can recover from login errors: result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=config + result["flow_id"], + user_input={ + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_USERNAME - assert result["data"] == { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - } -async def test_duplicate_error(hass: HomeAssistant, config, setup_config_entry) -> None: +async def test_duplicate_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test that errors are shown when duplicates are added.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=config + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_import_entry(hass: HomeAssistant, config, mock_pytile) -> None: - """Test importing an entry.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_USERNAME - assert result["data"] == { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - } - - async def test_step_reauth( - hass: HomeAssistant, config, config_entry: MockConfigEntry, setup_config_entry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_pytile: AsyncMock, ) -> None: """Test that the reauth step works.""" - result = await config_entry.start_reauth_flow(hass) - assert result["step_id"] == "reauth_confirm" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -95,3 +127,45 @@ async def test_step_reauth( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "errors"), + [ + (InvalidAuthError, {"base": "invalid_auth"}), + (TileError, {"base": "unknown"}), + ], +) +async def test_step_reauth_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_pytile: AsyncMock, + exception: Exception, + errors: dict[str, str], +) -> None: + """Test that the reauth step can recover from an error.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.tile.config_flow.async_login", side_effect=exception + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == errors + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "password"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/tile/test_device_tracker.py b/tests/components/tile/test_device_tracker.py new file mode 100644 index 00000000000..105cae1a7d7 --- /dev/null +++ b/tests/components/tile/test_device_tracker.py @@ -0,0 +1,27 @@ +"""Tests for the Tile Device tracker platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_pytile: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.tile.PLATFORMS", [Platform.DEVICE_TRACKER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tile/test_diagnostics.py b/tests/components/tile/test_diagnostics.py index 2ac3e06ccd8..87bc670d604 100644 --- a/tests/components/tile/test_diagnostics.py +++ b/tests/components/tile/test_diagnostics.py @@ -1,22 +1,28 @@ """Test Tile diagnostics.""" +from unittest.mock import AsyncMock + from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant +from . import setup_integration + +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, - config_entry, hass_client: ClientSessionGenerator, - setup_config_entry, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_pytile: AsyncMock, ) -> None: """Test config entry diagnostics.""" + await setup_integration(hass, mock_config_entry) assert ( - await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) == snapshot ) diff --git a/tests/components/tile/test_init.py b/tests/components/tile/test_init.py new file mode 100644 index 00000000000..fba354ade17 --- /dev/null +++ b/tests/components/tile/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the Tile integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.tile.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_pytile: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "19264d2dffdbca32")} + ) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index fd052a7f8a3..8e8c010f758 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -27,7 +27,11 @@ from homeassistant.components.todo import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceNotSupported, + ServiceValidationError, +) from homeassistant.helpers import intent from homeassistant.setup import async_setup_component @@ -941,14 +945,15 @@ async def test_unsupported_service( payload: dict[str, Any] | None, ) -> None: """Test a To-do list that does not support features.""" - + # Fetch translations + await async_setup_component(hass, "homeassistant", "") entity1 = TodoListEntity() entity1.entity_id = "todo.entity1" await create_mock_platform(hass, [entity1]) with pytest.raises( - HomeAssistantError, - match="does not support this service", + ServiceNotSupported, + match=f"Entity todo.entity1 does not support action {DOMAIN}.{service_name}", ): await hass.services.async_call( DOMAIN, diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index 73382944cf0..e918edf70a4 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -5,14 +5,14 @@ from unittest.mock import Mock, patch import pytest from tololib import ToloCommunicationError -from homeassistant.components import dhcp from homeassistant.components.tolo.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( +MOCK_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="mock_hostname" ) diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 75eab8eeb73..a056555f4c0 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -1,119 +1,49 @@ """Tests for the TP-Link component.""" from collections import namedtuple +from dataclasses import replace from datetime import datetime from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from kasa import ( + BaseProtocol, Device, - DeviceConfig, - DeviceConnectionParameters, - DeviceEncryptionType, - DeviceFamily, DeviceType, Feature, KasaException, Module, + ThermostatState, ) -from kasa.interfaces import Fan, Light, LightEffect, LightState -from kasa.protocol import BaseProtocol +from kasa.interfaces import Fan, Light, LightEffect, LightState, Thermostat from kasa.smart.modules.alarm import Alarm +from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera from syrupy import SnapshotAssertion from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN -from homeassistant.components.tplink import ( - CONF_AES_KEYS, - CONF_ALIAS, - CONF_CONNECTION_PARAMETERS, - CONF_CREDENTIALS_HASH, - CONF_HOST, - CONF_MODEL, - CONF_USES_HTTP, - Credentials, -) from homeassistant.components.tplink.const import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component +from .const import ( + ALIAS, + CREDENTIALS_HASH_LEGACY, + DEVICE_CONFIG_LEGACY, + DEVICE_ID, + IP_ADDRESS, + MAC_ADDRESS, + MODEL, +) + from tests.common import MockConfigEntry, load_json_value_fixture ColorTempRange = namedtuple("ColorTempRange", ["min", "max"]) # noqa: PYI024 -MODULE = "homeassistant.components.tplink" -MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" -IP_ADDRESS = "127.0.0.1" -IP_ADDRESS2 = "127.0.0.2" -ALIAS = "My Bulb" -MODEL = "HS100" -MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" -DEVICE_ID = "123456789ABCDEFGH" -DEVICE_ID_MAC = "AA:BB:CC:DD:EE:FF" -DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "") -MAC_ADDRESS2 = "11:22:33:44:55:66" -DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" -CREDENTIALS_HASH_LEGACY = "" -CONN_PARAMS_LEGACY = DeviceConnectionParameters( - DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor -) -DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) -DEVICE_CONFIG_DICT_LEGACY = DEVICE_CONFIG_LEGACY.to_dict(exclude_credentials=True) -CREDENTIALS = Credentials("foo", "bar") -CREDENTIALS_HASH_AES = "AES/abcdefghijklmnopqrstuvabcdefghijklmnopqrstuv==" -CREDENTIALS_HASH_KLAP = "KLAP/abcdefghijklmnopqrstuv==" -CONN_PARAMS_KLAP = DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap -) -DEVICE_CONFIG_KLAP = DeviceConfig( - IP_ADDRESS, - credentials=CREDENTIALS, - connection_type=CONN_PARAMS_KLAP, - uses_http=True, -) -CONN_PARAMS_AES = DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes -) -AES_KEYS = {"private": "foo", "public": "bar"} -DEVICE_CONFIG_AES = DeviceConfig( - IP_ADDRESS2, - credentials=CREDENTIALS, - connection_type=CONN_PARAMS_AES, - uses_http=True, - aes_keys=AES_KEYS, -) -DEVICE_CONFIG_DICT_KLAP = DEVICE_CONFIG_KLAP.to_dict(exclude_credentials=True) -DEVICE_CONFIG_DICT_AES = DEVICE_CONFIG_AES.to_dict(exclude_credentials=True) -CREATE_ENTRY_DATA_LEGACY = { - CONF_HOST: IP_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_LEGACY.to_dict(), - CONF_USES_HTTP: False, -} - -CREATE_ENTRY_DATA_KLAP = { - CONF_HOST: IP_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_KLAP, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_KLAP.to_dict(), - CONF_USES_HTTP: True, -} -CREATE_ENTRY_DATA_AES = { - CONF_HOST: IP_ADDRESS2, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES.to_dict(), - CONF_USES_HTTP: True, - CONF_AES_KEYS: AES_KEYS, -} - def _load_feature_fixtures(): fixtures = load_json_value_fixture("features.json", DOMAIN) @@ -141,7 +71,7 @@ async def setup_platform_for_device( _patch_discovery(device=device), _patch_connect(device=device), ): - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) # Good practice to wait background tasks in tests see PR #112726 await hass.async_block_till_done(wait_background_tasks=True) @@ -157,15 +87,15 @@ async def snapshot_platform( device_entries = dr.async_entries_for_config_entry(device_registry, config_entry_id) assert device_entries for device_entry in device_entries: - assert device_entry == snapshot( - name=f"{device_entry.name}-entry" - ), f"device entry snapshot failed for {device_entry.name}" + assert device_entry == snapshot(name=f"{device_entry.name}-entry"), ( + f"device entry snapshot failed for {device_entry.name}" + ) entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) assert entity_entries - assert ( - len({entity_entry.domain for entity_entry in entity_entries}) == 1 - ), "Please limit the loaded platforms to 1 platform." + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Please limit the loaded platforms to 1 platform." + ) translations = await async_get_translations(hass, "en", "entity", [DOMAIN]) unique_device_classes = [] @@ -177,18 +107,18 @@ async def snapshot_platform( if entity_entry.original_device_class not in unique_device_classes: single_device_class_translation = True unique_device_classes.append(entity_entry.original_device_class) - assert ( - (key in translations) or single_device_class_translation - ), f"No translation or non unique device_class for entity {entity_entry.unique_id}, expected {key}" - assert entity_entry == snapshot( - name=f"{entity_entry.entity_id}-entry" - ), f"entity entry snapshot failed for {entity_entry.entity_id}" + assert (key in translations) or single_device_class_translation, ( + f"No translation or non unique device_class for entity {entity_entry.unique_id}, expected {key}" + ) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry"), ( + f"entity entry snapshot failed for {entity_entry.entity_id}" + ) if entity_entry.disabled_by is None: state = hass.states.get(entity_entry.entity_id) assert state, f"State not found for {entity_entry.entity_id}" - assert state == snapshot( - name=f"{entity_entry.entity_id}-state" - ), f"state snapshot failed for {entity_entry.entity_id}" + assert state == snapshot(name=f"{entity_entry.entity_id}-state"), ( + f"state snapshot failed for {entity_entry.entity_id}" + ) async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> None: @@ -239,6 +169,9 @@ def _mocked_device( device.modules = {} device.features = {} + # replace device_config to prevent changes affecting between tests + device_config = replace(device_config) + if not ip_address: ip_address = IP_ADDRESS else: @@ -251,24 +184,33 @@ def _mocked_device( for module_name in modules } + device_features = {} if features: - device.features = { + device_features = { feature_id: _mocked_feature(feature_id, require_fixture=True) for feature_id in features if isinstance(feature_id, str) } - device.features.update( + device_features.update( { feature.id: feature for feature in features if isinstance(feature, Feature) } ) + device.features = device_features + + for mod in device.modules.values(): + mod.get_feature.side_effect = device_features.get + mod.has_feature.side_effect = lambda id: id in device_features + + device.parent = None device.children = [] if children: for child in children: child.mac = mac + child.parent = device device.children = children device.device_type = device_type if device_type else DeviceType.Unknown if ( @@ -283,6 +225,7 @@ def _mocked_device( device.protocol = _mock_protocol() device.config = device_config device.credentials_hash = credentials_hash + return device @@ -297,8 +240,8 @@ def _mocked_feature( precision_hint=None, choices=None, unit=None, - minimum_value=0, - maximum_value=2**16, # Arbitrary max + minimum_value=None, + maximum_value=None, ) -> Feature: """Get a mocked feature. @@ -309,12 +252,12 @@ def _mocked_feature( feature.name = name or id.upper() feature.set_value = AsyncMock() if not (fixture := FEATURES_FIXTURE.get(id)): - assert ( - require_fixture is False - ), f"No fixture defined for feature {id} and require_fixture is True" - assert ( - value is not UNDEFINED - ), f"Value must be provided if feature {id} not defined in features.json" + assert require_fixture is False, ( + f"No fixture defined for feature {id} and require_fixture is True" + ) + assert value is not UNDEFINED, ( + f"Value must be provided if feature {id} not defined in features.json" + ) fixture = {"value": value, "category": "Primary", "type": "Sensor"} elif value is not UNDEFINED: fixture["value"] = value @@ -328,11 +271,14 @@ def _mocked_feature( feature.unit = unit or fixture.get("unit") # number - feature.minimum_value = minimum_value or fixture.get("minimum_value") - feature.maximum_value = maximum_value or fixture.get("maximum_value") + min_val = minimum_value or fixture.get("minimum_value") + feature.minimum_value = 0 if min_val is None else min_val + max_val = maximum_value or fixture.get("maximum_value") + feature.maximum_value = 2**16 if max_val is None else max_val # select feature.choices = choices or fixture.get("choices") + return feature @@ -344,13 +290,7 @@ def _mocked_light_module(device) -> Light: light.state = LightState( light_on=True, brightness=light.brightness, color_temp=light.color_temp ) - light.is_color = True - light.is_variable_color_temp = True - light.is_dimmable = True - light.is_brightness = True - light.has_effects = False light.hsv = (10, 30, 5) - light.valid_temperature_range = ColorTempRange(min=4000, max=9000) light.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} async def _set_state(state, *_, **__): @@ -383,18 +323,17 @@ def _mocked_light_module(device) -> Light: def _mocked_light_effect_module(device) -> LightEffect: effect = MagicMock(spec=LightEffect, name="Mocked light effect") - effect.has_effects = True effect.has_custom_effects = True effect.effect = "Effect1" effect.effect_list = ["Off", "Effect1", "Effect2"] async def _set_effect(effect_name, *_, **__): - assert ( - effect_name in effect.effect_list - ), f"set_effect '{effect_name}' not in {effect.effect_list}" - assert device.modules[ - Module.Light - ], "Need a light module to test set_effect method" + assert effect_name in effect.effect_list, ( + f"set_effect '{effect_name}' not in {effect.effect_list}" + ) + assert device.modules[Module.Light], ( + "Need a light module to test set_effect method" + ) device.modules[Module.Light].state.light_on = True effect.effect = effect_name @@ -419,6 +358,29 @@ def _mocked_alarm_module(device): return alarm +def _mocked_camera_module(device): + camera = MagicMock(auto_spec=Camera, name="Mocked camera") + camera.is_on = True + camera.set_state = AsyncMock() + camera.stream_rtsp_url.return_value = ( + f"rtsp://user:pass@{device.host}:{LOCAL_STREAMING_PORT}/stream1" + ) + + return camera + + +def _mocked_thermostat_module(device): + therm = MagicMock(auto_spec=Thermostat, name="Mocked thermostat") + therm.state = True + therm.temperature = 20.2 + therm.target_temperature = 22.2 + therm.mode = ThermostatState.Heating + therm.set_state = AsyncMock() + therm.set_target_temperature = AsyncMock() + + return therm + + def _mocked_strip_children(features=None, alias=None) -> list[Device]: plug0 = _mocked_device( alias="Plug0" if alias is None else alias, @@ -486,6 +448,8 @@ MODULE_TO_MOCK_GEN = { Module.LightEffect: _mocked_light_effect_module, Module.Fan: _mocked_fan_module, Module.Alarm: _mocked_alarm_module, + Module.Camera: _mocked_camera_module, + Module.Thermostat: _mocked_thermostat_module, } diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index 25a4bd20270..19cd5aa9acf 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -1,30 +1,73 @@ """tplink conftest.""" from collections.abc import Generator +from contextlib import contextmanager from unittest.mock import DEFAULT, AsyncMock, patch -from kasa import DeviceConfig +from kasa import DeviceConfig, Module import pytest from homeassistant.components.tplink import DOMAIN from homeassistant.core import HomeAssistant -from . import ( +from . import _mocked_device +from .const import ( + ALIAS_CAMERA, + CREATE_ENTRY_DATA_AES_CAMERA, CREATE_ENTRY_DATA_LEGACY, CREDENTIALS_HASH_AES, CREDENTIALS_HASH_KLAP, DEVICE_CONFIG_AES, + DEVICE_CONFIG_AES_CAMERA, DEVICE_CONFIG_KLAP, IP_ADDRESS, IP_ADDRESS2, + IP_ADDRESS3, MAC_ADDRESS, MAC_ADDRESS2, - _mocked_device, + MAC_ADDRESS3, + MODEL_CAMERA, ) from tests.common import MockConfigEntry +@contextmanager +def override_side_effect(mock: AsyncMock, effect): + """Temporarily override a mock side effect and replace afterwards.""" + try: + default_side_effect = mock.side_effect + mock.side_effect = effect + yield mock + finally: + mock.side_effect = default_side_effect + + +def _get_mock_devices(): + return { + IP_ADDRESS: _mocked_device( + device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()), + credentials_hash=CREDENTIALS_HASH_KLAP, + ip_address=IP_ADDRESS, + ), + IP_ADDRESS2: _mocked_device( + device_config=DeviceConfig.from_dict(DEVICE_CONFIG_AES.to_dict()), + credentials_hash=CREDENTIALS_HASH_AES, + mac=MAC_ADDRESS2, + ip_address=IP_ADDRESS2, + ), + IP_ADDRESS3: _mocked_device( + device_config=DeviceConfig.from_dict(DEVICE_CONFIG_AES_CAMERA.to_dict()), + credentials_hash=CREDENTIALS_HASH_AES, + mac=MAC_ADDRESS3, + ip_address=IP_ADDRESS3, + modules=[Module.Camera], + alias=ALIAS_CAMERA, + model=MODEL_CAMERA, + ), + } + + @pytest.fixture def mock_discovery(): """Mock python-kasa discovery.""" @@ -34,22 +77,15 @@ def mock_discovery(): discover_single=DEFAULT, try_connect_all=DEFAULT, ) as mock_discovery: - device = _mocked_device( - device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()), - credentials_hash=CREDENTIALS_HASH_KLAP, - alias="My Bulb", - ) - devices = { - "127.0.0.1": _mocked_device( - device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()), - credentials_hash=CREDENTIALS_HASH_KLAP, - alias=None, - ) - } + devices = _get_mock_devices() + + def get_device(host, **kwargs): + return devices[host] + mock_discovery["discover"].return_value = devices - mock_discovery["discover_single"].return_value = device - mock_discovery["try_connect_all"].return_value = device - mock_discovery["mock_device"] = device + mock_discovery["discover_single"].side_effect = get_device + mock_discovery["try_connect_all"].side_effect = get_device + mock_discovery["mock_devices"] = devices yield mock_discovery @@ -57,22 +93,9 @@ def mock_discovery(): def mock_connect(): """Mock python-kasa connect.""" with patch("homeassistant.components.tplink.Device.connect") as mock_connect: - devices = { - IP_ADDRESS: _mocked_device( - device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()), - credentials_hash=CREDENTIALS_HASH_KLAP, - ip_address=IP_ADDRESS, - ), - IP_ADDRESS2: _mocked_device( - device_config=DeviceConfig.from_dict(DEVICE_CONFIG_AES.to_dict()), - credentials_hash=CREDENTIALS_HASH_AES, - mac=MAC_ADDRESS2, - ip_address=IP_ADDRESS2, - ), - } + devices = _get_mock_devices() def get_device(config): - nonlocal devices return devices[config.host] mock_connect.side_effect = get_device @@ -92,8 +115,12 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_init() -> Generator[AsyncMock]: - """Override async_setup_entry.""" +def mock_init() -> Generator[dict[str, AsyncMock]]: + """Override async_setup and async_setup_entry. + + This fixture must be declared before the hass fixture to avoid errors + in the logs during teardown of the hass fixture which calls async_unload. + """ with patch.multiple( "homeassistant.components.tplink", async_setup=DEFAULT, @@ -117,6 +144,17 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_camera_config_entry() -> MockConfigEntry: + """Mock camera ConfigEntry.""" + return MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AES_CAMERA}, + unique_id=MAC_ADDRESS3, + ) + + @pytest.fixture async def mock_added_config_entry( hass: HomeAssistant, diff --git a/tests/components/tplink/const.py b/tests/components/tplink/const.py new file mode 100644 index 00000000000..57829a7aa34 --- /dev/null +++ b/tests/components/tplink/const.py @@ -0,0 +1,146 @@ +"""Constants for the tplink component tests.""" + +from kasa import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) + +from homeassistant.components.tplink import ( + CONF_AES_KEYS, + CONF_ALIAS, + CONF_CAMERA_CREDENTIALS, + CONF_CONNECTION_PARAMETERS, + CONF_CREDENTIALS_HASH, + CONF_HOST, + CONF_LIVE_VIEW, + CONF_MODEL, + CONF_USES_HTTP, + Credentials, +) + +MODULE = "homeassistant.components.tplink" +MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" +IP_ADDRESS = "127.0.0.1" +IP_ADDRESS2 = "127.0.0.2" +IP_ADDRESS3 = "127.0.0.3" +ALIAS = "My Bulb" +ALIAS_CAMERA = "My Camera" +MODEL = "HS100" +MODEL_CAMERA = "C210" +MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +DEVICE_ID = "123456789ABCDEFGH" +DEVICE_ID_MAC = "AA:BB:CC:DD:EE:FF" +DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "") +MAC_ADDRESS2 = "11:22:33:44:55:66" +MAC_ADDRESS3 = "66:55:44:33:22:11" +DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" +DEFAULT_ENTRY_TITLE_CAMERA = f"{ALIAS_CAMERA} {MODEL_CAMERA}" +CREDENTIALS_HASH_LEGACY = "" +CONN_PARAMS_LEGACY = DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor +) +DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) +DEVICE_CONFIG_DICT_LEGACY = { + k: v for k, v in DEVICE_CONFIG_LEGACY.to_dict().items() if k != "credentials" +} +CREDENTIALS = Credentials("foo", "bar") +CREDENTIALS_HASH_AES = "AES/abcdefghijklmnopqrstuvabcdefghijklmnopqrstuv==" +CREDENTIALS_HASH_KLAP = "KLAP/abcdefghijklmnopqrstuv==" +CONN_PARAMS_KLAP = DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap +) +DEVICE_CONFIG_KLAP = DeviceConfig( + IP_ADDRESS, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_KLAP, + uses_http=True, +) +CONN_PARAMS_AES = DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes +) +_test_privkey = ( + "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKLJKmBWGj6WYo9sewI8vkqar" + "Ed5H1JUr8Jj/LEWLTtV6+Mm4mfyEk6YKFHSmIG4AGgrVsGK/EbEkTZk9CwtixNQpBVc36oN2R" + "vuWWV38YnP4vI63mNxTA/gQonCsahjN4HfwE87pM7O5z39aeunoYm6Be663t33DbJH1ZUbZjm" + "tAgMBAAECgYB1Bn1KaFvRprcQOIJt51E9vNghQbf8rhj0fIEKpdC6mVhNIoUdCO+URNqnh+hP" + "SQIx4QYreUlHbsSeABFxOQSDJm6/kqyQsp59nCVDo/bXTtlvcSJ/sU3riqJNxYqEU1iJ0xMvU" + "N1VKKTmik89J8e5sN9R0AFfUSJIk7MpdOoD2QJBANTbV27nenyvbqee/ul4frdt2rrPGcGpcV" + "QmY87qbbrZgqgL5LMHHD7T/v/I8D1wRog1sBz/AiZGcnv/ox8dHKsCQQDDx8DCGPySSVqKVua" + "yUkBNpglN83wiCXZjyEtWIt+aB1A2n5ektE/o8oHnnOuvMdooxvtid7Mdapi2VLHV7VMHAkAE" + "d0GjWwnv2cJpk+VnQpbuBEkFiFjS/loZWODZM4Pv2qZqHi3DL9AA5XPBLBcWQufH7dBvG06RP" + "QMj5N4oRfUXAkEAuJJkVliqHNvM4OkGewzyFII4+WVYHNqg43dcFuuvtA27AJQ6qYtYXrvp3k" + "phI3yzOIhHTNCea1goepSkR5ODFwJBAJCTRbB+P47aEr/xA51ZFHE6VefDBJG9yg6yK4jcOxg" + "5ficXEpx8442okNtlzwa+QHpm/L3JOFrHwiEeVqXtiqY=" +) +_test_pubkey = ( + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiySpgVho+lmKPbHsCPL5KmqxHeR9SVK/CY" + "/yxFi07VevjJuJn8hJOmChR0piBuABoK1bBivxGxJE2ZPQsLYsTUKQVXN+qDdkb7llld/GJz+" + "LyOt5jcUwP4EKJwrGoYzeB38BPO6TOzuc9/Wnrp6GJugXuut7d9w2yR9WVG2Y5rQIDAQAB" +) +AES_KEYS = {"private": _test_privkey, "public": _test_pubkey} +DEVICE_CONFIG_AES = DeviceConfig( + IP_ADDRESS2, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_AES, + uses_http=True, + aes_keys=AES_KEYS, +) +CONN_PARAMS_AES_CAMERA = DeviceConnectionParameters( + DeviceFamily.SmartIpCamera, DeviceEncryptionType.Aes, https=True, login_version=2 +) +DEVICE_CONFIG_AES_CAMERA = DeviceConfig( + IP_ADDRESS3, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_AES_CAMERA, + uses_http=True, +) + +DEVICE_CONFIG_DICT_KLAP = { + k: v for k, v in DEVICE_CONFIG_KLAP.to_dict().items() if k != "credentials" +} +DEVICE_CONFIG_DICT_AES = { + k: v for k, v in DEVICE_CONFIG_AES.to_dict().items() if k != "credentials" +} +CREATE_ENTRY_DATA_LEGACY = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_LEGACY.to_dict(), + CONF_USES_HTTP: False, +} + +CREATE_ENTRY_DATA_KLAP = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_KLAP, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_KLAP.to_dict(), + CONF_USES_HTTP: True, +} +CREATE_ENTRY_DATA_AES = { + CONF_HOST: IP_ADDRESS2, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES.to_dict(), + CONF_USES_HTTP: True, + CONF_AES_KEYS: AES_KEYS, +} +CREATE_ENTRY_DATA_AES_CAMERA = { + CONF_HOST: IP_ADDRESS3, + CONF_ALIAS: ALIAS_CAMERA, + CONF_MODEL: MODEL_CAMERA, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES_CAMERA.to_dict(), + CONF_USES_HTTP: True, + CONF_LIVE_VIEW: True, + CONF_CAMERA_CREDENTIALS: {"username": "camuser", "password": "campass"}, +} +SMALLEST_VALID_JPEG = ( + "ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060" + "6050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b08000100" + "0101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9" +) +SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index f60132fd2c2..3d27e63b06a 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -44,6 +44,26 @@ "type": "Switch", "category": "Config" }, + "motion_detection": { + "value": true, + "type": "Switch", + "category": "Primary" + }, + "person_detection": { + "value": true, + "type": "Switch", + "category": "Primary" + }, + "tamper_detection": { + "value": true, + "type": "Switch", + "category": "Primary" + }, + "baby_cry_detection": { + "value": true, + "type": "Switch", + "category": "Primary" + }, "current_consumption": { "value": 5.23, "type": "Sensor", @@ -267,7 +287,9 @@ "target_temperature": { "value": false, "type": "Number", - "category": "Primary" + "category": "Primary", + "minimum_value": 5, + "maximum_value": 30 }, "fan_speed_level": { "value": 2, @@ -318,5 +340,35 @@ "type": "Sensor", "category": "Info", "value": "2024-06-24 10:03:11.046643+01:00" + }, + "pan_left": { + "value": "", + "type": "Action", + "category": "Config" + }, + "pan_right": { + "value": "", + "type": "Action", + "category": "Config" + }, + "pan_step": { + "value": 10, + "type": "Number", + "category": "Config" + }, + "tilt_up": { + "value": "", + "type": "Action", + "category": "Config" + }, + "tilt_down": { + "value": "", + "type": "Action", + "category": "Config" + }, + "tilt_step": { + "value": 10, + "type": "Number", + "category": "Config" } } diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index 4a1cfe5b411..e16d4409511 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_states[binary_sensor.my_device_battery_low-entry] +# name: test_states[binary_sensor.my_device_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,7 +11,7 @@ 'disabled_by': , 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.my_device_battery_low', + 'entity_id': 'binary_sensor.my_device_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -23,7 +23,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery low', + 'original_name': 'Battery', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index bb75f4642e1..de626cd5818 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -1,4 +1,96 @@ # serializer version: 1 +# name: test_states[button.my_device_pan_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_pan_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pan left', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pan_left', + 'unique_id': '123456789ABCDEFGH_pan_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_pan_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Pan left', + }), + 'context': , + 'entity_id': 'button.my_device_pan_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[button.my_device_pan_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_pan_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pan right', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pan_right', + 'unique_id': '123456789ABCDEFGH_pan_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_pan_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Pan right', + }), + 'context': , + 'entity_id': 'button.my_device_pan_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[button.my_device_restart-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -124,6 +216,98 @@ 'state': 'unknown', }) # --- +# name: test_states[button.my_device_tilt_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_tilt_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tilt down', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tilt_down', + 'unique_id': '123456789ABCDEFGH_tilt_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_tilt_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Tilt down', + }), + 'context': , + 'entity_id': 'button.my_device_tilt_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[button.my_device_tilt_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_tilt_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tilt up', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tilt_up', + 'unique_id': '123456789ABCDEFGH_tilt_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_tilt_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Tilt up', + }), + 'context': , + 'entity_id': 'button.my_device_tilt_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr new file mode 100644 index 00000000000..4417395078a --- /dev/null +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -0,0 +1,87 @@ +# serializer version: 1 +# name: test_states[camera.my_camera_live_view-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.my_camera_live_view', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Live view', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'live_view', + 'unique_id': '123456789ABCDEFGH-live_view', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[camera.my_camera_live_view-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'entity_picture': '/api/camera_proxy/camera.my_camera_live_view?token=1caab5c3b3', + 'friendly_name': 'my_camera Live view', + 'frontend_stream_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.my_camera_live_view', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_states[my_camera-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '66:55:44:33:22:11', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': , + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'model_id': None, + 'name': 'my_camera', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index 8236f332046..e0173e8f59e 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -9,8 +9,8 @@ , , ]), - 'max_temp': 65536, - 'min_temp': None, + 'max_temp': 30, + 'min_temp': 5, }), 'config_entry_id': , 'device_class': None, @@ -49,8 +49,8 @@ , , ]), - 'max_temp': 65536, - 'min_temp': None, + 'max_temp': 30, + 'min_temp': 5, 'supported_features': , 'temperature': 22.2, }), @@ -91,6 +91,6 @@ 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', - 'via_device_id': None, + 'via_device_id': , }) # --- diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index 977d2098fb9..df5ef71bf44 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -35,7 +35,7 @@ 'via_device_id': None, }) # --- -# name: test_states[number.my_device_smooth_off-entry] +# name: test_states[number.my_device_pan_degrees-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -52,6 +52,61 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , + 'entity_id': 'number.my_device_pan_degrees', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pan degrees', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pan_step', + 'unique_id': '123456789ABCDEFGH_pan_step', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_pan_degrees-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Pan degrees', + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.my_device_pan_degrees', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_states[number.my_device_smooth_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , 'entity_id': 'number.my_device_smooth_off', 'has_entity_name': True, 'hidden_by': None, @@ -77,7 +132,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'my_device Smooth off', - 'max': 65536, + 'max': 60, 'min': 0, 'mode': , 'step': 1.0, @@ -96,7 +151,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 65536, + 'max': 60, 'min': 0, 'mode': , 'step': 1.0, @@ -132,7 +187,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'my_device Smooth on', - 'max': 65536, + 'max': 60, 'min': 0, 'mode': , 'step': 1.0, @@ -151,7 +206,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 65536, + 'max': 10, 'min': -10, 'mode': , 'step': 1.0, @@ -187,7 +242,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'my_device Temperature offset', - 'max': 65536, + 'max': 10, 'min': -10, 'mode': , 'step': 1.0, @@ -200,7 +255,7 @@ 'state': 'False', }) # --- -# name: test_states[number.my_device_turn_off_in-entry] +# name: test_states[number.my_device_tilt_degrees-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -217,6 +272,61 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , + 'entity_id': 'number.my_device_tilt_degrees', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tilt degrees', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tilt_step', + 'unique_id': '123456789ABCDEFGH_tilt_step', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_tilt_degrees-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Tilt degrees', + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.my_device_tilt_degrees', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_states[number.my_device_turn_off_in-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , 'entity_id': 'number.my_device_turn_off_in', 'has_entity_name': True, 'hidden_by': None, @@ -242,7 +352,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'my_device Turn off in', - 'max': 65536, + 'max': 60, 'min': 0, 'mode': , 'step': 1.0, diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 739f02e51f0..461e8c6e505 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -115,7 +115,7 @@ 'state': '2024-06-24T09:03:11+00:00', }) # --- -# name: test_states[sensor.my_device_battery_level-entry] +# name: test_states[sensor.my_device_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -129,7 +129,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.my_device_battery_level', + 'entity_id': 'sensor.my_device_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -141,7 +141,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery level', + 'original_name': 'Battery', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, @@ -150,16 +150,16 @@ 'unit_of_measurement': '%', }) # --- -# name: test_states[sensor.my_device_battery_level-state] +# name: test_states[sensor.my_device_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'my_device Battery level', + 'friendly_name': 'my_device Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.my_device_battery_level', + 'entity_id': 'sensor.my_device_battery', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 36c630474c8..7adda900c02 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -173,6 +173,52 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_baby_cry_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_device_baby_cry_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Baby cry detection', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'baby_cry_detection', + 'unique_id': '123456789ABCDEFGH_baby_cry_detection', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_baby_cry_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Baby cry detection', + }), + 'context': , + 'entity_id': 'switch.my_device_baby_cry_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.my_device_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -311,6 +357,52 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_motion_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_device_motion_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion detection', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion_detection', + 'unique_id': '123456789ABCDEFGH_motion_detection', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_motion_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Motion detection', + }), + 'context': , + 'entity_id': 'switch.my_device_motion_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.my_device_motion_sensor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -357,6 +449,52 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_person_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_device_person_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Person detection', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'person_detection', + 'unique_id': '123456789ABCDEFGH_person_detection', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_person_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Person detection', + }), + 'context': , + 'entity_id': 'switch.my_device_person_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.my_device_smooth_transitions-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -403,3 +541,49 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_tamper_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_device_tamper_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tamper detection', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tamper_detection', + 'unique_id': '123456789ABCDEFGH_tamper_detection', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_tamper_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Tamper detection', + }), + 'context': , + 'entity_id': 'switch.my_device_tamper_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tplink/test_binary_sensor.py b/tests/components/tplink/test_binary_sensor.py index e2b9cd08d13..b487fa51baf 100644 --- a/tests/components/tplink/test_binary_sensor.py +++ b/tests/components/tplink/test_binary_sensor.py @@ -4,18 +4,14 @@ from kasa import Feature import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.tplink.binary_sensor import BINARY_SENSOR_DESCRIPTIONS from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -24,6 +20,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -47,7 +44,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor states.""" features = {description.key for description in BINARY_SENSOR_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -68,7 +65,7 @@ async def test_binary_sensor( entity_registry: er.EntityRegistry, mocked_feature_binary_sensor: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor unique ids.""" mocked_feature = mocked_feature_binary_sensor already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -77,7 +74,7 @@ async def test_binary_sensor( plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -93,7 +90,7 @@ async def test_binary_sensor_children( device_registry: dr.DeviceRegistry, mocked_feature_binary_sensor: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor children.""" mocked_feature = mocked_feature_binary_sensor already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -105,7 +102,7 @@ async def test_binary_sensor_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "binary_sensor.my_plug_overheated" diff --git a/tests/components/tplink/test_button.py b/tests/components/tplink/test_button.py index a3eb8950336..c36d08337a7 100644 --- a/tests/components/tplink/test_button.py +++ b/tests/components/tplink/test_button.py @@ -4,7 +4,6 @@ from kasa import Feature import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.tplink.button import BUTTON_DESCRIPTIONS from homeassistant.components.tplink.const import DOMAIN @@ -16,11 +15,8 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -30,6 +26,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -83,7 +80,7 @@ def create_deprecated_child_button_entities( @pytest.fixture def mocked_feature_button() -> Feature: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink button feature.""" return _mocked_feature( "test_alarm", value="", @@ -101,7 +98,7 @@ async def test_states( snapshot: SnapshotAssertion, create_deprecated_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button states.""" features = {description.key for description in BUTTON_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -118,14 +115,15 @@ async def test_states( async def test_button( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button unique ids.""" mocked_feature = mocked_feature_button plug = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -139,11 +137,12 @@ async def test_button_children( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, create_deprecated_child_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button children.""" mocked_feature = mocked_feature_button plug = _mocked_device( alias="my_device", @@ -151,7 +150,7 @@ async def test_button_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() entity_id = "button.my_device_test_alarm" @@ -173,6 +172,7 @@ async def test_button_children( async def test_button_press( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, ) -> None: @@ -180,7 +180,7 @@ async def test_button_press( mocked_feature = mocked_feature_button plug = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() entity_id = "button.my_device_test_alarm" @@ -213,7 +213,7 @@ async def test_button_not_exists_with_deprecation( mocked_feature = mocked_feature_button dev = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert not entity_registry.async_get(entity_id) @@ -265,7 +265,7 @@ async def test_button_exists_with_deprecation( mocked_feature = mocked_feature_button dev = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity = entity_registry.async_get(entity_id) diff --git a/tests/components/tplink/test_camera.py b/tests/components/tplink/test_camera.py new file mode 100644 index 00000000000..4b062c4d0b2 --- /dev/null +++ b/tests/components/tplink/test_camera.py @@ -0,0 +1,454 @@ +"""The tests for the tplink camera platform.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +from aiohttp.test_utils import make_mocked_request +from freezegun.api import FrozenDateTimeFactory +from kasa import Module +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import stream +from homeassistant.components.camera import ( + DOMAIN as CAMERA_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + CameraEntityFeature, + StreamType, + async_get_image, + async_get_mjpeg_stream, + get_camera_from_entity_id, +) +from homeassistant.components.tplink.camera import TPLinkCameraEntity +from homeassistant.components.websocket_api import TYPE_RESULT +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import _mocked_device, setup_platform_for_device, snapshot_platform +from .const import DEVICE_ID, IP_ADDRESS3, MAC_ADDRESS3, SMALLEST_VALID_JPEG_BYTES + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator + + +async def test_states( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test camera states.""" + mock_camera_config_entry.add_to_hass(hass) + + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + + # Patch getrandbits so the access_token doesn't change on camera attributes + with patch("random.SystemRandom.getrandbits", return_value=123123123123): + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + await snapshot_platform( + hass, + entity_registry, + device_registry, + snapshot, + mock_camera_config_entry.entry_id, + ) + + +async def test_camera_unique_id( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test camera unique id.""" + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + device_id=DEVICE_ID, + ) + + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_camera_config_entry.entry_id + ) + assert device_entries + entity_id = "camera.my_camera_live_view" + + assert entity_registry.async_get(entity_id).unique_id == f"{DEVICE_ID}-live_view" + + +async def test_handle_mjpeg_stream( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, +) -> None: + """Test handle_async_mjpeg_stream.""" + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + state = hass.states.get("camera.my_camera_live_view") + assert state is not None + + mock_request = make_mocked_request("GET", "/", headers={"token": "x"}) + stream = await async_get_mjpeg_stream( + hass, mock_request, "camera.my_camera_live_view" + ) + assert stream is not None + + +async def test_handle_mjpeg_stream_not_supported( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, +) -> None: + """Test no stream if stream_rtsp_url is None after creation.""" + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + mock_camera = mock_device.modules[Module.Camera] + + mock_camera.stream_rtsp_url.side_effect = ("foo", None) + + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + mock_request = make_mocked_request("GET", "/", headers={"token": "x"}) + mjpeg_stream = await async_get_mjpeg_stream( + hass, mock_request, "camera.my_camera_live_view" + ) + assert mjpeg_stream is None + + +async def test_camera_image( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_get_image.""" + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + state = hass.states.get("camera.my_camera_live_view") + assert state is not None + + with patch( + "homeassistant.components.ffmpeg.async_get_image", + return_value=SMALLEST_VALID_JPEG_BYTES, + ) as mock_get_image: + image = await async_get_image(hass, "camera.my_camera_live_view") + assert image + assert image.content == SMALLEST_VALID_JPEG_BYTES + mock_get_image.assert_called_once() + + mock_get_image.reset_mock() + image = await async_get_image(hass, "camera.my_camera_live_view") + mock_get_image.assert_not_called() + + freezer.tick(TPLinkCameraEntity.IMAGE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_get_image.reset_mock() + image = await async_get_image(hass, "camera.my_camera_live_view") + mock_get_image.assert_called_once() + + freezer.tick(TPLinkCameraEntity.IMAGE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Test image returns None + with patch( + "homeassistant.components.ffmpeg.async_get_image", + return_value=None, + ) as mock_get_image: + msg = f"None camera image returned for {IP_ADDRESS3}" + assert msg not in caplog.text + + mock_get_image.reset_mock() + image = await async_get_image(hass, "camera.my_camera_live_view") + mock_get_image.assert_called_once() + + assert msg in caplog.text + + +async def test_no_camera_image_when_streaming( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test no camera image when streaming.""" + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + state = hass.states.get("camera.my_camera_live_view") + assert state is not None + + with patch( + "homeassistant.components.ffmpeg.async_get_image", + return_value=SMALLEST_VALID_JPEG_BYTES, + ) as mock_get_image: + await async_get_image(hass, "camera.my_camera_live_view") + mock_get_image.assert_called_once() + + freezer.tick(TPLinkCameraEntity.IMAGE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + start_event = asyncio.Event() + finish_event = asyncio.Event() + + async def _waiter(*_, **__): + start_event.set() + await finish_event.wait() + + async def _get_stream(): + mock_request = make_mocked_request("GET", "/", headers={"token": "x"}) + await async_get_mjpeg_stream( + hass, mock_request, "camera.my_camera_live_view" + ) + + mock_get_image.reset_mock() + with patch( + "homeassistant.components.tplink.camera.async_aiohttp_proxy_stream", + new=_waiter, + ): + task = asyncio.create_task(_get_stream()) + await start_event.wait() + await async_get_image(hass, "camera.my_camera_live_view") + finish_event.set() + await task + + mock_get_image.assert_not_called() + + +async def test_no_concurrent_camera_image( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, +) -> None: + """Test async_get_image doesn't make concurrent requests.""" + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + state = hass.states.get("camera.my_camera_live_view") + assert state is not None + + finish_event = asyncio.Event() + call_count = 0 + + async def _waiter(*_, **__): + nonlocal call_count + call_count += 1 + await finish_event.wait() + return SMALLEST_VALID_JPEG_BYTES + + with patch( + "homeassistant.components.ffmpeg.async_get_image", + new=_waiter, + ): + tasks = asyncio.gather( + async_get_image(hass, "camera.my_camera_live_view"), + async_get_image(hass, "camera.my_camera_live_view"), + ) + # Sleep to give both tasks chance to get to th asyncio.Lock() + await asyncio.sleep(0) + finish_event.set() + results = await tasks + assert len(results) == 2 + assert all(img and img.content == SMALLEST_VALID_JPEG_BYTES for img in results) + assert call_count == 1 + + +async def test_camera_image_auth_error( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, +) -> None: + """Test async_get_image auth error.""" + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + state = hass.states.get("camera.my_camera_live_view") + assert state is not None + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + with ( + patch( + "homeassistant.components.ffmpeg.async_get_image", + return_value=b"", + ), + patch( + "homeassistant.components.stream.async_check_stream_client_error", + side_effect=stream.StreamOpenClientError( + "Request was unauthorized", + error_code=stream.StreamClientError.Unauthorized, + ), + ), + pytest.raises(HomeAssistantError), + ): + await async_get_image(hass, "camera.my_camera_live_view") + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + + assert result["step_id"] == "camera_auth_confirm" + + +async def test_camera_stream_source( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test camera stream source. + + This test would fail if the integration didn't properly + put stream in the dependencies. + """ + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + state = hass.states.get("camera.my_camera_live_view") + assert state is not None + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "camera/stream", "entity_id": "camera.my_camera_live_view"} + ) + msg = await client.receive_json() + + # Assert WebSocket response + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert "url" in msg["result"] + + +async def test_camera_stream_attributes( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, +) -> None: + """Test stream attributes.""" + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + state = hass.states.get("camera.my_camera_live_view") + assert state is not None + + supported_features = state.attributes.get("supported_features") + assert supported_features is CameraEntityFeature.STREAM | CameraEntityFeature.ON_OFF + camera = get_camera_from_entity_id(hass, "camera.my_camera_live_view") + assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS} + + +async def test_camera_turn_on_off( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, +) -> None: + """Test camera turn on and off.""" + mock_device = _mocked_device( + modules=[Module.Camera], + alias="my_camera", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + mock_camera = mock_device.modules[Module.Camera] + + await setup_platform_for_device( + hass, mock_camera_config_entry, Platform.CAMERA, mock_device + ) + + state = hass.states.get("camera.my_camera_live_view") + assert state is not None + + await hass.services.async_call( + CAMERA_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "camera.my_camera_live_view"}, + blocking=True, + ) + mock_camera.set_state.assert_called_with(True) + + await hass.services.async_call( + CAMERA_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "camera.my_camera_live_view"}, + blocking=True, + ) + mock_camera.set_state.assert_called_with(False) diff --git a/tests/components/tplink/test_climate.py b/tests/components/tplink/test_climate.py index 3a54048e1d6..adcca24886b 100644 --- a/tests/components/tplink/test_climate.py +++ b/tests/components/tplink/test_climate.py @@ -2,7 +2,7 @@ from datetime import timedelta -from kasa import Device, Feature +from kasa import Device, Feature, Module from kasa.smart.modules.temperaturecontrol import ThermostatState import pytest from syrupy.assertion import SnapshotAssertion @@ -27,12 +27,12 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util from . import ( - DEVICE_ID, _mocked_device, _mocked_feature, setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -41,35 +41,28 @@ ENTITY_ID = "climate.thermostat" @pytest.fixture async def mocked_hub(hass: HomeAssistant) -> Device: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink hub.""" features = [ _mocked_feature( - "temperature", value=20.2, category=Feature.Category.Primary, unit="celsius" - ), - _mocked_feature( - "target_temperature", - value=22.2, + "temperature", type_=Feature.Type.Number, category=Feature.Category.Primary, unit="celsius", ), _mocked_feature( - "state", - value=True, - type_=Feature.Type.Switch, - category=Feature.Category.Primary, - ), - _mocked_feature( - "thermostat_mode", - value=ThermostatState.Heating, - type_=Feature.Type.Choice, + "target_temperature", + type_=Feature.Type.Number, category=Feature.Category.Primary, + unit="celsius", ), ] thermostat = _mocked_device( - alias="thermostat", features=features, device_type=Device.Type.Thermostat + alias="thermostat", + features=features, + modules=[Module.Thermostat], + device_type=Device.Type.Thermostat, ) return _mocked_device( @@ -121,7 +114,9 @@ async def test_set_temperature( ) -> None: """Test that set_temperature service calls the setter.""" mocked_thermostat = mocked_hub.children[0] - mocked_thermostat.features["target_temperature"].minimum_value = 0 + + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module await setup_platform_for_device( hass, mock_config_entry, Platform.CLIMATE, mocked_hub @@ -133,8 +128,8 @@ async def test_set_temperature( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 10}, blocking=True, ) - target_temp_feature = mocked_thermostat.features["target_temperature"] - target_temp_feature.set_value.assert_called_with(10) + + therm_module.set_target_temperature.assert_called_with(10) async def test_set_hvac_mode( @@ -146,8 +141,8 @@ async def test_set_hvac_mode( ) mocked_thermostat = mocked_hub.children[0] - mocked_state = mocked_thermostat.features["state"] - assert mocked_state is not None + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module await hass.services.async_call( CLIMATE_DOMAIN, @@ -156,7 +151,7 @@ async def test_set_hvac_mode( blocking=True, ) - mocked_state.set_value.assert_called_with(False) + therm_module.set_state.assert_called_with(False) await hass.services.async_call( CLIMATE_DOMAIN, @@ -164,9 +159,10 @@ async def test_set_hvac_mode( {ATTR_ENTITY_ID: [ENTITY_ID], ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) - mocked_state.set_value.assert_called_with(True) + therm_module.set_state.assert_called_with(True) - with pytest.raises(ServiceValidationError): + msg = "Tried to set unsupported mode: dry" + with pytest.raises(ServiceValidationError, match=msg): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -184,7 +180,8 @@ async def test_turn_on_and_off( ) mocked_thermostat = mocked_hub.children[0] - mocked_state = mocked_thermostat.features["state"] + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module await hass.services.async_call( CLIMATE_DOMAIN, @@ -193,7 +190,7 @@ async def test_turn_on_and_off( blocking=True, ) - mocked_state.set_value.assert_called_with(False) + therm_module.set_state.assert_called_with(False) await hass.services.async_call( CLIMATE_DOMAIN, @@ -202,7 +199,7 @@ async def test_turn_on_and_off( blocking=True, ) - mocked_state.set_value.assert_called_with(True) + therm_module.set_state.assert_called_with(True) async def test_unknown_mode( @@ -217,11 +214,31 @@ async def test_unknown_mode( ) mocked_thermostat = mocked_hub.children[0] - mocked_state = mocked_thermostat.features["thermostat_mode"] - mocked_state.value = ThermostatState.Unknown + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module + + therm_module.mode = ThermostatState.Unknown async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF assert "Unknown thermostat state, defaulting to OFF" in caplog.text + + +async def test_missing_feature_attributes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a module missing the min/max and unit feature logs an error.""" + mocked_thermostat = mocked_hub.children[0] + mocked_thermostat.features.pop("target_temperature") + mocked_thermostat.features.pop("temperature") + + await setup_platform_for_device( + hass, mock_config_entry, Platform.CLIMATE, mocked_hub + ) + assert "Unable to get min/max target temperature" in caplog.text + assert "Unable to get correct temperature unit" in caplog.text diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 2697696c667..b093847869e 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -1,14 +1,13 @@ """Test the tplink config flow.""" -from contextlib import contextmanager import logging from unittest.mock import ANY, AsyncMock, patch -from kasa import TimeoutError +from kasa import Module, TimeoutError import pytest from homeassistant import config_entries -from homeassistant.components import dhcp +from homeassistant.components import stream from homeassistant.components.tplink import ( DOMAIN, AuthenticationError, @@ -19,9 +18,11 @@ from homeassistant.components.tplink import ( ) from homeassistant.components.tplink.config_flow import TPLinkConfigFlow from homeassistant.components.tplink.const import ( + CONF_CAMERA_CREDENTIALS, CONF_CONNECTION_PARAMETERS, CONF_CREDENTIALS_HASH, CONF_DEVICE_CONFIG, + CONF_LIVE_VIEW, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -35,48 +36,45 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from . import ( +from . import _mocked_device, _patch_connect, _patch_discovery, _patch_single_discovery +from .conftest import override_side_effect +from .const import ( AES_KEYS, ALIAS, + ALIAS_CAMERA, CONN_PARAMS_AES, CONN_PARAMS_KLAP, CONN_PARAMS_LEGACY, CREATE_ENTRY_DATA_AES, + CREATE_ENTRY_DATA_AES_CAMERA, CREATE_ENTRY_DATA_KLAP, CREATE_ENTRY_DATA_LEGACY, CREDENTIALS_HASH_AES, CREDENTIALS_HASH_KLAP, DEFAULT_ENTRY_TITLE, + DEFAULT_ENTRY_TITLE_CAMERA, DEVICE_CONFIG_AES, + DEVICE_CONFIG_AES_CAMERA, DEVICE_CONFIG_DICT_KLAP, DEVICE_CONFIG_KLAP, DEVICE_CONFIG_LEGACY, DHCP_FORMATTED_MAC_ADDRESS, IP_ADDRESS, + IP_ADDRESS2, + IP_ADDRESS3, MAC_ADDRESS, MAC_ADDRESS2, + MAC_ADDRESS3, + MODEL_CAMERA, MODULE, - _mocked_device, - _patch_connect, - _patch_discovery, - _patch_single_discovery, + SMALLEST_VALID_JPEG_BYTES, ) from tests.common import MockConfigEntry -@contextmanager -def override_side_effect(mock: AsyncMock, effect): - """Temporarily override a mock side effect and replace afterwards.""" - try: - default_side_effect = mock.side_effect - mock.side_effect = effect - yield mock - finally: - mock.side_effect = default_side_effect - - @pytest.mark.parametrize( ("device_config", "expected_entry_data", "credentials_hash"), [ @@ -98,6 +96,7 @@ async def test_discovery( device_config=device_config, credentials_hash=credentials_hash, ip_address=ip_address, + mac=MAC_ADDRESS, ) with ( _patch_discovery(device, ip_address=ip_address), @@ -143,7 +142,7 @@ async def test_discovery( result["flow_id"], {CONF_DEVICE: MAC_ADDRESS}, ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == DEFAULT_ENTRY_TITLE @@ -167,13 +166,151 @@ async def test_discovery( assert result2["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_init") +async def test_discovery_camera( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test authenticated discovery for camera with stream.""" + mock_device = _mocked_device( + alias=ALIAS_CAMERA, + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + model=MODEL_CAMERA, + device_config=DEVICE_CONFIG_AES_CAMERA, + credentials_hash=CREDENTIALS_HASH_AES, + modules=[Module.Camera], + ) + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS3, + CONF_MAC: MAC_ADDRESS3, + CONF_ALIAS: ALIAS, + CONF_DEVICE: mock_device, + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert not result["errors"] + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + assert not result["errors"] + + with patch( + "homeassistant.components.stream.async_check_stream_client_error", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_ENTRY_TITLE_CAMERA + assert result["data"] == CREATE_ENTRY_DATA_AES_CAMERA + assert result["context"]["unique_id"] == MAC_ADDRESS3 + + +@pytest.mark.usefixtures("mock_init") +async def test_discovery_pick_device_camera( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test authenticated discovery for camera with stream.""" + mock_device = _mocked_device( + alias=ALIAS_CAMERA, + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + model=MODEL_CAMERA, + device_config=DEVICE_CONFIG_AES_CAMERA, + credentials_hash=CREDENTIALS_HASH_AES, + modules=[Module.Camera], + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + with override_side_effect( + mock_discovery["discover"], lambda *_, **__: {IP_ADDRESS3: mock_device} + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pick_device" + assert not result["errors"] + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_DEVICE: MAC_ADDRESS3}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + assert not result["errors"] + + with patch( + "homeassistant.components.stream.async_check_stream_client_error", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_ENTRY_TITLE_CAMERA + assert result["data"] == CREATE_ENTRY_DATA_AES_CAMERA + assert result["context"]["unique_id"] == MAC_ADDRESS3 + + +@pytest.mark.usefixtures("mock_init") async def test_discovery_auth( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery.""" - - mock_device = mock_connect["mock_devices"][IP_ADDRESS] - assert mock_device.config == DEVICE_CONFIG_KLAP + mock_device = _mocked_device( + alias=ALIAS, + ip_address=IP_ADDRESS, + mac=MAC_ADDRESS, + device_config=DEVICE_CONFIG_KLAP, + credentials_hash=CREDENTIALS_HASH_KLAP, + ) with override_side_effect(mock_connect["connect"], AuthenticationError): result = await hass.config_entries.flow.async_init( @@ -191,13 +328,14 @@ async def test_discovery_auth( assert result["step_id"] == "discovery_auth_confirm" assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - }, - ) + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == DEFAULT_ENTRY_TITLE @@ -205,6 +343,72 @@ async def test_discovery_auth( assert result2["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") +async def test_discovery_auth_camera( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test authenticated discovery for camera with stream.""" + mock_device = _mocked_device( + alias=ALIAS_CAMERA, + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + model=MODEL_CAMERA, + device_config=DEVICE_CONFIG_AES_CAMERA, + credentials_hash=CREDENTIALS_HASH_AES, + modules=[Module.Camera], + ) + + with override_side_effect(mock_connect["connect"], AuthenticationError): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS3, + CONF_MAC: MAC_ADDRESS3, + CONF_ALIAS: ALIAS, + CONF_DEVICE: mock_device, + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + assert not result["errors"] + + with patch( + "homeassistant.components.stream.async_check_stream_client_error", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_ENTRY_TITLE_CAMERA + assert result["data"] == CREATE_ENTRY_DATA_AES_CAMERA + assert result["context"]["unique_id"] == MAC_ADDRESS3 + + @pytest.mark.parametrize( ("error_type", "errors_msg", "error_placement"), [ @@ -213,13 +417,13 @@ async def test_discovery_auth( ], ids=["invalid-auth", "unknown-error"], ) +@pytest.mark.usefixtures("mock_init") async def test_discovery_auth_errors( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test handling of discovery authentication errors. @@ -271,10 +475,10 @@ async def test_discovery_auth_errors( assert result3["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_new_credentials( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, ) -> None: """Test setting up discovery with new credentials.""" mock_device = mock_connect["mock_devices"][IP_ADDRESS] @@ -320,10 +524,10 @@ async def test_discovery_new_credentials( assert result3["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_new_credentials_invalid( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, ) -> None: """Test setting up discovery with new invalid credentials.""" mock_device = mock_connect["mock_devices"][IP_ADDRESS] @@ -385,7 +589,7 @@ async def test_discovery_new_credentials_invalid( async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> None: """Test setting up discovery.""" config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "127.0.0.2"}, unique_id="dd:dd:dd:dd:dd:dd" + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS2}, unique_id="dd:dd:dd:dd:dd:dd" ) config_entry.add_to_hass(hass) @@ -535,6 +739,228 @@ async def test_manual(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" +async def test_manual_camera( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test manual camera.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS3} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + + # Test no username or pass + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + assert result["errors"] == {"base": "camera_creds"} + + # Test unknown error + with ( + patch( + "homeassistant.components.stream.async_check_stream_client_error", + side_effect=stream.StreamOpenClientError( + "Stream was not found", error_code=stream.StreamClientError.NotFound + ), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + assert result["errors"] == {"base": "cannot_connect_camera"} + assert "error" in result["description_placeholders"] + + # Test unknown error + with ( + patch( + "homeassistant.components.stream.async_check_stream_client_error", + side_effect=stream.StreamOpenClientError( + "Request is unauthorized", + error_code=stream.StreamClientError.Unauthorized, + ), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + assert result["errors"] == {"base": "invalid_camera_auth"} + + with patch( + "homeassistant.components.stream.async_check_stream_client_error", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_CAMERA_CREDENTIALS] == { + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + } + assert result["data"][CONF_LIVE_VIEW] is True + + +@pytest.mark.parametrize( + "exception", + [ + pytest.param( + stream.StreamOpenClientError( + "Stream was not found", error_code=stream.StreamClientError.NotFound + ), + id="open_client_error", + ), + pytest.param(Exception(), id="other_error"), + ], +) +async def test_manual_camera_no_hls( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + exception: Exception, +) -> None: + """Test manual camera when hls stream fails but mpeg stream works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS3} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + + # Test stream error + with ( + patch( + "homeassistant.components.stream.async_check_stream_client_error", + side_effect=exception, + ), + patch("homeassistant.components.ffmpeg.async_get_image", return_value=None), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + assert result["errors"] == {"base": "cannot_connect_camera"} + assert "error" in result["description_placeholders"] + + # async_get_image will succeed + with ( + patch( + "homeassistant.components.stream.async_check_stream_client_error", + side_effect=exception, + ), + patch( + "homeassistant.components.ffmpeg.async_get_image", + return_value=SMALLEST_VALID_JPEG_BYTES, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_CAMERA_CREDENTIALS] == { + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + } + assert result["data"][CONF_LIVE_VIEW] is True + + +async def test_manual_camera_no_live_view( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test manual camera.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS3} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: False, + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert CONF_CAMERA_CREDENTIALS not in result["data"] + assert result["data"][CONF_LIVE_VIEW] is False + + async def test_manual_no_capabilities(hass: HomeAssistant) -> None: """Test manually setup without successful get_capabilities.""" result = await hass.config_entries.flow.async_init( @@ -561,11 +987,11 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: assert result["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_manual_auth( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test manually setup.""" result = await hass.config_entries.flow.async_init( @@ -575,7 +1001,7 @@ async def test_manual_auth( assert result["step_id"] == "user" assert not result["errors"] - mock_discovery["mock_device"].update.side_effect = AuthenticationError + mock_discovery["mock_devices"][IP_ADDRESS].update.side_effect = AuthenticationError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: IP_ADDRESS} @@ -586,7 +1012,7 @@ async def test_manual_auth( assert result2["step_id"] == "user_auth_confirm" assert not result2["errors"] - mock_discovery["mock_device"].update.reset_mock(side_effect=True) + mock_discovery["mock_devices"][IP_ADDRESS].update.reset_mock(side_effect=True) result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -602,6 +1028,63 @@ async def test_manual_auth( assert result3["context"]["unique_id"] == MAC_ADDRESS +async def test_manual_auth_camera( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test manual camera.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + with override_side_effect( + mock_discovery["mock_devices"][IP_ADDRESS3].update, AuthenticationError + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS3} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user_auth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "foobar", + CONF_PASSWORD: "foobar", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + + with patch( + "homeassistant.components.stream.async_check_stream_client_error", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_CAMERA_CREDENTIALS] == { + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + } + assert result["data"][CONF_LIVE_VIEW] is True + + @pytest.mark.parametrize( ("error_type", "errors_msg", "error_placement"), [ @@ -610,14 +1093,14 @@ async def test_manual_auth( ], ids=["invalid-auth", "unknown-error"], ) +@pytest.mark.usefixtures("mock_init") async def test_manual_auth_errors( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test manually setup auth errors.""" result = await hass.config_entries.flow.async_init( @@ -627,7 +1110,7 @@ async def test_manual_auth_errors( assert result["step_id"] == "user" assert not result["errors"] - mock_discovery["mock_device"].update.side_effect = AuthenticationError + mock_discovery["mock_devices"][IP_ADDRESS].update.side_effect = AuthenticationError with override_side_effect(mock_connect["connect"], error_type): result2 = await hass.config_entries.flow.async_configure( @@ -677,16 +1160,32 @@ async def test_manual_port_override( hass: HomeAssistant, mock_connect: AsyncMock, mock_discovery: AsyncMock, - host_str, - host, - port, + host_str: str, + host: str, + port: int, ) -> None: """Test manually setup.""" - mock_discovery["mock_device"].config.port_override = port - mock_discovery["mock_device"].host = host - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + config = DeviceConfig( + host, + credentials=None, + port_override=port, + uses_http=True, + connection_type=CONN_PARAMS_KLAP, ) + mock_device = _mocked_device( + alias=ALIAS, + ip_address=host, + mac=MAC_ADDRESS, + device_config=config, + credentials_hash=CREDENTIALS_HASH_KLAP, + ) + + with override_side_effect( + mock_discovery["try_connect_all"], lambda *_, **__: mock_device + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] @@ -696,23 +1195,29 @@ async def test_manual_port_override( mock_discovery["discover_single"].side_effect = TimeoutError mock_connect["connect"].side_effect = AuthenticationError - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: host_str} - ) - await hass.async_block_till_done() + with override_side_effect( + mock_discovery["try_connect_all"], lambda *_, **__: mock_device + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: host_str} + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "user_auth_confirm" assert not result2["errors"] creds = Credentials("fake_username", "fake_password") - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={ - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - }, - ) + with override_side_effect( + mock_discovery["try_connect_all"], lambda *_, **__: mock_device + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) await hass.async_block_till_done() mock_discovery["try_connect_all"].assert_called_once_with( host, credentials=creds, port=port, http_client=ANY @@ -744,7 +1249,7 @@ async def test_manual_port_override_invalid( await hass.async_block_till_done() mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=None, port=None + IP_ADDRESS, credentials=None, port=None ) assert result2["type"] is FlowResultType.CREATE_ENTRY @@ -787,7 +1292,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) @@ -801,7 +1306,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -817,7 +1322,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -831,7 +1336,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ), @@ -847,7 +1352,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ], ) async def test_discovered_by_dhcp_or_discovery( - hass: HomeAssistant, source, data + hass: HomeAssistant, source: str, data: dict ) -> None: """Test we can setup when discovered from dhcp or discovery.""" @@ -885,7 +1390,7 @@ async def test_discovered_by_dhcp_or_discovery( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ), @@ -901,7 +1406,7 @@ async def test_discovered_by_dhcp_or_discovery( ], ) async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( - hass: HomeAssistant, source, data + hass: HomeAssistant, source: str, data: dict ) -> None: """Test we abort if we cannot get the unique id when discovered from dhcp.""" @@ -924,7 +1429,7 @@ async def test_integration_discovery_with_ip_change( mock_discovery: AsyncMock, mock_connect: AsyncMock, ) -> None: - """Test reauth flow.""" + """Test integration updates ip address from discovery.""" mock_config_entry.add_to_hass(hass) with ( patch("homeassistant.components.tplink.Discover.discover", return_value={}), @@ -941,7 +1446,7 @@ async def test_integration_discovery_with_ip_change( mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_LEGACY.to_dict() ) - assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS mocked_device = _mocked_device(device_config=DEVICE_CONFIG_KLAP) with override_side_effect(mock_connect["connect"], lambda *_, **__: mocked_device): @@ -949,7 +1454,7 @@ async def test_integration_discovery_with_ip_change( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={ - CONF_HOST: "127.0.0.2", + CONF_HOST: IP_ADDRESS2, CONF_MAC: MAC_ADDRESS, CONF_ALIAS: ALIAS, CONF_DEVICE: mocked_device, @@ -961,7 +1466,7 @@ async def test_integration_discovery_with_ip_change( assert ( mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() ) - assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS2 config = DeviceConfig.from_dict(DEVICE_CONFIG_DICT_KLAP) @@ -984,8 +1489,8 @@ async def test_integration_discovery_with_ip_change( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED # Check that init set the new host correctly before calling connect - assert config.host == "127.0.0.1" - config.host = "127.0.0.2" + assert config.host == IP_ADDRESS + config.host = IP_ADDRESS2 config.uses_http = False # Not passed in to new config class config.http_client = "Foo" mock_connect["connect"].assert_awaited_once_with(config=config) @@ -1024,7 +1529,7 @@ async def test_integration_discovery_with_connection_change( ) == 0 ) - assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS2 assert ( mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_AES.to_dict() ) @@ -1034,7 +1539,7 @@ async def test_integration_discovery_with_connection_change( NEW_DEVICE_CONFIG = { **DEVICE_CONFIG_DICT_KLAP, "connection_type": CONN_PARAMS_KLAP.to_dict(), - CONF_HOST: "127.0.0.2", + CONF_HOST: IP_ADDRESS2, } config = DeviceConfig.from_dict(NEW_DEVICE_CONFIG) # Reset the connect mock so when the config flow reloads the entry it succeeds @@ -1055,7 +1560,7 @@ async def test_integration_discovery_with_connection_change( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={ - CONF_HOST: "127.0.0.2", + CONF_HOST: IP_ADDRESS2, CONF_MAC: MAC_ADDRESS2, CONF_ALIAS: ALIAS, CONF_DEVICE: bulb, @@ -1067,12 +1572,12 @@ async def test_integration_discovery_with_connection_change( assert ( mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() ) - assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS2 assert CREDENTIALS_HASH_AES not in mock_config_entry.data assert mock_config_entry.state is ConfigEntryState.LOADED - config.host = "127.0.0.2" + config.host = IP_ADDRESS2 config.uses_http = False # Not passed in to new config class config.http_client = "Foo" config.aes_keys = AES_KEYS @@ -1097,18 +1602,18 @@ async def test_dhcp_discovery_with_ip_change( flows = hass.config_entries.flow.async_progress() assert len(flows) == 0 - assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip="127.0.0.2", macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS + data=DhcpServiceInfo( + ip=IP_ADDRESS2, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) assert discovery_result["type"] is FlowResultType.ABORT assert discovery_result["reason"] == "already_configured" - assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS2 async def test_dhcp_discovery_discover_fail( @@ -1121,14 +1626,14 @@ async def test_dhcp_discovery_discover_fail( flows = hass.config_entries.flow.async_progress() assert len(flows) == 0 - assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS with override_side_effect(mock_discovery["discover_single"], TimeoutError): discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip="127.0.0.2", macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS + data=DhcpServiceInfo( + ip=IP_ADDRESS2, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) assert discovery_result["type"] is FlowResultType.ABORT @@ -1160,15 +1665,58 @@ async def test_reauth( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials, port=None + IP_ADDRESS, credentials=credentials, port=None ) - mock_discovery["mock_device"].update.assert_called_once_with() + mock_discovery["mock_devices"][IP_ADDRESS].update.assert_called_once_with() assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" await hass.async_block_till_done() +async def test_reauth_camera( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow on invalid camera credentials.""" + mock_device = mock_connect["mock_devices"][IP_ADDRESS3] + mock_camera_config_entry.add_to_hass(hass) + mock_camera_config_entry.async_start_reauth( + hass, + config_entries.ConfigFlowContext( + reauth_source=CONF_CAMERA_CREDENTIALS, # type: ignore[typeddict-unknown-key] + ), + {"device": mock_device}, + ) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + + assert result["step_id"] == "camera_auth_confirm" + + with patch( + "homeassistant.components.stream.async_check_stream_client_error", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser2", + CONF_PASSWORD: "campass2", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert dict(mock_camera_config_entry.data) == { + **CREATE_ENTRY_DATA_AES_CAMERA, + CONF_CAMERA_CREDENTIALS: {CONF_USERNAME: "camuser2", CONF_PASSWORD: "campass2"}, + } + + async def test_reauth_try_connect_all( hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, @@ -1195,7 +1743,7 @@ async def test_reauth_try_connect_all( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials, port=None + IP_ADDRESS, credentials=credentials, port=None ) mock_discovery["try_connect_all"].assert_called_once() assert result2["type"] is FlowResultType.ABORT @@ -1224,7 +1772,7 @@ async def test_reauth_try_connect_all_fail( override_side_effect(mock_discovery["discover_single"], TimeoutError), override_side_effect(mock_discovery["try_connect_all"], lambda *_, **__: None), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_USERNAME: "fake_username", @@ -1233,10 +1781,26 @@ async def test_reauth_try_connect_all_fail( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials, port=None + IP_ADDRESS, credentials=credentials, port=None ) mock_discovery["try_connect_all"].assert_called_once() - assert result2["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "cannot_connect"} + + mock_discovery["try_connect_all"].reset_mock() + with ( + override_side_effect(mock_discovery["discover_single"], TimeoutError), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + mock_discovery["try_connect_all"].assert_called_once() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_reauth_update_with_encryption_change( @@ -1278,40 +1842,48 @@ async def test_reauth_update_with_encryption_change( assert CONF_CREDENTIALS_HASH not in mock_config_entry.data new_config = DeviceConfig( - "127.0.0.2", + IP_ADDRESS2, credentials=None, connection_type=Device.ConnectionParameters( Device.Family.SmartTapoPlug, Device.EncryptionType.Klap ), uses_http=True, ) - mock_discovery["mock_device"].host = "127.0.0.2" - mock_discovery["mock_device"].config = new_config - mock_discovery["mock_device"].credentials_hash = None - mock_connect["mock_devices"]["127.0.0.2"].config = new_config - mock_connect["mock_devices"]["127.0.0.2"].credentials_hash = CREDENTIALS_HASH_KLAP - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - }, + mock_device = _mocked_device( + alias="my_device", + ip_address=IP_ADDRESS2, + mac=MAC_ADDRESS2, + device_config=new_config, + credentials_hash=CREDENTIALS_HASH_KLAP, ) - await hass.async_block_till_done(wait_background_tasks=True) + + with ( + override_side_effect( + mock_discovery["discover_single"], lambda *_, **__: mock_device + ), + override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done(wait_background_tasks=True) assert "Connection type changed for 127.0.0.2" in caplog.text credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.2", credentials=credentials, port=None + IP_ADDRESS2, credentials=credentials, port=None ) - mock_discovery["mock_device"].update.assert_called_once_with() + mock_device.update.assert_called_once_with() assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" assert mock_config_entry.state is ConfigEntryState.LOADED assert ( mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() ) - assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS2 assert mock_config_entry.data[CONF_CREDENTIALS_HASH] == CREDENTIALS_HASH_KLAP @@ -1398,7 +1970,7 @@ async def test_reauth_update_from_discovery_with_ip_change( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={ - CONF_HOST: "127.0.0.2", + CONF_HOST: IP_ADDRESS2, CONF_MAC: MAC_ADDRESS, CONF_ALIAS: ALIAS, CONF_DEVICE: device, @@ -1410,7 +1982,7 @@ async def test_reauth_update_from_discovery_with_ip_change( assert ( mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() ) - assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS2 async def test_reauth_no_update_if_config_and_ip_the_same( @@ -1479,9 +2051,9 @@ async def test_reauth_errors( mock_added_config_entry: MockConfigEntry, mock_discovery: AsyncMock, mock_connect: AsyncMock, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test reauth errors.""" mock_added_config_entry.async_start_reauth(hass) @@ -1493,26 +2065,27 @@ async def test_reauth_errors( [result] = flows assert result["step_id"] == "reauth_confirm" - mock_discovery["mock_device"].update.side_effect = error_type - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - }, - ) + mock_device = mock_discovery["mock_devices"][IP_ADDRESS] + with override_side_effect(mock_device.update, error_type): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials, port=None + IP_ADDRESS, credentials=credentials, port=None ) - mock_discovery["mock_device"].update.assert_called_once_with() + mock_device.update.assert_called_once_with() assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {error_placement: errors_msg} assert result2["description_placeholders"]["error"] == str(error_type) mock_discovery["discover_single"].reset_mock() - mock_discovery["mock_device"].update.reset_mock(side_effect=True) + mock_device.update.reset_mock(side_effect=True) result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={ @@ -1522,9 +2095,9 @@ async def test_reauth_errors( ) mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials, port=None + IP_ADDRESS, credentials=credentials, port=None ) - mock_discovery["mock_device"].update.assert_called_once_with() + mock_device.update.assert_called_once_with() assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" @@ -1542,8 +2115,8 @@ async def test_pick_device_errors( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - error_type, - expected_flow, + error_type: type[Exception], + expected_flow: FlowResultType, ) -> None: """Test errors on pick_device.""" result = await hass.config_entries.flow.async_init( @@ -1580,11 +2153,11 @@ async def test_pick_device_errors( assert result4["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( @@ -1606,11 +2179,11 @@ async def test_discovery_timeout_try_connect_all( assert mock_connect["connect"].call_count == 1 +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all_needs_creds( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( @@ -1644,11 +2217,11 @@ async def test_discovery_timeout_try_connect_all_needs_creds( assert mock_connect["connect"].call_count == 1 +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all_fail( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( @@ -1731,12 +2304,169 @@ async def test_reauth_update_other_flows( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials, port=None + IP_ADDRESS, credentials=credentials, port=None ) - mock_discovery["mock_device"].update.assert_called_once_with() + mock_discovery["mock_devices"][IP_ADDRESS].update.assert_called_once_with() assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 0 + + +async def test_reconfigure( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reconfigure flow.""" + result = await mock_added_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: IP_ADDRESS, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_auth_discovered( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reconfigure auth flow for device that's discovered.""" + result = await mock_added_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # Simulate a bad host + with ( + override_side_effect( + mock_discovery["mock_devices"][IP_ADDRESS].update, KasaException + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "WRONG_IP", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + assert "error" in result["description_placeholders"] + + with ( + override_side_effect( + mock_discovery["mock_devices"][IP_ADDRESS].update, AuthenticationError + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: IP_ADDRESS, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user_auth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_auth_try_connect_all( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reconfigure auth flow for device that's not discovered.""" + result = await mock_added_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with ( + override_side_effect(mock_discovery["discover_single"], TimeoutError), + override_side_effect(mock_connect["connect"], KasaException), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: IP_ADDRESS, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user_auth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_camera( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reconfigure flow.""" + mock_camera_config_entry.add_to_hass(hass) + result = await mock_camera_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: IP_ADDRESS3, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "camera_auth_confirm" + + with patch( + "homeassistant.components.stream.async_check_stream_client_error", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LIVE_VIEW: True, + CONF_USERNAME: "camuser", + CONF_PASSWORD: "campass", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/tplink/test_fan.py b/tests/components/tplink/test_fan.py index deba33abfa5..13a768f683c 100644 --- a/tests/components/tplink/test_fan.py +++ b/tests/components/tplink/test_fan.py @@ -2,8 +2,7 @@ from __future__ import annotations -from datetime import timedelta - +from freezegun.api import FrozenDateTimeFactory from kasa import Device, Module from syrupy.assertion import SnapshotAssertion @@ -11,13 +10,15 @@ from homeassistant.components.fan import ( ATTR_PERCENTAGE, DOMAIN as FAN_DOMAIN, SERVICE_SET_PERCENTAGE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util -from . import DEVICE_ID, _mocked_device, setup_platform_for_device, snapshot_platform +from . import _mocked_device, setup_platform_for_device, snapshot_platform +from .const import DEVICE_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -56,6 +57,7 @@ async def test_fan_unique_id( hass: HomeAssistant, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test a fan unique id.""" fan = _mocked_device(modules=[Module.Fan], alias="my_fan") @@ -66,12 +68,16 @@ async def test_fan_unique_id( ) assert device_entries entity_id = "fan.my_fan" - entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == DEVICE_ID -async def test_fan(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: - """Test a color fan and that all transitions are correctly passed.""" +async def test_fan( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test fan functionality.""" device = _mocked_device(modules=[Module.Fan], alias="my_fan") fan = device.modules[Module.Fan] fan.fan_speed_level = 0 @@ -83,26 +89,29 @@ async def test_fan(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> N assert state.state == "off" await hass.services.async_call( - FAN_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) fan.set_fan_speed_level.assert_called_once_with(4) fan.set_fan_speed_level.reset_mock() fan.fan_speed_level = 4 - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + + freezer.tick(10) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) assert state.state == "on" await hass.services.async_call( - FAN_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) fan.set_fan_speed_level.assert_called_once_with(0) fan.set_fan_speed_level.reset_mock() await hass.services.async_call( FAN_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 50}, blocking=True, ) diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index dd01c381adf..ffcadc79faf 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -8,10 +8,18 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory -from kasa import AuthenticationError, DeviceConfig, Feature, KasaException, Module +from kasa import ( + AuthenticationError, + Device, + DeviceConfig, + DeviceType, + Feature, + KasaException, + Module, +) +from kasa.iot import IotStrip import pytest -from homeassistant import setup from homeassistant.components import tplink from homeassistant.components.tplink.const import ( CONF_AES_KEYS, @@ -38,6 +46,14 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( + _mocked_device, + _mocked_feature, + _patch_connect, + _patch_discovery, + _patch_single_discovery, +) +from .conftest import override_side_effect +from .const import ( ALIAS, CREATE_ENTRY_DATA_AES, CREATE_ENTRY_DATA_KLAP, @@ -45,17 +61,16 @@ from . import ( CREDENTIALS_HASH_AES, CREDENTIALS_HASH_KLAP, DEVICE_CONFIG_AES, + DEVICE_CONFIG_DICT_KLAP, DEVICE_CONFIG_KLAP, DEVICE_CONFIG_LEGACY, DEVICE_ID, DEVICE_ID_MAC, IP_ADDRESS, + IP_ADDRESS3, MAC_ADDRESS, + MAC_ADDRESS3, MODEL, - _mocked_device, - _patch_connect, - _patch_discovery, - _patch_single_discovery, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -68,6 +83,7 @@ async def test_configuring_tplink_causes_discovery( with ( patch("homeassistant.components.tplink.Discover.discover") as discover, patch("homeassistant.components.tplink.Discover.discover_single"), + patch("homeassistant.components.tplink.Device.connect"), ): discover.return_value = {MagicMock(): MagicMock()} await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) @@ -94,7 +110,7 @@ async def test_config_entry_reload(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(), _patch_single_discovery(), _patch_connect(): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(already_migrated_config_entry.entry_id) @@ -113,7 +129,7 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: _patch_single_discovery(no_device=True), _patch_connect(no_device=True), ): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -147,7 +163,7 @@ async def test_dimmer_switch_unique_id_fix_original_entity_still_exists( _patch_single_discovery(device=dimmer), _patch_connect(device=dimmer), ): - await setup.async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) migrated_dimmer_entity_reg = entity_registry.async_get_or_create( @@ -171,7 +187,7 @@ async def test_config_entry_wrong_mac_Address( ) already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(), _patch_single_discovery(), _patch_connect(): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -219,8 +235,12 @@ async def test_config_entry_with_stored_credentials( hass.data.setdefault(DOMAIN, {})[CONF_AUTHENTICATION] = auth mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.tplink.async_create_clientsession", return_value="Foo" + with ( + patch( + "homeassistant.components.tplink.async_create_clientsession", + return_value="Foo", + ), + override_side_effect(mock_discovery["discover"], lambda *_, **__: {}), ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -301,7 +321,7 @@ async def test_plug_auth_fails(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) device = _mocked_device(alias="my_plug", features=["state"]) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -334,13 +354,20 @@ async def test_update_attrs_fails_in_init( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) config_entry.add_to_hass(hass) - light = _mocked_device(modules=[Module.Light], alias="my_light") + features = [ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ] + light = _mocked_device(modules=[Module.Light], alias="my_light", features=features) light_module = light.modules[Module.Light] p = PropertyMock(side_effect=KasaException) type(light_module).color_temp = p light.__str__ = lambda _: "MockLight" with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -348,7 +375,7 @@ async def test_update_attrs_fails_in_init( assert entity state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE - assert "Unable to read data for MockLight None:" in caplog.text + assert f"Unable to read data for MockLight {entity_id}:" in caplog.text async def test_update_attrs_fails_on_update( @@ -362,11 +389,18 @@ async def test_update_attrs_fails_on_update( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) config_entry.add_to_hass(hass) - light = _mocked_device(modules=[Module.Light], alias="my_light") + features = [ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ] + light = _mocked_device(modules=[Module.Light], alias="my_light", features=features) light_module = light.modules[Module.Light] with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -412,7 +446,7 @@ async def test_feature_no_category( ) dev.features["led"].category = Feature.Category.Unset with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug_led" @@ -479,7 +513,7 @@ async def test_unlink_devices( # Generate list of test identifiers test_identifiers = [ - (domain, f"{device_id}{"" if i == 0 else f"_000{i}"}") + (domain, f"{device_id}{'' if i == 0 else f'_000{i}'}") for i in range(id_count) for domain in domains ] @@ -538,9 +572,8 @@ async def test_move_credentials_hash( from the device. """ device_config = { - **DEVICE_CONFIG_KLAP.to_dict( - exclude_credentials=True, credentials_hash="theHash" - ) + **DEVICE_CONFIG_DICT_KLAP, + "credentials_hash": "theHash", } entry_data = {**CREATE_ENTRY_DATA_KLAP, CONF_DEVICE_CONFIG: device_config} @@ -586,9 +619,8 @@ async def test_move_credentials_hash_auth_error( in async_setup_entry. """ device_config = { - **DEVICE_CONFIG_KLAP.to_dict( - exclude_credentials=True, credentials_hash="theHash" - ) + **DEVICE_CONFIG_DICT_KLAP, + "credentials_hash": "theHash", } entry_data = {**CREATE_ENTRY_DATA_KLAP, CONF_DEVICE_CONFIG: device_config} @@ -630,9 +662,8 @@ async def test_move_credentials_hash_other_error( at the end of the test. """ device_config = { - **DEVICE_CONFIG_KLAP.to_dict( - exclude_credentials=True, credentials_hash="theHash" - ) + **DEVICE_CONFIG_DICT_KLAP, + "credentials_hash": "theHash", } entry_data = {**CREATE_ENTRY_DATA_KLAP, CONF_DEVICE_CONFIG: device_config} @@ -729,7 +760,7 @@ async def test_credentials_hash_auth_error( await hass.async_block_till_done() expected_config = DeviceConfig.from_dict( - DEVICE_CONFIG_KLAP.to_dict(exclude_credentials=True, credentials_hash="theHash") + {**DEVICE_CONFIG_DICT_KLAP, "credentials_hash": "theHash"} ) expected_config.uses_http = False expected_config.http_client = "Foo" @@ -767,7 +798,9 @@ async def test_migrate_remove_device_config( CONF_HOST: expected_entry_data[CONF_HOST], CONF_ALIAS: ALIAS, CONF_MODEL: MODEL, - CONF_DEVICE_CONFIG: device_config.to_dict(exclude_credentials=True), + CONF_DEVICE_CONFIG: { + k: v for k, v in device_config.to_dict().items() if k != "credentials" + }, } entry = MockConfigEntry( @@ -804,3 +837,390 @@ async def test_migrate_remove_device_config( assert entry.data == expected_entry_data assert "Migration to version 1.5 complete" in caplog.text + + +@pytest.mark.parametrize( + ("parent_device_type"), + [ + (Device), + (IotStrip), + ], +) +@pytest.mark.parametrize( + ("platform", "feature_id", "translated_name"), + [ + pytest.param("switch", "led", "led", id="switch"), + pytest.param( + "sensor", "current_consumption", "current_consumption", id="sensor" + ), + pytest.param("binary_sensor", "overheated", "overheated", id="binary_sensor"), + pytest.param("number", "smooth_transition_on", "smooth_on", id="number"), + pytest.param("select", "light_preset", "light_preset", id="select"), + pytest.param("button", "reboot", "restart", id="button"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_automatic_feature_device_addition_and_removal( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, + platform: str, + feature_id: str, + translated_name: str, + parent_device_type: type, +) -> None: + """Test for automatic device with features addition and removal.""" + + children = { + f"child{index}": _mocked_device( + alias=f"child {index}", + features=[feature_id], + device_type=DeviceType.StripSocket, + device_id=f"child{index}", + ) + for index in range(1, 5) + } + + mock_device = _mocked_device( + alias="hub", + children=[children["child1"], children["child2"]], + features=[feature_id], + device_type=DeviceType.Hub, + spec=parent_device_type, + device_id="hub_parent", + ) + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + for child_id in (1, 2): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + parent_device = device_registry.async_get_device( + identifiers={(DOMAIN, "hub_parent")} + ) + assert parent_device + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Remove one of the devices + mock_device.children = [children["child1"]] + freezer.tick(5) + async_fire_time_changed(hass) + + entity_id = f"{platform}.child_2_{translated_name}" + state = hass.states.get(entity_id) + assert state is None + assert entity_registry.async_get(entity_id) is None + + assert device_registry.async_get_device(identifiers={(DOMAIN, "child2")}) is None + + # Re-dd the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Add child devices + mock_device.children = [children["child1"], children["child3"], children["child4"]] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 3, 4): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child3", "child4"): + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Add the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + children["child3"], + children["child4"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2, 3, 4): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2", "child3", "child4"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + +@pytest.mark.parametrize( + ("platform", "modules", "features", "translated_name", "child_device_type"), + [ + pytest.param( + "camera", [Module.Camera], [], "live_view", DeviceType.Camera, id="camera" + ), + pytest.param("fan", [Module.Fan], [], None, DeviceType.Fan, id="fan"), + pytest.param("siren", [Module.Alarm], [], None, DeviceType.Camera, id="siren"), + pytest.param("light", [Module.Light], [], None, DeviceType.Camera, id="light"), + pytest.param( + "light", + [Module.Light, Module.LightEffect], + [], + None, + DeviceType.Camera, + id="light_effect", + ), + pytest.param( + "climate", + [Module.Thermostat], + ["temperature", "target_temperature"], + None, + DeviceType.Thermostat, + id="climate", + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_automatic_module_device_addition_and_removal( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, + platform: str, + modules: list[str], + features: list[str], + translated_name: str | None, + child_device_type: DeviceType, +) -> None: + """Test for automatic device with modules addition and removal.""" + + children = { + f"child{index}": _mocked_device( + alias=f"child {index}", + modules=modules, + features=features, + device_type=child_device_type, + device_id=f"child{index}", + ) + for index in range(1, 5) + } + + mock_device = _mocked_device( + alias="hub", + children=[children["child1"], children["child2"]], + features=["ssid"], + device_type=DeviceType.Hub, + device_id="hub_parent", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + # Set the parent property for the dynamic children as mock_device only sets + # it on initialization + for child in children.values(): + child.parent = mock_device + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + mock_camera_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_camera_config_entry.entry_id) + await hass.async_block_till_done() + + for child_id in (1, 2): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + parent_device = device_registry.async_get_device( + identifiers={(DOMAIN, "hub_parent")} + ) + assert parent_device + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Remove one of the devices + mock_device.children = [children["child1"]] + freezer.tick(5) + async_fire_time_changed(hass) + + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_2{sub_id}" + state = hass.states.get(entity_id) + assert state is None + assert entity_registry.async_get(entity_id) is None + + assert device_registry.async_get_device(identifiers={(DOMAIN, "child2")}) is None + + # Re-dd the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Add child devices + mock_device.children = [children["child1"], children["child3"], children["child4"]] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 3, 4): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child3", "child4"): + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Add the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + children["child3"], + children["child4"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2, 3, 4): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2", "child3", "child4"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + +async def test_automatic_device_addition_does_not_remove_disabled_default( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for automatic device addition does not remove disabled default entities.""" + + features = ["ssid", "signal_level"] + children = { + f"child{index}": _mocked_device( + alias=f"child {index}", + features=features, + device_id=f"child{index}", + ) + for index in range(1, 5) + } + + mock_device = _mocked_device( + alias="hub", + children=[children["child1"], children["child2"]], + features=features, + device_type=DeviceType.Hub, + device_id="hub_parent", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + # Set the parent property for the dynamic children as mock_device only sets + # it on initialization + for child in children.values(): + child.parent = mock_device + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + mock_camera_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_camera_config_entry.entry_id) + await hass.async_block_till_done() + + def check_entities(entity_id_device): + entity_id = f"sensor.{entity_id_device}_signal_level" + state = hass.states.get(entity_id) + assert state + reg_ent = entity_registry.async_get(entity_id) + assert reg_ent + assert reg_ent.disabled is False + + entity_id = f"sensor.{entity_id_device}_ssid" + state = hass.states.get(entity_id) + assert state is None + reg_ent = entity_registry.async_get(entity_id) + assert reg_ent + assert reg_ent.disabled is True + assert reg_ent.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + check_entities("hub") + for child_id in (1, 2): + check_entities(f"child_{child_id}") + + # Add child devices + mock_device.children = [children["child1"], children["child2"], children["child3"]] + freezer.tick(5) + async_fire_time_changed(hass) + + check_entities("hub") + for child_id in (1, 2, 3): + check_entities(f"child_{child_id}") diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 6998d8fbcc7..565d4f1221a 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +import re from unittest.mock import MagicMock, PropertyMock from freezegun.api import FrozenDateTimeFactory @@ -19,6 +20,11 @@ from kasa.iot import IotDevice import pytest from homeassistant.components import tplink +from homeassistant.components.homeassistant.scene import ( + CONF_SCENE_ID, + CONF_SNAPSHOT, + SERVICE_CREATE, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -26,16 +32,23 @@ from homeassistant.components.light import ( ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, EFFECT_OFF, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.tplink.const import DOMAIN +from homeassistant.components.tplink.light import ( + SERVICE_RANDOM_EFFECT, + SERVICE_SEQUENCE_EFFECT, +) from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import ( ATTR_ENTITY_ID, @@ -51,13 +64,13 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, + _mocked_feature, _patch_connect, _patch_discovery, _patch_single_discovery, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry, async_fire_time_changed @@ -82,7 +95,7 @@ async def test_light_unique_id( light = _mocked_device(modules=[Module.Light], alias="my_light") light.device_type = device_type with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -92,8 +105,11 @@ async def test_light_unique_id( ) -async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: - """Test a light unique id.""" +async def test_legacy_dimmer_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test dimmer unique id.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -107,23 +123,52 @@ async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: light.device_type = DeviceType.Dimmer with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" - entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == "aa:bb:cc:dd:ee:ff" @pytest.mark.parametrize( - ("device", "transition"), + ("device", "extra_data", "expected_transition"), [ - (_mocked_device(modules=[Module.Light]), 2.0), - (_mocked_device(modules=[Module.Light, Module.LightEffect]), None), + ( + _mocked_device( + modules=[Module.Light], + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ), + {ATTR_TRANSITION: 2.0}, + 2.0 * 1_000, + ), + ( + _mocked_device( + modules=[Module.Light], + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ), + {}, + None, + ), ], ) async def test_color_light( - hass: HomeAssistant, device: MagicMock, transition: float | None + hass: HomeAssistant, + device: MagicMock, + extra_data: dict, + expected_transition: float | None, ) -> None: """Test a color light and that all transitions are correctly passed.""" already_migrated_config_entry = MockConfigEntry( @@ -131,101 +176,230 @@ async def test_color_light( ) already_migrated_config_entry.add_to_hass(hass) light = device.modules[Module.Light] + + # Setting color_temp to None emulates a device without color temp light.color_temp = None + with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_bulb" - KASA_TRANSITION_VALUE = transition * 1_000 if transition is not None else None BASE_PAYLOAD = {ATTR_ENTITY_ID: entity_id} - if transition: - BASE_PAYLOAD[ATTR_TRANSITION] = transition + BASE_PAYLOAD |= extra_data state = hass.states.get(entity_id) assert state.state == "on" attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] - # If effect is active, only the brightness can be controlled - if attributes.get(ATTR_EFFECT) is not None: - assert attributes[ATTR_COLOR_MODE] == "brightness" - else: - assert attributes[ATTR_COLOR_MODE] == "hs" - assert attributes[ATTR_MIN_MIREDS] == 111 - assert attributes[ATTR_MAX_MIREDS] == 250 - assert attributes[ATTR_HS_COLOR] == (10, 30) - assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) - assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) + + assert attributes.get(ATTR_EFFECT) is None + + assert attributes[ATTR_COLOR_MODE] == "hs" + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 + assert attributes[ATTR_HS_COLOR] == (10, 30) + assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) + assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", BASE_PAYLOAD, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, BASE_PAYLOAD, blocking=True ) light.set_state.assert_called_once_with( - LightState(light_on=False, transition=KASA_TRANSITION_VALUE) + LightState(light_on=False, transition=expected_transition) ) light.set_state.reset_mock() - await hass.services.async_call(LIGHT_DOMAIN, "turn_on", BASE_PAYLOAD, blocking=True) + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, BASE_PAYLOAD, blocking=True + ) light.set_state.assert_called_once_with( - LightState(light_on=True, transition=KASA_TRANSITION_VALUE) + LightState(light_on=True, transition=expected_transition) ) light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_BRIGHTNESS: 100}, blocking=True, ) - light.set_brightness.assert_called_with(39, transition=KASA_TRANSITION_VALUE) + light.set_brightness.assert_called_with(39, transition=expected_transition) light.set_brightness.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) light.set_color_temp.assert_called_with( - 6666, brightness=None, transition=KASA_TRANSITION_VALUE + 6666, brightness=None, transition=expected_transition ) light.set_color_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) light.set_color_temp.assert_called_with( - 6666, brightness=None, transition=KASA_TRANSITION_VALUE + 6666, brightness=None, transition=expected_transition ) light.set_color_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_HS_COLOR: (10, 30)}, blocking=True, ) - light.set_hsv.assert_called_with(10, 30, None, transition=KASA_TRANSITION_VALUE) + light.set_hsv.assert_called_with(10, 30, None, transition=expected_transition) light.set_hsv.reset_mock() -async def test_color_light_no_temp(hass: HomeAssistant) -> None: - """Test a light.""" +@pytest.mark.parametrize( + ("device", "extra_data", "expected_transition"), + [ + ( + _mocked_device( + modules=[Module.Light, Module.LightEffect], + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ), + {ATTR_TRANSITION: 2.0}, + 2.0 * 1_000, + ), + ( + _mocked_device( + modules=[Module.Light, Module.LightEffect], + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ), + {}, + None, + ), + ], +) +async def test_color_light_with_active_effect( + hass: HomeAssistant, + device: MagicMock, + extra_data: dict, + expected_transition: float | None, +) -> None: + """Test a color light and that all transitions are correctly passed.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - device = _mocked_device(modules=[Module.Light], alias="my_light") light = device.modules[Module.Light] - light.is_variable_color_temp = False + + with _patch_discovery(device=device), _patch_connect(device=device): + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + BASE_PAYLOAD = {ATTR_ENTITY_ID: entity_id} + BASE_PAYLOAD |= extra_data + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + + # If effect is active, only the brightness can be controlled + assert attributes.get(ATTR_EFFECT) is not None + assert attributes[ATTR_COLOR_MODE] == "brightness" + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, BASE_PAYLOAD, blocking=True + ) + light.set_state.assert_called_once_with( + LightState(light_on=False, transition=expected_transition) + ) + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, BASE_PAYLOAD, blocking=True + ) + light.set_state.assert_called_once_with( + LightState(light_on=True, transition=expected_transition) + ) + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + light.set_brightness.assert_called_with(39, transition=expected_transition) + light.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with( + 6666, brightness=None, transition=expected_transition + ) + light.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with( + 6666, brightness=None, transition=expected_transition + ) + light.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + light.set_hsv.assert_called_with(10, 30, None, transition=expected_transition) + light.set_hsv.reset_mock() + + +async def test_color_light_no_temp(hass: HomeAssistant) -> None: + """Test a color light with no color temp.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + features = [ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + ] + + device = _mocked_device(modules=[Module.Light], alias="my_light", features=features) + light = device.modules[Module.Light] + type(light).color_temp = PropertyMock(side_effect=Exception) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -241,20 +415,20 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -263,7 +437,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, blocking=True, ) @@ -271,29 +445,28 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: light.set_hsv.reset_mock() -@pytest.mark.parametrize( - ("bulb", "is_color"), - [ - (_mocked_device(modules=[Module.Light], alias="my_light"), True), - (_mocked_device(modules=[Module.Light], alias="my_light"), False), - ], -) -async def test_color_temp_light( - hass: HomeAssistant, bulb: MagicMock, is_color: bool -) -> None: - """Test a light.""" +async def test_color_temp_light_color(hass: HomeAssistant) -> None: + """Test a color temp light with color.""" + device = _mocked_device( + modules=[Module.Light], + alias="my_light", + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ) already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - device = _mocked_device(modules=[Module.Light], alias="my_light") + light = device.modules[Module.Light] - light.is_color = is_color - light.color_temp = 4000 - light.is_variable_color_temp = True with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -303,29 +476,24 @@ async def test_color_temp_light( attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "color_temp" - if light.is_color: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] - else: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] - assert attributes[ATTR_MIN_MIREDS] == 111 - assert attributes[ATTR_MAX_MIREDS] == 250 - assert attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 + + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -334,7 +502,7 @@ async def test_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) @@ -344,7 +512,7 @@ async def test_color_temp_light( # Verify color temp is clamped to the valid range await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000}, blocking=True, ) @@ -354,7 +522,94 @@ async def test_color_temp_light( # Verify color temp is clamped to the valid range await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1}, + blocking=True, + ) + light.set_color_temp.assert_called_with(4000, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + +async def test_color_temp_light_no_color(hass: HomeAssistant) -> None: + """Test a color temp light with no color.""" + device = _mocked_device( + modules=[Module.Light], + alias="my_light", + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ) + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + + light = device.modules[Module.Light] + + with _patch_discovery(device=device), _patch_connect(device=device): + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.my_light" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "color_temp" + + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + light.set_state.assert_called_once() + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + light.set_state.assert_called_once() + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + light.set_brightness.assert_called_with(39, transition=None) + light.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with(6666, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000}, + blocking=True, + ) + light.set_color_temp.assert_called_with(9000, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1}, blocking=True, ) @@ -363,18 +618,19 @@ async def test_color_temp_light( async def test_brightness_only_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light brightness.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - device = _mocked_device(modules=[Module.Light], alias="my_light") + features = [ + _mocked_feature("brightness", value=50), + ] + device = _mocked_device(modules=[Module.Light], alias="my_light", features=features) light = device.modules[Module.Light] - light.is_color = False - light.is_variable_color_temp = False with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -387,20 +643,20 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -409,19 +665,16 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: async def test_on_off_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light turns on and off.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - device = _mocked_device(modules=[Module.Light], alias="my_light") + device = _mocked_device(modules=[Module.Light], alias="my_light", features=[]) light = device.modules[Module.Light] - light.is_color = False - light.is_variable_color_temp = False - light.is_dimmable = False with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -432,33 +685,31 @@ async def test_on_off_light(hass: HomeAssistant) -> None: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() async def test_off_at_start_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light off at startup.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - device = _mocked_device(modules=[Module.Light], alias="my_light") + device = _mocked_device(modules=[Module.Light], alias="my_light", features=[]) light = device.modules[Module.Light] - light.is_color = False - light.is_variable_color_temp = False - light.is_dimmable = False + light.state = LightState(light_on=False) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -470,7 +721,7 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None: async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a dimmer turns on without brightness being set.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -481,7 +732,7 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: light.state = LightState(light_on=False) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -490,7 +741,7 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: assert state.state == "off" await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once_with( LightState( @@ -513,8 +764,15 @@ async def test_smart_strip_effects( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) + features = [ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ] device = _mocked_device( - modules=[Module.Light, Module.LightEffect], alias="my_light" + modules=[Module.Light, Module.LightEffect], alias="my_light", features=features ) light = device.modules[Module.Light] light_effect = device.modules[Module.LightEffect] @@ -524,7 +782,7 @@ async def test_smart_strip_effects( _patch_single_discovery(device=device), _patch_connect(device=device), ): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -538,7 +796,7 @@ async def test_smart_strip_effects( # is in progress calls set_effect to clear the effect await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) @@ -549,7 +807,7 @@ async def test_smart_strip_effects( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect2"}, blocking=True, ) @@ -566,7 +824,7 @@ async def test_smart_strip_effects( # Test setting light effect off await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "off"}, blocking=True, ) @@ -581,7 +839,7 @@ async def test_smart_strip_effects( caplog.clear() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect3"}, blocking=True, ) @@ -610,7 +868,7 @@ async def test_smart_strip_effects( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -640,7 +898,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: light_effect = device.modules[Module.LightEffect] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -650,7 +908,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -679,7 +937,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -729,7 +987,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -738,7 +996,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -781,6 +1039,84 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: light_effect.set_custom_effect.reset_mock() +@pytest.mark.parametrize( + ("service_name", "service_params", "expected_extra_params"), + [ + pytest.param( + SERVICE_SEQUENCE_EFFECT, + { + "sequence": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], + }, + { + "type": "sequence", + "sequence": [(340, 20, 50), (20, 50, 50), (0, 100, 50)], + "repeat_times": 0, + "spread": 1, + "direction": 4, + }, + id="sequence", + ), + pytest.param( + SERVICE_RANDOM_EFFECT, + {"init_states": [340, 20, 50]}, + {"type": "random", "init_states": [[340, 20, 50]], "random_seed": 100}, + id="random", + ), + ], +) +async def test_smart_strip_effect_service_error( + hass: HomeAssistant, + service_name: str, + service_params: dict, + expected_extra_params: dict, +) -> None: + """Test smart strip effect service errors.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + device = _mocked_device( + modules=[Module.Light, Module.LightEffect], alias="my_light" + ) + light_effect = device.modules[Module.LightEffect] + + with _patch_discovery(device=device), _patch_connect(device=device): + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.my_light" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + light_effect.set_custom_effect.side_effect = KasaException("failed") + + base = { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": 100, + "name": "Custom", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "duration": 0, + "transition": 0, + } + expected_params = {**base, **expected_extra_params} + expected_msg = f"Error trying to set custom effect {expected_params}: failed" + + with pytest.raises(HomeAssistantError, match=re.escape(expected_msg)): + await hass.services.async_call( + DOMAIN, + service_name, + { + ATTR_ENTITY_ID: entity_id, + **service_params, + }, + blocking=True, + ) + + async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> None: """Test smart strip custom random effects at startup.""" already_migrated_config_entry = MockConfigEntry( @@ -794,7 +1130,7 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> light_effect = device.modules[Module.LightEffect] light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -804,7 +1140,7 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> # fallback to set HSV when custom effect is not known so it does turn back on await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -824,7 +1160,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: light_effect = device.modules[Module.LightEffect] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -834,7 +1170,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "sequence_effect", + SERVICE_SEQUENCE_EFFECT, { ATTR_ENTITY_ID: entity_id, "sequence": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], @@ -899,7 +1235,7 @@ async def test_light_errors_when_turned_on( light.set_state.side_effect = exception_type(msg) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -910,7 +1246,7 @@ async def test_light_errors_when_turned_on( with pytest.raises(HomeAssistantError, match=msg): await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() assert light.set_state.call_count == 1 @@ -950,7 +1286,7 @@ async def test_light_child( ) with _patch_discovery(device=parent_device), _patch_connect(device=parent_device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_device" @@ -977,21 +1313,30 @@ async def test_scene_effect_light( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) + features = [ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ] device = _mocked_device( - modules=[Module.Light, Module.LightEffect], alias="my_light" + modules=[Module.Light, Module.LightEffect], alias="my_light", features=features ) light_effect = device.modules[Module.LightEffect] light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF with _patch_discovery(device=device), _patch_connect(device=device): - assert await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) - assert await async_setup_component(hass, "scene", {}) + assert await hass.config_entries.async_setup( + already_migrated_config_entry.entry_id + ) + assert await async_setup_component(hass, SCENE_DOMAIN, {}) await hass.async_block_till_done() entity_id = "light.my_light" await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() freezer.tick(5) @@ -1003,9 +1348,9 @@ async def test_scene_effect_light( assert state.attributes["effect"] is EFFECT_OFF await hass.services.async_call( - "scene", - "create", - {"scene_id": "effect_off_scene", "snapshot_entities": [entity_id]}, + SCENE_DOMAIN, + SERVICE_CREATE, + {CONF_SCENE_ID: "effect_off_scene", CONF_SNAPSHOT: [entity_id]}, blocking=True, ) await hass.async_block_till_done() @@ -1013,7 +1358,7 @@ async def test_scene_effect_light( assert scene_state.state is STATE_UNKNOWN await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() freezer.tick(5) @@ -1024,10 +1369,10 @@ async def test_scene_effect_light( assert state.state is STATE_OFF await hass.services.async_call( - "scene", - "turn_on", + SCENE_DOMAIN, + SERVICE_TURN_ON, { - "entity_id": "scene.effect_off_scene", + ATTR_ENTITY_ID: "scene.effect_off_scene", }, blocking=True, ) diff --git a/tests/components/tplink/test_number.py b/tests/components/tplink/test_number.py index 865ce27ffc0..07d64178dfa 100644 --- a/tests/components/tplink/test_number.py +++ b/tests/components/tplink/test_number.py @@ -3,7 +3,6 @@ from kasa import Feature from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -15,11 +14,8 @@ from homeassistant.components.tplink.number import NUMBER_DESCRIPTIONS from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -28,6 +24,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -39,7 +36,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a number states.""" features = {description.key for description in NUMBER_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -54,7 +51,7 @@ async def test_states( async def test_number(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: - """Test a sensor unique ids.""" + """Test number unique ids.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -70,7 +67,7 @@ async def test_number(hass: HomeAssistant, entity_registry: er.EntityRegistry) - ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" @@ -84,7 +81,7 @@ async def test_number_children( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: - """Test a sensor unique ids.""" + """Test number children.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -104,7 +101,7 @@ async def test_number_children( children=_mocked_strip_children(features=[new_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" @@ -142,7 +139,7 @@ async def test_number_set( ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" diff --git a/tests/components/tplink/test_select.py b/tests/components/tplink/test_select.py index 6c49185d91c..3b99412740a 100644 --- a/tests/components/tplink/test_select.py +++ b/tests/components/tplink/test_select.py @@ -4,7 +4,6 @@ from kasa import Feature import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -16,11 +15,8 @@ from homeassistant.components.tplink.select import SELECT_DESCRIPTIONS from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -29,13 +25,14 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @pytest.fixture def mocked_feature_select() -> Feature: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink select feature.""" return _mocked_feature( "light_preset", value="First choice", @@ -53,7 +50,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test select states.""" features = {description.key for description in SELECT_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -72,7 +69,7 @@ async def test_select( entity_registry: er.EntityRegistry, mocked_feature_select: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test select unique ids.""" mocked_feature = mocked_feature_select already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -81,7 +78,7 @@ async def test_select( plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -97,7 +94,7 @@ async def test_select_children( device_registry: dr.DeviceRegistry, mocked_feature_select: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test select children.""" mocked_feature = mocked_feature_select already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -109,7 +106,7 @@ async def test_select_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "select.my_plug_light_preset" @@ -141,7 +138,7 @@ async def test_select_select( already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "select.my_plug_light_preset" diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py index dda43c52430..857a2365527 100644 --- a/tests/components/tplink/test_sensor.py +++ b/tests/components/tplink/test_sensor.py @@ -4,18 +4,14 @@ from kasa import Device, Feature, Module import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.components.tplink.sensor import SENSOR_DESCRIPTIONS from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_energy_features, _mocked_feature, @@ -25,6 +21,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -36,7 +33,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a sensor states.""" features = {description.key for description in SENSOR_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -67,7 +64,7 @@ async def test_color_light_with_an_emeter(hass: HomeAssistant) -> None: alias="my_bulb", modules=[Module.Light], features=["state", *emeter_features] ) with _patch_discovery(device=bulb), _patch_connect(device=bulb): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -104,7 +101,7 @@ async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: ) plug = _mocked_device(alias="my_plug", features=["state", *emeter_features]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -129,9 +126,9 @@ async def test_color_light_no_emeter(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) bulb = _mocked_device(alias="my_bulb", modules=[Module.Light]) - bulb.has_emeter = False + with _patch_discovery(device=bulb), _patch_connect(device=bulb): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -167,7 +164,7 @@ async def test_sensor_unique_id( ) plug = _mocked_device(alias="my_plug", features=emeter_features) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() expected = { @@ -202,7 +199,7 @@ async def test_undefined_sensor( ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() msg = ( @@ -240,7 +237,7 @@ async def test_sensor_children_on_parent( device_type=Device.Type.WallSwitch, ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_this_month_s_consumption" @@ -288,7 +285,7 @@ async def test_sensor_children_on_child( device_type=Device.Type.Strip, ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_this_month_s_consumption" @@ -308,19 +305,18 @@ async def test_sensor_children_on_child( assert child_device.via_device_id == device.id -@pytest.mark.skip -async def test_new_datetime_sensor( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_datetime_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test a sensor unique ids.""" - # Skipped temporarily while datetime handling on hold. + """Test a timestamp sensor.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["on_since"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_on_since" diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index e9c8cc07b67..bdf54f10e8b 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -9,7 +9,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import tplink -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.components.tplink.switch import SWITCH_DESCRIPTIONS @@ -25,12 +29,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_strip_children, _patch_connect, @@ -38,6 +39,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry, async_fire_time_changed @@ -49,7 +51,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a switch states.""" features = {description.key for description in SWITCH_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -72,7 +74,7 @@ async def test_plug(hass: HomeAssistant) -> None: plug = _mocked_device(alias="my_plug", features=["state"]) feat = plug.features["state"] with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -80,13 +82,13 @@ async def test_plug(hass: HomeAssistant) -> None: assert state.state == STATE_ON await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -120,7 +122,7 @@ async def test_led_switch(hass: HomeAssistant, dev: Device, domain: str) -> None feat = dev.features["led"] already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_name = slugify(dev.alias) @@ -131,13 +133,13 @@ async def test_led_switch(hass: HomeAssistant, dev: Device, domain: str) -> None assert led_state.name == f"{dev.alias} LED" await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: led_entity_id}, blocking=True ) feat.set_value.assert_called_once_with(False) feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: led_entity_id}, blocking=True ) feat.set_value.assert_called_once_with(True) feat.set_value.reset_mock() @@ -153,7 +155,7 @@ async def test_plug_unique_id( already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["state", "led"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -168,7 +170,7 @@ async def test_plug_update_fails(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["state", "led"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -197,7 +199,7 @@ async def test_strip(hass: HomeAssistant) -> None: strip.children[0].features["state"].value = True strip.children[1].features["state"].value = False with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_strip_plug0" @@ -205,14 +207,14 @@ async def test_strip(hass: HomeAssistant) -> None: assert state.state == STATE_ON await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat = strip.children[0].features["state"] feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -222,14 +224,14 @@ async def test_strip(hass: HomeAssistant) -> None: assert state.state == STATE_OFF await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat = strip.children[1].features["state"] feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -249,7 +251,7 @@ async def test_strip_unique_ids( features=["state", "led"], ) with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() for plug_id in range(2): @@ -260,9 +262,11 @@ async def test_strip_unique_ids( async def test_strip_blank_alias( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: - """Test a strip unique id.""" + """Test a strip with blank parent alias.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -274,14 +278,30 @@ async def test_strip_blank_alias( features=["state", "led"], ) with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() + strip_entity_id = "switch.unnamed_ks123" + state = hass.states.get(strip_entity_id) + assert state.name == "Unnamed KS123" + reg_ent = entity_registry.async_get(strip_entity_id) + assert reg_ent + reg_dev = device_registry.async_get(reg_ent.device_id) + assert reg_dev + assert reg_dev.name == "Unnamed KS123" + for plug_id in range(2): entity_id = f"switch.unnamed_ks123_stripsocket_{plug_id + 1}" state = hass.states.get(entity_id) assert state.name == f"Unnamed KS123 Stripsocket {plug_id + 1}" + reg_ent = entity_registry.async_get(entity_id) + assert reg_ent + reg_dev = device_registry.async_get(reg_ent.device_id) + assert reg_dev + # Switch is a primary feature so entities go on the parent device. + assert reg_dev.name == "Unnamed KS123" + @pytest.mark.parametrize( ("exception_type", "msg", "reauth_expected"), @@ -320,7 +340,7 @@ async def test_plug_errors_when_turned_on( feat.set_value.side_effect = exception_type("test error") with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -331,7 +351,7 @@ async def test_plug_errors_when_turned_on( with pytest.raises(HomeAssistantError, match=msg): await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() assert feat.set_value.call_count == 1 diff --git a/tests/components/tractive/snapshots/test_sensor.ambr b/tests/components/tractive/snapshots/test_sensor.ambr index f1ed397450e..f10cfb29226 100644 --- a/tests/components/tractive/snapshots/test_sensor.ambr +++ b/tests/components/tractive/snapshots/test_sensor.ambr @@ -139,7 +139,7 @@ 'supported_features': 0, 'translation_key': 'calories', 'unique_id': 'pet_id_123_calories', - 'unit_of_measurement': 'kcal', + 'unit_of_measurement': , }) # --- # name: test_sensor[sensor.test_pet_calories_burned-state] @@ -147,7 +147,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Pet Calories burned', 'state_class': , - 'unit_of_measurement': 'kcal', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.test_pet_calories_burned', diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index b6f38b1d83d..6d6215a21ab 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -6,10 +6,13 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.tradfri import config_flow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import TRADFRI_PATH @@ -115,13 +118,13 @@ async def test_discovery_connection( flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -150,13 +153,13 @@ async def test_discovery_duplicate_aborted(hass: HomeAssistant) -> None: flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.124"), ip_addresses=[ip_address("123.123.123.124")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -174,13 +177,13 @@ async def test_duplicate_discovery( result = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -190,13 +193,13 @@ async def test_duplicate_discovery( result2 = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -215,13 +218,13 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) diff --git a/tests/components/tradfri/test_light.py b/tests/components/tradfri/test_light.py index 887b043689f..c7091e77343 100644 --- a/tests/components/tradfri/test_light.py +++ b/tests/components/tradfri/test_light.py @@ -9,10 +9,10 @@ from pytradfri.device import Device from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, ColorMode, @@ -67,9 +67,9 @@ def bulb_cws() -> str: "light.test_ws", { ATTR_BRIGHTNESS: 250, - ATTR_COLOR_TEMP: 400, - ATTR_MIN_MIREDS: 250, - ATTR_MAX_MIREDS: 454, + ATTR_COLOR_TEMP_KELVIN: 2500, + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 2202, ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_COLOR_MODE: ColorMode.COLOR_TEMP, }, diff --git a/tests/components/trafikverket_train/conftest.py b/tests/components/trafikverket_train/conftest.py index 234269cc9f8..9d7a3873957 100644 --- a/tests/components/trafikverket_train/conftest.py +++ b/tests/components/trafikverket_train/conftest.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from unittest.mock import patch import pytest -from pytrafikverket.models import TrainStopModel +from pytrafikverket import StationInfoModel, TrainStopModel from homeassistant.components.trafikverket_train.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -40,6 +40,9 @@ async def load_integration_from_entry( patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), ): await hass.config_entries.async_setup(config_entry_id) await hass.async_block_till_done() @@ -50,8 +53,8 @@ async def load_integration_from_entry( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) config_entry.add_to_hass(hass) await setup_config_entry_with_mocked_data(config_entry.entry_id) @@ -61,8 +64,8 @@ async def load_integration_from_entry( source=SOURCE_USER, data=ENTRY_CONFIG2, entry_id="2", - version=1, - minor_version=2, + version=2, + minor_version=1, ) config_entry2.add_to_hass(hass) await setup_config_entry_with_mocked_data(config_entry2.entry_id) @@ -171,3 +174,57 @@ def fixture_get_train_stop() -> TrainStopModel: modified_time=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), product_description=["Regionaltåg"], ) + + +@pytest.fixture(name="get_train_stations") +def fixture_get_train_station() -> list[list[StationInfoModel]]: + """Construct StationInfoModel Mock.""" + + return [ + [ + StationInfoModel( + signature="Cst", + station_name="Stockholm C", + advertised=True, + ) + ], + [ + StationInfoModel( + signature="U", + station_name="Uppsala C", + advertised=True, + ) + ], + ] + + +@pytest.fixture(name="get_multiple_train_stations") +def fixture_get_multiple_train_station() -> list[list[StationInfoModel]]: + """Construct StationInfoModel Mock.""" + + return [ + [ + StationInfoModel( + signature="Cst", + station_name="Stockholm C", + advertised=True, + ), + StationInfoModel( + signature="Csu", + station_name="Stockholm City", + advertised=True, + ), + ], + [ + StationInfoModel( + signature="U", + station_name="Uppsala C", + advertised=True, + ), + StationInfoModel( + signature="Ups", + station_name="Uppsala City", + advertised=True, + ), + ], + ] diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index eac5e629bf0..241831b5553 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -5,14 +5,13 @@ from __future__ import annotations from unittest.mock import patch import pytest -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, - MultipleTrainStationsFound, - NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrainStopModel, UnknownError, ) -from pytrafikverket.models import TrainStopModel from homeassistant import config_entries from homeassistant.components.trafikverket_train.const import ( @@ -26,24 +25,27 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import ENTRY_CONFIG, OPTIONS_CONFIG + from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" assert result["errors"] == {} with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -67,8 +69,8 @@ async def test_form(hass: HomeAssistant) -> None: assert result["data"] == { "api_key": "1234567890", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "fri"], } @@ -76,7 +78,70 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_entry_already_exist(hass: HomeAssistant) -> None: +async def test_form_multiple_stations( + hass: HomeAssistant, get_multiple_train_stations: list[StationInfoModel] +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FROM: "Csu", + CONF_TO: "Ups", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stockholm C to Uppsala C at 10:00" + assert result["data"] == { + "api_key": "1234567890", + "name": "Stockholm C to Uppsala C at 10:00", + "from": "Csu", + "to": "Ups", + "time": "10:00", + "weekday": ["mon", "fri"], + } + assert result["options"] == {"filter_product": None} + + +async def test_form_entry_already_exist( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test flow aborts when entry already exist.""" entry = MockConfigEntry( @@ -84,14 +149,14 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, CONF_FILTER_PRODUCT: None, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -103,10 +168,11 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", ), patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -130,28 +196,24 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("side_effect", "base_error"), + ("side_effect", "p_error"), [ ( InvalidAuthentication, - "invalid_auth", + {"base": "invalid_auth"}, ), ( NoTrainStationFound, - "invalid_station", - ), - ( - MultipleTrainStationsFound, - "more_stations", + {"from": "invalid_station", "to": "invalid_station"}, ), ( Exception, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_flow_fails( - hass: HomeAssistant, side_effect: Exception, base_error: str + hass: HomeAssistant, side_effect: Exception, p_error: dict[str, str] ) -> None: """Test config flow errors.""" result = await hass.config_entries.flow.async_init( @@ -159,16 +221,13 @@ async def test_flow_fails( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER + assert result["step_id"] == "initial" with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -179,24 +238,24 @@ async def test_flow_fails( }, ) - assert result["errors"] == {"base": base_error} + assert result["errors"] == p_error @pytest.mark.parametrize( - ("side_effect", "base_error"), + ("side_effect", "p_error"), [ ( - NoTrainAnnouncementFound, - "no_trains", + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, ), ( UnknownError, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_flow_fails_departures( - hass: HomeAssistant, side_effect: Exception, base_error: str + hass: HomeAssistant, side_effect: Exception, p_error: dict[str, str] ) -> None: """Test config flow errors.""" result = await hass.config_entries.flow.async_init( @@ -204,19 +263,13 @@ async def test_flow_fails_departures( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER + assert result["step_id"] == "initial" with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stops", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -227,23 +280,25 @@ async def test_flow_fails_departures( }, ) - assert result["errors"] == {"base": base_error} + assert result["errors"] == p_error -async def test_reauth_flow(hass: HomeAssistant) -> None: +async def test_reauth_flow( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -254,10 +309,8 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -275,8 +328,8 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -287,24 +340,27 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: [ ( InvalidAuthentication, - "invalid_auth", + {"base": "invalid_auth"}, ), ( NoTrainStationFound, - "invalid_station", + {"from": "invalid_station"}, ), ( - MultipleTrainStationsFound, - "more_stations", + UnknownError, + {"base": "cannot_connect"}, ), ( Exception, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_reauth_flow_error( - hass: HomeAssistant, side_effect: Exception, p_error: str + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -312,13 +368,13 @@ async def test_reauth_flow_error( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -326,12 +382,9 @@ async def test_reauth_flow_error( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -341,14 +394,12 @@ async def test_reauth_flow_error( assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": p_error} + assert result["errors"] == p_error with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -366,8 +417,8 @@ async def test_reauth_flow_error( assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -377,17 +428,20 @@ async def test_reauth_flow_error( ("side_effect", "p_error"), [ ( - NoTrainAnnouncementFound, - "no_trains", + NoTrainStationFound, + {"from": "invalid_station"}, ), ( UnknownError, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_reauth_flow_error_departures( - hass: HomeAssistant, side_effect: Exception, p_error: str + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -395,13 +449,13 @@ async def test_reauth_flow_error_departures( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -409,10 +463,7 @@ async def test_reauth_flow_error_departures( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), ): @@ -424,11 +475,12 @@ async def test_reauth_flow_error_departures( assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": p_error} + assert result["errors"] == p_error with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -449,8 +501,8 @@ async def test_reauth_flow_error_departures( assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -460,6 +512,7 @@ async def test_options_flow( hass: HomeAssistant, get_trains: list[TrainStopModel], get_train_stop: TrainStopModel, + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( @@ -467,24 +520,28 @@ async def test_options_flow( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", return_value=get_trains, ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", return_value=get_train_stop, @@ -520,3 +577,328 @@ async def test_options_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {"filter_product": None} + + +async def test_reconfigure_flow( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: + """Test reconfigure flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_multiple_stations( + hass: HomeAssistant, get_multiple_train_stations: list[StationInfoModel] +) -> None: + """Test we can reconfigure with multiple stations.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FROM: "Csu", + CONF_TO: "Ups", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_entry_already_exist( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: + """Test flow aborts when entry already exist in a reconfigure flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_NAME: "Stockholm C to Uppsala C at 10:00", + CONF_FROM: "Cst", + CONF_TO: "U", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + CONF_FILTER_PRODUCT: None, + }, + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "p_error"), + [ + ( + InvalidAuthentication, + {"base": "invalid_auth"}, + ), + ( + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, + ), + ( + Exception, + {"base": "cannot_connect"}, + ), + ], +) +async def test_reconfigure_flow_fails( + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], +) -> None: + """Test config flow errors.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + + assert result["errors"] == p_error + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("side_effect", "p_error"), + [ + ( + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, + ), + ( + UnknownError, + {"base": "cannot_connect"}, + ), + ], +) +async def test_reconfigure_flow_fails_departures( + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], +) -> None: + """Test config flow errors.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + + assert result["errors"] == p_error + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/trafikverket_train/test_init.py b/tests/components/trafikverket_train/test_init.py index 41c8e2432ef..cb048365700 100644 --- a/tests/components/trafikverket_train/test_init.py +++ b/tests/components/trafikverket_train/test_init.py @@ -4,8 +4,14 @@ from __future__ import annotations from unittest.mock import patch -from pytrafikverket.exceptions import InvalidAuthentication, NoTrainStationFound -from pytrafikverket.models import TrainStopModel +import pytest +from pytrafikverket import ( + InvalidAuthentication, + NoTrainStationFound, + StationInfoModel, + TrainStopModel, + UnknownError, +) from syrupy.assertion import SnapshotAssertion from homeassistant.components.trafikverket_train.const import DOMAIN @@ -28,14 +34,14 @@ async def test_unload_entry( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -65,13 +71,13 @@ async def test_auth_failed( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", side_effect=InvalidAuthentication, ): await hass.config_entries.async_setup(entry.entry_id) @@ -96,13 +102,13 @@ async def test_no_stations( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", side_effect=NoTrainStationFound, ): await hass.config_entries.async_setup(entry.entry_id) @@ -124,8 +130,8 @@ async def test_migrate_entity_unique_id( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -139,7 +145,7 @@ async def test_migrate_entity_unique_id( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -158,8 +164,9 @@ async def test_migrate_entity_unique_id( async def test_migrate_entry( hass: HomeAssistant, get_trains: list[TrainStopModel], + get_train_stations: list[StationInfoModel], ) -> None: - """Test migrate entry unique id.""" + """Test migrate entry.""" entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, @@ -174,7 +181,11 @@ async def test_migrate_entry( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -186,8 +197,18 @@ async def test_migrate_entry( assert entry.state is ConfigEntryState.LOADED - assert entry.version == 1 - assert entry.minor_version == 2 + assert entry.version == 2 + assert entry.minor_version == 1 + # Migration to version 2.1 changed from/to to use station signatures + assert entry.data == { + "api_key": "1234567890", + "from": "Cst", + "to": "U", + "time": None, + "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "name": "Stockholm C to Uppsala C", + } + # Migration to version 1.2 removed unique_id assert entry.unique_id is None @@ -201,18 +222,73 @@ async def test_migrate_entry_from_future_version_fails( source=SOURCE_USER, data=ENTRY_CONFIG, options=OPTIONS_CONFIG, - version=2, + version=3, + minor_version=1, + entry_id="1", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +@pytest.mark.parametrize( + ("side_effect"), + [ + (InvalidAuthentication), + (NoTrainStationFound), + (UnknownError), + (Exception), + ], +) +async def test_migrate_entry_fails(hass: HomeAssistant, side_effect: Exception) -> None: + """Test migrate entry fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + version=1, + minor_version=1, entry_id="1", ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", - return_value=get_trains, + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_migrate_entry_fails_multiple_stations( + hass: HomeAssistant, + get_multiple_train_stations: list[StationInfoModel], +) -> None: + """Test migrate entry fails on multiple stations found.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + version=1, + minor_version=1, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, ), ): await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/trafikverket_weatherstation/__init__.py b/tests/components/trafikverket_weatherstation/__init__.py index 836ee919195..4639211ed31 100644 --- a/tests/components/trafikverket_weatherstation/__init__.py +++ b/tests/components/trafikverket_weatherstation/__init__.py @@ -1 +1,9 @@ """Tests for the Trafikverket weatherstation integration.""" + +from homeassistant.components.trafikverket_weatherstation.const import CONF_STATION +from homeassistant.const import CONF_API_KEY + +ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_STATION: "Arlanda", +} diff --git a/tests/components/trafikverket_weatherstation/conftest.py b/tests/components/trafikverket_weatherstation/conftest.py new file mode 100644 index 00000000000..bd31c31c86b --- /dev/null +++ b/tests/components/trafikverket_weatherstation/conftest.py @@ -0,0 +1,80 @@ +"""Fixtures for the Trafikverket Weatherstation integration.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from datetime import datetime +from unittest.mock import patch + +import pytest +from pytrafikverket import WeatherStationInfoModel + +from homeassistant.components.trafikverket_weatherstation.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.fixture +async def load_int( + hass: HomeAssistant, mock_response: WeatherStationInfoModel +) -> MockConfigEntry: + """Set up the Trafikverket Weatherstation integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="mock_response") +async def mock_weather_response( + get_data: WeatherStationInfoModel, +) -> AsyncGenerator[None]: + """Mock a successful response.""" + with patch( + "homeassistant.components.trafikverket_weatherstation.coordinator.TrafikverketWeather.async_get_weather", + return_value=get_data, + ): + yield + + +@pytest.fixture(name="get_data") +async def get_data_from_library(hass: HomeAssistant) -> WeatherStationInfoModel: + """Retrieve data from Trafikverket Weatherstation library.""" + return WeatherStationInfoModel( + station_name="Arlanda", + station_id="227", + road_temp=-3.4, + air_temp=-3.0, + dew_point=-5.0, + humidity=84.1, + visible_distance=20000.0, + precipitationtype="no", + raining=False, + snowing=False, + road_ice=None, + road_ice_depth=None, + road_snow=None, + road_snow_depth=None, + road_water=None, + road_water_depth=None, + road_water_equivalent_depth=None, + winddirection="202", + wind_height=6.0, + windforce=1.2, + windforcemax=2.3, + measure_time=datetime.fromisoformat("2024-12-30T23:00:03+01:00"), + precipitation_amount=0.0, + modified_time=datetime.fromisoformat("2024-12-30T22:03:45.143000+00:00"), + ) diff --git a/tests/components/trafikverket_weatherstation/snapshots/test_diagnostics.ambr b/tests/components/trafikverket_weatherstation/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..95caa014d45 --- /dev/null +++ b/tests/components/trafikverket_weatherstation/snapshots/test_diagnostics.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'air_temp': -3.0, + 'dew_point': -5.0, + 'humidity': 84.1, + 'measure_time': '2024-12-30T23:00:03+01:00', + 'modified_time': '2024-12-30T22:03:45.143000+00:00', + 'precipitation_amount': 0.0, + 'precipitationtype': 'no', + 'raining': False, + 'road_ice': None, + 'road_ice_depth': None, + 'road_snow': None, + 'road_snow_depth': None, + 'road_temp': -3.4, + 'road_water': None, + 'road_water_depth': None, + 'road_water_equivalent_depth': None, + 'snowing': False, + 'station_id': '227', + 'station_name': 'Arlanda', + 'visible_distance': 20000.0, + 'wind_height': 6.0, + 'winddirection': '202', + 'windforce': 1.2, + 'windforcemax': 2.3, + }) +# --- diff --git a/tests/components/trafikverket_weatherstation/test_diagnostics.py b/tests/components/trafikverket_weatherstation/test_diagnostics.py new file mode 100644 index 00000000000..29b7b906dd0 --- /dev/null +++ b/tests/components/trafikverket_weatherstation/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Test Trafikverket Weatherstation diagnostics.""" + +from __future__ import annotations + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, load_int) == snapshot + ) diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index ad85f65a9fc..4a829bb86d2 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -9,7 +9,7 @@ import pytest from homeassistant import setup from homeassistant.components.trend.const import DOMAIN -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -395,3 +395,45 @@ async def test_device_id( trend_entity = entity_registry.async_get("binary_sensor.trend") assert trend_entity is not None assert trend_entity.device_id == source_entity.device_id + + +@pytest.mark.parametrize( + "error_state", + [ + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ], +) +async def test_unavailable_source( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + setup_component: ComponentSetup, + error_state: str, +) -> None: + """Test for unavailable source.""" + await setup_component( + { + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + }, + ) + + for val in (10, 20, 30, 40, 50, 60): + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" + + hass.states.async_set("sensor.test_state", error_state) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.test_state", 50) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 80dff87eb9b..2159d92ae4b 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -22,7 +22,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -45,6 +44,7 @@ from tests.common import ( mock_integration, mock_platform, mock_restore_cache, + reset_translation_cache, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -63,7 +63,6 @@ async def test_default_entity_attributes() -> None: entity = DefaultEntity() assert entity.hass is None - assert entity.name is UNDEFINED assert entity.default_language == DEFAULT_LANG assert entity.supported_languages == SUPPORT_LANGUAGES assert entity.supported_options is None @@ -1988,3 +1987,6 @@ async def test_default_engine_prefer_cloud_entity( provider_engine = tts.async_resolve_engine(hass, "test") assert provider_engine == "test" assert tts.async_default_engine(hass) == "tts.cloud_tts_entity" + + # Reset the `cloud` translations cache to avoid flaky translation checks + reset_translation_cache(hass, ["cloud"]) diff --git a/tests/components/twentemilieu/conftest.py b/tests/components/twentemilieu/conftest.py index 7ecf1657ce9..e3e3c97034c 100644 --- a/tests/components/twentemilieu/conftest.py +++ b/tests/components/twentemilieu/conftest.py @@ -51,7 +51,8 @@ def mock_twentemilieu() -> Generator[MagicMock]: """Return a mocked Twente Milieu client.""" with ( patch( - "homeassistant.components.twentemilieu.TwenteMilieu", autospec=True + "homeassistant.components.twentemilieu.coordinator.TwenteMilieu", + autospec=True, ) as twentemilieu_mock, patch( "homeassistant.components.twentemilieu.config_flow.TwenteMilieu", diff --git a/tests/components/twentemilieu/snapshots/test_config_flow.ambr b/tests/components/twentemilieu/snapshots/test_config_flow.ambr deleted file mode 100644 index a98119e81c9..00000000000 --- a/tests/components/twentemilieu/snapshots/test_config_flow.ambr +++ /dev/null @@ -1,93 +0,0 @@ -# serializer version: 1 -# name: test_full_user_flow - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': '12345', - }), - 'data': dict({ - 'house_letter': 'A', - 'house_number': '1', - 'id': 12345, - 'post_code': '1234AB', - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'twentemilieu', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'house_letter': 'A', - 'house_number': '1', - 'id': 12345, - 'post_code': '1234AB', - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'twentemilieu', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': '12345', - 'unique_id': '12345', - 'version': 1, - }), - 'title': '12345', - 'type': , - 'version': 1, - }) -# --- -# name: test_invalid_address - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': '12345', - }), - 'data': dict({ - 'house_letter': None, - 'house_number': '1', - 'id': 12345, - 'post_code': '1234AB', - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'twentemilieu', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - 'house_letter': None, - 'house_number': '1', - 'id': 12345, - 'post_code': '1234AB', - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'twentemilieu', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': '12345', - 'unique_id': '12345', - 'version': 1, - }), - 'title': '12345', - 'type': , - 'version': 1, - }) -# --- diff --git a/tests/components/twentemilieu/test_config_flow.py b/tests/components/twentemilieu/test_config_flow.py index dbc01c69acb..6dc261b8769 100644 --- a/tests/components/twentemilieu/test_config_flow.py +++ b/tests/components/twentemilieu/test_config_flow.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock import pytest -from syrupy.assertion import SnapshotAssertion from twentemilieu import TwenteMilieuAddressError, TwenteMilieuConnectionError from homeassistant import config_entries @@ -15,6 +14,7 @@ from homeassistant.components.twentemilieu.const import ( DOMAIN, ) from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -24,16 +24,16 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.usefixtures("mock_twentemilieu") -async def test_full_user_flow(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: +async def test_full_user_flow(hass: HomeAssistant) -> None: """Test registering an integration and finishing flow works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_POST_CODE: "1234AB", @@ -42,14 +42,22 @@ async def test_full_user_flow(hass: HomeAssistant, snapshot: SnapshotAssertion) }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "12345" + assert config_entry.data == { + CONF_HOUSE_LETTER: "A", + CONF_HOUSE_NUMBER: "1", + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + } + assert not config_entry.options async def test_invalid_address( hass: HomeAssistant, mock_twentemilieu: MagicMock, - snapshot: SnapshotAssertion, ) -> None: """Test full user flow when the user enters an incorrect address. @@ -60,11 +68,11 @@ async def test_invalid_address( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" mock_twentemilieu.unique_id.side_effect = TwenteMilieuAddressError - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_POST_CODE: "1234", @@ -72,12 +80,12 @@ async def test_invalid_address( }, ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "user" - assert result2.get("errors") == {"base": "invalid_address"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_address"} mock_twentemilieu.unique_id.side_effect = None - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_POST_CODE: "1234AB", @@ -85,8 +93,17 @@ async def test_invalid_address( }, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3 == snapshot + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "12345" + assert config_entry.data == { + CONF_HOUSE_LETTER: None, + CONF_HOUSE_NUMBER: "1", + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + } + assert not config_entry.options async def test_connection_error( @@ -106,9 +123,33 @@ async def test_connection_error( }, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # Recover from error + mock_twentemilieu.unique_id.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_POST_CODE: "1234AB", + CONF_HOUSE_NUMBER: "1", + CONF_HOUSE_LETTER: "A", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.unique_id == "12345" + assert config_entry.data == { + CONF_HOUSE_LETTER: "A", + CONF_HOUSE_NUMBER: "1", + CONF_ID: 12345, + CONF_POST_CODE: "1234AB", + } + assert not config_entry.options @pytest.mark.usefixtures("mock_twentemilieu") @@ -128,5 +169,5 @@ async def test_address_already_set_up( }, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/twentemilieu/test_init.py b/tests/components/twentemilieu/test_init.py index 7e08b5f4938..5cc09e6875d 100644 --- a/tests/components/twentemilieu/test_init.py +++ b/tests/components/twentemilieu/test_init.py @@ -29,7 +29,7 @@ async def test_load_unload_config_entry( @patch( - "homeassistant.components.twentemilieu.TwenteMilieu.update", + "homeassistant.components.twentemilieu.coordinator.TwenteMilieu.update", side_effect=RuntimeError, ) async def test_config_entry_not_ready( diff --git a/tests/components/twinkly/__init__.py b/tests/components/twinkly/__init__.py index f322004962a..7b0ca20fbe1 100644 --- a/tests/components/twinkly/__init__.py +++ b/tests/components/twinkly/__init__.py @@ -1,118 +1,13 @@ """Constants and mock for the twinkly component tests.""" -from aiohttp.client_exceptions import ClientConnectionError +from homeassistant.core import HomeAssistant -from homeassistant.components.twinkly.const import DEV_NAME - -TEST_HOST = "test.twinkly.com" -TEST_ID = "twinkly_test_device_id" -TEST_UID = "4c8fccf5-e08a-4173-92d5-49bf479252a2" -TEST_NAME = "twinkly_test_device_name" -TEST_NAME_ORIGINAL = "twinkly_test_original_device_name" # the original (deprecated) name stored in the conf -TEST_MODEL = "twinkly_test_device_model" +from tests.common import MockConfigEntry -class ClientMock: - """A mock of the ttls.client.Twinkly.""" +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) - def __init__(self) -> None: - """Create a mocked client.""" - self.is_offline = False - self.state = True - self.brightness = {"mode": "enabled", "value": 10} - self.color = None - self.movies = [{"id": 1, "name": "Rainbow"}, {"id": 2, "name": "Flare"}] - self.current_movie = {} - self.default_mode = "movie" - self.mode = None - self.version = "2.8.10" - - self.id = TEST_UID - self.device_info = { - "uuid": self.id, - "device_name": TEST_NAME, - "product_code": TEST_MODEL, - } - - @property - def host(self) -> str: - """Get the mocked host.""" - return TEST_HOST - - async def get_details(self): - """Get the mocked device info.""" - if self.is_offline: - raise ClientConnectionError - return self.device_info - - async def is_on(self) -> bool: - """Get the mocked on/off state.""" - if self.is_offline: - raise ClientConnectionError - return self.state - - async def turn_on(self) -> None: - """Set the mocked on state.""" - if self.is_offline: - raise ClientConnectionError - self.state = True - self.mode = self.default_mode - - async def turn_off(self) -> None: - """Set the mocked off state.""" - if self.is_offline: - raise ClientConnectionError - self.state = False - - async def get_brightness(self) -> int: - """Get the mocked brightness.""" - if self.is_offline: - raise ClientConnectionError - return self.brightness - - async def set_brightness(self, brightness: int) -> None: - """Set the mocked brightness.""" - if self.is_offline: - raise ClientConnectionError - self.brightness = {"mode": "enabled", "value": brightness} - - def change_name(self, new_name: str) -> None: - """Change the name of this virtual device.""" - self.device_info[DEV_NAME] = new_name - - async def set_static_colour(self, colour) -> None: - """Set static color.""" - self.color = colour - self.default_mode = "color" - - async def set_cycle_colours(self, colour) -> None: - """Set static color.""" - self.color = colour - self.default_mode = "movie" - - async def interview(self) -> None: - """Interview.""" - - async def get_saved_movies(self) -> dict: - """Get saved movies.""" - return self.movies - - async def get_current_movie(self) -> dict: - """Get current movie.""" - return self.current_movie - - async def set_current_movie(self, movie_id: int) -> dict: - """Set current movie.""" - self.current_movie = {"id": movie_id} - - async def set_mode(self, mode: str) -> None: - """Set mode.""" - if mode == "off": - await self.turn_off() - else: - await self.turn_on() - self.mode = mode - - async def get_firmware_version(self) -> dict: - """Get firmware version.""" - return {"version": self.version} + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/twinkly/conftest.py b/tests/components/twinkly/conftest.py index 19361af2003..33b8f82a488 100644 --- a/tests/components/twinkly/conftest.py +++ b/tests/components/twinkly/conftest.py @@ -1,55 +1,71 @@ """Configure tests for the Twinkly integration.""" -from collections.abc import Awaitable, Callable, Coroutine -from typing import Any -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch import pytest -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.components.twinkly import DOMAIN +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME -from . import TEST_MODEL, TEST_NAME, TEST_UID, ClientMock +from .const import TEST_MAC, TEST_MODEL, TEST_NAME -from tests.common import MockConfigEntry - -type ComponentSetup = Callable[[], Awaitable[ClientMock]] - -DOMAIN = "twinkly" -TITLE = "Twinkly" +from tests.common import MockConfigEntry, load_json_object_fixture -@pytest.fixture(name="config_entry") +@pytest.fixture def mock_config_entry() -> MockConfigEntry: """Create Twinkly entry in Home Assistant.""" - client = ClientMock() return MockConfigEntry( domain=DOMAIN, - title=TITLE, - unique_id=TEST_UID, - entry_id=TEST_UID, + title="Twinkly", + unique_id=TEST_MAC, data={ - "host": client.host, - "id": client.id, - "name": TEST_NAME, - "model": TEST_MODEL, - "device_name": TEST_NAME, + CONF_HOST: "192.168.0.123", + CONF_ID: "497dcba3-ecbf-4587-a2dd-5eb0665e6880", + CONF_NAME: TEST_NAME, + CONF_MODEL: TEST_MODEL, }, + entry_id="01JFMME2P6RA38V5AMPCJ2JYYV", + minor_version=2, ) -@pytest.fixture(name="setup_integration") -async def mock_setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> Callable[[], Coroutine[Any, Any, ClientMock]]: - """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) +@pytest.fixture +def mock_twinkly_client() -> Generator[AsyncMock]: + """Mock the Twinkly client.""" + with ( + patch( + "homeassistant.components.twinkly.Twinkly", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.twinkly.config_flow.Twinkly", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_details.return_value = load_json_object_fixture( + "get_details.json", DOMAIN + ) + client.get_firmware_version.return_value = load_json_object_fixture( + "get_firmware_version.json", DOMAIN + ) + client.get_saved_movies.return_value = load_json_object_fixture( + "get_saved_movies.json", DOMAIN + ) + client.get_current_movie.return_value = load_json_object_fixture( + "get_current_movie.json", DOMAIN + ) + client.get_mode.return_value = load_json_object_fixture("get_mode.json", DOMAIN) + client.is_on.return_value = True + client.get_brightness.return_value = {"mode": "enabled", "value": 10} + client.host = "192.168.0.123" + yield client - async def func() -> ClientMock: - mock = ClientMock() - with patch("homeassistant.components.twinkly.Twinkly", return_value=mock): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - return mock - return func +@pytest.fixture +def mock_setup_entry() -> Generator[None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.twinkly.async_setup_entry", return_value=True): + yield diff --git a/tests/components/twinkly/const.py b/tests/components/twinkly/const.py new file mode 100644 index 00000000000..c2530f1a19d --- /dev/null +++ b/tests/components/twinkly/const.py @@ -0,0 +1,5 @@ +"""Constants for the Twinkly tests.""" + +TEST_MAC = "00:2d:13:3b:aa:bb" +TEST_NAME = "Tree 1" +TEST_MODEL = "TW2016" diff --git a/tests/components/twinkly/fixtures/get_current_movie.json b/tests/components/twinkly/fixtures/get_current_movie.json new file mode 100644 index 00000000000..2572ae5fe7c --- /dev/null +++ b/tests/components/twinkly/fixtures/get_current_movie.json @@ -0,0 +1,3 @@ +{ + "id": 1 +} diff --git a/tests/components/twinkly/fixtures/get_details.json b/tests/components/twinkly/fixtures/get_details.json new file mode 100644 index 00000000000..1519520b0b9 --- /dev/null +++ b/tests/components/twinkly/fixtures/get_details.json @@ -0,0 +1,23 @@ +{ + "product_name": "Twinkly", + "product_version": "1", + "hardware_version": "1", + "flash_size": 4, + "led_type": 1, + "led_version": "1", + "product_code": "TW2016", + "device_name": "Tree 1", + "uptime": "4087441", + "rssi": -78, + "hw_id": "002d133b", + "mac": "00:2d:13:3b:aa:bb", + "uuid": "00000000-0000-0000-0000-000000000000", + "max_supported_led": 100, + "base_leds_number": 100, + "number_of_led": 100, + "led_profile": "RGB", + "frame_rate": 14, + "movie_capacity": 708, + "copyright": "LEDWORKS 2017", + "code": 1000 +} diff --git a/tests/components/twinkly/fixtures/get_firmware_version.json b/tests/components/twinkly/fixtures/get_firmware_version.json new file mode 100644 index 00000000000..4f3df8b9ed1 --- /dev/null +++ b/tests/components/twinkly/fixtures/get_firmware_version.json @@ -0,0 +1 @@ +{ "version": "2.7.2" } diff --git a/tests/components/twinkly/fixtures/get_mode.json b/tests/components/twinkly/fixtures/get_mode.json new file mode 100644 index 00000000000..38aed254a62 --- /dev/null +++ b/tests/components/twinkly/fixtures/get_mode.json @@ -0,0 +1,3 @@ +{ + "mode": "color" +} diff --git a/tests/components/twinkly/fixtures/get_saved_movies.json b/tests/components/twinkly/fixtures/get_saved_movies.json new file mode 100644 index 00000000000..0fa7696d3df --- /dev/null +++ b/tests/components/twinkly/fixtures/get_saved_movies.json @@ -0,0 +1,12 @@ +{ + "movies": [ + { + "id": 1, + "name": "Rainbow" + }, + { + "id": 2, + "name": "Flare" + } + ] +} diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index 28ec98cf572..814dc7dfc1f 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -3,44 +3,76 @@ dict({ 'attributes': dict({ 'brightness': 26, - 'color_mode': 'brightness', - 'effect': None, + 'color_mode': 'rgb', + 'effect': '1 Rainbow', 'effect_list': list([ + '1 Rainbow', + '2 Flare', + ]), + 'friendly_name': 'Tree 1', + 'hs_color': list([ + 0.0, + 0.0, + ]), + 'rgb_color': list([ + 255, + 255, + 255, ]), - 'friendly_name': 'twinkly_test_device_name', 'supported_color_modes': list([ - 'brightness', + 'rgb', ]), 'supported_features': 4, + 'xy_color': list([ + 0.323, + 0.329, + ]), }), 'device_info': dict({ - 'device_name': 'twinkly_test_device_name', - 'product_code': 'twinkly_test_device_model', - 'uuid': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'base_leds_number': 100, + 'code': 1000, + 'copyright': 'LEDWORKS 2017', + 'device_name': 'Tree 1', + 'flash_size': 4, + 'frame_rate': 14, + 'hardware_version': '1', + 'hw_id': '002d133b', + 'led_profile': 'RGB', + 'led_type': 1, + 'led_version': '1', + 'mac': '**REDACTED**', + 'max_supported_led': 100, + 'movie_capacity': 708, + 'number_of_led': 100, + 'product_code': 'TW2016', + 'product_name': 'Twinkly', + 'product_version': '1', + 'rssi': -78, + 'uptime': '4087441', + 'uuid': '00000000-0000-0000-0000-000000000000', }), 'entry': dict({ 'data': dict({ - 'device_name': 'twinkly_test_device_name', 'host': '**REDACTED**', - 'id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', - 'model': 'twinkly_test_device_model', - 'name': 'twinkly_test_device_name', + 'id': '497dcba3-ecbf-4587-a2dd-5eb0665e6880', + 'model': 'TW2016', + 'name': 'Tree 1', }), 'disabled_by': None, 'discovery_keys': dict({ }), 'domain': 'twinkly', - 'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', - 'minor_version': 1, + 'entry_id': '01JFMME2P6RA38V5AMPCJ2JYYV', + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', 'title': 'Twinkly', - 'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'unique_id': '00:2d:13:3b:aa:bb', 'version': 1, }), - 'sw_version': '2.8.10', + 'sw_version': '2.7.2', }) # --- diff --git a/tests/components/twinkly/snapshots/test_light.ambr b/tests/components/twinkly/snapshots/test_light.ambr new file mode 100644 index 00000000000..a97c3f941ff --- /dev/null +++ b/tests/components/twinkly/snapshots/test_light.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_entities[light.tree_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + '1 Rainbow', + '2 Flare', + ]), + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.tree_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'twinkly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'light', + 'unique_id': '00:2d:13:3b:aa:bb', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.tree_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 26, + 'color_mode': , + 'effect': '1 Rainbow', + 'effect_list': list([ + '1 Rainbow', + '2 Flare', + ]), + 'friendly_name': 'Tree 1', + 'hs_color': tuple( + 0.0, + 0.0, + ), + 'rgb_color': tuple( + 255, + 255, + 255, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.323, + 0.329, + ), + }), + 'context': , + 'entity_id': 'light.tree_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/twinkly/snapshots/test_select.ambr b/tests/components/twinkly/snapshots/test_select.ambr new file mode 100644 index 00000000000..21e09d6b022 --- /dev/null +++ b/tests/components/twinkly/snapshots/test_select.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_select_entities[select.tree_1_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'color', + 'demo', + 'effect', + 'movie', + 'off', + 'playlist', + 'rt', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.tree_1_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'twinkly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:2d:13:3b:aa:bb_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entities[select.tree_1_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tree 1 Mode', + 'options': list([ + 'color', + 'demo', + 'effect', + 'movie', + 'off', + 'playlist', + 'rt', + ]), + }), + 'context': , + 'entity_id': 'select.tree_1_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'color', + }) +# --- diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 9b9aeafd082..352c5249b0b 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -1,159 +1,170 @@ """Tests for the config_flow of the twinly component.""" -from unittest.mock import patch +from unittest.mock import AsyncMock -from homeassistant import config_entries -from homeassistant.components import dhcp -from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN +import pytest + +from homeassistant.components.twinkly.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from . import TEST_MODEL, TEST_NAME, ClientMock +from .const import TEST_MAC, TEST_MODEL, TEST_NAME from tests.common import MockConfigEntry -async def test_invalid_host(hass: HomeAssistant) -> None: - """Test the failure when invalid host provided.""" - client = ClientMock() - client.is_offline = True - with patch( - "homeassistant.components.twinkly.config_flow.Twinkly", return_value=client - ): - result = await hass.config_entries.flow.async_init( - TWINKLY_DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "dummy"}, - ) +@pytest.mark.usefixtures("mock_twinkly_client", "mock_setup_entry") +async def test_full_flow(hass: HomeAssistant) -> None: + """Test the full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.123"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: "192.168.0.123", + CONF_ID: "00000000-0000-0000-0000-000000000000", + CONF_NAME: TEST_NAME, + CONF_MODEL: TEST_MODEL, + } + assert result["result"].unique_id == TEST_MAC + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_exceptions(hass: HomeAssistant, mock_twinkly_client: AsyncMock) -> None: + """Test the failure when raising exceptions.""" + mock_twinkly_client.get_details.side_effect = TimeoutError + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.123"}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "cannot_connect"} + mock_twinkly_client.get_details.side_effect = None -async def test_success_flow(hass: HomeAssistant) -> None: - """Test that an entity is created when the flow completes.""" - client = ClientMock() - with ( - patch( - "homeassistant.components.twinkly.config_flow.Twinkly", return_value=client - ), - patch("homeassistant.components.twinkly.async_setup_entry", return_value=True), - ): - result = await hass.config_entries.flow.async_init( - TWINKLY_DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "dummy"}, - ) - + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.123"}, + ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_NAME - assert result["data"] == { - CONF_HOST: "dummy", - CONF_ID: client.id, - CONF_NAME: TEST_NAME, - CONF_MODEL: TEST_MODEL, - } -async def test_dhcp_can_confirm(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_twinkly_client", "mock_setup_entry") +async def test_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the device is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.0.123"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_twinkly_client", "mock_setup_entry") +async def test_dhcp_full_flow(hass: HomeAssistant) -> None: """Test DHCP discovery flow can confirm right away.""" - client = ClientMock() - with patch( - "homeassistant.components.twinkly.config_flow.Twinkly", return_value=client - ): - result = await hass.config_entries.flow.async_init( - TWINKLY_DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - hostname="Twinkly_XYZ", - ip="1.2.3.4", - macaddress="aabbccddeeff", - ), - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="Twinkly_XYZ", + ip="1.2.3.4", + macaddress="002d133baabb", + ), + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" - -async def test_dhcp_success(hass: HomeAssistant) -> None: - """Test DHCP discovery flow success.""" - client = ClientMock() - with ( - patch( - "homeassistant.components.twinkly.config_flow.Twinkly", return_value=client - ), - patch("homeassistant.components.twinkly.async_setup_entry", return_value=True), - ): - result = await hass.config_entries.flow.async_init( - TWINKLY_DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - hostname="Twinkly_XYZ", - ip="1.2.3.4", - macaddress="aabbccddeeff", - ), - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "discovery_confirm" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_NAME assert result["data"] == { CONF_HOST: "1.2.3.4", - CONF_ID: client.id, + CONF_ID: "00000000-0000-0000-0000-000000000000", CONF_NAME: TEST_NAME, CONF_MODEL: TEST_MODEL, } + assert result["result"].unique_id == TEST_MAC -async def test_dhcp_already_exists(hass: HomeAssistant) -> None: - """Test DHCP discovery flow that fails to connect.""" - client = ClientMock() +@pytest.mark.usefixtures("mock_twinkly_client") +async def test_dhcp_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test DHCP discovery flow aborts if entry already setup.""" + mock_config_entry.add_to_hass(hass) - entry = MockConfigEntry( - domain=TWINKLY_DOMAIN, - data={ - CONF_HOST: "1.2.3.4", - CONF_ID: client.id, - CONF_NAME: TEST_NAME, - CONF_MODEL: TEST_MODEL, - }, - unique_id=client.id, + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="Twinkly_XYZ", + ip="1.2.3.4", + macaddress="002d133baabb", + ), ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.twinkly.config_flow.Twinkly", return_value=client - ): - result = await hass.config_entries.flow.async_init( - TWINKLY_DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - hostname="Twinkly_XYZ", - ip="1.2.3.4", - macaddress="aabbccddeeff", - ), - ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "1.2.3.4" + + +@pytest.mark.usefixtures("mock_twinkly_client", "mock_setup_entry") +async def test_user_flow_works_discovery(hass: HomeAssistant) -> None: + """Test user flow can continue after discovery happened.""" + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="Twinkly_XYZ", + ip="1.2.3.4", + macaddress="002d133baabb", + ), + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert len(hass.config_entries.flow.async_progress(DOMAIN)) == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + # Verify the discovery flow was aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) diff --git a/tests/components/twinkly/test_diagnostics.py b/tests/components/twinkly/test_diagnostics.py index f9cf0bc562c..d7ef4dd9b11 100644 --- a/tests/components/twinkly/test_diagnostics.py +++ b/tests/components/twinkly/test_diagnostics.py @@ -1,32 +1,28 @@ """Tests for the diagnostics of the twinkly component.""" -from collections.abc import Awaitable, Callable - +import pytest from syrupy import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant -from . import ClientMock +from . import setup_integration +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -type ComponentSetup = Callable[[], Awaitable[ClientMock]] - -DOMAIN = "twinkly" - +@pytest.mark.usefixtures("mock_twinkly_client") async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - setup_integration: ComponentSetup, + mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - await setup_integration() - entry = hass.config_entries.async_entries(DOMAIN)[0] + await setup_integration(hass, mock_config_entry) - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( - exclude=props("created_at", "modified_at") - ) + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/twinkly/test_init.py b/tests/components/twinkly/test_init.py index 6642807ac3f..0a76a399b63 100644 --- a/tests/components/twinkly/test_init.py +++ b/tests/components/twinkly/test_init.py @@ -1,64 +1,86 @@ -"""Tests of the initialization of the twinly integration.""" +"""Tests of the initialization of the twinkly integration.""" -from unittest.mock import patch -from uuid import uuid4 +from unittest.mock import AsyncMock -from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN +from aiohttp import ClientConnectionError +import pytest + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.twinkly.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import TEST_HOST, TEST_MODEL, TEST_NAME_ORIGINAL, ClientMock +from . import setup_integration +from .const import TEST_MAC, TEST_MODEL from tests.common import MockConfigEntry -async def test_load_unload_entry(hass: HomeAssistant) -> None: - """Validate that setup entry also configure the client.""" - client = ClientMock() +@pytest.mark.usefixtures("mock_twinkly_client") +async def test_load_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the load/unload of the config entry.""" - device_id = str(uuid4()) + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: + """Validate that config entry is retried.""" + mock_twinkly_client.get_details.side_effect = ClientConnectionError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_twinkly_client") +async def test_mac_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Validate that the unique_id is migrated to the MAC address.""" config_entry = MockConfigEntry( - domain=TWINKLY_DOMAIN, + domain=DOMAIN, + minor_version=1, + unique_id="unique_id", data={ - CONF_HOST: TEST_HOST, - CONF_ID: device_id, - CONF_NAME: TEST_NAME_ORIGINAL, + CONF_HOST: "192.168.0.123", + CONF_ID: id, + CONF_NAME: "Tree 1", CONF_MODEL: TEST_MODEL, }, - entry_id=device_id, + ) + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + LIGHT_DOMAIN, + DOMAIN, + config_entry.unique_id, + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, config_entry.unique_id)}, ) - config_entry.add_to_hass(hass) - - with patch("homeassistant.components.twinkly.Twinkly", return_value=client): - await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state is ConfigEntryState.LOADED - await hass.config_entries.async_unload(config_entry.entry_id) - - assert config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_config_entry_not_ready(hass: HomeAssistant) -> None: - """Validate that config entry is retried.""" - client = ClientMock() - client.is_offline = True - - config_entry = MockConfigEntry( - domain=TWINKLY_DOMAIN, - data={ - CONF_HOST: TEST_HOST, - CONF_ID: id, - CONF_NAME: TEST_NAME_ORIGINAL, - CONF_MODEL: TEST_MODEL, - }, - ) - - config_entry.add_to_hass(hass) - - with patch("homeassistant.components.twinkly.Twinkly", return_value=client): - await hass.config_entries.async_setup(config_entry.entry_id) - - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert entity_registry.async_get(entity_entry.entity_id).unique_id == TEST_MAC + assert device_registry.async_get_device( + identifiers={(DOMAIN, config_entry.unique_id)} + ).identifiers == {(DOMAIN, TEST_MAC)} + assert config_entry.unique_id == TEST_MAC diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index 7a55dbec14a..acf30764bab 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -3,290 +3,311 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from typing import Any +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion +from ttls.client import TwinklyError -from homeassistant.components.light import ATTR_BRIGHTNESS, LightEntityFeature -from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN -from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + DOMAIN as LIGHT_DOMAIN, + LightEntityFeature, +) +from homeassistant.components.twinkly import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.entity_registry import RegistryEntry -from . import TEST_MODEL, TEST_NAME, TEST_NAME_ORIGINAL, ClientMock +from . import setup_integration +from .const import TEST_MAC -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def test_initial_state(hass: HomeAssistant) -> None: - """Validate that entity and device states are updated on startup.""" - entity, device, _, _ = await _create_entries(hass) +@pytest.mark.usefixtures("mock_twinkly_client") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the created entities.""" + with patch("homeassistant.components.twinkly.PLATFORMS", [Platform.LIGHT]): + await setup_integration(hass, mock_config_entry) - state = hass.states.get(entity.entity_id) - - # Basic state properties - assert state.name == TEST_NAME - assert state.state == "on" - assert state.attributes[ATTR_BRIGHTNESS] == 26 - assert state.attributes["friendly_name"] == TEST_NAME - - assert device.name == TEST_NAME - assert device.model == TEST_MODEL - assert device.manufacturer == "LEDWORKS" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_turn_on_off(hass: HomeAssistant) -> None: +async def test_turn_on_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: """Test support of the light.turn_on service.""" - client = ClientMock() - client.state = False - client.brightness = {"mode": "enabled", "value": 20} - entity, _, _, _ = await _create_entries(hass, client) + mock_twinkly_client.is_on.return_value = False - assert hass.states.get(entity.entity_id).state == "off" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.tree_1").state == STATE_OFF await hass.services.async_call( - "light", "turn_on", service_data={"entity_id": entity.entity_id}, blocking=True + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "light.tree_1"}, + blocking=True, ) - state = hass.states.get(entity.entity_id) - - assert state.state == "on" - assert state.attributes[ATTR_BRIGHTNESS] == 51 + mock_twinkly_client.turn_on.assert_called_once_with() -async def test_turn_on_with_brightness(hass: HomeAssistant) -> None: +async def test_turn_on_with_brightness( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: """Test support of the light.turn_on service with a brightness parameter.""" - client = ClientMock() - client.state = False - client.brightness = {"mode": "enabled", "value": 20} - entity, _, _, _ = await _create_entries(hass, client) + mock_twinkly_client.is_on.return_value = False - assert hass.states.get(entity.entity_id).state == "off" + await setup_integration(hass, mock_config_entry) await hass.services.async_call( - "light", - "turn_on", - service_data={"entity_id": entity.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "light.tree_1", ATTR_BRIGHTNESS: 255}, blocking=True, ) - state = hass.states.get(entity.entity_id) + mock_twinkly_client.set_brightness.assert_called_once_with(100) + mock_twinkly_client.turn_on.assert_called_once_with() - assert state.state == "on" - assert state.attributes[ATTR_BRIGHTNESS] == 255 + +async def test_brightness_to_zero( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: + """Test support of the light.turn_on service with a brightness parameter.""" + await setup_integration(hass, mock_config_entry) await hass.services.async_call( - "light", - "turn_on", - service_data={"entity_id": entity.entity_id, "brightness": 1}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "light.tree_1", ATTR_BRIGHTNESS: 1}, blocking=True, ) - state = hass.states.get(entity.entity_id) - - assert state.state == "off" + mock_twinkly_client.set_brightness.assert_not_called() + mock_twinkly_client.turn_off.assert_called_once_with() -async def test_turn_on_with_color_rgbw(hass: HomeAssistant) -> None: +async def test_turn_on_with_color_rgbw( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: """Test support of the light.turn_on service with a rgbw parameter.""" - client = ClientMock() - client.state = False - client.device_info["led_profile"] = "RGBW" - client.brightness = {"mode": "enabled", "value": 255} - entity, _, _, _ = await _create_entries(hass, client) + mock_twinkly_client.is_on.return_value = False + mock_twinkly_client.get_details.return_value["led_profile"] = "RGBW" - assert hass.states.get(entity.entity_id).state == "off" + await setup_integration(hass, mock_config_entry) assert ( LightEntityFeature.EFFECT - & hass.states.get(entity.entity_id).attributes["supported_features"] + & hass.states.get("light.tree_1").attributes[ATTR_SUPPORTED_FEATURES] ) await hass.services.async_call( - "light", - "turn_on", - service_data={"entity_id": entity.entity_id, "rgbw_color": (128, 64, 32, 0)}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data={ + ATTR_ENTITY_ID: "light.tree_1", + ATTR_RGBW_COLOR: (128, 64, 32, 0), + }, blocking=True, ) - state = hass.states.get(entity.entity_id) - - assert state.state == "on" - assert client.color == (128, 64, 32) - assert client.default_mode == "color" - assert client.mode == "color" + mock_twinkly_client.interview.assert_called_once_with() + mock_twinkly_client.set_static_colour.assert_called_once_with((128, 64, 32)) + mock_twinkly_client.set_mode.assert_called_once_with("color") + assert mock_twinkly_client.default_mode == "color" -async def test_turn_on_with_color_rgb(hass: HomeAssistant) -> None: +async def test_turn_on_with_color_rgb( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: """Test support of the light.turn_on service with a rgb parameter.""" - client = ClientMock() - client.state = False - client.device_info["led_profile"] = "RGB" - client.brightness = {"mode": "enabled", "value": 255} - entity, _, _, _ = await _create_entries(hass, client) + mock_twinkly_client.is_on.return_value = False + mock_twinkly_client.get_details.return_value["led_profile"] = "RGB" - assert hass.states.get(entity.entity_id).state == "off" + await setup_integration(hass, mock_config_entry) assert ( LightEntityFeature.EFFECT - & hass.states.get(entity.entity_id).attributes["supported_features"] + & hass.states.get("light.tree_1").attributes[ATTR_SUPPORTED_FEATURES] ) await hass.services.async_call( - "light", - "turn_on", - service_data={"entity_id": entity.entity_id, "rgb_color": (128, 64, 32)}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "light.tree_1", ATTR_RGB_COLOR: (128, 64, 32)}, blocking=True, ) - state = hass.states.get(entity.entity_id) - - assert state.state == "on" - assert client.color == (128, 64, 32) - assert client.default_mode == "color" - assert client.mode == "color" + mock_twinkly_client.interview.assert_called_once_with() + mock_twinkly_client.set_static_colour.assert_called_once_with((128, 64, 32)) + mock_twinkly_client.set_mode.assert_called_once_with("color") + assert mock_twinkly_client.default_mode == "color" -async def test_turn_on_with_effect(hass: HomeAssistant) -> None: +async def test_turn_on_with_effect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: """Test support of the light.turn_on service with effects.""" - client = ClientMock() - client.state = False - client.device_info["led_profile"] = "RGB" - client.brightness = {"mode": "enabled", "value": 255} - entity, _, _, _ = await _create_entries(hass, client) + mock_twinkly_client.is_on.return_value = False + mock_twinkly_client.get_details.return_value["led_profile"] = "RGB" - assert hass.states.get(entity.entity_id).state == "off" - assert not client.current_movie + await setup_integration(hass, mock_config_entry) assert ( LightEntityFeature.EFFECT - & hass.states.get(entity.entity_id).attributes["supported_features"] + & hass.states.get("light.tree_1").attributes[ATTR_SUPPORTED_FEATURES] ) await hass.services.async_call( - "light", - "turn_on", - service_data={"entity_id": entity.entity_id, "effect": "1 Rainbow"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "light.tree_1", ATTR_EFFECT: "2 Rainbow"}, blocking=True, ) - state = hass.states.get(entity.entity_id) - - assert state.state == "on" - assert client.current_movie["id"] == 1 - assert client.default_mode == "movie" - assert client.mode == "movie" + mock_twinkly_client.interview.assert_called_once_with() + mock_twinkly_client.set_current_movie.assert_called_once_with(2) + mock_twinkly_client.set_mode.assert_called_once_with("movie") + assert mock_twinkly_client.default_mode == "movie" -async def test_turn_on_with_color_rgbw_and_missing_effect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("data"), + [ + {ATTR_RGBW_COLOR: (128, 64, 32, 0)}, + {ATTR_RGB_COLOR: (128, 64, 32)}, + ], +) +async def test_turn_on_with_missing_effect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, + data: dict[str, Any], +) -> None: """Test support of the light.turn_on service with rgbw color and missing effect support.""" - client = ClientMock() - client.state = False - client.device_info["led_profile"] = "RGBW" - client.brightness = {"mode": "enabled", "value": 255} - client.version = "2.7.0" - entity, _, _, _ = await _create_entries(hass, client) + mock_twinkly_client.is_on.return_value = False + mock_twinkly_client.get_firmware_version.return_value["version"] = "2.7.0" - assert hass.states.get(entity.entity_id).state == "off" + await setup_integration(hass, mock_config_entry) assert ( - not LightEntityFeature.EFFECT - & hass.states.get(entity.entity_id).attributes["supported_features"] + LightEntityFeature.EFFECT + ^ hass.states.get("light.tree_1").attributes[ATTR_SUPPORTED_FEATURES] ) await hass.services.async_call( - "light", - "turn_on", - service_data={"entity_id": entity.entity_id, "rgbw_color": (128, 64, 32, 0)}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "light.tree_1"} | data, blocking=True, ) - state = hass.states.get(entity.entity_id) - - assert state.state == "on" - assert client.color == (0, 128, 64, 32) - assert client.mode == "movie" - assert client.default_mode == "movie" + mock_twinkly_client.interview.assert_called_once_with() + mock_twinkly_client.set_cycle_colours.assert_called_once_with((128, 64, 32)) + mock_twinkly_client.set_mode.assert_called_once_with("movie") + assert mock_twinkly_client.default_mode == "movie" + mock_twinkly_client.set_current_movie.assert_not_called() -async def test_turn_on_with_color_rgb_and_missing_effect(hass: HomeAssistant) -> None: - """Test support of the light.turn_on service with rgb color and missing effect support.""" - client = ClientMock() - client.state = False - client.device_info["led_profile"] = "RGB" - client.brightness = {"mode": "enabled", "value": 255} - client.version = "2.7.0" - entity, _, _, _ = await _create_entries(hass, client) +async def test_turn_on_with_color_rgbw_and_missing_effect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: + """Test support of the light.turn_on service with missing effect support.""" + mock_twinkly_client.is_on.return_value = False + mock_twinkly_client.get_firmware_version.return_value["version"] = "2.7.0" - assert hass.states.get(entity.entity_id).state == "off" + await setup_integration(hass, mock_config_entry) assert ( - not LightEntityFeature.EFFECT - & hass.states.get(entity.entity_id).attributes["supported_features"] + LightEntityFeature.EFFECT + ^ hass.states.get("light.tree_1").attributes[ATTR_SUPPORTED_FEATURES] ) await hass.services.async_call( - "light", - "turn_on", - service_data={"entity_id": entity.entity_id, "rgb_color": (128, 64, 32)}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "light.tree_1", ATTR_EFFECT: "2 Rainbow"}, blocking=True, ) - state = hass.states.get(entity.entity_id) - - assert state.state == "on" - assert client.color == (128, 64, 32) - assert client.mode == "movie" - assert client.default_mode == "movie" + mock_twinkly_client.set_current_movie.assert_not_called() -async def test_turn_on_with_effect_missing_effects(hass: HomeAssistant) -> None: - """Test support of the light.turn_on service with effect set even if effects are not supported.""" - client = ClientMock() - client.state = False - client.device_info["led_profile"] = "RGB" - client.brightness = {"mode": "enabled", "value": 255} - client.version = "2.7.0" - entity, _, _, _ = await _create_entries(hass, client) - - assert hass.states.get(entity.entity_id).state == "off" - assert not client.current_movie - assert ( - not LightEntityFeature.EFFECT - & hass.states.get(entity.entity_id).attributes["supported_features"] - ) - - await hass.services.async_call( - "light", - "turn_on", - service_data={"entity_id": entity.entity_id, "effect": "1 Rainbow"}, - blocking=True, - ) - - state = hass.states.get(entity.entity_id) - - assert state.state == "on" - assert not client.current_movie - assert client.default_mode == "movie" - assert client.mode == "movie" - - -async def test_turn_off(hass: HomeAssistant) -> None: +async def test_turn_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: """Test support of the light.turn_off service.""" - entity, _, _, _ = await _create_entries(hass) - - assert hass.states.get(entity.entity_id).state == "on" + await setup_integration(hass, mock_config_entry) await hass.services.async_call( - "light", "turn_off", service_data={"entity_id": entity.entity_id}, blocking=True + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: "light.tree_1"}, + blocking=True, ) + mock_twinkly_client.turn_off.assert_called_once_with() - state = hass.states.get(entity.entity_id) - assert state.state == "off" +async def test_no_current_movie( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling of missing current movie data.""" + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.tree_1").attributes[ATTR_EFFECT] == "1 Rainbow" + + mock_twinkly_client.get_current_movie.side_effect = TwinklyError + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("light.tree_1").state != STATE_UNAVAILABLE + assert hass.states.get("light.tree_1").attributes[ATTR_EFFECT] is None async def test_update_name( hass: HomeAssistant, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, ) -> None: """Validate device's name update behavior. @@ -294,56 +315,19 @@ async def test_update_name( then the name of the entity is updated and it's also persisted, so it can be restored when starting HA while Twinkly is offline. """ - entity, _, client, config_entry = await _create_entries(hass) - client.change_name("new_device_name") + await setup_integration(hass, mock_config_entry) + + dev_entry = device_registry.async_get_device({(DOMAIN, TEST_MAC)}) + + assert dev_entry.name == "Tree 1" + + mock_twinkly_client.get_details.return_value["device_name"] = "new_device_name" + freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() - dev_entry = device_registry.async_get_device({(TWINKLY_DOMAIN, client.id)}) + dev_entry = device_registry.async_get_device({(DOMAIN, TEST_MAC)}) assert dev_entry.name == "new_device_name" - assert config_entry.data[CONF_NAME] == "new_device_name" - - -async def test_unload(hass: HomeAssistant) -> None: - """Validate that entities can be unloaded from the UI.""" - - _, _, client, _ = await _create_entries(hass) - entry_id = client.id - - assert await hass.config_entries.async_unload(entry_id) - - -async def _create_entries( - hass: HomeAssistant, client=None -) -> tuple[RegistryEntry, DeviceEntry, ClientMock]: - client = ClientMock() if client is None else client - - with patch("homeassistant.components.twinkly.Twinkly", return_value=client): - config_entry = MockConfigEntry( - domain=TWINKLY_DOMAIN, - data={ - CONF_HOST: client, - CONF_ID: client.id, - CONF_NAME: TEST_NAME_ORIGINAL, - CONF_MODEL: TEST_MODEL, - }, - entry_id=client.id, - ) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(client.id) - await hass.async_block_till_done() - - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - - entity_id = entity_registry.async_get_entity_id("light", TWINKLY_DOMAIN, client.id) - entity_entry = entity_registry.async_get(entity_id) - device = device_registry.async_get_device(identifiers={(TWINKLY_DOMAIN, client.id)}) - - assert entity_entry is not None - assert device is not None - - return entity_entry, device, client, config_entry diff --git a/tests/components/twinkly/test_select.py b/tests/components/twinkly/test_select.py new file mode 100644 index 00000000000..103fbe0f634 --- /dev/null +++ b/tests/components/twinkly/test_select.py @@ -0,0 +1,77 @@ +"""Tests for the Twinkly select component.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("mock_twinkly_client") +async def test_select_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the created select entities.""" + with patch("homeassistant.components.twinkly.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_select_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: + """Test selecting a mode.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("select.tree_1_mode") + assert state is not None + assert state.state == "color" + + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.tree_1_mode", + ATTR_OPTION: "movie", + }, + blocking=True, + ) + + mock_twinkly_client.set_mode.assert_called_once_with("movie") + mock_twinkly_client.interview.assert_not_called() + + +async def test_mode_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling of unavailable mode data.""" + await setup_integration(hass, mock_config_entry) + + mock_twinkly_client.get_mode.side_effect = Exception + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("select.tree_1_mode") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py index 2d70aaf9649..1887861f6e5 100644 --- a/tests/components/twitch/__init__.py +++ b/tests/components/twitch/__init__.py @@ -5,7 +5,7 @@ from typing import Any, Generic, TypeVar from twitchAPI.object.base import TwitchObject -from homeassistant.components.twitch import DOMAIN +from homeassistant.components.twitch.const import DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_json_array_fixture diff --git a/tests/components/twitch/test_sensor.py b/tests/components/twitch/test_sensor.py index 613c0919c49..c8cc009f3e1 100644 --- a/tests/components/twitch/test_sensor.py +++ b/tests/components/twitch/test_sensor.py @@ -7,7 +7,7 @@ from dateutil.tz import tzutc from twitchAPI.object.api import FollowedChannel, Stream, UserSubscription from twitchAPI.type import TwitchResourceNotFound -from homeassistant.components.twitch import DOMAIN +from homeassistant.components.twitch.const import DOMAIN from homeassistant.core import HomeAssistant from . import TwitchIterObject, get_generator_from_data, setup_integration diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 798b613b18d..702f8629219 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -174,6 +174,7 @@ def fixture_request( dpi_group_payload: list[dict[str, Any]], port_forward_payload: list[dict[str, Any]], traffic_rule_payload: list[dict[str, Any]], + traffic_route_payload: list[dict[str, Any]], site_payload: list[dict[str, Any]], system_information_payload: list[dict[str, Any]], wlan_payload: list[dict[str, Any]], @@ -214,6 +215,7 @@ def fixture_request( mock_get_request(f"/api/s/{site_id}/stat/sysinfo", system_information_payload) mock_get_request(f"/api/s/{site_id}/rest/wlanconf", wlan_payload) mock_get_request(f"/v2/api/site/{site_id}/trafficrules", traffic_rule_payload) + mock_get_request(f"/v2/api/site/{site_id}/trafficroutes", traffic_route_payload) return __mock_requests @@ -291,6 +293,12 @@ def traffic_rule_payload_data() -> list[dict[str, Any]]: return [] +@pytest.fixture(name="traffic_route_payload") +def traffic_route_payload_data() -> list[dict[str, Any]]: + """Traffic route data.""" + return [] + + @pytest.fixture(name="wlan_payload") def fixture_wlan_data() -> list[dict[str, Any]]: """WLAN data.""" diff --git a/tests/components/unifi/snapshots/test_sensor.ambr b/tests/components/unifi/snapshots/test_sensor.ambr index fc86a57a294..e14658b2b96 100644 --- a/tests/components/unifi/snapshots/test_sensor.ambr +++ b/tests/components/unifi/snapshots/test_sensor.ambr @@ -55,19 +55,18 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'Disconnected', - 'Connected', - 'Pending', - 'Firmware Mismatch', - 'Upgrading', - 'Provisioning', - 'Heartbeat Missed', - 'Adopting', - 'Deleting', - 'Inform Error', - 'Adoption Failed', - 'Isolated', - 'Unknown', + 'disconnected', + 'connected', + 'pending', + 'firmware_mismatch', + 'upgrading', + 'provisioning', + 'heartbeat_missed', + 'adopting', + 'deleting', + 'inform_error', + 'adoption_failed', + 'isolated', ]), }), 'config_entry_id': , @@ -103,19 +102,18 @@ 'device_class': 'enum', 'friendly_name': 'Device State', 'options': list([ - 'Disconnected', - 'Connected', - 'Pending', - 'Firmware Mismatch', - 'Upgrading', - 'Provisioning', - 'Heartbeat Missed', - 'Adopting', - 'Deleting', - 'Inform Error', - 'Adoption Failed', - 'Isolated', - 'Unknown', + 'disconnected', + 'connected', + 'pending', + 'firmware_mismatch', + 'upgrading', + 'provisioning', + 'heartbeat_missed', + 'adopting', + 'deleting', + 'inform_error', + 'adoption_failed', + 'isolated', ]), }), 'context': , @@ -123,7 +121,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Connected', + 'state': 'connected', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.device_temperature-entry] @@ -536,19 +534,18 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'Disconnected', - 'Connected', - 'Pending', - 'Firmware Mismatch', - 'Upgrading', - 'Provisioning', - 'Heartbeat Missed', - 'Adopting', - 'Deleting', - 'Inform Error', - 'Adoption Failed', - 'Isolated', - 'Unknown', + 'disconnected', + 'connected', + 'pending', + 'firmware_mismatch', + 'upgrading', + 'provisioning', + 'heartbeat_missed', + 'adopting', + 'deleting', + 'inform_error', + 'adoption_failed', + 'isolated', ]), }), 'config_entry_id': , @@ -584,19 +581,18 @@ 'device_class': 'enum', 'friendly_name': 'Dummy USP-PDU-Pro State', 'options': list([ - 'Disconnected', - 'Connected', - 'Pending', - 'Firmware Mismatch', - 'Upgrading', - 'Provisioning', - 'Heartbeat Missed', - 'Adopting', - 'Deleting', - 'Inform Error', - 'Adoption Failed', - 'Isolated', - 'Unknown', + 'disconnected', + 'connected', + 'pending', + 'firmware_mismatch', + 'upgrading', + 'provisioning', + 'heartbeat_missed', + 'adopting', + 'deleting', + 'inform_error', + 'adoption_failed', + 'isolated', ]), }), 'context': , @@ -604,7 +600,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Connected', + 'state': 'connected', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.dummy_usp_pdu_pro_uptime-entry] @@ -1601,19 +1597,18 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'Disconnected', - 'Connected', - 'Pending', - 'Firmware Mismatch', - 'Upgrading', - 'Provisioning', - 'Heartbeat Missed', - 'Adopting', - 'Deleting', - 'Inform Error', - 'Adoption Failed', - 'Isolated', - 'Unknown', + 'disconnected', + 'connected', + 'pending', + 'firmware_mismatch', + 'upgrading', + 'provisioning', + 'heartbeat_missed', + 'adopting', + 'deleting', + 'inform_error', + 'adoption_failed', + 'isolated', ]), }), 'config_entry_id': , @@ -1649,19 +1644,18 @@ 'device_class': 'enum', 'friendly_name': 'mock-name State', 'options': list([ - 'Disconnected', - 'Connected', - 'Pending', - 'Firmware Mismatch', - 'Upgrading', - 'Provisioning', - 'Heartbeat Missed', - 'Adopting', - 'Deleting', - 'Inform Error', - 'Adoption Failed', - 'Isolated', - 'Unknown', + 'disconnected', + 'connected', + 'pending', + 'firmware_mismatch', + 'upgrading', + 'provisioning', + 'heartbeat_missed', + 'adopting', + 'deleting', + 'inform_error', + 'adoption_failed', + 'isolated', ]), }), 'context': , @@ -1669,7 +1663,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Connected', + 'state': 'connected', }) # --- # name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.mock_name_uptime-entry] diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index fc3aeccea9f..6a493e32b02 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -262,7 +262,7 @@ async def test_device_button_entities( WLAN_REGENERATE_PASSWORD, "button.ssid_1_regenerate_password", "put", - f"/rest/wlanconf/{WLAN_REGENERATE_PASSWORD[0]["_id"]}", + f"/rest/wlanconf/{WLAN_REGENERATE_PASSWORD[0]['_id']}", { "json": {"data": "password changed successfully", "meta": {"rc": "ok"}}, "headers": {"content-type": CONTENT_TYPE_JSON}, diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 71b196550da..9d85dedbc9a 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -7,7 +7,6 @@ import aiounifi import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.unifi.config_flow import _async_discover_unifi from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, @@ -33,6 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ConfigEntryFactoryType @@ -482,7 +482,7 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.208.1:41417/rootDesc.xml", @@ -522,7 +522,7 @@ async def test_form_ssdp_aborts_if_host_already_exists(hass: HomeAssistant) -> N result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", @@ -544,7 +544,7 @@ async def test_form_ssdp_aborts_if_serial_already_exists(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", @@ -570,7 +570,7 @@ async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index c653370656d..b37e4f47137 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -589,14 +589,14 @@ async def test_restoring_client( entity_registry.async_get_or_create( # Make sure unique ID converts to site_id-mac TRACKER_DOMAIN, UNIFI_DOMAIN, - f'{clients_all_payload[0]["mac"]}-site_id', + f"{clients_all_payload[0]['mac']}-site_id", suggested_object_id=clients_all_payload[0]["hostname"], config_entry=config_entry, ) entity_registry.async_get_or_create( # Unique ID already follow format site_id-mac TRACKER_DOMAIN, UNIFI_DOMAIN, - f'site_id-{client_payload[0]["mac"]}', + f"site_id-{client_payload[0]['mac']}", suggested_object_id=client_payload[0]["hostname"], config_entry=config_entry, ) diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 3c94d12018d..5e47d263079 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -30,6 +30,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, + STATE_UNKNOWN, EntityCategory, Platform, ) @@ -891,7 +892,9 @@ async def test_device_state( for i in list(map(int, DeviceState)): device["state"] = i mock_websocket_message(message=MessageKey.DEVICE, data=device) - assert hass.states.get("sensor.device_state").state == DEVICE_STATES[i] + assert hass.states.get("sensor.device_state").state == DEVICE_STATES.get( + i, STATE_UNKNOWN + ) @pytest.mark.parametrize( diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index ef93afa7e3e..e4765d1181e 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -809,6 +809,24 @@ TRAFFIC_RULE = { "target_devices": [{"client_mac": CLIENT_1["mac"], "type": "CLIENT"}], } +TRAFFIC_ROUTE = { + "_id": "676f8dbb8f1d54503bba19ab", + "description": "Test traffic route", + "domains": [{"domain": "youtube.com", "port_ranges": [], "ports": []}], + "enabled": True, + "ip_addresses": [], + "ip_ranges": [], + "kill_switch_enabled": True, + "matching_target": "DOMAIN", + "network_id": "676f8d288f1d54503bba1987", + "next_hop": "", + "regions": [], + "target_devices": [ + {"network_id": "6060b00f45de3905133cea14", "type": "NETWORK"}, + {"network_id": "6060ae6045de3905133cea0a", "type": "NETWORK"}, + ], +} + @pytest.mark.parametrize( "config_entry_options", [{CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}] @@ -1154,6 +1172,60 @@ async def test_traffic_rules( assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call +@pytest.mark.parametrize(("traffic_route_payload"), [([TRAFFIC_ROUTE])]) +async def test_traffic_routes( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, + traffic_route_payload: list[dict[str, Any]], +) -> None: + """Test control of UniFi traffic routes.""" + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + # Validate state object + assert hass.states.get("switch.unifi_network_test_traffic_route").state == STATE_ON + + traffic_route = deepcopy(traffic_route_payload[0]) + + # Disable traffic route + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/v2/api/site/{config_entry_setup.data[CONF_SITE_ID]}" + f"/trafficroutes/{traffic_route['_id']}", + ) + + call_count = aioclient_mock.call_count + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.unifi_network_test_traffic_route"}, + blocking=True, + ) + # Updating the value for traffic routes will make another call to retrieve the values + assert aioclient_mock.call_count == call_count + 2 + expected_disable_call = deepcopy(traffic_route) + expected_disable_call["enabled"] = False + + assert aioclient_mock.mock_calls[call_count][2] == expected_disable_call + + call_count = aioclient_mock.call_count + + # Enable traffic route + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.unifi_network_test_traffic_route"}, + blocking=True, + ) + + expected_enable_call = deepcopy(traffic_route) + expected_enable_call["enabled"] = True + + assert aioclient_mock.call_count == call_count + 2 + assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call + + @pytest.mark.parametrize( ("device_payload", "entity_id", "outlet_index", "expected_switches"), [ @@ -1577,14 +1649,14 @@ async def test_updating_unique_id( entity_registry.async_get_or_create( SWITCH_DOMAIN, UNIFI_DOMAIN, - f'{device_payload[0]["mac"]}-outlet-1', + f"{device_payload[0]['mac']}-outlet-1", suggested_object_id="plug_outlet_1", config_entry=config_entry, ) entity_registry.async_get_or_create( SWITCH_DOMAIN, UNIFI_DOMAIN, - f'{device_payload[1]["mac"]}-poe-1', + f"{device_payload[1]['mac']}-poe-1", suggested_object_id="switch_port_1_poe", config_entry=config_entry, ) diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index fad65c095df..352c33297ba 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -51,11 +51,11 @@ def mock_nvr(): nvr = NVR.from_unifi_dict(**data) # disable pydantic validation so mocking can happen - NVR.__config__.validate_assignment = False + NVR.model_config["validate_assignment"] = False yield nvr - NVR.__config__.validate_assignment = True + NVR.model_config["validate_assignment"] = True @pytest.fixture(name="ufp_config_entry") @@ -98,6 +98,7 @@ def bootstrap_fixture(nvr: NVR): data["events"] = [] data["doorlocks"] = [] data["chimes"] = [] + data["aiports"] = [] return Bootstrap.from_unifi_dict(**data) @@ -120,7 +121,11 @@ def mock_ufp_client(bootstrap: Bootstrap): client.base_url = "https://127.0.0.1" client.connection_host = IPv4Address("127.0.0.1") - client.get_nvr = AsyncMock(return_value=nvr) + + async def get_nvr(*args: Any, **kwargs: Any) -> NVR: + return client.bootstrap.nvr + + client.get_nvr = get_nvr client.get_bootstrap = AsyncMock(return_value=bootstrap) client.update = AsyncMock(return_value=bootstrap) client.async_disconnect_ws = AsyncMock() @@ -173,7 +178,7 @@ def camera_fixture(fixed_now: datetime): """Mock UniFi Protect Camera device.""" # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False + Camera.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_camera.json", integration=DOMAIN)) camera = Camera.from_unifi_dict(**data) @@ -181,23 +186,23 @@ def camera_fixture(fixed_now: datetime): yield camera - Camera.__config__.validate_assignment = True + Camera.model_config["validate_assignment"] = True @pytest.fixture(name="camera_all") def camera_all_fixture(camera: Camera): """Mock UniFi Protect Camera device.""" - all_camera = camera.copy() - all_camera.channels = [all_camera.channels[0].copy()] + all_camera = camera.model_copy() + all_camera.channels = [all_camera.channels[0].model_copy()] - medium_channel = all_camera.channels[0].copy() + medium_channel = all_camera.channels[0].model_copy() medium_channel.name = "Medium" medium_channel.id = 1 medium_channel.rtsp_alias = "test_medium_alias" all_camera.channels.append(medium_channel) - low_channel = all_camera.channels[0].copy() + low_channel = all_camera.channels[0].model_copy() low_channel.name = "Low" low_channel.id = 2 low_channel.rtsp_alias = "test_medium_alias" @@ -210,10 +215,10 @@ def camera_all_fixture(camera: Camera): def doorbell_fixture(camera: Camera, fixed_now: datetime): """Mock UniFi Protect Camera device (with chime).""" - doorbell = camera.copy() - doorbell.channels = [c.copy() for c in doorbell.channels] + doorbell = camera.model_copy() + doorbell.channels = [c.model_copy() for c in doorbell.channels] - package_channel = doorbell.channels[0].copy() + package_channel = doorbell.channels[0].model_copy() package_channel.name = "Package Camera" package_channel.id = 3 package_channel.fps = 2 @@ -247,8 +252,8 @@ def doorbell_fixture(camera: Camera, fixed_now: datetime): def unadopted_camera(camera: Camera): """Mock UniFi Protect Camera device (unadopted).""" - no_camera = camera.copy() - no_camera.channels = [c.copy() for c in no_camera.channels] + no_camera = camera.model_copy() + no_camera.channels = [c.model_copy() for c in no_camera.channels] no_camera.name = "Unadopted Camera" no_camera.is_adopted = False return no_camera @@ -259,19 +264,19 @@ def light_fixture(): """Mock UniFi Protect Light device.""" # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False + Light.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_light.json", integration=DOMAIN)) yield Light.from_unifi_dict(**data) - Light.__config__.validate_assignment = True + Light.model_config["validate_assignment"] = True @pytest.fixture def unadopted_light(light: Light): """Mock UniFi Protect Light device (unadopted).""" - no_light = light.copy() + no_light = light.model_copy() no_light.name = "Unadopted Light" no_light.is_adopted = False return no_light @@ -282,12 +287,12 @@ def viewer(): """Mock UniFi Protect Viewport device.""" # disable pydantic validation so mocking can happen - Viewer.__config__.validate_assignment = False + Viewer.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_viewport.json", integration=DOMAIN)) yield Viewer.from_unifi_dict(**data) - Viewer.__config__.validate_assignment = True + Viewer.model_config["validate_assignment"] = True @pytest.fixture(name="sensor") @@ -295,7 +300,7 @@ def sensor_fixture(fixed_now: datetime): """Mock UniFi Protect Sensor device.""" # disable pydantic validation so mocking can happen - Sensor.__config__.validate_assignment = False + Sensor.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_sensor.json", integration=DOMAIN)) sensor: Sensor = Sensor.from_unifi_dict(**data) @@ -304,14 +309,14 @@ def sensor_fixture(fixed_now: datetime): sensor.alarm_triggered_at = fixed_now - timedelta(hours=1) yield sensor - Sensor.__config__.validate_assignment = True + Sensor.model_config["validate_assignment"] = True @pytest.fixture(name="sensor_all") def csensor_all_fixture(sensor: Sensor): """Mock UniFi Protect Sensor device.""" - all_sensor = sensor.copy() + all_sensor = sensor.model_copy() all_sensor.light_settings.is_enabled = True all_sensor.humidity_settings.is_enabled = True all_sensor.temperature_settings.is_enabled = True @@ -327,19 +332,19 @@ def doorlock_fixture(): """Mock UniFi Protect Doorlock device.""" # disable pydantic validation so mocking can happen - Doorlock.__config__.validate_assignment = False + Doorlock.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_doorlock.json", integration=DOMAIN)) yield Doorlock.from_unifi_dict(**data) - Doorlock.__config__.validate_assignment = True + Doorlock.model_config["validate_assignment"] = True @pytest.fixture def unadopted_doorlock(doorlock: Doorlock): """Mock UniFi Protect Light device (unadopted).""" - no_doorlock = doorlock.copy() + no_doorlock = doorlock.model_copy() no_doorlock.name = "Unadopted Lock" no_doorlock.is_adopted = False return no_doorlock @@ -350,12 +355,12 @@ def chime(): """Mock UniFi Protect Chime device.""" # disable pydantic validation so mocking can happen - Chime.__config__.validate_assignment = False + Chime.model_config["validate_assignment"] = False data = json.loads(load_fixture("sample_chime.json", integration=DOMAIN)) yield Chime.from_unifi_dict(**data) - Chime.__config__.validate_assignment = True + Chime.model_config["validate_assignment"] = True @pytest.fixture(name="fixed_now") diff --git a/tests/components/unifiprotect/fixtures/sample_bootstrap.json b/tests/components/unifiprotect/fixtures/sample_bootstrap.json index 240a9938b64..4c8d86a787d 100644 --- a/tests/components/unifiprotect/fixtures/sample_bootstrap.json +++ b/tests/components/unifiprotect/fixtures/sample_bootstrap.json @@ -564,6 +564,24 @@ "legacyUFVs": [], "lastUpdateId": "ebf25bac-d5a1-4f1d-a0ee-74c15981eb70", "displays": [], + "ringtones": [ + { + "id": "66a14fa502d44203e40003eb", + "name": "Default", + "size": 208, + "isDefault": true, + "nvrMac": "A1E00C826924", + "modelKey": "ringtone" + }, + { + "id": "66a14fa502da4203e40003ec", + "name": "Traditional", + "size": 180, + "isDefault": false, + "nvrMac": "A1E00C826924", + "modelKey": "ringtone" + } + ], "bridges": [ { "mac": "A28D0DB15AE1", diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 31669aa62bb..3a8d5d952ce 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -305,7 +305,7 @@ async def test_binary_sensor_update_motion( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_motion_detected = True new_camera.last_motion_event_id = event.id @@ -352,7 +352,7 @@ async def test_binary_sensor_update_light_motion( api=ufp.api, ) - new_light = light.copy() + new_light = light.model_copy() new_light.is_pir_motion_detected = True new_light.last_motion_event_id = event.id @@ -386,7 +386,7 @@ async def test_binary_sensor_update_mount_type_window( assert state assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value - new_sensor = sensor_all.copy() + new_sensor = sensor_all.model_copy() new_sensor.mount_type = MountType.WINDOW mock_msg = Mock() @@ -418,7 +418,7 @@ async def test_binary_sensor_update_mount_type_garage( assert state assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value - new_sensor = sensor_all.copy() + new_sensor = sensor_all.model_copy() new_sensor.mount_type = MountType.GARAGE mock_msg = Mock() @@ -468,7 +468,7 @@ async def test_binary_sensor_package_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = event.id @@ -501,7 +501,7 @@ async def test_binary_sensor_package_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = event.id @@ -534,7 +534,7 @@ async def test_binary_sensor_package_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = event.id @@ -611,7 +611,7 @@ async def test_binary_sensor_person_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True ufp.api.bootstrap.cameras = {new_camera.id: new_camera} @@ -641,7 +641,7 @@ async def test_binary_sensor_person_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] = event.id @@ -680,7 +680,7 @@ async def test_binary_sensor_person_detected( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] = event.id diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 689352d8aa3..12b92beedd0 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -236,15 +236,15 @@ async def test_basic_setup( ) -> None: """Test working setup of unifiprotect entry.""" - camera_high_only = camera_all.copy() - camera_high_only.channels = [c.copy() for c in camera_all.channels] + camera_high_only = camera_all.model_copy() + camera_high_only.channels = [c.model_copy() for c in camera_all.channels] camera_high_only.name = "Test Camera 1" camera_high_only.channels[0].is_rtsp_enabled = True camera_high_only.channels[1].is_rtsp_enabled = False camera_high_only.channels[2].is_rtsp_enabled = False - camera_medium_only = camera_all.copy() - camera_medium_only.channels = [c.copy() for c in camera_all.channels] + camera_medium_only = camera_all.model_copy() + camera_medium_only.channels = [c.model_copy() for c in camera_all.channels] camera_medium_only.name = "Test Camera 2" camera_medium_only.channels[0].is_rtsp_enabled = False camera_medium_only.channels[1].is_rtsp_enabled = True @@ -252,8 +252,8 @@ async def test_basic_setup( camera_all.name = "Test Camera 3" - camera_no_channels = camera_all.copy() - camera_no_channels.channels = [c.copy() for c in camera_all.channels] + camera_no_channels = camera_all.model_copy() + camera_no_channels.channels = [c.model_copy() for c in camera_all.channels] camera_no_channels.name = "Test Camera 4" camera_no_channels.channels[0].is_rtsp_enabled = False camera_no_channels.channels[1].is_rtsp_enabled = False @@ -337,8 +337,8 @@ async def test_webrtc_support( camera_all: ProtectCamera, ) -> None: """Test webrtc support is available.""" - camera_high_only = camera_all.copy() - camera_high_only.channels = [c.copy() for c in camera_all.channels] + camera_high_only = camera_all.model_copy() + camera_high_only.channels = [c.model_copy() for c in camera_all.channels] camera_high_only.name = "Test Camera 1" camera_high_only.channels[0].is_rtsp_enabled = True camera_high_only.channels[1].is_rtsp_enabled = False @@ -355,7 +355,7 @@ async def test_adopt( ) -> None: """Test setting up camera with no camera channels.""" - camera1 = camera.copy() + camera1 = camera.model_copy() camera1.channels = [] await init_entry(hass, ufp, [camera1]) @@ -450,7 +450,7 @@ async def test_camera_interval_update( state = hass.states.get(entity_id) assert state and state.state == "idle" - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_recording = True ufp.api.bootstrap.cameras = {new_camera.id: new_camera} @@ -527,10 +527,10 @@ async def test_camera_ws_update( state = hass.states.get(entity_id) assert state and state.state == "idle" - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_recording = True - no_camera = camera.copy() + no_camera = camera.model_copy() no_camera.is_adopted = False ufp.api.bootstrap.cameras = {new_camera.id: new_camera} @@ -563,7 +563,7 @@ async def test_camera_ws_update_offline( assert state and state.state == "idle" # camera goes offline - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.state = StateType.DISCONNECTED mock_msg = Mock() @@ -601,7 +601,7 @@ async def test_camera_enable_motion( assert_entity_counts(hass, Platform.CAMERA, 2, 1) entity_id = "camera.test_camera_high_resolution_channel" - camera.__fields__["set_motion_detection"] = Mock(final=False) + camera.__pydantic_fields__["set_motion_detection"] = Mock(final=False, frozen=False) camera.set_motion_detection = AsyncMock() await hass.services.async_call( @@ -623,7 +623,7 @@ async def test_camera_disable_motion( assert_entity_counts(hass, Platform.CAMERA, 2, 1) entity_id = "camera.test_camera_high_resolution_channel" - camera.__fields__["set_motion_detection"] = Mock(final=False) + camera.__pydantic_fields__["set_motion_detection"] = Mock(final=False, frozen=False) camera.set_motion_detection = AsyncMock() await hass.services.async_call( diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 8bfdc004092..0eae2a48fea 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -11,7 +11,6 @@ from uiprotect import NotAuthorized, NvrError, ProtectApiClient from uiprotect.data import NVR, Bootstrap, CloudAccount from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp from homeassistant.components.unifiprotect.const import ( CONF_ALL_UPDATES, CONF_DISABLE_RTSP, @@ -23,6 +22,8 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from . import ( DEVICE_HOSTNAME, @@ -37,13 +38,13 @@ from .conftest import MAC_ADDR from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=DEVICE_HOSTNAME, ip=DEVICE_IP_ADDRESS, macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""), ) SSDP_DISCOVERY = ( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{DEVICE_IP_ADDRESS}:41417/rootDesc.xml", @@ -338,7 +339,7 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - ], ) async def test_discovered_by_ssdp_or_dhcp( - hass: HomeAssistant, source: str, data: dhcp.DhcpServiceInfo | ssdp.SsdpServiceInfo + hass: HomeAssistant, source: str, data: DhcpServiceInfo | SsdpServiceInfo ) -> None: """Test we handoff to unifi-discovery when discovered via ssdp or dhcp.""" diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index cc2195c1dba..032a3b253a7 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -75,7 +75,7 @@ async def test_doorbell_ring( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.last_ring_event_id = "test_event_id" ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} @@ -107,7 +107,7 @@ async def test_doorbell_ring( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} @@ -137,7 +137,7 @@ async def test_doorbell_ring( api=ufp.api, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} @@ -175,6 +175,10 @@ async def test_doorbell_nfc_scanned( Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) + ulp_id = "ulp_id" + test_user_full_name = "Test User" + test_nfc_id = "test_nfc_id" + unsub = async_track_state_change_event(hass, entity_id, _capture_event) event = Event( model=ModelType.EVENT, @@ -187,10 +191,227 @@ async def test_doorbell_nfc_scanned( smart_detect_event_ids=[], camera_id=doorbell.id, api=ufp.api, - metadata={"nfc": {"nfc_id": "test_nfc_id", "user_id": "test_user_id"}}, + metadata={"nfc": {"nfc_id": test_nfc_id, "user_id": "test_user_id"}}, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() + new_camera.last_nfc_card_scanned_event_id = "test_event_id" + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_keyring = Mock() + mock_keyring.registry_id = test_nfc_id + mock_keyring.registry_type = "nfc" + mock_keyring.ulp_user = ulp_id + ufp.api.bootstrap.keyrings.add(mock_keyring) + + mock_ulp_user = Mock() + mock_ulp_user.ulp_id = ulp_id + mock_ulp_user.full_name = test_user_full_name + mock_ulp_user.status = "ACTIVE" + ufp.api.bootstrap.ulp_users.add(mock_ulp_user) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + assert len(events) == 1 + state = events[0].data["new_state"] + assert state + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_ID] == "test_event_id" + assert state.attributes["nfc_id"] == "test_nfc_id" + assert state.attributes["full_name"] == test_user_full_name + + unsub() + + +async def test_doorbell_nfc_scanned_ulpusr_deactivated( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test a doorbell NFC scanned event.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 3, 3) + events: list[HAEvent] = [] + + @callback + def _capture_event(event: HAEvent) -> None: + events.append(event) + + _, entity_id = ids_from_device_description( + Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + ) + + ulp_id = "ulp_id" + test_user_full_name = "Test User" + test_nfc_id = "test_nfc_id" + + unsub = async_track_state_change_event(hass, entity_id, _capture_event) + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.NFC_CARD_SCANNED, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + metadata={"nfc": {"nfc_id": test_nfc_id, "user_id": "test_user_id"}}, + ) + + new_camera = doorbell.model_copy() + new_camera.last_nfc_card_scanned_event_id = "test_event_id" + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_keyring = Mock() + mock_keyring.registry_id = test_nfc_id + mock_keyring.registry_type = "nfc" + mock_keyring.ulp_user = ulp_id + ufp.api.bootstrap.keyrings.add(mock_keyring) + + mock_ulp_user = Mock() + mock_ulp_user.ulp_id = ulp_id + mock_ulp_user.full_name = test_user_full_name + mock_ulp_user.status = "DEACTIVATED" + ufp.api.bootstrap.ulp_users.add(mock_ulp_user) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + assert len(events) == 1 + state = events[0].data["new_state"] + assert state + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_ID] == "test_event_id" + assert state.attributes["nfc_id"] == "test_nfc_id" + assert state.attributes["full_name"] == "Test User" + assert state.attributes["user_status"] == "DEACTIVATED" + + unsub() + + +async def test_doorbell_nfc_scanned_no_ulpusr( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test a doorbell NFC scanned event.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 3, 3) + events: list[HAEvent] = [] + + @callback + def _capture_event(event: HAEvent) -> None: + events.append(event) + + _, entity_id = ids_from_device_description( + Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + ) + + ulp_id = "ulp_id" + test_nfc_id = "test_nfc_id" + + unsub = async_track_state_change_event(hass, entity_id, _capture_event) + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.NFC_CARD_SCANNED, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + metadata={"nfc": {"nfc_id": test_nfc_id, "user_id": "test_user_id"}}, + ) + + new_camera = doorbell.model_copy() + new_camera.last_nfc_card_scanned_event_id = "test_event_id" + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_keyring = Mock() + mock_keyring.registry_id = test_nfc_id + mock_keyring.registry_type = "nfc" + mock_keyring.ulp_user = ulp_id + ufp.api.bootstrap.keyrings.add(mock_keyring) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + assert len(events) == 1 + state = events[0].data["new_state"] + assert state + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_ID] == "test_event_id" + assert state.attributes["nfc_id"] == "test_nfc_id" + assert state.attributes["full_name"] == "" + + unsub() + + +async def test_doorbell_nfc_scanned_no_keyring( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test a doorbell NFC scanned event.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 3, 3) + events: list[HAEvent] = [] + + @callback + def _capture_event(event: HAEvent) -> None: + events.append(event) + + _, entity_id = ids_from_device_description( + Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + ) + + test_nfc_id = "test_nfc_id" + + unsub = async_track_state_change_event(hass, entity_id, _capture_event) + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.NFC_CARD_SCANNED, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + metadata={"nfc": {"nfc_id": test_nfc_id, "user_id": "test_user_id"}}, + ) + + new_camera = doorbell.model_copy() new_camera.last_nfc_card_scanned_event_id = "test_event_id" ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} @@ -208,6 +429,7 @@ async def test_doorbell_nfc_scanned( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION assert state.attributes[ATTR_EVENT_ID] == "test_event_id" assert state.attributes["nfc_id"] == "test_nfc_id" + assert state.attributes["full_name"] == "" unsub() @@ -233,6 +455,9 @@ async def test_doorbell_fingerprint_identified( Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) + ulp_id = "ulp_id" + test_user_full_name = "Test User" + unsub = async_track_state_change_event(hass, entity_id, _capture_event) event = Event( model=ModelType.EVENT, @@ -245,10 +470,146 @@ async def test_doorbell_fingerprint_identified( smart_detect_event_ids=[], camera_id=doorbell.id, api=ufp.api, - metadata={"fingerprint": {"ulp_id": "test_ulp_id"}}, + metadata={"fingerprint": {"ulp_id": ulp_id}}, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() + new_camera.last_fingerprint_identified_event_id = "test_event_id" + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_ulp_user = Mock() + mock_ulp_user.ulp_id = ulp_id + mock_ulp_user.full_name = test_user_full_name + mock_ulp_user.status = "ACTIVE" + ufp.api.bootstrap.ulp_users.add(mock_ulp_user) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + assert len(events) == 1 + state = events[0].data["new_state"] + assert state + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_ID] == "test_event_id" + assert state.attributes["ulp_id"] == ulp_id + assert state.attributes["full_name"] == test_user_full_name + + unsub() + + +async def test_doorbell_fingerprint_identified_user_deactivated( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test a doorbell fingerprint identified event.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 3, 3) + events: list[HAEvent] = [] + + @callback + def _capture_event(event: HAEvent) -> None: + events.append(event) + + _, entity_id = ids_from_device_description( + Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + ) + + ulp_id = "ulp_id" + test_user_full_name = "Test User" + + unsub = async_track_state_change_event(hass, entity_id, _capture_event) + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.FINGERPRINT_IDENTIFIED, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + metadata={"fingerprint": {"ulp_id": ulp_id}}, + ) + + new_camera = doorbell.model_copy() + new_camera.last_fingerprint_identified_event_id = "test_event_id" + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_ulp_user = Mock() + mock_ulp_user.ulp_id = ulp_id + mock_ulp_user.full_name = test_user_full_name + mock_ulp_user.status = "DEACTIVATED" + ufp.api.bootstrap.ulp_users.add(mock_ulp_user) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + assert len(events) == 1 + state = events[0].data["new_state"] + assert state + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_ID] == "test_event_id" + assert state.attributes["ulp_id"] == ulp_id + assert state.attributes["full_name"] == "Test User" + assert state.attributes["user_status"] == "DEACTIVATED" + + unsub() + + +async def test_doorbell_fingerprint_identified_no_user( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test a doorbell fingerprint identified event.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.EVENT, 3, 3) + events: list[HAEvent] = [] + + @callback + def _capture_event(event: HAEvent) -> None: + events.append(event) + + _, entity_id = ids_from_device_description( + Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + ) + + ulp_id = "ulp_id" + + unsub = async_track_state_change_event(hass, entity_id, _capture_event) + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.FINGERPRINT_IDENTIFIED, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + metadata={"fingerprint": {"ulp_id": ulp_id}}, + ) + + new_camera = doorbell.model_copy() new_camera.last_fingerprint_identified_event_id = "test_event_id" ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} @@ -265,7 +626,8 @@ async def test_doorbell_fingerprint_identified( assert state assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION assert state.attributes[ATTR_EVENT_ID] == "test_event_id" - assert state.attributes["ulp_id"] == "test_ulp_id" + assert state.attributes["ulp_id"] == ulp_id + assert state.attributes["full_name"] == "" unsub() @@ -306,7 +668,7 @@ async def test_doorbell_fingerprint_not_identified( metadata={"fingerprint": {}}, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.last_fingerprint_identified_event_id = "test_event_id" ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 46e57c62101..b01c7e0cf4a 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -2,8 +2,9 @@ from __future__ import annotations -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch +import pytest from uiprotect import NotAuthorized, NvrError, ProtectApiClient from uiprotect.api import DEVICE_UPDATE_INTERVAL from uiprotect.data import NVR, Bootstrap, CloudAccount, Light @@ -13,6 +14,9 @@ from homeassistant.components.unifiprotect.const import ( CONF_DISABLE_RTSP, DOMAIN, ) +from homeassistant.components.unifiprotect.data import ( + async_ufp_instance_for_config_entry_ids, +) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -114,7 +118,7 @@ async def test_setup_too_old( ) -> None: """Test setup of unifiprotect entry with too old of version of UniFi Protect.""" - old_bootstrap = ufp.api.bootstrap.copy() + old_bootstrap = ufp.api.bootstrap.model_copy() old_bootstrap.nvr = old_nvr ufp.api.update.return_value = old_bootstrap ufp.api.bootstrap = old_bootstrap @@ -286,3 +290,58 @@ async def test_device_remove_devices_nvr( client = await hass_ws_client(hass) response = await client.remove_device(live_device_entry.id, entry_id) assert not response["success"] + + +@pytest.mark.parametrize( + ("mock_entries", "expected_result"), + [ + pytest.param( + [ + MockConfigEntry( + domain=DOMAIN, + entry_id="1", + data={}, + ), + MockConfigEntry( + domain="other_domain", + entry_id="2", + data={}, + ), + ], + "mock_api_instance_1", + id="one_matching_domain", + ), + pytest.param( + [ + MockConfigEntry( + domain="other_domain", + entry_id="1", + data={}, + ), + MockConfigEntry( + domain="other_domain", + entry_id="2", + data={}, + ), + ], + None, + id="no_matching_domain", + ), + ], +) +async def test_async_ufp_instance_for_config_entry_ids( + hass: HomeAssistant, + mock_entries: list[MockConfigEntry], + expected_result: str | None, +) -> None: + """Test async_ufp_instance_for_config_entry_ids with various entry configurations.""" + + for index, entry in enumerate(mock_entries): + entry.add_to_hass(hass) + entry.runtime_data = Mock(api=f"mock_api_instance_{index + 1}") + + entry_ids = {entry.entry_id for entry in mock_entries} + + result = async_ufp_instance_for_config_entry_ids(hass, entry_ids) + + assert result == expected_result diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index bb0b6992e4e..85de85cd1c1 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -74,7 +74,7 @@ async def test_light_update( await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) - new_light = light.copy() + new_light = light.model_copy() new_light.is_light_on = True new_light.light_device_settings.led_level = LEDLevel(3) @@ -95,42 +95,38 @@ async def test_light_update( async def test_light_turn_on( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: - """Test light entity turn off.""" + """Test light entity turn on.""" + + light._api = ufp.api + light.api.set_light_is_led_force_on = AsyncMock() await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) entity_id = "light.test_light" - light.__fields__["set_light"] = Mock(final=False) - light.set_light = AsyncMock() - await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, - blocking=True, + "light", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - light.set_light.assert_called_once_with(True, 3) + assert light.api.set_light_is_led_force_on.called + assert light.api.set_light_is_led_force_on.call_args == ((light.id, True),) async def test_light_turn_off( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: - """Test light entity turn on.""" + """Test light entity turn off.""" + + light._api = ufp.api + light.api.set_light_is_led_force_on = AsyncMock() await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) entity_id = "light.test_light" - light.__fields__["set_light"] = Mock(final=False) - light.set_light = AsyncMock() - await hass.services.async_call( - "light", - "turn_off", - {ATTR_ENTITY_ID: entity_id}, - blocking=True, + "light", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - light.set_light.assert_called_once_with(False) + assert light.api.set_light_is_led_force_on.called + assert light.api.set_light_is_led_force_on.call_args == ((light.id, False),) diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index 8b37b1c5928..9095c092ea2 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -75,7 +75,7 @@ async def test_lock_locked( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.CLOSED mock_msg = Mock() @@ -102,7 +102,7 @@ async def test_lock_unlocking( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.OPENING mock_msg = Mock() @@ -129,7 +129,7 @@ async def test_lock_locking( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.CLOSING mock_msg = Mock() @@ -156,7 +156,7 @@ async def test_lock_jammed( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.JAMMED_WHILE_CLOSING mock_msg = Mock() @@ -183,7 +183,7 @@ async def test_lock_unavailable( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.NOT_CALIBRATED mock_msg = Mock() @@ -210,7 +210,7 @@ async def test_lock_do_lock( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - doorlock.__fields__["close_lock"] = Mock(final=False) + doorlock.__pydantic_fields__["close_lock"] = Mock(final=False, frozen=False) doorlock.close_lock = AsyncMock() await hass.services.async_call( @@ -234,7 +234,7 @@ async def test_lock_do_unlock( await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) assert_entity_counts(hass, Platform.LOCK, 1, 1) - new_lock = doorlock.copy() + new_lock = doorlock.model_copy() new_lock.lock_status = LockStatusType.CLOSED mock_msg = Mock() @@ -245,7 +245,7 @@ async def test_lock_do_unlock( ufp.ws_msg(mock_msg) await hass.async_block_till_done() - new_lock.__fields__["open_lock"] = Mock(final=False) + doorlock.__pydantic_fields__["open_lock"] = Mock(final=False, frozen=False) new_lock.open_lock = AsyncMock() await hass.services.async_call( diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index 642a3a1e372..6d27eb2a206 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -88,7 +88,7 @@ async def test_media_player_update( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.talkback_stream = Mock() new_camera.talkback_stream.is_running = True @@ -116,7 +116,7 @@ async def test_media_player_set_volume( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - doorbell.__fields__["set_speaker_volume"] = Mock(final=False) + doorbell.__pydantic_fields__["set_speaker_volume"] = Mock(final=False, frozen=False) doorbell.set_speaker_volume = AsyncMock() await hass.services.async_call( @@ -140,7 +140,7 @@ async def test_media_player_stop( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.talkback_stream = AsyncMock() new_camera.talkback_stream.is_running = True @@ -173,9 +173,11 @@ async def test_media_player_play( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - doorbell.__fields__["stop_audio"] = Mock(final=False) - doorbell.__fields__["play_audio"] = Mock(final=False) - doorbell.__fields__["wait_until_audio_completes"] = Mock(final=False) + doorbell.__pydantic_fields__["stop_audio"] = Mock(final=False, frozen=False) + doorbell.__pydantic_fields__["play_audio"] = Mock(final=False, frozen=False) + doorbell.__pydantic_fields__["wait_until_audio_completes"] = Mock( + final=False, frozen=False + ) doorbell.stop_audio = AsyncMock() doorbell.play_audio = AsyncMock() doorbell.wait_until_audio_completes = AsyncMock() @@ -208,9 +210,11 @@ async def test_media_player_play_media_source( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - doorbell.__fields__["stop_audio"] = Mock(final=False) - doorbell.__fields__["play_audio"] = Mock(final=False) - doorbell.__fields__["wait_until_audio_completes"] = Mock(final=False) + doorbell.__pydantic_fields__["stop_audio"] = Mock(final=False, frozen=False) + doorbell.__pydantic_fields__["play_audio"] = Mock(final=False, frozen=False) + doorbell.__pydantic_fields__["wait_until_audio_completes"] = Mock( + final=False, frozen=False + ) doorbell.stop_audio = AsyncMock() doorbell.play_audio = AsyncMock() doorbell.wait_until_audio_completes = AsyncMock() @@ -247,7 +251,7 @@ async def test_media_player_play_invalid( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - doorbell.__fields__["play_audio"] = Mock(final=False) + doorbell.__pydantic_fields__["play_audio"] = Mock(final=False, frozen=False) doorbell.play_audio = AsyncMock() with pytest.raises(HomeAssistantError): @@ -276,8 +280,10 @@ async def test_media_player_play_error( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - doorbell.__fields__["play_audio"] = Mock(final=False) - doorbell.__fields__["wait_until_audio_completes"] = Mock(final=False) + doorbell.__pydantic_fields__["play_audio"] = Mock(final=False, frozen=False) + doorbell.__pydantic_fields__["wait_until_audio_completes"] = Mock( + final=False, frozen=False + ) doorbell.play_audio = AsyncMock(side_effect=StreamError) doorbell.wait_until_audio_completes = AsyncMock() diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 18944460ca5..61f9680bdbc 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -204,9 +204,9 @@ async def test_browse_media_root_multiple_consoles( await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - bootstrap2 = bootstrap.copy() + bootstrap2 = bootstrap.model_copy() bootstrap2._has_media = True - bootstrap2.nvr = bootstrap.nvr.copy() + bootstrap2.nvr = bootstrap.nvr.model_copy() bootstrap2.nvr.id = "test_id2" bootstrap2.nvr.mac = "A2E00C826924" bootstrap2.nvr.name = "UnifiProtect2" @@ -270,9 +270,9 @@ async def test_browse_media_root_multiple_consoles_only_one_media( await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - bootstrap2 = bootstrap.copy() + bootstrap2 = bootstrap.model_copy() bootstrap2._has_media = False - bootstrap2.nvr = bootstrap.nvr.copy() + bootstrap2.nvr = bootstrap.nvr.model_copy() bootstrap2.nvr.id = "test_id2" bootstrap2.nvr.mac = "A2E00C826924" bootstrap2.nvr.name = "UnifiProtect2" diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 77a409551b1..1838a574bc4 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -162,7 +162,7 @@ async def test_number_light_sensitivity( description = LIGHT_NUMBERS[0] assert description.ufp_set_method is not None - light.__fields__["set_sensitivity"] = Mock(final=False) + light.__pydantic_fields__["set_sensitivity"] = Mock(final=False, frozen=False) light.set_sensitivity = AsyncMock() _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) @@ -184,7 +184,7 @@ async def test_number_light_duration( description = LIGHT_NUMBERS[1] - light.__fields__["set_duration"] = Mock(final=False) + light.__pydantic_fields__["set_duration"] = Mock(final=False, frozen=False) light.set_duration = AsyncMock() _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) @@ -210,7 +210,9 @@ async def test_number_camera_simple( assert description.ufp_set_method is not None - camera.__fields__[description.ufp_set_method] = Mock(final=False) + camera.__pydantic_fields__[description.ufp_set_method] = Mock( + final=False, frozen=False + ) setattr(camera, description.ufp_set_method, AsyncMock()) _, entity_id = ids_from_device_description(Platform.NUMBER, camera, description) @@ -230,7 +232,9 @@ async def test_number_lock_auto_close( description = DOORLOCK_NUMBERS[0] - doorlock.__fields__["set_auto_close_time"] = Mock(final=False) + doorlock.__pydantic_fields__["set_auto_close_time"] = Mock( + final=False, frozen=False + ) doorlock.set_auto_close_time = AsyncMock() _, entity_id = ids_from_device_description(Platform.NUMBER, doorlock, description) diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index fe102c2fdbc..1f025a63306 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -51,7 +51,7 @@ async def test_exclude_attributes( camera_id=doorbell.id, ) - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.is_motion_detected = True new_camera.last_motion_event_id = event.id diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index adb9555e6ea..1117038bbd0 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -363,3 +363,30 @@ async def test_rtsp_writable_fix_when_not_setup( ufp.api.update_device.assert_called_with( ModelType.CAMERA, doorbell.id, {"channels": channels} ) + + +async def test_rtsp_no_fix_if_third_party( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test no RTSP disabled warning if camera is third-party.""" + + for channel in doorbell.channels: + channel.is_rtsp_enabled = False + for user in ufp.api.bootstrap.users.values(): + user.all_permissions = [] + + ufp.api.get_camera = AsyncMock(return_value=doorbell) + doorbell.is_third_party_camera = True + + await init_entry(hass, ufp, [doorbell]) + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert not msg["result"]["issues"] diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 8795af57214..6db3ae22dcb 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -262,7 +262,7 @@ async def test_select_update_doorbell_settings( expected_length += 1 new_nvr = copy(ufp.api.bootstrap.nvr) - new_nvr.__fields__["update_all_messages"] = Mock(final=False) + new_nvr.__pydantic_fields__["update_all_messages"] = Mock(final=False, frozen=False) new_nvr.update_all_messages = Mock() new_nvr.doorbell_settings.all_messages = [ @@ -304,7 +304,7 @@ async def test_select_update_doorbell_message( assert state assert state.state == "Default Message (Welcome)" - new_camera = doorbell.copy() + new_camera = doorbell.model_copy() new_camera.lcd_message = LCDMessage( type=DoorbellMessageType.CUSTOM_MESSAGE, text="Test" ) @@ -332,7 +332,7 @@ async def test_select_set_option_light_motion( _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[0]) - light.__fields__["set_light_settings"] = Mock(final=False) + light.__pydantic_fields__["set_light_settings"] = Mock(final=False, frozen=False) light.set_light_settings = AsyncMock() await hass.services.async_call( @@ -357,7 +357,7 @@ async def test_select_set_option_light_camera( _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[1]) - light.__fields__["set_paired_camera"] = Mock(final=False) + light.__pydantic_fields__["set_paired_camera"] = Mock(final=False, frozen=False) light.set_paired_camera = AsyncMock() camera = list(light.api.bootstrap.cameras.values())[0] @@ -393,7 +393,7 @@ async def test_select_set_option_camera_recording( Platform.SELECT, doorbell, CAMERA_SELECTS[0] ) - doorbell.__fields__["set_recording_mode"] = Mock(final=False) + doorbell.__pydantic_fields__["set_recording_mode"] = Mock(final=False, frozen=False) doorbell.set_recording_mode = AsyncMock() await hass.services.async_call( @@ -418,7 +418,7 @@ async def test_select_set_option_camera_ir( Platform.SELECT, doorbell, CAMERA_SELECTS[1] ) - doorbell.__fields__["set_ir_led_model"] = Mock(final=False) + doorbell.__pydantic_fields__["set_ir_led_model"] = Mock(final=False, frozen=False) doorbell.set_ir_led_model = AsyncMock() await hass.services.async_call( @@ -443,7 +443,7 @@ async def test_select_set_option_camera_doorbell_custom( Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - doorbell.__fields__["set_lcd_text"] = Mock(final=False) + doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( @@ -470,7 +470,7 @@ async def test_select_set_option_camera_doorbell_unifi( Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - doorbell.__fields__["set_lcd_text"] = Mock(final=False) + doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( @@ -512,7 +512,7 @@ async def test_select_set_option_camera_doorbell_default( Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - doorbell.__fields__["set_lcd_text"] = Mock(final=False) + doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( @@ -541,7 +541,7 @@ async def test_select_set_option_viewer( Platform.SELECT, viewer, VIEWER_SELECTS[0] ) - viewer.__fields__["set_liveview"] = Mock(final=False) + viewer.__pydantic_fields__["set_liveview"] = Mock(final=False, frozen=False) viewer.set_liveview = AsyncMock() liveview = list(viewer.api.bootstrap.liveviews.values())[0] diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index bc5f372c598..9489a49bf22 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -464,7 +464,7 @@ async def test_sensor_update_alarm( api=ufp.api, ) - new_sensor = sensor_all.copy() + new_sensor = sensor_all.model_copy() new_sensor.set_alarm_timeout() new_sensor.last_alarm_event_id = event.id @@ -548,7 +548,7 @@ async def test_camera_update_license_plate( api=ufp.api, ) - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( event.id @@ -663,7 +663,7 @@ async def test_camera_update_license_plate_changes_number_during_detect( api=ufp.api, ) - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( event.id @@ -750,7 +750,7 @@ async def test_camera_update_license_plate_multiple_updates( api=ufp.api, ) - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( event.id @@ -873,7 +873,7 @@ async def test_camera_update_license_no_dupes( api=ufp.api, ) - new_camera = camera.copy() + new_camera = camera.model_copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( event.id diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 6808bacb40c..9697d1f11a4 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -9,9 +9,19 @@ from uiprotect.data import Camera, Chime, Color, Light, ModelType from uiprotect.data.devices import CameraZone from uiprotect.exceptions import BadRequest -from homeassistant.components.unifiprotect.const import ATTR_MESSAGE, DOMAIN +from homeassistant.components.unifiprotect.const import ( + ATTR_MESSAGE, + DOMAIN, + KEYRINGS_KEY_TYPE, + KEYRINGS_KEY_TYPE_ID_FINGERPRINT, + KEYRINGS_KEY_TYPE_ID_NFC, + KEYRINGS_ULP_ID, + KEYRINGS_USER_FULL_NAME, + KEYRINGS_USER_STATUS, +) from homeassistant.components.unifiprotect.services import ( SERVICE_ADD_DOORBELL_TEXT, + SERVICE_GET_USER_KEYRING_INFO, SERVICE_REMOVE_DOORBELL_TEXT, SERVICE_REMOVE_PRIVACY_ZONE, SERVICE_SET_CHIME_PAIRED, @@ -56,7 +66,9 @@ async def test_global_service_bad_device( """Test global service, invalid device ID.""" nvr = ufp.api.bootstrap.nvr - nvr.__fields__["add_custom_doorbell_message"] = Mock(final=False) + nvr.__pydantic_fields__["add_custom_doorbell_message"] = Mock( + final=False, frozen=False + ) nvr.add_custom_doorbell_message = AsyncMock() with pytest.raises(HomeAssistantError): @@ -75,7 +87,9 @@ async def test_global_service_exception( """Test global service, unexpected error.""" nvr = ufp.api.bootstrap.nvr - nvr.__fields__["add_custom_doorbell_message"] = Mock(final=False) + nvr.__pydantic_fields__["add_custom_doorbell_message"] = Mock( + final=False, frozen=False + ) nvr.add_custom_doorbell_message = AsyncMock(side_effect=BadRequest) with pytest.raises(HomeAssistantError): @@ -94,7 +108,9 @@ async def test_add_doorbell_text( """Test add_doorbell_text service.""" nvr = ufp.api.bootstrap.nvr - nvr.__fields__["add_custom_doorbell_message"] = Mock(final=False) + nvr.__pydantic_fields__["add_custom_doorbell_message"] = Mock( + final=False, frozen=False + ) nvr.add_custom_doorbell_message = AsyncMock() await hass.services.async_call( @@ -112,7 +128,9 @@ async def test_remove_doorbell_text( """Test remove_doorbell_text service.""" nvr = ufp.api.bootstrap.nvr - nvr.__fields__["remove_custom_doorbell_message"] = Mock(final=False) + nvr.__pydantic_fields__["remove_custom_doorbell_message"] = Mock( + final=False, frozen=False + ) nvr.remove_custom_doorbell_message = AsyncMock() await hass.services.async_call( @@ -129,7 +147,9 @@ async def test_add_doorbell_text_disabled_config_entry( ) -> None: """Test add_doorbell_text service.""" nvr = ufp.api.bootstrap.nvr - nvr.__fields__["add_custom_doorbell_message"] = Mock(final=False) + nvr.__pydantic_fields__["add_custom_doorbell_message"] = Mock( + final=False, frozen=False + ) nvr.add_custom_doorbell_message = AsyncMock() await hass.config_entries.async_set_disabled_by( @@ -158,10 +178,10 @@ async def test_set_chime_paired_doorbells( ufp.api.update_device = AsyncMock() - camera1 = doorbell.copy() + camera1 = doorbell.model_copy() camera1.name = "Test Camera 1" - camera2 = doorbell.copy() + camera2 = doorbell.model_copy() camera2.name = "Test Camera 2" await init_entry(hass, ufp, [camera1, camera2, chime]) @@ -239,3 +259,86 @@ async def test_remove_privacy_zone( ) ufp.api.update_device.assert_called() assert not doorbell.privacy_zones + + +@pytest.mark.asyncio +async def get_user_keyring_info( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + doorbell: Camera, +) -> None: + """Test get_user_keyring_info service.""" + + ulp_user = Mock(full_name="Test User", status="active", ulp_id="user_ulp_id") + keyring = Mock( + registry_type="nfc", + registry_id="123456", + ulp_user="user_ulp_id", + ) + keyring_2 = Mock( + registry_type="fingerprint", + registry_id="2", + ulp_user="user_ulp_id", + ) + ufp.api.bootstrap.ulp_users.as_list = Mock(return_value=[ulp_user]) + ufp.api.bootstrap.keyrings.as_list = Mock(return_value=[keyring, keyring_2]) + + await init_entry(hass, ufp, [doorbell]) + + camera_entry = entity_registry.async_get("binary_sensor.test_camera_doorbell") + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_USER_KEYRING_INFO, + {ATTR_DEVICE_ID: camera_entry.device_id}, + blocking=True, + return_response=True, + ) + + assert response == { + "users": [ + { + KEYRINGS_USER_FULL_NAME: "Test User", + "keys": [ + { + KEYRINGS_KEY_TYPE: "nfc", + KEYRINGS_KEY_TYPE_ID_NFC: "123456", + }, + { + KEYRINGS_KEY_TYPE_ID_FINGERPRINT: "2", + KEYRINGS_KEY_TYPE: "fingerprint", + }, + ], + KEYRINGS_USER_STATUS: "active", + KEYRINGS_ULP_ID: "user_ulp_id", + }, + ], + } + + +async def test_get_user_keyring_info_no_users( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + doorbell: Camera, +) -> None: + """Test get_user_keyring_info service with no users.""" + + ufp.api.bootstrap.ulp_users.as_list = Mock(return_value=[]) + ufp.api.bootstrap.keyrings.as_list = Mock(return_value=[]) + + await init_entry(hass, ufp, [doorbell]) + + camera_entry = entity_registry.async_get("binary_sensor.test_camera_doorbell") + + with pytest.raises( + HomeAssistantError, match="No users found, please check Protect permissions." + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_USER_KEYRING_INFO, + {ATTR_DEVICE_ID: camera_entry.device_id}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 9e0e9efa0ce..194e46681ce 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -89,7 +89,7 @@ async def test_switch_nvr(hass: HomeAssistant, ufp: MockUFPFixture) -> None: assert_entity_counts(hass, Platform.SWITCH, 2, 2) nvr = ufp.api.bootstrap.nvr - nvr.__fields__["set_insights"] = Mock(final=False) + nvr.__pydantic_fields__["set_insights"] = Mock(final=False, frozen=False) nvr.set_insights = AsyncMock() entity_id = "switch.unifiprotect_insights_enabled" @@ -272,7 +272,7 @@ async def test_switch_light_status( description = LIGHT_SWITCHES[1] - light.__fields__["set_status_light"] = Mock(final=False) + light.__pydantic_fields__["set_status_light"] = Mock(final=False, frozen=False) light.set_status_light = AsyncMock() _, entity_id = ids_from_device_description(Platform.SWITCH, light, description) @@ -300,7 +300,7 @@ async def test_switch_camera_ssh( description = CAMERA_SWITCHES[0] - doorbell.__fields__["set_ssh"] = Mock(final=False) + doorbell.__pydantic_fields__["set_ssh"] = Mock(final=False, frozen=False) doorbell.set_ssh = AsyncMock() _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) @@ -333,7 +333,9 @@ async def test_switch_camera_simple( assert description.ufp_set_method is not None - doorbell.__fields__[description.ufp_set_method] = Mock(final=False) + doorbell.__pydantic_fields__[description.ufp_set_method] = Mock( + final=False, frozen=False + ) setattr(doorbell, description.ufp_set_method, AsyncMock()) set_method = getattr(doorbell, description.ufp_set_method) @@ -362,7 +364,7 @@ async def test_switch_camera_highfps( description = CAMERA_SWITCHES[3] - doorbell.__fields__["set_video_mode"] = Mock(final=False) + doorbell.__pydantic_fields__["set_video_mode"] = Mock(final=False, frozen=False) doorbell.set_video_mode = AsyncMock() _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) @@ -393,7 +395,7 @@ async def test_switch_camera_privacy( description = PRIVACY_MODE_SWITCH - doorbell.__fields__["set_privacy"] = Mock(final=False) + doorbell.__pydantic_fields__["set_privacy"] = Mock(final=False, frozen=False) doorbell.set_privacy = AsyncMock() _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) @@ -409,7 +411,7 @@ async def test_switch_camera_privacy( doorbell.set_privacy.assert_called_with(True, 0, RecordingMode.NEVER) - new_doorbell = doorbell.copy() + new_doorbell = doorbell.model_copy() new_doorbell.add_privacy_zone() new_doorbell.mic_volume = 0 new_doorbell.recording_settings.mode = RecordingMode.NEVER @@ -445,7 +447,7 @@ async def test_switch_camera_privacy_already_on( description = PRIVACY_MODE_SWITCH - doorbell.__fields__["set_privacy"] = Mock(final=False) + doorbell.__pydantic_fields__["set_privacy"] = Mock(final=False, frozen=False) doorbell.set_privacy = AsyncMock() _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) diff --git a/tests/components/unifiprotect/test_text.py b/tests/components/unifiprotect/test_text.py index 3ca11744abb..c34611c43a9 100644 --- a/tests/components/unifiprotect/test_text.py +++ b/tests/components/unifiprotect/test_text.py @@ -78,7 +78,7 @@ async def test_text_camera_set( Platform.TEXT, doorbell, description ) - doorbell.__fields__["set_lcd_text"] = Mock(final=False) + doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( diff --git a/tests/components/unifiprotect/test_views.py b/tests/components/unifiprotect/test_views.py index fed0a98552d..f787089b83f 100644 --- a/tests/components/unifiprotect/test_views.py +++ b/tests/components/unifiprotect/test_views.py @@ -11,6 +11,8 @@ from uiprotect.exceptions import ClientError from homeassistant.components.unifiprotect.views import ( async_generate_event_video_url, + async_generate_proxy_event_video_url, + async_generate_snapshot_url, async_generate_thumbnail_url, ) from homeassistant.core import HomeAssistant @@ -168,6 +170,231 @@ async def test_thumbnail_invalid_entry_entry_id( assert response.status == 404 +async def test_snapshot_bad_nvr_id( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad NVR id.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + url = url.replace(ufp.api.bootstrap.nvr.id, "bad_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_snapshot_bad_camera_id( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad camera id.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + url = url.replace(camera.id, "bad_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_snapshot_bad_camera_perms( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad camera perms.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + + ufp.api.bootstrap.auth_user.all_permissions = [] + ufp.api.bootstrap.auth_user._perm_cache = {} + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 403 + ufp.api.request.assert_not_called() + + +async def test_snapshot_bad_timestamp( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad timestamp params.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + url = url.replace(fixed_now.replace(microsecond=0).isoformat(), "bad_time") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 400 + ufp.api.request.assert_not_called() + + +async def test_snapshot_client_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot triggers client error at API.""" + + ufp.api.get_camera_snapshot = AsyncMock(side_effect=ClientError()) + + tomorrow = fixed_now + timedelta(days=1) + + await init_entry(hass, ufp, [camera]) + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, tomorrow) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.get_camera_snapshot.assert_called_once() + + +async def test_snapshot_notfound( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot not found.""" + + ufp.api.get_camera_snapshot = AsyncMock(return_value=None) + + tomorrow = fixed_now + timedelta(days=1) + + await init_entry(hass, ufp, [camera]) + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, tomorrow) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.get_camera_snapshot.assert_called_once() + + +@pytest.mark.parametrize(("width", "height"), [("test", None), (None, "test")]) +async def test_snapshot_bad_params( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, + width: Any, + height: Any, +) -> None: + """Test invalid bad query parameters.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url( + ufp.api.bootstrap.nvr.id, camera.id, fixed_now, width=width, height=height + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 400 + ufp.api.request.assert_not_called() + + +async def test_snapshot( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot at timestamp in URL.""" + + ufp.api.get_camera_snapshot = AsyncMock(return_value=b"testtest") + await init_entry(hass, ufp, [camera]) + + # replace microseconds to match behavior of underlying library + fixed_now = fixed_now.replace(microsecond=0) + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + # verify when height is None that it is called with camera high channel height + height = camera.high_camera_channel.height + + assert response.status == 200 + assert response.content_type == "image/jpeg" + assert await response.content.read() == b"testtest" + ufp.api.get_camera_snapshot.assert_called_once_with( + camera.id, None, height, dt=fixed_now + ) + + +@pytest.mark.parametrize(("width", "height"), [(123, None), (None, 456), (123, 456)]) +async def test_snapshot_with_dimensions( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, + width: Any, + height: Any, +) -> None: + """Test snapshot at timestamp in URL with specified width and height.""" + + ufp.api.get_camera_snapshot = AsyncMock(return_value=b"testtest") + await init_entry(hass, ufp, [camera]) + + # Replace microseconds to match behavior of underlying library + fixed_now = fixed_now.replace(microsecond=0) + url = async_generate_snapshot_url( + ufp.api.bootstrap.nvr.id, camera.id, fixed_now, width=width, height=height + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + # Assertions + assert response.status == 200 + assert response.content_type == "image/jpeg" + assert await response.content.read() == b"testtest" + ufp.api.get_camera_snapshot.assert_called_once_with( + camera.id, width, height, dt=fixed_now + ) + + async def test_video_bad_event( hass: HomeAssistant, ufp: MockUFPFixture, @@ -520,3 +747,219 @@ async def test_video_entity_id( assert response.status == 200 ufp.api.request.assert_called_once() + + +async def test_video_event_bad_nvr_id( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + camera: Camera, + ufp: MockUFPFixture, +) -> None: + """Test video proxy URL with bad NVR id.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_proxy_event_video_url("bad_id", "test_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_video_event_bad_event( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, +) -> None: + """Test generating event with bad event ID.""" + + ufp.api.get_event = AsyncMock(side_effect=ClientError()) + + await init_entry(hass, ufp, [camera]) + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "bad_event_id") + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_video_event_bad_camera( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, +) -> None: + """Test generating event with bad camera ID.""" + + ufp.api.get_event = AsyncMock(side_effect=ClientError()) + + await init_entry(hass, ufp, [camera]) + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "bad_event_id") + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_video_event_bad_camera_perms( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test video URL with bad camera perms.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + event_start = fixed_now - timedelta(seconds=30) + event = Event( + model=ModelType.EVENT, + api=ufp.api, + start=event_start, + end=fixed_now, + id="test_id", + type=EventType.MOTION, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id="bad_id", + camera=camera, + ) + + ufp.api.get_event = AsyncMock(return_value=event) + + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "test_id") + + ufp.api.bootstrap.auth_user.all_permissions = [] + ufp.api.bootstrap.auth_user._perm_cache = {} + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_video_event_ongoing( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test video URL with ongoing event.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + event_start = fixed_now - timedelta(seconds=30) + event = Event( + model=ModelType.EVENT, + api=ufp.api, + start=event_start, + id="test_id", + type=EventType.MOTION, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=camera.id, + camera=camera, + ) + + ufp.api.get_event = AsyncMock(return_value=event) + + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "test_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 400 + ufp.api.request.assert_not_called() + + +async def test_event_video_no_data( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test invalid no event video returned.""" + + await init_entry(hass, ufp, [camera]) + event_start = fixed_now - timedelta(seconds=30) + event = Event( + model=ModelType.EVENT, + api=ufp.api, + start=event_start, + end=fixed_now, + id="test_id", + type=EventType.MOTION, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=camera.id, + camera=camera, + ) + + ufp.api.request = AsyncMock(side_effect=ClientError) + ufp.api.get_event = AsyncMock(return_value=event) + + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "test_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + + +async def test_event_video( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test event video URL with no video.""" + + content = Mock() + content.__anext__ = AsyncMock(side_effect=[b"test", b"test", StopAsyncIteration()]) + content.__aiter__ = Mock(return_value=content) + + mock_response = Mock() + mock_response.content_length = 8 + mock_response.content.iter_chunked = Mock(return_value=content) + + ufp.api.request = AsyncMock(return_value=mock_response) + await init_entry(hass, ufp, [camera]) + event_start = fixed_now - timedelta(seconds=30) + event = Event( + model=ModelType.EVENT, + api=ufp.api, + start=event_start, + end=fixed_now, + id="test_id", + type=EventType.MOTION, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=camera.id, + camera=camera, + ) + + ufp.api.get_event = AsyncMock(return_value=event) + + url = async_generate_proxy_event_video_url(ufp.api.bootstrap.nvr.id, "test_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + assert await response.content.read() == b"testtest" + + assert response.status == 200 + ufp.api.request.assert_called_once() diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 5ebfd2c13ad..5be9cb3fe02 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -27,7 +27,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -from tests.common import async_mock_service, get_fixture_path +from tests.common import MockEntityPlatform, async_mock_service, get_fixture_path CONFIG_CHILDREN_ONLY = { "name": "test", @@ -74,6 +74,7 @@ class MockMediaPlayer(media_player.MediaPlayerEntity): self._shuffle = False self._sound_mode = None self._repeat = None + self.platform = MockEntityPlatform(hass) self.service_calls = { "turn_on": async_mock_service( @@ -361,26 +362,10 @@ async def test_config_bad_key(hass: HomeAssistant) -> None: async def test_platform_setup(hass: HomeAssistant) -> None: """Test platform setup.""" config = {"name": "test", "platform": "universal"} - bad_config = {"platform": "universal"} - entities = [] - - def add_entities(new_entities): - """Add devices to list.""" - entities.extend(new_entities) - - setup_ok = True - try: - await universal.async_setup_platform( - hass, validate_config(bad_config), add_entities - ) - except MultipleInvalid: - setup_ok = False - assert not setup_ok - assert len(entities) == 0 - - await universal.async_setup_platform(hass, validate_config(config), add_entities) - assert len(entities) == 1 - assert entities[0].name == "test" + assert await async_setup_component(hass, "media_player", {"media_player": config}) + await hass.async_block_till_done() + assert hass.states.async_all() != [] + assert hass.states.get("media_player.test") is not None async def test_master_state(hass: HomeAssistant) -> None: @@ -461,11 +446,10 @@ async def test_active_child_state(hass: HomeAssistant, mock_states) -> None: async def test_name(hass: HomeAssistant) -> None: """Test name property.""" - config = validate_config(CONFIG_CHILDREN_ONLY) - - ump = universal.UniversalMediaPlayer(hass, config) - - assert config["name"] == ump.name + assert await async_setup_component( + hass, "media_player", {"media_player": CONFIG_CHILDREN_ONLY} + ) + assert hass.states.get("media_player.test") is not None async def test_polling(hass: HomeAssistant) -> None: diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index a35f7bb0f12..d4916de8039 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -896,98 +896,6 @@ async def test_name(hass: HomeAssistant) -> None: assert expected.items() <= state.attributes.items() -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockUpdateEntity(UpdateEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockUpdateEntity() - assert entity.supported_features_compat is UpdateEntityFeature(1) - assert "MockUpdateEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "UpdateEntityFeature.INSTALL" in caplog.text - caplog.clear() - assert entity.supported_features_compat is UpdateEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text - - -async def test_deprecated_supported_features_ints_with_service_call( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test deprecated supported features ints with install service.""" - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - ), - ) - - class MockUpdateEntity(UpdateEntity): - _attr_supported_features = 1 | 2 - - def install(self, version: str | None = None, backup: bool = False) -> None: - """Install an update.""" - - entity = MockUpdateEntity() - entity.entity_id = ( - "update.test_deprecated_supported_features_ints_with_service_call" - ) - - async def async_setup_entry_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test update platform via config entry.""" - async_add_entities([entity]) - - mock_platform( - hass, - f"{TEST_DOMAIN}.{DOMAIN}", - MockPlatform(async_setup_entry=async_setup_entry_platform), - ) - - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert "is using deprecated supported features values" in caplog.text - - assert isinstance(entity.supported_features, int) - - with pytest.raises( - HomeAssistantError, - match="Backup is not supported for update.test_deprecated_supported_features_ints_with_service_call", - ): - await hass.services.async_call( - DOMAIN, - SERVICE_INSTALL, - { - ATTR_VERSION: "0.9.9", - ATTR_BACKUP: True, - ATTR_ENTITY_ID: "update.test_deprecated_supported_features_ints_with_service_call", - }, - blocking=True, - ) - - async def test_custom_version_is_newer(hass: HomeAssistant) -> None: """Test UpdateEntity with overridden version_is_newer method.""" @@ -1032,7 +940,7 @@ async def test_custom_version_is_newer(hass: HomeAssistant) -> None: ("supported_features", "extra_expected_attributes"), [ ( - 0, + UpdateEntityFeature(0), [ {}, {}, diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 4bee5c0e589..300d925b82b 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -7,7 +7,7 @@ import copy from datetime import datetime import socket from typing import Any -from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from urllib.parse import urlparse from async_upnp_client.aiohttp import AiohttpNotifyServer @@ -25,6 +25,15 @@ from homeassistant.components.upnp.const import ( DOMAIN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -36,7 +45,7 @@ TEST_LOCATION6 = "http://[fe80::1%2]/desc.xml" TEST_HOST = urlparse(TEST_LOCATION).hostname TEST_FRIENDLY_NAME = "mock-name" TEST_MAC_ADDRESS = "00:11:22:33:44:55" -TEST_DISCOVERY = ssdp.SsdpServiceInfo( +TEST_DISCOVERY = SsdpServiceInfo( ssdp_st=TEST_ST, ssdp_udn=TEST_UDN, ssdp_usn=TEST_USN, @@ -45,12 +54,12 @@ TEST_DISCOVERY = ssdp.SsdpServiceInfo( "_udn": TEST_UDN, "location": TEST_LOCATION, "usn": TEST_USN, - ssdp.ATTR_UPNP_DEVICE_TYPE: TEST_ST, - ssdp.ATTR_UPNP_FRIENDLY_NAME: TEST_FRIENDLY_NAME, - ssdp.ATTR_UPNP_MANUFACTURER: "mock-manufacturer", - ssdp.ATTR_UPNP_MODEL_NAME: "mock-model-name", - ssdp.ATTR_UPNP_SERIAL: "mock-serial", - ssdp.ATTR_UPNP_UDN: TEST_UDN, + ATTR_UPNP_DEVICE_TYPE: TEST_ST, + ATTR_UPNP_FRIENDLY_NAME: TEST_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER: "mock-manufacturer", + ATTR_UPNP_MODEL_NAME: "mock-model-name", + ATTR_UPNP_SERIAL: "mock-serial", + ATTR_UPNP_UDN: TEST_UDN, }, ssdp_headers={ "_host": TEST_HOST, @@ -75,13 +84,13 @@ def mock_igd_device(mock_async_create_device) -> IgdDevice: """Mock async_upnp_client device.""" mock_upnp_device = create_autospec(UpnpDevice, instance=True) mock_upnp_device.device_url = TEST_DISCOVERY.ssdp_location - mock_upnp_device.serial_number = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_SERIAL] + mock_upnp_device.serial_number = TEST_DISCOVERY.upnp[ATTR_UPNP_SERIAL] mock_igd_device = create_autospec(IgdDevice) mock_igd_device.device_type = TEST_DISCOVERY.ssdp_st - mock_igd_device.name = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] - mock_igd_device.manufacturer = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_MANUFACTURER] - mock_igd_device.model_name = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + mock_igd_device.name = TEST_DISCOVERY.upnp[ATTR_UPNP_FRIENDLY_NAME] + mock_igd_device.manufacturer = TEST_DISCOVERY.upnp[ATTR_UPNP_MANUFACTURER] + mock_igd_device.model_name = TEST_DISCOVERY.upnp[ATTR_UPNP_MODEL_NAME] mock_igd_device.udn = TEST_DISCOVERY.ssdp_udn mock_igd_device.device = mock_upnp_device @@ -179,7 +188,7 @@ async def ssdp_instant_discovery(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: @@ -212,7 +221,7 @@ async def ssdp_instant_discovery_multi_location(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: @@ -241,7 +250,7 @@ async def ssdp_no_discovery(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: @@ -286,11 +295,8 @@ async def mock_config_entry( # Load config_entry. entry.add_to_hass(hass) - with patch( - "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", - PropertyMock(return_value=True), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 8799e0faab3..fb650ac7a47 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.upnp.const import ( CONFIG_ENTRY_FORCE_POLL, CONFIG_ENTRY_HOST, @@ -21,6 +20,11 @@ from homeassistant.components.upnp.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( TEST_DISCOVERY, @@ -109,14 +113,14 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=TEST_USN, # ssdp_udn=TEST_UDN, # Not provided. ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: ST_IGD_V1, - # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. + ATTR_UPNP_DEVICE_TYPE: ST_IGD_V1, + # ATTR_UPNP_UDN: TEST_UDN, # Not provided. }, ), ) @@ -130,14 +134,14 @@ async def test_flow_ssdp_non_igd_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=TEST_USN, ssdp_udn=TEST_UDN, ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, ssdp_all_locations=[TEST_LOCATION], upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD }, ), ) @@ -449,7 +453,7 @@ async def test_flow_ssdp_with_mismatched_udn(hass: HomeAssistant) -> None: """Test config flow: discovered + configured through ssdp, where the UDN differs in the SSDP-discovery vs device description.""" # Discovered via step ssdp. test_discovery = copy.deepcopy(TEST_DISCOVERY) - test_discovery.upnp[ssdp.ATTR_UPNP_UDN] = "uuid:another_udn" + test_discovery.upnp[ATTR_UPNP_UDN] = "uuid:another_udn" result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index ff74ca87b12..ef799a1b8af 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -22,6 +22,7 @@ from homeassistant.components.upnp.const import ( DOMAIN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo from .conftest import ( TEST_DISCOVERY, @@ -125,7 +126,7 @@ async def test_async_setup_udn_mismatch( ) -> None: """Test async_setup_entry for a device which reports a different UDN from SSDP-discovery and device description.""" test_discovery = copy.deepcopy(TEST_DISCOVERY) - test_discovery.upnp[ssdp.ATTR_UPNP_UDN] = "uuid:another_udn" + test_discovery.upnp[ATTR_UPNP_UDN] = "uuid:another_udn" entry = MockConfigEntry( domain=DOMAIN, @@ -146,7 +147,7 @@ async def test_async_setup_udn_mismatch( async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index 67a64b265d9..e9d8a9cce8f 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from async_upnp_client.profiles.igd import IgdDevice, IgdState +import pytest from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant @@ -11,6 +12,7 @@ import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_upnp_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index bbd802afc95..8f8ed672374 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1,18 +1,24 @@ """Tests for the USB Discovery integration.""" +import asyncio +from datetime import timedelta +import logging import os -import sys +from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel import pytest from homeassistant.components import usb +from homeassistant.components.usb.utils import usb_device_from_port from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.setup import async_setup_component from . import conbee_device, slae_sh_device +from tests.common import import_and_test_deprecated_constant from tests.typing import WebSocketGenerator @@ -56,10 +62,6 @@ def mock_venv(): yield -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_observer_discovery( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv ) -> None: @@ -80,7 +82,7 @@ async def test_observer_discovery( async def _mock_monitor_observer_callback(callback): await hass.async_add_executor_job( - callback, MagicMock(action="create", device_path="/dev/new") + callback, MagicMock(action="add", device_path="/dev/new") ) def _create_mock_monitor_observer(monitor, callback, name): @@ -90,6 +92,7 @@ async def test_observer_discovery( return mock_observer with ( + patch("sys.platform", "linux"), patch("pyudev.Context"), patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), patch("pyudev.Monitor.filter_by"), @@ -112,10 +115,65 @@ async def test_observer_discovery( assert mock_observer.mock_calls == [call.start(), call.__bool__(), call.stop()] -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) +async def test_polling_discovery( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +) -> None: + """Test that polling can discover a device without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + mock_comports_found_device = asyncio.Event() + + def get_comports() -> list: + nonlocal mock_comports + + # Only "find" a device after a few invocations + if len(mock_comports.mock_calls) < 5: + return [] + + mock_comports_found_device.set() + return [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with ( + patch("sys.platform", "linux"), + patch( + "homeassistant.components.usb.USBDiscovery._get_monitor_observer", + return_value=None, + ), + patch( + "homeassistant.components.usb.POLLING_MONITOR_SCAN_PERIOD", + timedelta(seconds=0.01), + ), + patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), + patch( + "homeassistant.components.usb.comports", side_effect=get_comports + ) as mock_comports, + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + # Wait until a new device is discovered after a few polling attempts + assert len(mock_config_flow.mock_calls) == 0 + await mock_comports_found_device.wait() + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + async def test_removal_by_observer_before_started( hass: HomeAssistant, operating_system ) -> None: @@ -668,10 +726,6 @@ async def test_non_matching_discovered_by_scanner_after_started( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_observer_on_wsl_fallback_without_throwing_exception( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv ) -> None: @@ -710,10 +764,6 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_not_discovered_by_observer_before_started_on_docker( hass: HomeAssistant, docker ) -> None: @@ -1160,3 +1210,192 @@ async def test_cp2102n_ordering_on_macos( # We always use `cu.SLAB_USBtoUART` assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/cu.SLAB_USBtoUART2" + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "UsbServiceInfo", + "homeassistant.helpers.service_info.usb.UsbServiceInfo", + UsbServiceInfo, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + usb, + constant_name, + replacement_name, + replacement, + "2026.2", + ) + + +@patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) +async def test_register_port_event_callback( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the registration of a port event callback.""" + + port1 = Mock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + + port2 = Mock( + device=conbee_device.device, + vid=12346, + pid=12346, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + + port1_usb = usb_device_from_port(port1) + port2_usb = usb_device_from_port(port2) + + ws_client = await hass_ws_client(hass) + + mock_callback1 = Mock() + mock_callback2 = Mock() + + # Start off with no ports + with ( + patch("pyudev.Context", side_effect=ImportError), + patch("homeassistant.components.usb.comports", return_value=[]), + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + + _cancel1 = usb.async_register_port_event_callback(hass, mock_callback1) + cancel2 = usb.async_register_port_event_callback(hass, mock_callback2) + + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Add two new ports + with patch("homeassistant.components.usb.comports", return_value=[port1, port2]): + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + + assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + + # Cancel the second callback + cancel2() + cancel2() + + mock_callback1.reset_mock() + mock_callback2.reset_mock() + + # Remove port 2 + with patch("homeassistant.components.usb.comports", return_value=[port1]): + await ws_client.send_json({"id": 2, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert mock_callback1.mock_calls == [call(set(), {port2_usb})] + assert mock_callback2.mock_calls == [] # The second callback was unregistered + + mock_callback1.reset_mock() + mock_callback2.reset_mock() + + # Keep port 2 removed + with patch("homeassistant.components.usb.comports", return_value=[port1]): + await ws_client.send_json({"id": 3, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + # Nothing changed so no callback is called + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Unplug one and plug in the other + with patch("homeassistant.components.usb.comports", return_value=[port2]): + await ws_client.send_json({"id": 4, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert mock_callback1.mock_calls == [call({port2_usb}, {port1_usb})] + assert mock_callback2.mock_calls == [] + + +@patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) +async def test_register_port_event_callback_failure( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test port event callback failure handling.""" + + port1 = Mock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + + port2 = Mock( + device=conbee_device.device, + vid=12346, + pid=12346, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + + port1_usb = usb_device_from_port(port1) + port2_usb = usb_device_from_port(port2) + + ws_client = await hass_ws_client(hass) + + mock_callback1 = Mock(side_effect=RuntimeError("Failure 1")) + mock_callback2 = Mock(side_effect=RuntimeError("Failure 2")) + + # Start off with no ports + with ( + patch("pyudev.Context", side_effect=ImportError), + patch("homeassistant.components.usb.comports", return_value=[]), + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + + usb.async_register_port_event_callback(hass, mock_callback1) + usb.async_register_port_event_callback(hass, mock_callback2) + + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Add two new ports + with ( + patch("homeassistant.components.usb.comports", return_value=[port1, port2]), + caplog.at_level(logging.ERROR, logger="homeassistant.components.usb"), + ): + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + # Both were called even though they raised exceptions + assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + + assert caplog.text.count("Error in USB port event callback") == 2 + assert "Failure 1" in caplog.text + assert "Failure 2" in caplog.text diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 560566d7c49..4901e069aee 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -374,6 +374,7 @@ async def test_change_device_source( # Configure source entity 3 (without a device) source_config_entry_3 = MockConfigEntry() + source_config_entry_3.add_to_hass(hass) source_entity_3 = entity_registry.async_get_or_create( "sensor", "test", diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 0ab78739f7f..348afac57f7 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1764,6 +1764,23 @@ async def test_self_reset_hourly_dst2(hass: HomeAssistant) -> None: assert state.attributes.get("next_reset") == next_reset +async def test_tz_changes(hass: HomeAssistant) -> None: + """Test that a timezone change changes the scheduler.""" + + await hass.config.async_update(time_zone="Europe/Prague") + + await _test_self_reset( + hass, gen_config("daily"), "2024-10-26T23:59:00.000000+02:00" + ) + state = hass.states.get("sensor.energy_bill") + assert state.attributes.get("next_reset") == "2024-10-28T00:00:00+01:00" + + await hass.config.async_update(time_zone="Pacific/Fiji") + + state = hass.states.get("sensor.energy_bill") + assert state.attributes.get("next_reset") != "2024-10-28T00:00:00+01:00" + + async def test_self_reset_daily(hass: HomeAssistant) -> None: """Test daily reset of meter.""" await _test_self_reset( diff --git a/tests/components/vacuum/__init__.py b/tests/components/vacuum/__init__.py index 0a681730cb2..26e31a87eee 100644 --- a/tests/components/vacuum/__init__.py +++ b/tests/components/vacuum/__init__.py @@ -4,12 +4,8 @@ from typing import Any from homeassistant.components.vacuum import ( DOMAIN, - STATE_CLEANING, - STATE_DOCKED, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -39,20 +35,20 @@ class MockVacuum(MockEntity, StateVacuumEntity): def __init__(self, **values: Any) -> None: """Initialize a mock vacuum entity.""" super().__init__(**values) - self._attr_state = STATE_DOCKED + self._attr_activity = VacuumActivity.DOCKED self._attr_fan_speed = "slow" def stop(self, **kwargs: Any) -> None: """Stop cleaning.""" - self._attr_state = STATE_IDLE + self._attr_activity = VacuumActivity.IDLE def return_to_base(self, **kwargs: Any) -> None: """Return to base.""" - self._attr_state = STATE_RETURNING + self._attr_activity = VacuumActivity.RETURNING def clean_spot(self, **kwargs: Any) -> None: """Clean a spot.""" - self._attr_state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set the fan speed.""" @@ -60,11 +56,11 @@ class MockVacuum(MockEntity, StateVacuumEntity): def start(self) -> None: """Start cleaning.""" - self._attr_state = STATE_CLEANING + self._attr_activity = VacuumActivity.CLEANING def pause(self) -> None: """Pause cleaning.""" - self._attr_state = STATE_PAUSED + self._attr_activity = VacuumActivity.PAUSED async def help_async_setup_entry_init( diff --git a/tests/components/vacuum/conftest.py b/tests/components/vacuum/conftest.py index d298260c575..6e6639431d0 100644 --- a/tests/components/vacuum/conftest.py +++ b/tests/components/vacuum/conftest.py @@ -1,13 +1,28 @@ """Fixtures for Vacuum platform tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator +from unittest.mock import MagicMock, patch import pytest -from homeassistant.config_entries import ConfigFlow +from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN, VacuumEntityFeature +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, frame +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from tests.common import mock_config_flow, mock_platform +from . import MockVacuum + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" class MockFlow(ConfigFlow): @@ -17,7 +32,94 @@ class MockFlow(ConfigFlow): @pytest.fixture def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" - mock_platform(hass, "test.config_flow") + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with mock_config_flow("test", MockFlow): + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(name="supported_features") +async def vacuum_supported_features() -> VacuumEntityFeature: + """Return the supported features for the test vacuum entity.""" + return ( + VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.CLEAN_SPOT + | VacuumEntityFeature.MAP + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + ) + + +@pytest.fixture(name="mock_vacuum_entity") +async def setup_vacuum_platform_test_entity( + hass: HomeAssistant, + config_flow_fixture: None, + entity_registry: er.EntityRegistry, + supported_features: VacuumEntityFeature, +) -> MagicMock: + """Set up vacuum entity using an entity platform.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [VACUUM_DOMAIN] + ) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + entity = MockVacuum( + supported_features=supported_features, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test vacuum platform via config entry.""" + async_add_entities([entity]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{VACUUM_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + + return entity + + +@pytest.fixture(name="mock_as_custom_component") +async def mock_frame(hass: HomeAssistant) -> AsyncGenerator[None]: + """Mock frame.""" + with patch( + "homeassistant.helpers.frame.get_integration_frame", + return_value=frame.IntegrationFrame( + custom_integration=True, + integration="alarm_control_panel", + module="test_init.py", + relative_filename="test_init.py", + frame=frame.get_current_frame(), + ), + ): yield diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index 9a2a67f7141..5a1b1fea7de 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -5,12 +5,7 @@ from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.vacuum import ( - DOMAIN, - STATE_CLEANING, - STATE_DOCKED, - STATE_RETURNING, -) +from homeassistant.components.vacuum import DOMAIN, VacuumActivity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -122,7 +117,7 @@ async def test_if_state( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_DOCKED) + hass.states.async_set(entry.entity_id, VacuumActivity.DOCKED) assert await async_setup_component( hass, @@ -174,7 +169,7 @@ async def test_if_state( assert len(service_calls) == 1 assert service_calls[0].data["some"] == "is_docked - event - test_event2" - hass.states.async_set(entry.entity_id, STATE_CLEANING) + hass.states.async_set(entry.entity_id, VacuumActivity.CLEANING) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() @@ -182,7 +177,7 @@ async def test_if_state( assert service_calls[1].data["some"] == "is_cleaning - event - test_event1" # Returning means it's still cleaning - hass.states.async_set(entry.entity_id, STATE_RETURNING) + hass.states.async_set(entry.entity_id, VacuumActivity.RETURNING) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() @@ -207,7 +202,7 @@ async def test_if_state_legacy( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_CLEANING) + hass.states.async_set(entry.entity_id, VacuumActivity.CLEANING) assert await async_setup_component( hass, diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index c186bd4d9eb..3a0cbafb4a1 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -7,7 +7,7 @@ from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.vacuum import DOMAIN, STATE_CLEANING, STATE_DOCKED +from homeassistant.components.vacuum import DOMAIN, VacuumActivity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -188,7 +188,7 @@ async def test_if_fires_on_state_change( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_DOCKED) + hass.states.async_set(entry.entity_id, VacuumActivity.DOCKED) assert await async_setup_component( hass, @@ -238,7 +238,7 @@ async def test_if_fires_on_state_change( ) # Fake that the entity is cleaning - hass.states.async_set(entry.entity_id, STATE_CLEANING) + hass.states.async_set(entry.entity_id, VacuumActivity.CLEANING) await hass.async_block_till_done() assert len(service_calls) == 1 assert ( @@ -247,7 +247,7 @@ async def test_if_fires_on_state_change( ) # Fake that the entity is docked - hass.states.async_set(entry.entity_id, STATE_DOCKED) + hass.states.async_set(entry.entity_id, VacuumActivity.DOCKED) await hass.async_block_till_done() assert len(service_calls) == 2 assert ( @@ -273,7 +273,7 @@ async def test_if_fires_on_state_change_legacy( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_DOCKED) + hass.states.async_set(entry.entity_id, VacuumActivity.DOCKED) assert await async_setup_component( hass, @@ -304,7 +304,7 @@ async def test_if_fires_on_state_change_legacy( ) # Fake that the entity is cleaning - hass.states.async_set(entry.entity_id, STATE_CLEANING) + hass.states.async_set(entry.entity_id, VacuumActivity.CLEANING) await hass.async_block_till_done() assert len(service_calls) == 1 assert ( @@ -330,7 +330,7 @@ async def test_if_fires_on_state_change_with_for( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_DOCKED) + hass.states.async_set(entry.entity_id, VacuumActivity.DOCKED) assert await async_setup_component( hass, @@ -365,7 +365,7 @@ async def test_if_fires_on_state_change_with_for( await hass.async_block_till_done() assert len(service_calls) == 0 - hass.states.async_set(entry.entity_id, STATE_CLEANING) + hass.states.async_set(entry.entity_id, VacuumActivity.CLEANING) await hass.async_block_till_done() assert len(service_calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index d03f1d28b58..8ae054b5646 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -5,12 +5,13 @@ from __future__ import annotations from enum import Enum from types import ModuleType from typing import Any +from unittest.mock import patch import pytest from homeassistant.components import vacuum from homeassistant.components.vacuum import ( - DOMAIN, + DOMAIN as VACUUM_DOMAIN, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -19,19 +20,20 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, StateVacuumEntity, + VacuumActivity, VacuumEntityFeature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import frame from . import MockVacuum, help_async_setup_entry_init, help_async_unload_entry +from .common import async_start from tests.common import ( MockConfigEntry, + MockEntity, + MockEntityPlatform, MockModule, help_test_all, import_and_test_deprecated_constant_enum, @@ -72,14 +74,33 @@ def test_deprecated_constants( ) +@pytest.mark.parametrize( + ("enum", "constant_prefix"), _create_tuples(vacuum.VacuumActivity, "STATE_") +) +@pytest.mark.parametrize( + "module", + [vacuum], +) +def test_deprecated_constants_for_state( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2026.1" + ) + + @pytest.mark.parametrize( ("service", "expected_state"), [ - (SERVICE_CLEAN_SPOT, STATE_CLEANING), - (SERVICE_PAUSE, STATE_PAUSED), - (SERVICE_RETURN_TO_BASE, STATE_RETURNING), - (SERVICE_START, STATE_CLEANING), - (SERVICE_STOP, STATE_IDLE), + (SERVICE_CLEAN_SPOT, VacuumActivity.CLEANING), + (SERVICE_PAUSE, VacuumActivity.PAUSED), + (SERVICE_RETURN_TO_BASE, VacuumActivity.RETURNING), + (SERVICE_START, VacuumActivity.CLEANING), + (SERVICE_STOP, VacuumActivity.IDLE), ], ) async def test_state_services( @@ -101,18 +122,20 @@ async def test_state_services( async_unload_entry=help_async_unload_entry, ), ) - setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + setup_test_component_platform( + hass, VACUUM_DOMAIN, [mock_vacuum], from_config_entry=True + ) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, service, {"entity_id": mock_vacuum.entity_id}, blocking=True, ) - vacuum_state = hass.states.get(mock_vacuum.entity_id) + activity = hass.states.get(mock_vacuum.entity_id) - assert vacuum_state.state == expected_state + assert activity.state == expected_state async def test_fan_speed(hass: HomeAssistant, config_flow_fixture: None) -> None: @@ -132,14 +155,16 @@ async def test_fan_speed(hass: HomeAssistant, config_flow_fixture: None) -> None async_unload_entry=help_async_unload_entry, ), ) - setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + setup_test_component_platform( + hass, VACUUM_DOMAIN, [mock_vacuum], from_config_entry=True + ) assert await hass.config_entries.async_setup(config_entry.entry_id) config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, SERVICE_SET_FAN_SPEED, {"entity_id": mock_vacuum.entity_id, "fan_speed": "high"}, blocking=True, @@ -178,11 +203,13 @@ async def test_locate(hass: HomeAssistant, config_flow_fixture: None) -> None: async_unload_entry=help_async_unload_entry, ), ) - setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + setup_test_component_platform( + hass, VACUUM_DOMAIN, [mock_vacuum], from_config_entry=True + ) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, SERVICE_LOCATE, {"entity_id": mock_vacuum.entity_id}, blocking=True, @@ -227,11 +254,13 @@ async def test_send_command(hass: HomeAssistant, config_flow_fixture: None) -> N async_unload_entry=help_async_unload_entry, ), ) - setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + setup_test_component_platform( + hass, VACUUM_DOMAIN, [mock_vacuum], from_config_entry=True + ) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, SERVICE_SEND_COMMAND, { "entity_id": mock_vacuum.entity_id, @@ -260,6 +289,8 @@ async def test_supported_features_compat(hass: HomeAssistant) -> None: _attr_fan_speed_list = ["silent", "normal", "pet hair"] entity = _LegacyConstantsStateVacuum() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert isinstance(entity.supported_features, int) assert entity.supported_features == int(features) assert entity.supported_features_compat is ( @@ -278,3 +309,178 @@ async def test_supported_features_compat(hass: HomeAssistant) -> None: "fan_speed_list": ["silent", "normal", "pet hair"] } assert entity._deprecated_supported_features_reported + + +async def test_vacuum_not_log_deprecated_state_warning( + hass: HomeAssistant, + mock_vacuum_entity: MockVacuum, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test correctly using activity doesn't log issue or raise repair.""" + state = hass.states.get(mock_vacuum_entity.entity_id) + assert state is not None + assert ( + "should implement the 'activity' property and return its state using the VacuumActivity enum" + not in caplog.text + ) + + +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_vacuum_log_deprecated_state_warning_using_state_prop( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test incorrectly using state property does log issue and raise repair.""" + + class MockLegacyVacuum(MockVacuum): + """Mocked vacuum entity.""" + + @property + def state(self) -> str: + """Return the state of the entity.""" + return VacuumActivity.CLEANING + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + + assert ( + "should implement the 'activity' property and return its state using the VacuumActivity enum" + in caplog.text + ) + + +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test incorrectly using _attr_state attribute does log issue and raise repair.""" + + class MockLegacyVacuum(MockVacuum): + """Mocked vacuum entity.""" + + def start(self) -> None: + """Start cleaning.""" + self._attr_state = VacuumActivity.CLEANING + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + + assert ( + "should implement the 'activity' property and return its state using the VacuumActivity enum" + not in caplog.text + ) + + await async_start(hass, entity.entity_id) + + assert ( + "should implement the 'activity' property and return its state using the VacuumActivity enum" + in caplog.text + ) + caplog.clear() + await async_start(hass, entity.entity_id) + # Test we only log once + assert ( + "should implement the 'activity' property and return its state using the VacuumActivity enum" + not in caplog.text + ) + + +@pytest.mark.usefixtures("mock_as_custom_component") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_alarm_control_panel_deprecated_state_does_not_break_state( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test using _attr_state attribute does not break state.""" + + class MockLegacyVacuum(MockEntity, StateVacuumEntity): + """Mocked vacuum entity.""" + + _attr_supported_features = VacuumEntityFeature.STATE | VacuumEntityFeature.START + + def __init__(self, **values: Any) -> None: + """Initialize a mock vacuum entity.""" + super().__init__(**values) + self._attr_state = VacuumActivity.DOCKED + + def start(self) -> None: + """Start cleaning.""" + self._attr_state = VacuumActivity.CLEANING + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "docked" + + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_START, + { + "entity_id": entity.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "cleaning" diff --git a/tests/components/vacuum/test_intent.py b/tests/components/vacuum/test_intent.py index cf96d32ad49..9ede7dbc04e 100644 --- a/tests/components/vacuum/test_intent.py +++ b/tests/components/vacuum/test_intent.py @@ -37,6 +37,27 @@ async def test_start_vacuum_intent(hass: HomeAssistant) -> None: assert call.data == {"entity_id": entity_id} +async def test_start_vacuum_without_name(hass: HomeAssistant) -> None: + """Test starting a vacuum without specifying the name.""" + await vacuum_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_vacuum" + hass.states.async_set(entity_id, STATE_IDLE) + calls = async_mock_service(hass, DOMAIN, SERVICE_START) + + response = await intent.async_handle( + hass, "test", vacuum_intent.INTENT_VACUUM_START, {} + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_START + assert call.data == {"entity_id": entity_id} + + async def test_stop_vacuum_intent(hass: HomeAssistant) -> None: """Test HassTurnOff intent for vacuums.""" await vacuum_intent.async_setup_intents(hass) @@ -59,3 +80,24 @@ async def test_stop_vacuum_intent(hass: HomeAssistant) -> None: assert call.domain == DOMAIN assert call.service == SERVICE_RETURN_TO_BASE assert call.data == {"entity_id": entity_id} + + +async def test_stop_vacuum_without_name(hass: HomeAssistant) -> None: + """Test stopping a vacuum without specifying the name.""" + await vacuum_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_vacuum" + hass.states.async_set(entity_id, STATE_IDLE) + calls = async_mock_service(hass, DOMAIN, SERVICE_RETURN_TO_BASE) + + response = await intent.async_handle( + hass, "test", vacuum_intent.INTENT_VACUUM_RETURN_TO_BASE, {} + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_RETURN_TO_BASE + assert call.data == {"entity_id": entity_id} diff --git a/tests/components/vacuum/test_reproduce_state.py b/tests/components/vacuum/test_reproduce_state.py index ff8da28e98c..dc5d81e8f08 100644 --- a/tests/components/vacuum/test_reproduce_state.py +++ b/tests/components/vacuum/test_reproduce_state.py @@ -9,18 +9,9 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_DOCKED, - STATE_RETURNING, -) -from homeassistant.const import ( - SERVICE_TURN_OFF, - SERVICE_TURN_ON, - STATE_IDLE, - STATE_OFF, - STATE_ON, - STATE_PAUSED, + VacuumActivity, ) +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State from homeassistant.helpers.state import async_reproduce_state @@ -39,11 +30,11 @@ async def test_reproducing_states( hass.states.async_set( "vacuum.entity_on_fan", STATE_ON, {ATTR_FAN_SPEED: FAN_SPEED_LOW} ) - hass.states.async_set("vacuum.entity_cleaning", STATE_CLEANING, {}) - hass.states.async_set("vacuum.entity_docked", STATE_DOCKED, {}) - hass.states.async_set("vacuum.entity_idle", STATE_IDLE, {}) - hass.states.async_set("vacuum.entity_returning", STATE_RETURNING, {}) - hass.states.async_set("vacuum.entity_paused", STATE_PAUSED, {}) + hass.states.async_set("vacuum.entity_cleaning", VacuumActivity.CLEANING, {}) + hass.states.async_set("vacuum.entity_docked", VacuumActivity.DOCKED, {}) + hass.states.async_set("vacuum.entity_idle", VacuumActivity.IDLE, {}) + hass.states.async_set("vacuum.entity_returning", VacuumActivity.RETURNING, {}) + hass.states.async_set("vacuum.entity_paused", VacuumActivity.PAUSED, {}) turn_on_calls = async_mock_service(hass, "vacuum", SERVICE_TURN_ON) turn_off_calls = async_mock_service(hass, "vacuum", SERVICE_TURN_OFF) @@ -60,11 +51,11 @@ async def test_reproducing_states( State("vacuum.entity_off", STATE_OFF), State("vacuum.entity_on", STATE_ON), State("vacuum.entity_on_fan", STATE_ON, {ATTR_FAN_SPEED: FAN_SPEED_LOW}), - State("vacuum.entity_cleaning", STATE_CLEANING), - State("vacuum.entity_docked", STATE_DOCKED), - State("vacuum.entity_idle", STATE_IDLE), - State("vacuum.entity_returning", STATE_RETURNING), - State("vacuum.entity_paused", STATE_PAUSED), + State("vacuum.entity_cleaning", VacuumActivity.CLEANING), + State("vacuum.entity_docked", VacuumActivity.DOCKED), + State("vacuum.entity_idle", VacuumActivity.IDLE), + State("vacuum.entity_returning", VacuumActivity.RETURNING), + State("vacuum.entity_paused", VacuumActivity.PAUSED), ], ) @@ -95,11 +86,11 @@ async def test_reproducing_states( State("vacuum.entity_off", STATE_ON), State("vacuum.entity_on", STATE_OFF), State("vacuum.entity_on_fan", STATE_ON, {ATTR_FAN_SPEED: FAN_SPEED_HIGH}), - State("vacuum.entity_cleaning", STATE_PAUSED), - State("vacuum.entity_docked", STATE_CLEANING), - State("vacuum.entity_idle", STATE_DOCKED), - State("vacuum.entity_returning", STATE_CLEANING), - State("vacuum.entity_paused", STATE_IDLE), + State("vacuum.entity_cleaning", VacuumActivity.PAUSED), + State("vacuum.entity_docked", VacuumActivity.CLEANING), + State("vacuum.entity_idle", VacuumActivity.DOCKED), + State("vacuum.entity_returning", VacuumActivity.CLEANING), + State("vacuum.entity_paused", VacuumActivity.IDLE), # Should not raise State("vacuum.non_existing", STATE_ON), ], diff --git a/tests/components/velbus/__init__.py b/tests/components/velbus/__init__.py index 68bd639f464..102c4a4e777 100644 --- a/tests/components/velbus/__init__.py +++ b/tests/components/velbus/__init__.py @@ -1 +1,13 @@ """Tests for the Velbus component.""" + +from homeassistant.components.velbus import VelbusConfigEntry +from homeassistant.core import HomeAssistant + + +async def init_integration( + hass: HomeAssistant, + config_entry: VelbusConfigEntry, +) -> None: + """Load the Velbus integration.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 402acb821be..65418790280 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -1,12 +1,24 @@ """Fixtures for the Velbus tests.""" from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest +from velbusaio.channels import ( + Blind, + Button, + ButtonCounter, + Dimmer, + LightSensor, + Relay, + SelectedProgram, + SensorNumber, + Temperature, +) +from velbusaio.module import Module +from homeassistant.components.velbus import VelbusConfigEntry from homeassistant.components.velbus.const import DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant @@ -16,14 +28,301 @@ from tests.common import MockConfigEntry @pytest.fixture(name="controller") -def mock_controller() -> Generator[MagicMock]: +def mock_controller( + mock_button: AsyncMock, + mock_relay: AsyncMock, + mock_temperature: AsyncMock, + mock_select: AsyncMock, + mock_buttoncounter: AsyncMock, + mock_sensornumber: AsyncMock, + mock_lightsensor: AsyncMock, + mock_dimmer: AsyncMock, + mock_module_no_subdevices: AsyncMock, + mock_module_subdevices: AsyncMock, + mock_cover: AsyncMock, + mock_cover_no_position: AsyncMock, +) -> Generator[AsyncMock]: """Mock a successful velbus controller.""" - with patch("homeassistant.components.velbus.Velbus", autospec=True) as controller: + with ( + patch("homeassistant.components.velbus.Velbus", autospec=True) as controller, + patch( + "homeassistant.components.velbus.config_flow.velbusaio.controller.Velbus", + new=controller, + ), + ): + cont = controller.return_value + cont.get_all_binary_sensor.return_value = [mock_button] + cont.get_all_button.return_value = [mock_button] + cont.get_all_switch.return_value = [mock_relay] + cont.get_all_climate.return_value = [mock_temperature] + cont.get_all_select.return_value = [mock_select] + cont.get_all_sensor.return_value = [ + mock_buttoncounter, + mock_temperature, + mock_sensornumber, + mock_lightsensor, + ] + cont.get_all_light.return_value = [mock_dimmer] + cont.get_all_led.return_value = [mock_button] + cont.get_all_cover.return_value = [mock_cover, mock_cover_no_position] + cont.get_modules.return_value = { + 1: mock_module_no_subdevices, + 2: mock_module_no_subdevices, + 3: mock_module_no_subdevices, + 4: mock_module_no_subdevices, + 99: mock_module_subdevices, + } + cont.get_module.return_value = mock_module_subdevices yield controller +@pytest.fixture +def mock_module_no_subdevices( + mock_relay: AsyncMock, +) -> AsyncMock: + """Mock a velbus module.""" + module = AsyncMock(spec=Module) + module.get_type_name.return_value = "VMB4RYLD" + module.get_addresses.return_value = [1, 2, 3, 4] + module.get_name.return_value = "BedRoom" + module.get_sw_version.return_value = "1.0.0" + module.is_loaded.return_value = True + module.get_channels.return_value = {} + return module + + +@pytest.fixture +def mock_module_subdevices() -> AsyncMock: + """Mock a velbus module.""" + module = AsyncMock(spec=Module) + module.get_type_name.return_value = "VMB2BLE" + module.get_addresses.return_value = [88] + module.get_name.return_value = "Kitchen" + module.get_sw_version.return_value = "2.0.0" + module.is_loaded.return_value = True + module.get_channels.return_value = {} + return module + + +@pytest.fixture +def mock_button() -> AsyncMock: + """Mock a successful velbus channel.""" + channel = AsyncMock(spec=Button) + channel.get_categories.return_value = ["binary_sensor", "led", "button"] + channel.get_name.return_value = "ButtonOn" + channel.get_module_address.return_value = 1 + channel.get_channel_number.return_value = 1 + channel.get_module_type_name.return_value = "VMB4RYLD" + channel.get_module_type.return_value = 99 + channel.get_full_name.return_value = "Bedroom kid 1" + channel.get_module_sw_version.return_value = "1.0.0" + channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False + channel.is_closed.return_value = True + channel.is_on.return_value = False + return channel + + +@pytest.fixture +def mock_temperature() -> AsyncMock: + """Mock a successful velbus channel.""" + channel = AsyncMock(spec=Temperature) + channel.get_categories.return_value = ["sensor", "climate"] + channel.get_name.return_value = "Temperature" + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 3 + channel.get_module_type_name.return_value = "VMB4GPO" + channel.get_full_name.return_value = "Living room" + channel.get_module_sw_version.return_value = "3.0.0" + channel.get_module_serial.return_value = "asdfghjk" + channel.get_module_type.return_value = 1 + channel.is_sub_device.return_value = False + channel.is_counter_channel.return_value = False + channel.get_class.return_value = "temperature" + channel.get_unit.return_value = "°C" + channel.get_state.return_value = 20.0 + channel.is_temperature.return_value = True + channel.get_max.return_value = 30.0 + channel.get_min.return_value = 10.0 + channel.get_climate_target.return_value = 21.0 + channel.get_climate_preset.return_value = 1 + channel.get_climate_mode.return_value = "day" + channel.get_cool_mode.return_value = "heat" + return channel + + +@pytest.fixture +def mock_relay() -> AsyncMock: + """Mock a successful velbus channel.""" + channel = AsyncMock(spec=Relay) + channel.get_categories.return_value = ["switch"] + channel.get_name.return_value = "RelayName" + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 55 + channel.get_module_type_name.return_value = "VMB4RYNO" + channel.get_full_name.return_value = "Living room" + channel.get_module_sw_version.return_value = "1.0.1" + channel.get_module_serial.return_value = "qwerty123" + channel.get_module_type.return_value = 2 + channel.is_sub_device.return_value = True + channel.is_on.return_value = True + return channel + + +@pytest.fixture +def mock_select() -> AsyncMock: + """Mock a successful velbus channel.""" + channel = AsyncMock(spec=SelectedProgram) + channel.get_categories.return_value = ["select"] + channel.get_name.return_value = "select" + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 33 + channel.get_module_type_name.return_value = "VMB4RYNO" + channel.get_module_type.return_value = 3 + channel.get_full_name.return_value = "Kitchen" + channel.get_module_sw_version.return_value = "1.1.1" + channel.get_module_serial.return_value = "qwerty1234567" + channel.is_sub_device.return_value = False + channel.get_options.return_value = ["none", "summer", "winter", "holiday"] + channel.get_selected_program.return_value = "winter" + return channel + + +@pytest.fixture +def mock_buttoncounter() -> AsyncMock: + """Mock a successful velbus channel.""" + channel = AsyncMock(spec=ButtonCounter) + channel.get_categories.return_value = ["sensor"] + channel.get_name.return_value = "ButtonCounter" + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 2 + channel.get_module_type_name.return_value = "VMB7IN" + channel.get_module_type.return_value = 4 + channel.get_full_name.return_value = "Input" + channel.get_module_sw_version.return_value = "1.0.0" + channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = True + channel.is_counter_channel.return_value = True + channel.is_temperature.return_value = False + channel.get_state.return_value = 100 + channel.get_unit.return_value = "W" + channel.get_counter_state.return_value = 100 + channel.get_counter_unit.return_value = "kWh" + return channel + + +@pytest.fixture +def mock_sensornumber() -> AsyncMock: + """Mock a successful velbus channel.""" + channel = AsyncMock(spec=SensorNumber) + channel.get_categories.return_value = ["sensor"] + channel.get_name.return_value = "SensorNumber" + channel.get_module_address.return_value = 2 + channel.get_channel_number.return_value = 3 + channel.get_module_type_name.return_value = "VMB7IN" + channel.get_module_type.return_value = 8 + channel.get_full_name.return_value = "Input" + channel.get_module_sw_version.return_value = "1.0.0" + channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False + channel.is_counter_channel.return_value = False + channel.is_temperature.return_value = False + channel.get_unit.return_value = "m" + channel.get_state.return_value = 10 + return channel + + +@pytest.fixture +def mock_lightsensor() -> AsyncMock: + """Mock a successful velbus channel.""" + channel = AsyncMock(spec=LightSensor) + channel.get_categories.return_value = ["sensor"] + channel.get_name.return_value = "LightSensor" + channel.get_module_address.return_value = 2 + channel.get_channel_number.return_value = 4 + channel.get_module_type_name.return_value = "VMB7IN" + channel.get_module_type.return_value = 8 + channel.get_full_name.return_value = "Input" + channel.get_module_sw_version.return_value = "1.0.0" + channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False + channel.is_counter_channel.return_value = False + channel.is_temperature.return_value = False + channel.get_unit.return_value = "illuminance" + channel.get_state.return_value = 250 + return channel + + +@pytest.fixture +def mock_dimmer() -> AsyncMock: + """Mock a successful velbus channel.""" + channel = AsyncMock(spec=Dimmer) + channel.get_categories.return_value = ["light"] + channel.get_name.return_value = "Dimmer" + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 10 + channel.get_module_type_name.return_value = "VMBDN1" + channel.get_module_type.return_value = 9 + channel.get_full_name.return_value = "Dimmer full name" + channel.get_module_sw_version.return_value = "1.0.0" + channel.get_module_serial.return_value = "a1b2c3d4e5f6g7" + channel.is_sub_device.return_value = True + channel.is_on.return_value = False + channel.get_dimmer_state.return_value = 33 + return channel + + +@pytest.fixture +def mock_cover() -> AsyncMock: + """Mock a successful velbus channel.""" + channel = AsyncMock(spec=Blind) + channel.get_categories.return_value = ["cover"] + channel.get_name.return_value = "CoverName" + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 9 + channel.get_module_type_name.return_value = "VMB2BLE" + channel.get_module_type.return_value = 10 + channel.get_full_name.return_value = "Basement" + channel.get_module_sw_version.return_value = "1.0.1" + channel.get_module_serial.return_value = "1234" + channel.is_sub_device.return_value = True + channel.support_position.return_value = True + channel.get_position.return_value = 50 + channel.is_closed.return_value = False + channel.is_open.return_value = True + channel.is_opening.return_value = False + channel.is_closing.return_value = False + return channel + + +@pytest.fixture +def mock_cover_no_position() -> AsyncMock: + """Mock a successful velbus channel.""" + channel = AsyncMock(spec=Blind) + channel.get_categories.return_value = ["cover"] + channel.get_name.return_value = "CoverNameNoPos" + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 11 + channel.get_module_type_name.return_value = "VMB2BLE" + channel.get_module_type.return_value = 10 + channel.get_full_name.return_value = "Basement" + channel.get_module_sw_version.return_value = "1.0.1" + channel.get_module_serial.return_value = "12345" + channel.is_sub_device.return_value = True + channel.support_position.return_value = False + channel.get_position.return_value = None + channel.is_closed.return_value = False + channel.is_open.return_value = True + channel.is_opening.return_value = True + channel.is_closing.return_value = True + return channel + + @pytest.fixture(name="config_entry") -def mock_config_entry(hass: HomeAssistant) -> ConfigEntry: +async def mock_config_entry( + hass: HomeAssistant, + controller: AsyncMock, +) -> VelbusConfigEntry: """Create and register mock config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/velbus/snapshots/test_binary_sensor.ambr b/tests/components/velbus/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..58630b9f6c9 --- /dev/null +++ b/tests/components/velbus/snapshots/test_binary_sensor.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_entities[binary_sensor.bedroom_kid_1_buttonon-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.bedroom_kid_1_buttonon', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ButtonOn', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.bedroom_kid_1_buttonon-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom kid 1 ButtonOn', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom_kid_1_buttonon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/velbus/snapshots/test_button.ambr b/tests/components/velbus/snapshots/test_button.ambr new file mode 100644 index 00000000000..952af21b43c --- /dev/null +++ b/tests/components/velbus/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_entities[button.bedroom_kid_1_buttonon-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.bedroom_kid_1_buttonon', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ButtonOn', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[button.bedroom_kid_1_buttonon-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom kid 1 ButtonOn', + }), + 'context': , + 'entity_id': 'button.bedroom_kid_1_buttonon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/velbus/snapshots/test_climate.ambr b/tests/components/velbus/snapshots/test_climate.ambr new file mode 100644 index 00000000000..b1933e51868 --- /dev/null +++ b/tests/components/velbus/snapshots/test_climate.ambr @@ -0,0 +1,76 @@ +# serializer version: 1 +# name: test_entities[climate.living_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'eco', + 'away', + 'home', + 'comfort', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'asdfghjk-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.living_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.0, + 'friendly_name': 'Living room Temperature', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'away', + 'home', + 'comfort', + ]), + 'supported_features': , + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.living_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/velbus/snapshots/test_cover.ambr b/tests/components/velbus/snapshots/test_cover.ambr new file mode 100644 index 00000000000..a9cbd4aae73 --- /dev/null +++ b/tests/components/velbus/snapshots/test_cover.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_entities[cover.basement_covername-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.basement_covername', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CoverName', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1234-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[cover.basement_covername-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 50, + 'friendly_name': 'Basement CoverName', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.basement_covername', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_entities[cover.basement_covernamenopos-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.basement_covernamenopos', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CoverNameNoPos', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345-11', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[cover.basement_covernamenopos-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'friendly_name': 'Basement CoverNameNoPos', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.basement_covernamenopos', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'opening', + }) +# --- diff --git a/tests/components/velbus/snapshots/test_diagnostics.ambr b/tests/components/velbus/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..406a5f2d84e --- /dev/null +++ b/tests/components/velbus/snapshots/test_diagnostics.ambr @@ -0,0 +1,93 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'entry': dict({ + 'data': dict({ + 'name': 'velbus home', + 'port': '127.0.1.0.1:3788', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'velbus', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 2, + }), + 'modules': list([ + dict({ + 'address': list([ + 1, + 2, + 3, + 4, + ]), + 'channels': dict({ + }), + 'is_loaded': True, + 'name': 'BedRoom', + 'sw_version': '1.0.0', + 'type': 'VMB4RYLD', + }), + dict({ + 'address': list([ + 1, + 2, + 3, + 4, + ]), + 'channels': dict({ + }), + 'is_loaded': True, + 'name': 'BedRoom', + 'sw_version': '1.0.0', + 'type': 'VMB4RYLD', + }), + dict({ + 'address': list([ + 1, + 2, + 3, + 4, + ]), + 'channels': dict({ + }), + 'is_loaded': True, + 'name': 'BedRoom', + 'sw_version': '1.0.0', + 'type': 'VMB4RYLD', + }), + dict({ + 'address': list([ + 1, + 2, + 3, + 4, + ]), + 'channels': dict({ + }), + 'is_loaded': True, + 'name': 'BedRoom', + 'sw_version': '1.0.0', + 'type': 'VMB4RYLD', + }), + dict({ + 'address': list([ + 88, + ]), + 'channels': dict({ + }), + 'is_loaded': True, + 'name': 'Kitchen', + 'sw_version': '2.0.0', + 'type': 'VMB2BLE', + }), + ]), + }) +# --- diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr new file mode 100644 index 00000000000..a55a00ef0f2 --- /dev/null +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -0,0 +1,245 @@ +# serializer version: 1 +# name: test_device_registry + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4RYLD', + 'model_id': '99', + 'name': 'Bedroom kid 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-9', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '10', + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-11', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '10', + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '12345', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-10', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMBDN1', + 'model_id': '9', + 'name': 'Dimmer full name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6g7', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-2', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB7IN', + 'model_id': '4', + 'name': 'Input', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4GPO', + 'model_id': '1', + 'name': 'Living room', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'asdfghjk', + 'suggested_area': None, + 'sw_version': '3.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '2', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB7IN', + 'model_id': '8', + 'name': 'Input', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-55', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4RYNO', + 'model_id': '2', + 'name': 'Living room', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'qwerty123', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + ]) +# --- diff --git a/tests/components/velbus/snapshots/test_light.ambr b/tests/components/velbus/snapshots/test_light.ambr new file mode 100644 index 00000000000..b7009a0c66a --- /dev/null +++ b/tests/components/velbus/snapshots/test_light.ambr @@ -0,0 +1,112 @@ +# serializer version: 1 +# name: test_entities[light.bedroom_kid_1_led_buttonon-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.bedroom_kid_1_led_buttonon', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED ButtonOn', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.bedroom_kid_1_led_buttonon-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Bedroom kid 1 LED ButtonOn', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.bedroom_kid_1_led_buttonon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[light.dimmer_full_name_dimmer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dimmer_full_name_dimmer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dimmer', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6g7-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.dimmer_full_name_dimmer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Dimmer full name Dimmer', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmer_full_name_dimmer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/velbus/snapshots/test_select.ambr b/tests/components/velbus/snapshots/test_select.ambr new file mode 100644 index 00000000000..288eb10a3c3 --- /dev/null +++ b/tests/components/velbus/snapshots/test_select.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_entities[select.kitchen_select-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'summer', + 'winter', + 'holiday', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.kitchen_select', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'select', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'qwerty1234567-33-program_select', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[select.kitchen_select-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen select', + 'options': list([ + 'none', + 'summer', + 'winter', + 'holiday', + ]), + }), + 'context': , + 'entity_id': 'select.kitchen_select', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'winter', + }) +# --- diff --git a/tests/components/velbus/snapshots/test_sensor.ambr b/tests/components/velbus/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6860ad73e2c --- /dev/null +++ b/tests/components/velbus/snapshots/test_sensor.ambr @@ -0,0 +1,255 @@ +# serializer version: 1 +# name: test_entities[sensor.input_buttoncounter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.input_buttoncounter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ButtonCounter', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6-2', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_entities[sensor.input_buttoncounter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Input ButtonCounter', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.input_buttoncounter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_entities[sensor.input_buttoncounter_counter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.input_buttoncounter_counter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:counter', + 'original_name': 'ButtonCounter-counter', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6-2-counter', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_entities[sensor.input_buttoncounter_counter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Input ButtonCounter-counter', + 'icon': 'mdi:counter', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.input_buttoncounter_counter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_entities[sensor.input_lightsensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.input_lightsensor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LightSensor', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6-4', + 'unit_of_measurement': 'illuminance', + }) +# --- +# name: test_entities[sensor.input_lightsensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Input LightSensor', + 'state_class': , + 'unit_of_measurement': 'illuminance', + }), + 'context': , + 'entity_id': 'sensor.input_lightsensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '250.0', + }) +# --- +# name: test_entities[sensor.input_sensornumber-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.input_sensornumber', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SensorNumber', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6-3', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_entities[sensor.input_sensornumber-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Input SensorNumber', + 'state_class': , + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'sensor.input_sensornumber', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_entities[sensor.living_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'asdfghjk-3', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.living_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Living room Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- diff --git a/tests/components/velbus/snapshots/test_switch.ambr b/tests/components/velbus/snapshots/test_switch.ambr new file mode 100644 index 00000000000..e9090c396d1 --- /dev/null +++ b/tests/components/velbus/snapshots/test_switch.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_entities[switch.living_room_relayname-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.living_room_relayname', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RelayName', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'qwerty123-55', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.living_room_relayname-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room RelayName', + }), + 'context': , + 'entity_id': 'switch.living_room_relayname', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/velbus/test_binary_sensor.py b/tests/components/velbus/test_binary_sensor.py new file mode 100644 index 00000000000..29497cd36e1 --- /dev/null +++ b/tests/components/velbus/test_binary_sensor.py @@ -0,0 +1,26 @@ +"""Velbus binary_sensor platform tests.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.velbus.PLATFORMS", [Platform.BINARY_SENSOR]): + await init_integration(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/velbus/test_button.py b/tests/components/velbus/test_button.py new file mode 100644 index 00000000000..cf334a29b05 --- /dev/null +++ b/tests/components/velbus/test_button.py @@ -0,0 +1,46 @@ +"""Velbus button platform tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.velbus.PLATFORMS", [Platform.BUTTON]): + await init_integration(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_press( + hass: HomeAssistant, + mock_button: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test button press.""" + await init_integration(hass, config_entry) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.bedroom_kid_1_buttonon"}, + blocking=True, + ) + mock_button.press.assert_called_once_with() diff --git a/tests/components/velbus/test_climate.py b/tests/components/velbus/test_climate.py new file mode 100644 index 00000000000..c843bca6e68 --- /dev/null +++ b/tests/components/velbus/test_climate.py @@ -0,0 +1,125 @@ +"""Velbus climate platform tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_AWAY, + PRESET_COMFORT, + PRESET_ECO, + PRESET_HOME, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.velbus.PLATFORMS", [Platform.CLIMATE]): + await init_integration(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_set_target_temperature( + hass: HomeAssistant, + mock_temperature: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test the target temperature climate action.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_TEMPERATURE: 29}, + blocking=True, + ) + mock_temperature.set_temp.assert_called_once_with(29) + + +@pytest.mark.parametrize( + ("set_mode", "expected_mode"), + [ + (PRESET_AWAY, "night"), + (PRESET_COMFORT, "comfort"), + (PRESET_ECO, "safe"), + (PRESET_HOME, "day"), + ], +) +async def test_set_preset_mode( + hass: HomeAssistant, + mock_temperature: AsyncMock, + config_entry: MockConfigEntry, + set_mode: str, + expected_mode: str, +) -> None: + """Test the preset mode climate action.""" + await init_integration(hass, config_entry) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_PRESET_MODE: set_mode}, + blocking=True, + ) + mock_temperature.set_preset.assert_called_once_with(expected_mode) + + +@pytest.mark.parametrize( + ("set_mode"), + [ + ("heat"), + ("cool"), + ], +) +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_temperature: AsyncMock, + config_entry: MockConfigEntry, + set_mode: str, +) -> None: + """Test the hvac mode climate action.""" + await init_integration(hass, config_entry) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_HVAC_MODE: set_mode}, + blocking=True, + ) + mock_temperature.set_mode.assert_called_once_with(set_mode) + + +async def test_set_hvac_mode_invalid( + hass: HomeAssistant, + mock_temperature: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test the hvac mode climate action with an invalid mode.""" + await init_integration(hass, config_entry) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_HVAC_MODE: "auto"}, + blocking=True, + ) + mock_temperature.set_mode.assert_not_called() diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 432fcea10db..04b6a51043f 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -7,18 +7,18 @@ import pytest import serial.tools.list_ports from velbusaio.exceptions import VelbusConnectionFailed -from homeassistant.components import usb from homeassistant.components.velbus.const import DOMAIN from homeassistant.config_entries import SOURCE_USB, SOURCE_USER from homeassistant.const import CONF_NAME, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import PORT_SERIAL, PORT_TCP from tests.common import MockConfigEntry -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device=PORT_SERIAL, pid="10CF", vid="0B1B", @@ -156,12 +156,18 @@ async def test_flow_usb(hass: HomeAssistant) -> None: user_input={}, ) assert result + assert result["result"].unique_id == "0B1B:10CF_1234_Velleman_Velbus VMB1USB" assert result.get("type") is FlowResultType.CREATE_ENTRY - # test an already configured discovery + +@pytest.mark.usefixtures("controller") +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_usb_if_already_setup(hass: HomeAssistant) -> None: + """Test we abort if Velbus USB discovbery aborts in case it is already setup.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_PORT: PORT_SERIAL}, + unique_id="0B1B:10CF_1234_Velleman_Velbus VMB1USB", ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/velbus/test_cover.py b/tests/components/velbus/test_cover.py new file mode 100644 index 00000000000..24a90e0f8d1 --- /dev/null +++ b/tests/components/velbus/test_cover.py @@ -0,0 +1,90 @@ +"""Velbus cover platform tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import ATTR_POSITION, DOMAIN as COVER_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.velbus.PLATFORMS", [Platform.COVER]): + await init_integration(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "entity_num"), + [ + ("cover.basement_covername", 0), + ("cover.basement_covernamenopos", 1), + ], +) +async def test_actions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_id: str, + entity_num: int, +) -> None: + """Test the cover actions.""" + await init_integration(hass, config_entry) + entity = config_entry.runtime_data.controller.get_all_cover()[entity_num] + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + entity.close.assert_called_once() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + entity.open.assert_called_once() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + entity.stop.assert_called_once() + + +async def test_position( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_cover: AsyncMock, +) -> None: + """Test the set_postion over action.""" + await init_integration(hass, config_entry) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.basement_covername", ATTR_POSITION: 25}, + blocking=True, + ) + mock_cover.set_position.assert_called_once_with(75) diff --git a/tests/components/velbus/test_diagnostics.py b/tests/components/velbus/test_diagnostics.py new file mode 100644 index 00000000000..af84115ff14 --- /dev/null +++ b/tests/components/velbus/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test Velbus diagnostics.""" + +import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await init_integration(hass, config_entry) + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot(exclude=props("created_at", "modified_at", "entry_id")) diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index dea246b8a86..3285099f2a2 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -1,20 +1,55 @@ """Tests for the Velbus component initialisation.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from syrupy.assertion import SnapshotAssertion +from velbusaio.exceptions import VelbusConnectionFailed +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.velbus import VelbusConfigEntry from homeassistant.components.velbus.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_PORT, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import init_integration from tests.common import MockConfigEntry -@pytest.mark.usefixtures("controller") -async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def test_setup_connection_failed( + hass: HomeAssistant, + config_entry: VelbusConfigEntry, + controller: MagicMock, +) -> None: + """Test the setup that fails during velbus connect.""" + controller.return_value.connect.side_effect = VelbusConnectionFailed() + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_start_failed( + hass: HomeAssistant, + config_entry: VelbusConfigEntry, + controller: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test the setup that fails during velbus start task, should result in no entries.""" + controller.return_value.start.side_effect = ConnectionError() + await init_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.LOADED + assert ( + er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) == [] + ) + + +async def test_unload_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> None: """Test being able to unload an entry.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -29,9 +64,10 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N assert not hass.data.get(DOMAIN) -@pytest.mark.usefixtures("controller") async def test_device_identifier_migration( - hass: HomeAssistant, config_entry: ConfigEntry, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test being able to unload an entry.""" original_identifiers = {(DOMAIN, "module_address", "module_serial")} @@ -64,8 +100,10 @@ async def test_device_identifier_migration( assert device_entry.sw_version == "module_sw_version" -@pytest.mark.usefixtures("controller") -async def test_migrate_config_entry(hass: HomeAssistant) -> None: +async def test_migrate_config_entry( + hass: HomeAssistant, + controller: MagicMock, +) -> None: """Test successful migration of entry data.""" legacy_config = {CONF_NAME: "fake_name", CONF_PORT: "1.2.3.4:5678"} entry = MockConfigEntry(domain=DOMAIN, unique_id="my own id", data=legacy_config) @@ -79,3 +117,46 @@ async def test_migrate_config_entry(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) assert dict(entry.data) == legacy_config assert entry.version == 2 + + +async def test_api_call( + hass: HomeAssistant, + mock_relay: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test the api call decorator action.""" + await init_integration(hass, config_entry) + + mock_relay.turn_on.side_effect = OSError() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, + blocking=True, + ) + + +async def test_device_registry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the velbus device registry.""" + await init_integration(hass, config_entry) + + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot + + device_parent = device_registry.async_get_device(identifiers={(DOMAIN, "88")}) + assert device_parent.via_device_id is None + + device = device_registry.async_get_device(identifiers={(DOMAIN, "88-9")}) + assert device.via_device_id == device_parent.id + + device_no_sub = device_registry.async_get_device(identifiers={(DOMAIN, "2")}) + assert device_no_sub.via_device_id is None diff --git a/tests/components/velbus/test_light.py b/tests/components/velbus/test_light.py new file mode 100644 index 00000000000..0ce93d6e6bb --- /dev/null +++ b/tests/components/velbus/test_light.py @@ -0,0 +1,142 @@ +"""Velbus light platform tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_FLASH, + ATTR_TRANSITION, + DOMAIN as LIGHT_DOMAIN, + FLASH_LONG, + FLASH_SHORT, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.velbus.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_dimmer_actions( + hass: HomeAssistant, + mock_dimmer: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test every supported dimmer action.""" + await init_integration(hass, config_entry) + # turn off + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer"}, + blocking=True, + ) + mock_dimmer.set_dimmer_state.assert_called_once_with(0, 0) + # turn on without brightness == restore previous brightness + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", ATTR_TRANSITION: 1}, + blocking=True, + ) + mock_dimmer.restore_dimmer_state.assert_called_once_with(1) + # turn on with brightness == 0 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", + ATTR_BRIGHTNESS: 0, + ATTR_TRANSITION: 1, + }, + blocking=True, + ) + mock_dimmer.set_dimmer_state.assert_called_with(0, 1) + assert mock_dimmer.set_dimmer_state.call_count == 2 + # turn on with brightness == 33 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", ATTR_BRIGHTNESS: 33}, + blocking=True, + ) + mock_dimmer.set_dimmer_state.assert_called_with(12, 0) + assert mock_dimmer.set_dimmer_state.call_count == 3 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_led_actions( + hass: HomeAssistant, + mock_button: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test every supported button led action.""" + await init_integration(hass, config_entry) + # turn off + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon"}, + blocking=True, + ) + mock_button.set_led_state.assert_called_once_with("off") + # turn on + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon"}, + blocking=True, + ) + mock_button.set_led_state.assert_called_with("on") + assert mock_button.set_led_state.call_count == 2 + # turn on with FLASH_LONG + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_LONG}, + blocking=True, + ) + mock_button.set_led_state.assert_called_with("slow") + assert mock_button.set_led_state.call_count == 3 + # turn on with FLASH_SHORT + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_SHORT}, + blocking=True, + ) + mock_button.set_led_state.assert_called_with("fast") + assert mock_button.set_led_state.call_count == 4 + # turn on with UNKNOWN flash option + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_SHORT}, + blocking=True, + ) + mock_button.set_led_state.assert_called_with("fast") + assert mock_button.set_led_state.call_count == 5 diff --git a/tests/components/velbus/test_select.py b/tests/components/velbus/test_select.py new file mode 100644 index 00000000000..782ae53d440 --- /dev/null +++ b/tests/components/velbus/test_select.py @@ -0,0 +1,52 @@ +"""Velbus select platform tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.velbus.PLATFORMS", [Platform.SELECT]): + await init_integration(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("set_program"), [("none"), ("summer"), ("winter"), ("holiday")] +) +async def test_select_program( + hass: HomeAssistant, + mock_select: AsyncMock, + config_entry: MockConfigEntry, + set_program: str, +) -> None: + """Test program selection.""" + await init_integration(hass, config_entry) + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.kitchen_select", ATTR_OPTION: set_program}, + blocking=True, + ) + mock_select.set_selected_program.assert_called_once_with(set_program) diff --git a/tests/components/velbus/test_sensor.py b/tests/components/velbus/test_sensor.py new file mode 100644 index 00000000000..d89d2de59db --- /dev/null +++ b/tests/components/velbus/test_sensor.py @@ -0,0 +1,26 @@ +"""Velbus sensor platform tests.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.velbus.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/velbus/test_services.py b/tests/components/velbus/test_services.py new file mode 100644 index 00000000000..2bcbac7b80d --- /dev/null +++ b/tests/components/velbus/test_services.py @@ -0,0 +1,172 @@ +"""Velbus services tests.""" + +from unittest.mock import AsyncMock + +import pytest +import voluptuous as vol + +from homeassistant.components.velbus.const import ( + CONF_CONFIG_ENTRY, + CONF_INTERFACE, + CONF_MEMO_TEXT, + DOMAIN, + SERVICE_CLEAR_CACHE, + SERVICE_SCAN, + SERVICE_SET_MEMO_TEXT, + SERVICE_SYNC, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.issue_registry as ir + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_global_services_with_interface( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test services directed at the bus with an interface parameter.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_INTERFACE: config_entry.data["port"]}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + assert issue_registry.async_get_issue(DOMAIN, "deprecated_interface_parameter") + + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {CONF_INTERFACE: config_entry.data["port"]}, + blocking=True, + ) + config_entry.runtime_data.controller.sync_clock.assert_called_once_with() + + # Test invalid interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_INTERFACE: "nonexistent"}, + blocking=True, + ) + + # Test missing interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {}, + blocking=True, + ) + + +async def test_global_survices_with_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test services directed at the bus with a config_entry.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.sync_clock.assert_called_once_with() + + # Test invalid interface + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_CONFIG_ENTRY: "nonexistent"}, + blocking=True, + ) + + # Test missing interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {}, + blocking=True, + ) + + +async def test_set_memo_text( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: AsyncMock, +) -> None: + """Test the set_memo_text service.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_MEMO_TEXT: "Test", + CONF_ADDRESS: 1, + }, + blocking=True, + ) + config_entry.runtime_data.controller.get_module( + 1 + ).set_memo_text.assert_called_once_with("Test") + + # Test with unfound module + controller.return_value.get_module.return_value = None + with pytest.raises(ServiceValidationError, match="Module not found"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_MEMO_TEXT: "Test", + CONF_ADDRESS: 2, + }, + blocking=True, + ) + + +async def test_clear_cache( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test the clear_cache service.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_CACHE, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_CACHE, + {CONF_CONFIG_ENTRY: config_entry.entry_id, CONF_ADDRESS: 1}, + blocking=True, + ) + assert config_entry.runtime_data.controller.scan.call_count == 2 diff --git a/tests/components/velbus/test_switch.py b/tests/components/velbus/test_switch.py new file mode 100644 index 00000000000..ebb1da084c4 --- /dev/null +++ b/tests/components/velbus/test_switch.py @@ -0,0 +1,57 @@ +"""Velbus switch platform tests.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.velbus.PLATFORMS", [Platform.SWITCH]): + await init_integration(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_switch_on_off( + hass: HomeAssistant, + mock_relay: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test switching relay on and off press.""" + await init_integration(hass, config_entry) + # turn off + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, + blocking=True, + ) + mock_relay.turn_off.assert_called_once_with() + # turn on + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, + blocking=True, + ) + mock_relay.turn_on.assert_called_once_with() diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 512b2a007ed..c88a21d2bba 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -5,6 +5,11 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.components.velux import DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD + +from tests.common import MockConfigEntry + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +18,44 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.velux.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_velux_client() -> Generator[AsyncMock]: + """Mock a Velux client.""" + with ( + patch( + "homeassistant.components.velux.config_flow.PyVLX", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + yield client + + +@pytest.fixture +def mock_user_config_entry() -> MockConfigEntry: + """Return the user config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="127.0.0.1", + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + +@pytest.fixture +def mock_discovered_config_entry() -> MockConfigEntry: + """Return the user config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="127.0.0.1", + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + CONF_MAC: "64:61:84:00:ab:cd", + }, + unique_id="VELUX_KLF_ABCD", + ) diff --git a/tests/components/velux/test_config_flow.py b/tests/components/velux/test_config_flow.py index 5f7932d358a..22ad10e1188 100644 --- a/tests/components/velux/test_config_flow.py +++ b/tests/components/velux/test_config_flow.py @@ -2,86 +2,288 @@ from __future__ import annotations -from copy import deepcopy -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest from pyvlx import PyVLXException from homeassistant.components.velux import DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry -DUMMY_DATA: dict[str, Any] = { - CONF_HOST: "127.0.0.1", - CONF_PASSWORD: "NotAStrongPassword", -} - -PYVLX_CONFIG_FLOW_CONNECT_FUNCTION_PATH = ( - "homeassistant.components.velux.config_flow.PyVLX.connect" +DHCP_DISCOVERY = DhcpServiceInfo( + ip="127.0.0.1", + hostname="VELUX_KLF_LAN_ABCD", + macaddress="64618400abcd", ) -PYVLX_CONFIG_FLOW_CLASS_PATH = "homeassistant.components.velux.config_flow.PyVLX" - -error_types_to_test: list[tuple[Exception, str]] = [ - (PyVLXException("DUMMY"), "cannot_connect"), - (Exception("DUMMY"), "unknown"), -] - -pytest.mark.usefixtures("mock_setup_entry") -async def test_user_success(hass: HomeAssistant) -> None: +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_velux_client: AsyncMock, +) -> None: """Test starting a flow by user with valid values.""" - with patch(PYVLX_CONFIG_FLOW_CLASS_PATH, autospec=True) as client_mock: - result: dict[str, Any] = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - client_mock.return_value.disconnect.assert_called_once() - client_mock.return_value.connect.assert_called_once() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DUMMY_DATA[CONF_HOST] - assert result["data"] == DUMMY_DATA + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "127.0.0.1" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + } + assert not result["result"].unique_id + + mock_velux_client.disconnect.assert_called_once() + mock_velux_client.connect.assert_called_once() -@pytest.mark.parametrize(("error", "error_name"), error_types_to_test) +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PyVLXException("DUMMY"), "cannot_connect"), + (Exception("DUMMY"), "unknown"), + ], +) async def test_user_errors( - hass: HomeAssistant, error: Exception, error_name: str + hass: HomeAssistant, + mock_velux_client: AsyncMock, + exception: Exception, + error: str, + mock_setup_entry: AsyncMock, ) -> None: """Test starting a flow by user but with exceptions.""" - with patch( - PYVLX_CONFIG_FLOW_CONNECT_FUNCTION_PATH, side_effect=error - ) as connect_mock: - result: dict[str, Any] = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) - ) - connect_mock.assert_called_once() + mock_velux_client.connect.side_effect = exception - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": error_name} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_velux_client.connect.assert_called_once() + + mock_velux_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_flow_duplicate_entry(hass: HomeAssistant) -> None: +async def test_user_flow_duplicate_entry( + hass: HomeAssistant, + mock_user_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: """Test initialized flow with a duplicate entry.""" - with patch(PYVLX_CONFIG_FLOW_CLASS_PATH, autospec=True): - conf_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, title=DUMMY_DATA[CONF_HOST], data=DUMMY_DATA - ) + mock_user_config_entry.add_to_hass(hass) - conf_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=DUMMY_DATA, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can setup from dhcp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "VELUX_KLF_ABCD" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "64:61:84:00:ab:cd", + CONF_NAME: "VELUX_KLF_ABCD", + CONF_PASSWORD: "NotAStrongPassword", + } + assert result["result"].unique_id == "VELUX_KLF_ABCD" + + mock_velux_client.disconnect.assert_called() + mock_velux_client.connect.assert_called() + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PyVLXException("DUMMY"), "cannot_connect"), + (Exception("DUMMY"), "unknown"), + ], +) +async def test_dhcp_discovery_errors( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + exception: Exception, + error: str, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can setup from dhcp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + mock_velux_client.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": error} + + mock_velux_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "VELUX_KLF_ABCD" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "64:61:84:00:ab:cd", + CONF_NAME: "VELUX_KLF_ABCD", + CONF_PASSWORD: "NotAStrongPassword", + } + + +async def test_dhcp_discovery_already_configured( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_discovered_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test dhcp discovery when already configured.""" + mock_discovered_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discover_unique_id( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_velux_client: AsyncMock, + mock_user_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery when already configured.""" + mock_user_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_user_config_entry.entry_id) + + assert mock_user_config_entry.state is ConfigEntryState.LOADED + assert mock_user_config_entry.unique_id is None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_user_config_entry.unique_id == "VELUX_KLF_ABCD" + + +async def test_dhcp_discovery_not_loaded( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_user_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test dhcp discovery when entry with same host not loaded.""" + mock_user_config_entry.add_to_hass(hass) + + assert mock_user_config_entry.state is not ConfigEntryState.LOADED + assert mock_user_config_entry.unique_id is None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_user_config_entry.unique_id is None diff --git a/tests/components/verisure/test_config_flow.py b/tests/components/verisure/test_config_flow.py index e6dd11669d1..eb7e3eb1811 100644 --- a/tests/components/verisure/test_config_flow.py +++ b/tests/components/verisure/test_config_flow.py @@ -8,7 +8,6 @@ import pytest from verisure import Error as VerisureError, LoginError as VerisureLoginError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.verisure.const import ( CONF_GIID, CONF_LOCK_CODE_DIGITS, @@ -18,6 +17,7 @@ from homeassistant.components.verisure.const import ( from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -333,7 +333,7 @@ async def test_dhcp(hass: HomeAssistant) -> None: """Test that DHCP discovery works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="0123456789ab", hostname="mock_hostname" ), context={"source": config_entries.SOURCE_DHCP}, diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 94e1511ce19..ead3ecdc173 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -1,20 +1,26 @@ """Common methods used across tests for VeSync.""" import json +from typing import Any import requests_mock from homeassistant.components.vesync.const import DOMAIN +from homeassistant.util.json import JsonObjectType from tests.common import load_fixture, load_json_object_fixture +ENTITY_HUMIDIFIER = "humidifier.humidifier_200s" +ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" +ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" + ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ dev["deviceName"] for dev in ALL_DEVICES["result"]["list"] ] DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { "Humidifier 200s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-200s.json") ], "Humidifier 600S": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") @@ -26,7 +32,7 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") ], "Air Purifier 400s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-400s-detail.json") ], "Air Purifier 600s": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") @@ -37,7 +43,10 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { "Temperature Light": [ ("post", "/cloud/v1/deviceManaged/bypass", "device-detail.json") ], - "Outlet": [("get", "/v1/device/outlet/detail", "outlet-detail.json")], + "Outlet": [ + ("get", "/v1/device/outlet/detail", "outlet-detail.json"), + ("get", "/v1/device/outlet/energy/week", "outlet-energy-week.json"), + ], "Wall Switch": [ ("post", "/inwallswitch/v1/device/devicedetail", "device-detail.json") ], @@ -71,6 +80,99 @@ def mock_devices_response( ) +def mock_multiple_device_responses( + requests_mock: requests_mock.Mocker, device_names: list[str] +) -> None: + """Build a response for the Helpers.call_api method for multiple devices.""" + device_list = [ + device + for device in ALL_DEVICES["result"]["list"] + if device["deviceName"] in device_names + ] + + requests_mock.post( + "https://smartapi.vesync.com/cloud/v1/deviceManaged/devices", + json={"code": 0, "result": {"list": device_list}}, + ) + requests_mock.post( + "https://smartapi.vesync.com/cloud/v1/user/login", + json=load_json_object_fixture("vesync-login.json", DOMAIN), + ) + for device_name in device_names: + for fixture in DEVICE_FIXTURES[device_name]: + requests_mock.request( + fixture[0], + f"https://smartapi.vesync.com{fixture[1]}", + json=load_json_object_fixture(fixture[2], DOMAIN), + ) + + +def mock_air_purifier_400s_update_response(requests_mock: requests_mock.Mocker) -> None: + """Build a response for the Helpers.call_api method for air_purifier_400s with updated data.""" + + device_name = "Air Purifier 400s" + for fixture in DEVICE_FIXTURES[device_name]: + requests_mock.request( + fixture[0], + f"https://smartapi.vesync.com{fixture[1]}", + json=load_json_object_fixture( + "air-purifier-400s-detail-updated.json", DOMAIN + ), + ) + + +def mock_device_response( + requests_mock: requests_mock.Mocker, device_name: str, override: Any +) -> None: + """Build a response for the Helpers.call_api method with updated data.""" + + def load_and_merge(source: str) -> JsonObjectType: + json = load_json_object_fixture(source, DOMAIN) + + if override: + json.update(override) + + return json + + fixtures = DEVICE_FIXTURES[device_name] + + # The first item contain basic device details + if len(fixtures) > 0: + item = fixtures[0] + + requests_mock.request( + item[0], + f"https://smartapi.vesync.com{item[1]}", + json=load_and_merge(item[2]), + ) + + +def mock_outlet_energy_response( + requests_mock: requests_mock.Mocker, device_name: str, override: Any +) -> None: + """Build a response for the Helpers.call_api energy request with updated data.""" + + def load_and_merge(source: str) -> JsonObjectType: + json = load_json_object_fixture(source, DOMAIN) + + if override: + json.update(override) + + return json + + fixtures = DEVICE_FIXTURES[device_name] + + # The 2nd item contain energy details + if len(fixtures) > 1: + item = fixtures[1] + + requests_mock.request( + item[0], + f"https://smartapi.vesync.com{item[1]}", + json=load_and_merge(item[2]), + ) + + def call_api_side_effect__no_devices(*args, **kwargs): """Build a side_effects method for the Helpers.call_api method.""" if args[0] == "/cloud/v1/user/login" and args[1] == "post": diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 5500ef1a55f..8272da8dfad 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -7,9 +7,10 @@ from unittest.mock import Mock, patch import pytest from pyvesync import VeSync from pyvesync.vesyncbulb import VeSyncBulb -from pyvesync.vesyncfan import VeSyncAirBypass +from pyvesync.vesyncfan import VeSyncAirBypass, VeSyncHumid200300S from pyvesync.vesyncoutlet import VeSyncOutlet from pyvesync.vesyncswitch import VeSyncSwitch +import requests_mock from homeassistant.components.vesync import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -17,6 +18,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from .common import mock_multiple_device_responses + from tests.common import MockConfigEntry @@ -100,3 +103,29 @@ def dimmable_switch_fixture(): def outlet_fixture(): """Create a mock VeSync outlet fixture.""" return Mock(VeSyncOutlet) + + +@pytest.fixture(name="humidifier") +def humidifier_fixture(): + """Create a mock VeSync humidifier fixture.""" + return Mock(VeSyncHumid200300S) + + +@pytest.fixture(name="humidifier_config_entry") +async def humidifier_config_entry( + hass: HomeAssistant, requests_mock: requests_mock.Mocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `Humidifier 200s`.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + ) + entry.add_to_hass(hass) + + device_name = "Humidifier 200s" + mock_multiple_device_responses(requests_mock, [device_name]) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json b/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json new file mode 100644 index 00000000000..b48eefba4c9 --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json @@ -0,0 +1,39 @@ +{ + "code": 0, + "brightNess": "50", + "result": { + "light": { + "brightness": 50, + "colorTempe": 5400 + }, + "result": { + "brightness": 50, + "red": 178.5, + "green": 255, + "blue": 25.5, + "colorMode": "rgb", + "humidity": 35, + "mist_virtual_level": 6, + "mode": "manual", + "water_lacks": true, + "water_tank_lifted": true, + "automatic_stop_reach_target": true, + "night_light_brightness": 10, + "enabled": true, + "filter_life": 99, + "level": 1, + "display": true, + "display_forever": false, + "child_lock": false, + "night_light": "on", + "air_quality": 15, + "air_quality_value": 1, + "configuration": { + "auto_target_humidity": 40, + "display": true, + "automatic_stop": true + } + }, + "code": 0 + } +} diff --git a/tests/components/vesync/fixtures/air-purifier-400s-detail.json b/tests/components/vesync/fixtures/air-purifier-400s-detail.json new file mode 100644 index 00000000000..a26d9e2a975 --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-400s-detail.json @@ -0,0 +1,39 @@ +{ + "code": 0, + "brightNess": "50", + "result": { + "light": { + "brightness": 50, + "colorTempe": 5400 + }, + "result": { + "brightness": 50, + "red": 178.5, + "green": 255, + "blue": 25.5, + "colorMode": "rgb", + "humidity": 35, + "mist_virtual_level": 6, + "mode": "manual", + "water_lacks": true, + "water_tank_lifted": true, + "automatic_stop_reach_target": true, + "night_light_brightness": 10, + "enabled": true, + "filter_life": 99, + "level": 1, + "display": true, + "display_forever": false, + "child_lock": false, + "night_light": "off", + "air_quality": 5, + "air_quality_value": 1, + "configuration": { + "auto_target_humidity": 40, + "display": true, + "automatic_stop": true + } + }, + "code": 0 + } +} diff --git a/tests/components/vesync/fixtures/humidifier-200s.json b/tests/components/vesync/fixtures/humidifier-200s.json new file mode 100644 index 00000000000..a0a98bde110 --- /dev/null +++ b/tests/components/vesync/fixtures/humidifier-200s.json @@ -0,0 +1,27 @@ +{ + "code": 0, + "result": { + "result": { + "humidity": 35, + "mist_level": 6, + "mist_virtual_level": 6, + "mode": "manual", + "water_lacks": true, + "water_tank_lifted": true, + "automatic_stop_reach_target": true, + "night_light_brightness": 10, + "enabled": true, + "level": 1, + "display": true, + "display_forever": false, + "child_lock": false, + "night_light": "off", + "configuration": { + "auto_target_humidity": 40, + "display": true, + "automatic_stop": true + } + }, + "code": 0 + } +} diff --git a/tests/components/vesync/fixtures/outlet-energy-week.json b/tests/components/vesync/fixtures/outlet-energy-week.json new file mode 100644 index 00000000000..6e23be2e197 --- /dev/null +++ b/tests/components/vesync/fixtures/outlet-energy-week.json @@ -0,0 +1,7 @@ +{ + "energyConsumptionOfToday": 1, + "costPerKWH": 0.15, + "maxEnergy": 6, + "totalEnergy": 0, + "currency": "$" +} diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index 699084507ea..eac2bf9f5fa 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -9,7 +9,8 @@ "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", - "uuid": "00000000-1111-2222-3333-444444444444" + "uuid": "00000000-1111-2222-3333-444444444444", + "configModule": "configModule" }, { "cid": "600s-humidifier", @@ -30,7 +31,8 @@ "deviceName": "Air Purifier 131s", "subDeviceNo": null, "deviceStatus": "on", - "connectionStatus": "online" + "connectionStatus": "online", + "configModule": "configModule" }, { "cid": "asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55", @@ -39,7 +41,8 @@ "subDeviceNo": null, "deviceStatus": "on", "type": "wifi-air", - "connectionStatus": "online" + "connectionStatus": "online", + "configModule": "configModule" }, { "cid": "400s-purifier", @@ -48,7 +51,8 @@ "subDeviceNo": null, "deviceStatus": "on", "type": "wifi-air", - "connectionStatus": "online" + "connectionStatus": "online", + "configModule": "configModule" }, { "cid": "600s-purifier", @@ -57,7 +61,8 @@ "subDeviceNo": null, "type": "wifi-air", "deviceStatus": "on", - "connectionStatus": "online" + "connectionStatus": "online", + "configModule": "configModule" }, { "cid": "dimmable-bulb", @@ -65,7 +70,8 @@ "deviceName": "Dimmable Light", "subDeviceNo": null, "deviceStatus": "on", - "connectionStatus": "online" + "connectionStatus": "online", + "configModule": "configModule" }, { "cid": "tunable-bulb", @@ -73,7 +79,8 @@ "deviceName": "Temperature Light", "subDeviceNo": null, "deviceStatus": "on", - "connectionStatus": "online" + "connectionStatus": "online", + "configModule": "configModule" }, { "cid": "outlet", @@ -81,7 +88,8 @@ "deviceName": "Outlet", "subDeviceNo": null, "deviceStatus": "on", - "connectionStatus": "online" + "connectionStatus": "online", + "configModule": "configModule" }, { "cid": "switch", @@ -89,7 +97,8 @@ "deviceName": "Wall Switch", "subDeviceNo": null, "deviceStatus": "on", - "connectionStatus": "online" + "connectionStatus": "online", + "configModule": "configModule" }, { "cid": "dimmable-switch", @@ -97,7 +106,8 @@ "deviceName": "Dimmer Switch", "subDeviceNo": null, "deviceStatus": "on", - "connectionStatus": "online" + "connectionStatus": "online", + "configModule": "configModule" } ] } diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 60af4ae3d5b..1dea5f28f2c 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -464,6 +464,36 @@ # --- # name: test_fan_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_fan_state[Humidifier 200s][entities] @@ -472,6 +502,36 @@ # --- # name: test_fan_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_fan_state[Humidifier 600S][entities] diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 36694ae3ef6..ba6c7ab51b9 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -335,6 +335,36 @@ # --- # name: test_light_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_light_state[Humidifier 200s][entities] @@ -343,6 +373,36 @@ # --- # name: test_light_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_light_state[Humidifier 600S][entities] @@ -428,10 +488,10 @@ }), 'area_id': None, 'capabilities': dict({ - 'max_color_temp_kelvin': 6493, + 'max_color_temp_kelvin': 6500, 'max_mireds': 370, - 'min_color_temp_kelvin': 2702, - 'min_mireds': 154, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, 'supported_color_modes': list([ , ]), @@ -473,10 +533,10 @@ 'color_temp_kelvin': None, 'friendly_name': 'Temperature Light', 'hs_color': None, - 'max_color_temp_kelvin': 6493, + 'max_color_temp_kelvin': 6500, 'max_mireds': 370, - 'min_color_temp_kelvin': 2702, - 'min_mireds': 154, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ , diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 11d931e023a..50bee417a28 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -651,20 +651,178 @@ # --- # name: test_sensor_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_sensor_state[Humidifier 200s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_200s_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '200s-humidifier-humidity', + 'unit_of_measurement': '%', + }), ]) # --- +# name: test_sensor_state[Humidifier 200s][sensor.humidifier_200s_humidity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 200s Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humidifier_200s_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- # name: test_sensor_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_sensor_state[Humidifier 600S][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_600s_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '600s-humidifier-humidity', + 'unit_of_measurement': '%', + }), ]) # --- +# name: test_sensor_state[Humidifier 600S][sensor.humidifier_600s_humidity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 600S Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humidifier_600s_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- # name: test_sensor_state[Outlet][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 4b271ee55d9..596aa0c94ad 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -229,6 +229,36 @@ # --- # name: test_switch_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_switch_state[Humidifier 200s][entities] @@ -237,6 +267,36 @@ # --- # name: test_switch_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_switch_state[Humidifier 600S][entities] diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py new file mode 100644 index 00000000000..3b89ba8e742 --- /dev/null +++ b/tests/components/vesync/test_humidifier.py @@ -0,0 +1,227 @@ +"""Tests for the humidifier platform.""" + +from contextlib import nullcontext +from unittest.mock import patch + +import pytest + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from .common import ( + ENTITY_HUMIDIFIER, + ENTITY_HUMIDIFIER_HUMIDITY, + ENTITY_HUMIDIFIER_MIST_LEVEL, +) + +from tests.common import MockConfigEntry + +NoException = nullcontext() + + +async def test_humidifier_state( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the resulting setup state is as expected for the platform.""" + + expected_entities = [ + ENTITY_HUMIDIFIER, + ENTITY_HUMIDIFIER_HUMIDITY, + ENTITY_HUMIDIFIER_MIST_LEVEL, + ] + + assert humidifier_config_entry.state is ConfigEntryState.LOADED + + for entity_id in expected_entities: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + state = hass.states.get(ENTITY_HUMIDIFIER) + + # ATTR_HUMIDITY represents the target_humidity which comes from configuration.auto_target_humidity node + assert state.attributes.get(ATTR_HUMIDITY) == 40 + + +async def test_set_target_humidity_invalid( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, +) -> None: + """Test handling of invalid value in set_humidify method.""" + + # Setting value out of range results in ServiceValidationError and + # VeSyncHumid200300S.set_humidity does not get called. + with ( + patch("pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity") as method_mock, + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_HUMIDITY: 20}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_set_target_humidity( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test handling of return value from VeSyncHumid200300S.set_humidity.""" + + # If VeSyncHumid200300S.set_humidity fails (returns False), then HomeAssistantError is raised + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity", + return_value=api_response, + ) as method_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_HUMIDITY: 54}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(False, pytest.raises(HomeAssistantError)), (True, NoException)], +) +async def test_turn_on( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test turn_on method.""" + + # turn_on returns False indicating failure in which case humidifier.turn_on + # raises HomeAssistantError. + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on", return_value=api_response + ) as method_mock, + ): + with patch( + "homeassistant.components.vesync.humidifier.VeSyncHumidifierHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(False, pytest.raises(HomeAssistantError)), (True, NoException)], +) +async def test_turn_off( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test turn_off method.""" + + # turn_off returns False indicating failure in which case humidifier.turn_off + # raises HomeAssistantError. + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off", return_value=api_response + ) as method_mock, + ): + with patch( + "homeassistant.components.vesync.humidifier.VeSyncHumidifierHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +async def test_set_mode_invalid( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, +) -> None: + """Test handling of invalid value in set_mode method.""" + + with patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode" + ) as method_mock: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: "something_invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_set_mode( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test handling of value in set_mode method.""" + + # If VeSyncHumid200300S.set_humidity_mode fails (returns False), then HomeAssistantError is raised + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode", + return_value=api_response, + ) as method_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: "auto"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index a089a270c94..3b0df128240 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -5,16 +5,9 @@ from unittest.mock import Mock, patch import pytest from pyvesync import VeSync -from homeassistant.components.vesync import async_setup_entry -from homeassistant.components.vesync.const import ( - DOMAIN, - VS_FANS, - VS_LIGHTS, - VS_MANAGER, - VS_SENSORS, - VS_SWITCHES, -) -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.vesync import SERVICE_UPDATE_DEVS, async_setup_entry +from homeassistant.components.vesync.const import DOMAIN, VS_DEVICES, VS_MANAGER +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -30,7 +23,9 @@ async def test_async_setup_entry__not_login( with ( patch.object(hass.config_entries, "async_forward_entry_setups") as setups_mock, - patch("homeassistant.components.vesync.async_process_devices") as process_mock, + patch( + "homeassistant.components.vesync.async_generate_device_list" + ) as process_mock, ): assert not await async_setup_entry(hass, config_entry) await hass.async_block_till_done() @@ -52,20 +47,24 @@ async def test_async_setup_entry__no_devices( await hass.async_block_till_done() assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry - assert setups_mock.call_args.args[1] == [] + assert setups_mock.call_args.args[1] == [ + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + ] assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert not hass.data[DOMAIN][VS_SWITCHES] - assert not hass.data[DOMAIN][VS_FANS] - assert not hass.data[DOMAIN][VS_LIGHTS] - assert not hass.data[DOMAIN][VS_SENSORS] + assert not hass.data[DOMAIN][VS_DEVICES] async def test_async_setup_entry__loads_fans( hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan ) -> None: - """Test setup connects to vesync and loads fan platform.""" + """Test setup connects to vesync and loads fan.""" fans = [fan] manager.fans = fans manager._dev_list = { @@ -78,10 +77,36 @@ async def test_async_setup_entry__loads_fans( await hass.async_block_till_done() assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry - assert setups_mock.call_args.args[1] == [Platform.FAN, Platform.SENSOR] + assert setups_mock.call_args.args[1] == [ + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + ] assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert not hass.data[DOMAIN][VS_SWITCHES] - assert hass.data[DOMAIN][VS_FANS] == [fan] - assert not hass.data[DOMAIN][VS_LIGHTS] - assert hass.data[DOMAIN][VS_SENSORS] == [fan] + assert hass.data[DOMAIN][VS_DEVICES] == [fan] + + +async def test_async_new_device_discovery__loads_fans( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan +) -> None: + """Test setup connects to vesync and loads fan as an update call.""" + + assert await hass.config_entries.async_setup(config_entry.entry_id) + # Assert platforms loaded + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert not hass.data[DOMAIN][VS_DEVICES] + fans = [fan] + manager.fans = fans + manager._dev_list = { + "fans": fans, + } + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert hass.data[DOMAIN][VS_DEVICES] == [fan] diff --git a/tests/components/vesync/test_number.py b/tests/components/vesync/test_number.py new file mode 100644 index 00000000000..a9230b76db0 --- /dev/null +++ b/tests/components/vesync/test_number.py @@ -0,0 +1,66 @@ +"""Tests for the number platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .common import ENTITY_HUMIDIFIER_MIST_LEVEL + +from tests.common import MockConfigEntry + + +async def test_set_mist_level_bad_range( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test set_mist_level invalid value.""" + with ( + pytest.raises(ServiceValidationError), + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + return_value=True, + ) as method_mock, + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_MIST_LEVEL, ATTR_VALUE: "10"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +async def test_set_mist_level( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test set_mist_level usage.""" + + with patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + return_value=True, + ) as method_mock: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_MIST_LEVEL, ATTR_VALUE: "3"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() + + +async def test_mist_level( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the state of mist_level number entity.""" + + assert hass.states.get(ENTITY_HUMIDIFIER_MIST_LEVEL).state == "6" diff --git a/tests/components/vesync/test_platform.py b/tests/components/vesync/test_platform.py new file mode 100644 index 00000000000..fa1e24f4628 --- /dev/null +++ b/tests/components/vesync/test_platform.py @@ -0,0 +1,92 @@ +"""Tests for the coordinator.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory +import requests_mock + +from homeassistant.components.vesync.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from .common import ( + mock_air_purifier_400s_update_response, + mock_device_response, + mock_multiple_device_responses, + mock_outlet_energy_response, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_entity_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + requests_mock: requests_mock.Mocker, +) -> None: + """Test Vesync coordinator data update. + + This test sets up a single device `Air Purifier 400s` and then updates it via the coordinator. + """ + + config_data = {CONF_PASSWORD: "username", CONF_USERNAME: "password"} + config_entry = MockConfigEntry( + data=config_data, + domain=DOMAIN, + unique_id="vesync_unique_id_1", + entry_id="1", + ) + + mock_multiple_device_responses(requests_mock, ["Air Purifier 400s", "Outlet"]) + + expected_entities = [ + # From "Air Purifier 400s" + "fan.air_purifier_400s", + "sensor.air_purifier_400s_filter_lifetime", + "sensor.air_purifier_400s_air_quality", + "sensor.air_purifier_400s_pm2_5", + # From Outlet + "switch.outlet", + "sensor.outlet_current_power", + "sensor.outlet_energy_use_today", + "sensor.outlet_energy_use_weekly", + "sensor.outlet_energy_use_monthly", + "sensor.outlet_energy_use_yearly", + "sensor.outlet_current_voltage", + ] + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + for entity_id in expected_entities: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "5" + assert hass.states.get("sensor.outlet_current_voltage").state == "120.0" + assert hass.states.get("sensor.outlet_energy_use_weekly").state == "0" + + # Update the mock responses + mock_air_purifier_400s_update_response(requests_mock) + mock_outlet_energy_response(requests_mock, "Outlet", {"totalEnergy": 2.2}) + mock_device_response(requests_mock, "Outlet", {"voltage": 129}) + + freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done(True) + + assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "15" + assert hass.states.get("sensor.outlet_current_voltage").state == "129.0" + + # Test energy update + # pyvesync only updates energy parameters once every 6 hours. + freezer.tick(timedelta(hours=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done(True) + + assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "15" + assert hass.states.get("sensor.outlet_current_voltage").state == "129.0" + assert hass.states.get("sensor.outlet_energy_use_weekly").state == "2.2" diff --git a/tests/components/vesync/test_sensor.py b/tests/components/vesync/test_sensor.py index bd3a8eb8591..04d759de584 100644 --- a/tests/components/vesync/test_sensor.py +++ b/tests/components/vesync/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import ALL_DEVICE_NAMES, mock_devices_response +from .common import ALL_DEVICE_NAMES, ENTITY_HUMIDIFIER_HUMIDITY, mock_devices_response from tests.common import MockConfigEntry @@ -49,3 +49,11 @@ async def test_sensor_state( # Check states for entity in entities: assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +async def test_humidity( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the state of humidity sensor entity.""" + + assert hass.states.get(ENTITY_HUMIDIFIER_HUMIDITY).state == "35" diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index aadf85e7081..8e10d2f1a25 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -84,7 +84,7 @@ async def mock_vicare_gas_boiler( """Return a mocked ViCare API representing a single gas boiler device.""" fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] with patch( - f"{MODULE}.vicare_login", + f"{MODULE}.login", return_value=MockPyViCare(fixtures), ): await setup_integration(hass, mock_config_entry) @@ -102,7 +102,7 @@ async def mock_vicare_room_sensors( Fixture({"type:climateSensor"}, "vicare/RoomSensor2.json"), ] with patch( - f"{MODULE}.vicare_login", + f"{MODULE}.login", return_value=MockPyViCare(fixtures), ): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/vicare/fixtures/Vitocal250A.json b/tests/components/vicare/fixtures/Vitocal250A.json new file mode 100644 index 00000000000..1da43531a89 --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal250A.json @@ -0,0 +1,4447 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productIdentification", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "object", + "value": { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + } + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.productIdentification" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productMatrix", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "array", + "value": [ + { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + }, + { + "busAddress": 71, + "busType": "CanExternal", + "productFamily": "B_00012_VCH200", + "viessmannIdentificationNumber": "################" + } + ] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.productMatrix" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "deviceSerialVitocal250A" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 44.6 + } + }, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.main", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.3 + } + }, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.3 + } + }, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["1"] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.remoteController", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.zone.mode" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "Heizkreis" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -13, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0.2, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 0 + }, + "slope": { + "type": "number", + "unit": "", + "value": 1.1 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "reduced", + "maxEntries": 4, + "modes": ["normal", "comfort"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.1.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "Heizkreis" + } + }, + "timestamp": "2024-09-20T08:56:49.795Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["heating", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "heating" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "comfortHeating" + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 24 + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 24 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "unknown" + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 24 + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": { + "removeZigbeeController": { + "isExecutable": false, + "name": "removeZigbeeController", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.remoteController/commands/removeZigbeeController" + }, + "setZigbeeController": { + "isExecutable": true, + "name": "setZigbeeController", + "params": { + "deviceId": { + "constraints": { + "enum": [] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.remoteController/commands/setZigbeeController" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.remoteController", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 24.1 + } + }, + "timestamp": "2024-10-01T16:05:52.313Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 39 + } + }, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:26:48.295Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": { + "setLevels": { + "isExecutable": true, + "name": "setLevels", + "params": { + "maxTemperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "minTemperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels/commands/setLevels" + }, + "setMax": { + "isExecutable": true, + "name": "setMax", + "params": { + "temperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels/commands/setMax" + }, + "setMin": { + "isExecutable": true, + "name": "setMin", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 55 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.zone.mode", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.remoteController", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.remoteController", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "phase": { + "type": "string", + "value": "ready" + } + }, + "timestamp": "2024-10-01T16:12:14.713Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.heat.production.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 13.317 + } + }, + "timestamp": "2024-10-01T16:28:29.219Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.heat.production.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "kilowatt", + "value": 3.107 + } + }, + "timestamp": "2024-10-01T16:28:29.219Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 5.4, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T11:46:35.700Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 93.9, 41.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T11:46:35.768Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [13, 21.799999999999997, 20.5, 27.4, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T11:46:35.700Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [143, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T11:45:28.937Z" + } + }, + "timestamp": "2024-10-01T12:18:26.686Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 31.2, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 36.7, 2.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [47.599999999999994, 0, 0, 5.5, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [55.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 36.6, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 130.60000000000002, 43.6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [60.599999999999994, 21.799999999999997, 20.5, 32.9, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [198.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 71 + }, + "starts": { + "type": "number", + "unit": "", + "value": 121 + } + }, + "timestamp": "2024-10-01T16:12:54.682Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitocal250A" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.device.variant" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/activate" + }, + "disable": { + "isExecutable": false, + "name": "disable", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/disable" + }, + "enable": { + "isExecutable": true, + "name": "enable", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/enable" + } + }, + "deviceId": "0", + "feature": "heating.dhw.hygiene", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.hygiene.trigger", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene.trigger" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.dhw.oneTimeCharge", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["efficientWithMinComfort", "efficient", "off"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "efficientWithMinComfort" + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.balanced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.balanced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficient", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficient" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficientWithMinComfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficientWithMinComfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.off", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.off" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.secondary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.secondary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 4, + "modes": ["on"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ] + } + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 58.8 + } + }, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 58.8 + } + }, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.middle", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.hygiene", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hygiene" + }, + { + "apiVersion": 1, + "commands": { + "setHysteresis": { + "isExecutable": true, + "name": "setHysteresis", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresis" + }, + "setHysteresisSwitchOffValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOffValue", + "params": { + "hysteresis": { + "constraints": { + "max": 2.5, + "min": 0, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOffValue" + }, + "setHysteresisSwitchOnValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOnValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOnValue" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.hysteresis", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "switchOffValue": { + "type": "number", + "unit": "kelvin", + "value": 0 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "default": { + "type": "number", + "unit": "celsius", + "value": 50 + }, + "max": { + "type": "number", + "unit": "celsius", + "value": 10 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 10 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.levels" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 0, + "efficientUpperBorder": 55, + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 47 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.heat.production.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.heat.production.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 0 + }, + "starts": { + "type": "number", + "unit": "", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.status", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "level1": { + "type": "boolean", + "value": false + }, + "level2": { + "type": "boolean", + "value": false + }, + "level3": { + "type": "boolean", + "value": false + }, + "overall": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.status" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holidayAtHome", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "kilowatt", + "value": 3.107 + } + }, + "timestamp": "2024-10-01T16:28:29.219Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 5.4, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 93.9, 41.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [13, 21.799999999999997, 20.5, 27.4, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [143, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T12:18:26.686Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 31.2, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 36.7, 2.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [47.599999999999994, 0, 0, 5.5, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [55.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 7.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 7.6 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 143 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 93.9 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 28.3 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T11:46:54.639Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 16.4 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 16.4 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 55.2 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 36.7 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 47.6 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 36.6, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 130.60000000000002, 43.6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [60.599999999999994, 21.799999999999997, 20.5, 32.9, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [198.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 12.8 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.dhw", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 4.1 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.scop.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.heating", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.2 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.scop.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.total", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.scop.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 45.1 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.pressure.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "bar", + "value": 2.1 + } + }, + "timestamp": "2024-10-01T15:06:07.125Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.pressure.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.8 + } + }, + "timestamp": "2024-10-01T16:28:20.497Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.hydraulicSeparator", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.hydraulicSeparator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 14.3 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.outside" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.3 + } + }, + "timestamp": "2024-10-01T16:28:04.882Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.volumetricFlow.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "liter/hour", + "value": 1015 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.volumetricFlow.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 4.1 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.spf.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.2 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.spf.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.spf.total" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 793f3e87611..aaf75e6753a 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[sensor.model0_boiler_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_boiler_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_boiler_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_boiler_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -50,7 +50,7 @@ 'state': '63', }) # --- -# name: test_all_entities[sensor.model0_burner_hours-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_hours-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -85,7 +85,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_burner_hours-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_hours-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner hours', @@ -100,7 +100,7 @@ 'state': '18726.3', }) # --- -# name: test_all_entities[sensor.model0_burner_modulation-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_modulation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -135,7 +135,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[sensor.model0_burner_modulation-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_modulation-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner modulation', @@ -150,7 +150,7 @@ 'state': '0', }) # --- -# name: test_all_entities[sensor.model0_burner_starts-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_starts-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -185,7 +185,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_burner_starts-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_starts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner starts', @@ -199,7 +199,7 @@ 'state': '14315', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_month-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_month-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -234,7 +234,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_month-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_month-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this month', @@ -248,7 +248,7 @@ 'state': '805', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_week-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -283,7 +283,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_week-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this week', @@ -297,7 +297,7 @@ 'state': '84', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_year-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_year-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -332,7 +332,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_year-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_year-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this year', @@ -346,7 +346,7 @@ 'state': '8203', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_today-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -381,7 +381,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_today-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption today', @@ -395,7 +395,7 @@ 'state': '22', }) # --- -# name: test_all_entities[sensor.model0_dhw_max_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_max_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -430,7 +430,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_dhw_max_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_max_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -446,7 +446,7 @@ 'state': '60', }) # --- -# name: test_all_entities[sensor.model0_dhw_min_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_min_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -481,7 +481,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_dhw_min_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_min_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -497,407 +497,7 @@ 'state': '10', }) # --- -# name: test_all_entities[sensor.model0_energy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_energy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Energy', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'power consumption this month', - 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this month', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'model0 Energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '7.843', - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_year-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_electricity_consumption_this_year', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Electricity consumption this year', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'power_consumption_this_year', - 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this year', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_year-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'model0 Electricity consumption this year', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_electricity_consumption_this_year', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '207.106', - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_electricity_consumption_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Electricity consumption today', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'power_consumption_today', - 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption today', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'model0 Electricity consumption today', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_electricity_consumption_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.219', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_month-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Heating gas consumption this month', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_this_month', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_month', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_month-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption this month', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_week-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Heating gas consumption this week', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_this_week', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_week', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_week-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption this week', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_year-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Heating gas consumption this year', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_this_year', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_year', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_year-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption this year', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30946', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Heating gas consumption today', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_today', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_today', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption today', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_all_entities[sensor.model0_outside_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_outside_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Outside temperature', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'outside_temperature', - 'unique_id': 'gateway0_deviceSerialVitodens300W-outside_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_outside_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'model0 Outside temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_outside_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '20.8', - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_week-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -932,7 +532,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_week-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -948,7 +548,407 @@ 'state': '0.829', }) # --- -# name: test_all_entities[sensor.model0_supply_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_electricity_consumption_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_consumption_this_year', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this year', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Electricity consumption this year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_electricity_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '207.106', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_consumption_today', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.219', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power consumption this month', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this month', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.843', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating gas consumption this month', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_this_month', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_month', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption this month', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_week-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating gas consumption this week', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_this_week', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_week', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_week-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption this week', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating gas consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_this_year', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_year', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption this year', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30946', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating gas consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_today', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_today', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption today', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'gateway0_deviceSerialVitodens300W-outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.8', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -983,7 +983,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_supply_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -999,7 +999,7 @@ 'state': '63', }) # --- -# name: test_all_entities[sensor.model0_supply_temperature_2-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1034,7 +1034,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_supply_temperature_2-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -1050,6 +1050,1295 @@ 'state': '25.5', }) # --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_buffer_main_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_buffer_main_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Buffer main temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'buffer_main_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-buffer main temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_buffer_main_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Buffer main temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_buffer_main_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_hours-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_compressor_hours', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Compressor hours', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_hours', + 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_hours-0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Compressor hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_compressor_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '71', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_compressor_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Compressor phase', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_phase', + 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_phase-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Compressor phase', + }), + 'context': , + 'entity_id': 'sensor.model0_compressor_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_starts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_compressor_starts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Compressor starts', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_starts', + 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_starts-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_starts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Compressor starts', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_compressor_starts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '121', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption last seven days', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_dhw_consumption_heating_lastsevendays', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_dhw_consumption_heating_lastsevendays', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption last seven days', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption this month', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_dhw_summary_consumption_heating_currentmonth', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentmonth', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption this month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.6', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_dhw_summary_consumption_heating_currentyear', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentyear', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption this year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '143', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_dhw_summary_consumption_heating_currentday', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.6', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_max_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_max_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW max temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hotwater_max_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-hotwater_max_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_max_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW max temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_max_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_min_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_min_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW min temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hotwater_min_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-hotwater_min_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_min_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW min temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_min_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_storage_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_storage_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW storage temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_storage_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-dhw_storage_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_storage_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW storage temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_storage_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '58.8', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_consumption_today', + 'unique_id': 'gateway0_deviceSerialVitocal250A-power consumption today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption last seven days', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_lastsevendays', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_lastsevendays', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption last seven days', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.6', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption this month', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_currentmonth', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentmonth', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption this month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.4', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_currentyear', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentyear', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption this year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.2', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_currentday', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.4', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_primary_circuit_supply_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_primary_circuit_supply_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Primary circuit supply temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'primary_circuit_supply_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-primary_circuit_supply_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_primary_circuit_supply_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Primary circuit supply temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_primary_circuit_supply_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.8', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_return_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_return_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'return_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-return_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_return_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Return temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_return_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_secondary_circuit_supply_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_secondary_circuit_supply_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Secondary circuit supply temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'secondary_circuit_supply_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-secondary_circuit_supply_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_secondary_circuit_supply_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Secondary circuit supply temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_secondary_circuit_supply_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.1', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_supply_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply pressure', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_pressure', + 'unique_id': 'gateway0_deviceSerialVitocal250A-supply_pressure', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'model0 Supply pressure', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_supply_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.1', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_supply_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-supply_temperature-1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Supply temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_supply_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_volumetric_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_volumetric_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volumetric flow', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volumetric_flow', + 'unique_id': 'gateway0_deviceSerialVitocal250A-volumetric_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_volumetric_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Volumetric flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_volumetric_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.015', + }) +# --- +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'levelone', + 'leveltwo', + 'levelthree', + 'levelfour', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_ventilation_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ventilation level', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ventilation_level', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'model0 Ventilation level', + 'options': list([ + 'standby', + 'levelone', + 'leveltwo', + 'levelthree', + 'levelfour', + ]), + }), + 'context': , + 'entity_id': 'sensor.model0_ventilation_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'levelone', + }) +# --- +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'permanent', + 'schedule', + 'sensordriven', + 'silent', + 'forcedlevelfour', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_ventilation_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ventilation reason', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ventilation_reason', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'model0 Ventilation reason', + 'options': list([ + 'standby', + 'permanent', + 'schedule', + 'sensordriven', + 'silent', + 'forcedlevelfour', + ]), + }), + 'context': , + 'entity_id': 'sensor.model0_ventilation_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'permanent', + }) +# --- # name: test_room_sensors[sensor.model0_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/vicare/test_binary_sensor.py b/tests/components/vicare/test_binary_sensor.py index b9b8a57a59b..44612673a11 100644 --- a/tests/components/vicare/test_binary_sensor.py +++ b/tests/components/vicare/test_binary_sensor.py @@ -43,7 +43,7 @@ async def test_all_entities( """Test all entities.""" fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] with ( - patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.BINARY_SENSOR]), ): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/vicare/test_button.py b/tests/components/vicare/test_button.py index c024af41d78..cdc47e3833d 100644 --- a/tests/components/vicare/test_button.py +++ b/tests/components/vicare/test_button.py @@ -25,7 +25,7 @@ async def test_all_entities( """Test all entities.""" fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] with ( - patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.BUTTON]), ): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/vicare/test_climate.py b/tests/components/vicare/test_climate.py index 44df87276e7..f48a8988cf0 100644 --- a/tests/components/vicare/test_climate.py +++ b/tests/components/vicare/test_climate.py @@ -25,7 +25,7 @@ async def test_all_entities( """Test all entities.""" fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] with ( - patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), ): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index a522cf75d5d..ce3b3c27f06 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -9,12 +9,12 @@ from PyViCare.PyViCareUtils import ( ) from syrupy.assertion import SnapshotAssertion -from homeassistant.components import dhcp from homeassistant.components.vicare.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import MOCK_MAC, MODULE @@ -28,7 +28,7 @@ VALID_CONFIG = { CONF_CLIENT_ID: "5678", } -DHCP_INFO = dhcp.DhcpServiceInfo( +DHCP_INFO = DhcpServiceInfo( ip="1.1.1.1", hostname="mock_hostname", macaddress=MOCK_MAC.lower().replace(":", ""), @@ -49,7 +49,7 @@ async def test_user_create_entry( # test PyViCareInvalidConfigurationError with patch( - f"{MODULE}.config_flow.vicare_login", + f"{MODULE}.config_flow.login", side_effect=PyViCareInvalidConfigurationError( {"error": "foo", "error_description": "bar"} ), @@ -65,7 +65,7 @@ async def test_user_create_entry( # test PyViCareInvalidCredentialsError with patch( - f"{MODULE}.config_flow.vicare_login", + f"{MODULE}.config_flow.login", side_effect=PyViCareInvalidCredentialsError, ): result = await hass.config_entries.flow.async_configure( @@ -79,7 +79,7 @@ async def test_user_create_entry( # test success with patch( - f"{MODULE}.config_flow.vicare_login", + f"{MODULE}.config_flow.login", return_value=None, ): result = await hass.config_entries.flow.async_configure( @@ -110,7 +110,7 @@ async def test_step_reauth(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> # test PyViCareInvalidConfigurationError with patch( - f"{MODULE}.config_flow.vicare_login", + f"{MODULE}.config_flow.login", side_effect=PyViCareInvalidConfigurationError( {"error": "foo", "error_description": "bar"} ), @@ -125,7 +125,7 @@ async def test_step_reauth(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> # test success with patch( - f"{MODULE}.config_flow.vicare_login", + f"{MODULE}.config_flow.login", return_value=None, ): result = await hass.config_entries.flow.async_configure( @@ -160,7 +160,7 @@ async def test_form_dhcp( assert result["errors"] == {} with patch( - f"{MODULE}.config_flow.vicare_login", + f"{MODULE}.config_flow.login", return_value=None, ): result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/vicare/test_fan.py b/tests/components/vicare/test_fan.py index ba5db6e42c7..aaf6a968ffd 100644 --- a/tests/components/vicare/test_fan.py +++ b/tests/components/vicare/test_fan.py @@ -25,7 +25,7 @@ async def test_all_entities( """Test all entities.""" fixtures: list[Fixture] = [Fixture({"type:ventilation"}, "vicare/ViAir300F.json")] with ( - patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.FAN]), ): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/vicare/test_init.py b/tests/components/vicare/test_init.py index 62bec7f50c5..d553f2758b8 100644 --- a/tests/components/vicare/test_init.py +++ b/tests/components/vicare/test_init.py @@ -26,7 +26,7 @@ async def test_device_and_entity_migration( Fixture({"type:boiler"}, "vicare/dummy-device-no-serial.json"), ] with ( - patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), ): mock_config_entry.add_to_hass(hass) diff --git a/tests/components/vicare/test_number.py b/tests/components/vicare/test_number.py index c3aa66a86f6..7b9c1915b95 100644 --- a/tests/components/vicare/test_number.py +++ b/tests/components/vicare/test_number.py @@ -25,7 +25,7 @@ async def test_all_entities( """Test all entities.""" fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] with ( - patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.NUMBER]), ): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/vicare/test_sensor.py b/tests/components/vicare/test_sensor.py index 06c8b963680..daad6bfa1c8 100644 --- a/tests/components/vicare/test_sensor.py +++ b/tests/components/vicare/test_sensor.py @@ -16,18 +16,28 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("fixture_type", "fixture_data"), + [ + ("type:boiler", "vicare/Vitodens300W.json"), + ("type:heatpump", "vicare/Vitocal250A.json"), + ("type:ventilation", "vicare/ViAir300F.json"), + ], +) async def test_all_entities( hass: HomeAssistant, + fixture_type: str, + fixture_data: str, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" fixtures: list[Fixture] = [ - Fixture({"type:boiler"}, "vicare/Vitodens300W.json"), + Fixture({fixture_type}, fixture_data), ] with ( - patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.SENSOR]), ): await setup_integration(hass, mock_config_entry) @@ -48,7 +58,7 @@ async def test_room_sensors( Fixture({"type:climateSensor"}, "vicare/RoomSensor2.json"), ] with ( - patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.SENSOR]), ): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/vicare/test_utils.py b/tests/components/vicare/test_utils.py new file mode 100644 index 00000000000..13ca77f2792 --- /dev/null +++ b/tests/components/vicare/test_utils.py @@ -0,0 +1,23 @@ +"""Test ViCare utils.""" + +import pytest + +from homeassistant.components.vicare.utils import filter_state + + +@pytest.mark.parametrize( + ("state", "expected_result"), + [ + (None, None), + ("unknown", None), + ("nothing", None), + ("levelOne", "levelOne"), + ], +) +async def test_filter_state( + state: str | None, + expected_result: str | None, +) -> None: + """Test filter_state.""" + + assert filter_state(state) == expected_result diff --git a/tests/components/vicare/test_water_heater.py b/tests/components/vicare/test_water_heater.py index fbb5863cf7a..f9ca431af6d 100644 --- a/tests/components/vicare/test_water_heater.py +++ b/tests/components/vicare/test_water_heater.py @@ -25,7 +25,7 @@ async def test_all_entities( """Test all entities.""" fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] with ( - patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.WATER_HEATER]), ): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 3e7b0c83c70..5fbf61a58da 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -2,7 +2,6 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, MediaPlayerDeviceClass, @@ -27,6 +26,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PIN, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import slugify NAME = "Vizio" @@ -112,14 +112,6 @@ MOCK_OPTIONS = { CONF_VOLUME_STEP: VOLUME_STEP, } -MOCK_IMPORT_VALID_TV_CONFIG = { - CONF_NAME: NAME, - CONF_HOST: HOST, - CONF_DEVICE_CLASS: MediaPlayerDeviceClass.TV, - CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_VOLUME_STEP: VOLUME_STEP, -} - MOCK_TV_WITH_INCLUDE_CONFIG = { CONF_NAME: NAME, CONF_HOST: HOST, @@ -147,23 +139,6 @@ MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG = { CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]}, } -MOCK_SPEAKER_APPS_FAILURE = { - CONF_NAME: NAME, - CONF_HOST: HOST, - CONF_DEVICE_CLASS: MediaPlayerDeviceClass.SPEAKER, - CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_VOLUME_STEP: VOLUME_STEP, - CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]}, -} - -MOCK_TV_APPS_FAILURE = { - CONF_NAME: NAME, - CONF_HOST: HOST, - CONF_DEVICE_CLASS: MediaPlayerDeviceClass.TV, - CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_VOLUME_STEP: VOLUME_STEP, - CONF_APPS: None, -} MOCK_TV_APPS_WITH_VALID_APPS_CONFIG = { CONF_HOST: HOST, @@ -198,7 +173,7 @@ VIZIO_ZEROCONF_SERVICE_TYPE = "_viziocast._tcp.local." ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}" ZEROCONF_HOST, ZEROCONF_PORT = HOST.split(":", maxsplit=2) -MOCK_ZEROCONF_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_SERVICE_INFO = ZeroconfServiceInfo( ip_address=ip_address(ZEROCONF_HOST), ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 42d4394ca80..2ef7c18bd04 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -3,30 +3,20 @@ import dataclasses import pytest -import voluptuous as vol from homeassistant.components.media_player import MediaPlayerDeviceClass -from homeassistant.components.vizio.config_flow import _get_config_schema from homeassistant.components.vizio.const import ( CONF_APPS, CONF_APPS_TO_INCLUDE_OR_EXCLUDE, - CONF_INCLUDE, CONF_VOLUME_STEP, - DEFAULT_NAME, - DEFAULT_VOLUME_STEP, DOMAIN, - VIZIO_SCHEMA, -) -from homeassistant.config_entries import ( - SOURCE_IGNORE, - SOURCE_IMPORT, - SOURCE_USER, - SOURCE_ZEROCONF, ) +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, CONF_HOST, + CONF_INCLUDE, CONF_NAME, CONF_PIN, ) @@ -38,14 +28,11 @@ from .const import ( CURRENT_APP, HOST, HOST2, - MOCK_IMPORT_VALID_TV_CONFIG, MOCK_INCLUDE_APPS, MOCK_INCLUDE_NO_APPS, MOCK_PIN_CONFIG, MOCK_SPEAKER_CONFIG, MOCK_TV_CONFIG_NO_TOKEN, - MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, - MOCK_TV_WITH_EXCLUDE_CONFIG, MOCK_USER_VALID_TV_CONFIG, MOCK_ZEROCONF_SERVICE_INFO, NAME, @@ -370,297 +357,6 @@ async def test_user_ignore(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") -async def test_import_flow_minimum_fields(hass: HomeAssistant) -> None: - """Test import config flow with minimum fields.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)( - {CONF_HOST: HOST, CONF_DEVICE_CLASS: MediaPlayerDeviceClass.SPEAKER} - ), - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"][CONF_NAME] == DEFAULT_NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.SPEAKER - assert result["data"][CONF_VOLUME_STEP] == DEFAULT_VOLUME_STEP - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") -async def test_import_flow_all_fields(hass: HomeAssistant) -> None: - """Test import config flow with all fields.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_IMPORT_VALID_TV_CONFIG), - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == NAME - assert result["data"][CONF_NAME] == NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN - assert result["data"][CONF_VOLUME_STEP] == VOLUME_STEP - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") -async def test_import_entity_already_configured(hass: HomeAssistant) -> None: - """Test entity is already configured during import setup.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), - options={CONF_VOLUME_STEP: VOLUME_STEP}, - ) - entry.add_to_hass(hass) - fail_entry = vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG.copy()) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=fail_entry - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured_device" - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update") -async def test_import_flow_update_options(hass: HomeAssistant) -> None: - """Test import config flow with updated options.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), - ) - await hass.async_block_till_done() - - assert result["result"].options == {CONF_VOLUME_STEP: DEFAULT_VOLUME_STEP} - assert result["type"] is FlowResultType.CREATE_ENTRY - entry_id = result["result"].entry_id - - updated_config = MOCK_SPEAKER_CONFIG.copy() - updated_config[CONF_VOLUME_STEP] = VOLUME_STEP + 1 - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(updated_config), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "updated_entry" - config_entry = hass.config_entries.async_get_entry(entry_id) - assert config_entry.options[CONF_VOLUME_STEP] == VOLUME_STEP + 1 - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update") -async def test_import_flow_update_name_and_apps(hass: HomeAssistant) -> None: - """Test import config flow with updated name and apps.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_IMPORT_VALID_TV_CONFIG), - ) - await hass.async_block_till_done() - - assert result["result"].data[CONF_NAME] == NAME - assert result["type"] is FlowResultType.CREATE_ENTRY - entry_id = result["result"].entry_id - - updated_config = MOCK_IMPORT_VALID_TV_CONFIG.copy() - updated_config[CONF_NAME] = NAME2 - updated_config[CONF_APPS] = {CONF_INCLUDE: [CURRENT_APP]} - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(updated_config), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "updated_entry" - config_entry = hass.config_entries.async_get_entry(entry_id) - assert config_entry.data[CONF_NAME] == NAME2 - assert config_entry.data[CONF_APPS] == {CONF_INCLUDE: [CURRENT_APP]} - assert config_entry.options[CONF_APPS] == {CONF_INCLUDE: [CURRENT_APP]} - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update") -async def test_import_flow_update_remove_apps(hass: HomeAssistant) -> None: - """Test import config flow with removed apps.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_TV_WITH_EXCLUDE_CONFIG), - ) - await hass.async_block_till_done() - - assert result["result"].data[CONF_NAME] == NAME - assert result["type"] is FlowResultType.CREATE_ENTRY - config_entry = hass.config_entries.async_get_entry(result["result"].entry_id) - assert CONF_APPS in config_entry.data - assert CONF_APPS in config_entry.options - - updated_config = MOCK_TV_WITH_EXCLUDE_CONFIG.copy() - updated_config.pop(CONF_APPS) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(updated_config), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "updated_entry" - assert CONF_APPS not in config_entry.data - assert CONF_APPS not in config_entry.options - - -@pytest.mark.usefixtures( - "vizio_connect", "vizio_bypass_setup", "vizio_complete_pairing" -) -async def test_import_needs_pairing(hass: HomeAssistant) -> None: - """Test pairing config flow when access token not provided for tv during import.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_TV_CONFIG_NO_TOKEN - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_TV_CONFIG_NO_TOKEN - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pair_tv" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_PIN_CONFIG - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pairing_complete_import" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == NAME - assert result["data"][CONF_NAME] == NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - - -@pytest.mark.usefixtures( - "vizio_connect", "vizio_bypass_setup", "vizio_complete_pairing" -) -async def test_import_with_apps_needs_pairing(hass: HomeAssistant) -> None: - """Test pairing config flow when access token not provided for tv but apps are included during import.""" - import_config = MOCK_TV_CONFIG_NO_TOKEN.copy() - import_config[CONF_APPS] = {CONF_INCLUDE: [CURRENT_APP]} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=import_config - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - # Mock inputting info without apps to make sure apps get stored - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=_get_config_schema(MOCK_TV_CONFIG_NO_TOKEN)(MOCK_TV_CONFIG_NO_TOKEN), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pair_tv" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_PIN_CONFIG - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pairing_complete_import" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == NAME - assert result["data"][CONF_NAME] == NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - assert result["data"][CONF_APPS][CONF_INCLUDE] == [CURRENT_APP] - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update") -async def test_import_flow_additional_configs(hass: HomeAssistant) -> None: - """Test import config flow with additional configs defined in CONF_APPS.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG), - ) - await hass.async_block_till_done() - - assert result["result"].data[CONF_NAME] == NAME - assert result["type"] is FlowResultType.CREATE_ENTRY - config_entry = hass.config_entries.async_get_entry(result["result"].entry_id) - assert CONF_APPS in config_entry.data - assert CONF_APPS not in config_entry.options - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") -async def test_import_error( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that error is logged when import config has an error.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), - options={CONF_VOLUME_STEP: VOLUME_STEP}, - unique_id=UNIQUE_ID, - ) - entry.add_to_hass(hass) - fail_entry = MOCK_SPEAKER_CONFIG.copy() - fail_entry[CONF_HOST] = "0.0.0.0" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(fail_entry), - ) - - assert result["type"] is FlowResultType.FORM - - # Ensure error gets logged - vizio_log_list = [ - log - for log in caplog.records - if log.name == "homeassistant.components.vizio.config_flow" - ] - assert len(vizio_log_list) == 1 - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") -async def test_import_ignore(hass: HomeAssistant) -> None: - """Test import config flow doesn't throw an error when there's an existing ignored source.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=MOCK_SPEAKER_CONFIG, - options={CONF_VOLUME_STEP: VOLUME_STEP}, - source=SOURCE_IGNORE, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - - @pytest.mark.usefixtures( "vizio_connect", "vizio_bypass_setup", "vizio_guess_device_type" ) @@ -854,26 +550,3 @@ async def test_zeroconf_flow_already_configured_hostname(hass: HomeAssistant) -> # Flow should abort because device is already setup assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup", "vizio_hostname_check") -async def test_import_flow_already_configured_hostname(hass: HomeAssistant) -> None: - """Test entity is already configured during import setup when existing entry uses hostname.""" - config = MOCK_SPEAKER_CONFIG.copy() - config[CONF_HOST] = "hostname" - entry = MockConfigEntry( - domain=DOMAIN, data=config, options={CONF_VOLUME_STEP: VOLUME_STEP} - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), - ) - - # Flow should abort because device was updated - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "updated_entry" - - assert entry.data[CONF_HOST] == HOST diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index c2b19377809..e004255ec6d 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -7,7 +7,6 @@ import pytest from homeassistant.components.vizio.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from .const import MOCK_SPEAKER_CONFIG, MOCK_USER_VALID_TV_CONFIG, UNIQUE_ID @@ -15,16 +14,6 @@ from .const import MOCK_SPEAKER_CONFIG, MOCK_USER_VALID_TV_CONFIG, UNIQUE_ID from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_setup_component(hass: HomeAssistant) -> None: - """Test component setup.""" - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: MOCK_USER_VALID_TV_CONFIG} - ) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1 - - @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_tv_load_and_unload(hass: HomeAssistant) -> None: """Test loading and unloading TV entry.""" diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 12e19077c8e..a76dfa3fa2d 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -19,7 +19,6 @@ from pyvizio.const import ( MAX_VOLUME, UNKNOWN_APP, ) -import voluptuous as vol from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, @@ -42,7 +41,6 @@ from homeassistant.components.media_player import ( SERVICE_VOLUME_UP, MediaPlayerDeviceClass, ) -from homeassistant.components.vizio import validate_apps from homeassistant.components.vizio.const import ( CONF_ADDITIONAL_CONFIGS, CONF_APPS, @@ -50,7 +48,6 @@ from homeassistant.components.vizio.const import ( DEFAULT_VOLUME_STEP, DOMAIN, SERVICE_UPDATE_SETTING, - VIZIO_SCHEMA, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -69,9 +66,7 @@ from .const import ( EQ_LIST, INPUT_LIST, INPUT_LIST_WITH_APPS, - MOCK_SPEAKER_APPS_FAILURE, MOCK_SPEAKER_CONFIG, - MOCK_TV_APPS_FAILURE, MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, MOCK_TV_WITH_EXCLUDE_CONFIG, MOCK_TV_WITH_INCLUDE_CONFIG, @@ -155,7 +150,7 @@ async def _test_setup_tv(hass: HomeAssistant, vizio_power_state: bool | None) -> config_entry = MockConfigEntry( domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG), + data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID, ) @@ -181,7 +176,7 @@ async def _test_setup_speaker( config_entry = MockConfigEntry( domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), + data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID, ) @@ -215,7 +210,7 @@ async def _cm_for_test_setup_tv_with_apps( ) -> AsyncIterator[None]: """Context manager to setup test for Vizio TV with support for apps.""" config_entry = MockConfigEntry( - domain=DOMAIN, data=vol.Schema(VIZIO_SCHEMA)(device_config), unique_id=UNIQUE_ID + domain=DOMAIN, data=device_config, unique_id=UNIQUE_ID ) async with _cm_for_test_setup_without_apps( @@ -641,15 +636,6 @@ async def test_setup_with_apps_additional_apps_config( assert not service_call2.called -def test_invalid_apps_config(hass: HomeAssistant) -> None: - """Test that schema validation fails on certain conditions.""" - with pytest.raises(vol.Invalid): - vol.Schema(vol.All(VIZIO_SCHEMA, validate_apps))(MOCK_TV_APPS_FAILURE) - - with pytest.raises(vol.Invalid): - vol.Schema(vol.All(VIZIO_SCHEMA, validate_apps))(MOCK_SPEAKER_APPS_FAILURE) - - @pytest.mark.usefixtures("vizio_connect", "vizio_update_with_apps") async def test_setup_with_unknown_app_config( hass: HomeAssistant, @@ -687,7 +673,7 @@ async def test_setup_tv_without_mute(hass: HomeAssistant) -> None: """Test Vizio TV entity setup when mute property isn't returned by Vizio API.""" config_entry = MockConfigEntry( domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG), + data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID, ) @@ -742,7 +728,7 @@ async def test_vizio_update_with_apps_on_input(hass: HomeAssistant) -> None: """Test a vizio TV with apps that is on a TV input.""" config_entry = MockConfigEntry( domain=DOMAIN, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG), + data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID, ) await _add_config_entry_to_hass(hass, config_entry) diff --git a/tests/components/vodafone_station/__init__.py b/tests/components/vodafone_station/__init__.py index 68f11a27b95..6119d94c06c 100644 --- a/tests/components/vodafone_station/__init__.py +++ b/tests/components/vodafone_station/__init__.py @@ -1 +1,13 @@ """Tests for the Vodafone Station integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/vodafone_station/conftest.py b/tests/components/vodafone_station/conftest.py new file mode 100644 index 00000000000..7763db5044a --- /dev/null +++ b/tests/components/vodafone_station/conftest.py @@ -0,0 +1,77 @@ +"""Configure tests for Vodafone Station.""" + +from datetime import UTC, datetime + +from aiovodafone import VodafoneStationDevice +import pytest + +from homeassistant.components.vodafone_station import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from .const import DEVICE_1_MAC + +from tests.common import ( + AsyncMock, + Generator, + MockConfigEntry, + load_json_object_fixture, + patch, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.vodafone_station.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_vodafone_station_router() -> Generator[AsyncMock]: + """Mock a Vodafone Station router.""" + with ( + patch( + "homeassistant.components.vodafone_station.coordinator.VodafoneStationSercommApi", + autospec=True, + ) as mock_router, + patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi", + new=mock_router, + ), + ): + router = mock_router.return_value + router.get_devices_data.return_value = { + DEVICE_1_MAC: VodafoneStationDevice( + connected=True, + connection_type="wifi", + ip_address="192.168.1.10", + name="WifiDevice0", + mac=DEVICE_1_MAC, + type="laptop", + wifi="2.4G", + ), + } + router.get_sensor_data.return_value = load_json_object_fixture( + "get_sensor_data.json", DOMAIN + ) + router.convert_uptime.return_value = datetime( + 2024, 11, 19, 20, 19, 0, tzinfo=UTC + ) + router.base_url = "https://fake_host" + yield router + + +@pytest.fixture +def mock_config_entry() -> Generator[MockConfigEntry]: + """Mock a Vodafone Station config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) diff --git a/tests/components/vodafone_station/const.py b/tests/components/vodafone_station/const.py index 9adf32b339d..0f1ed2ba7da 100644 --- a/tests/components/vodafone_station/const.py +++ b/tests/components/vodafone_station/const.py @@ -1,115 +1,3 @@ """Common stuff for Vodafone Station tests.""" -from aiovodafone.api import VodafoneStationDevice - -from homeassistant.components.vodafone_station.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME - -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - } - ] - } -} - -MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] - - -DEVICE_DATA_QUERY = { - "xx:xx:xx:xx:xx:xx": VodafoneStationDevice( - connected=True, - connection_type="wifi", - ip_address="192.168.1.10", - name="WifiDevice0", - mac="xx:xx:xx:xx:xx:xx", - type="laptop", - wifi="2.4G", - ) -} - -SENSOR_DATA_QUERY = { - "sys_serial_number": "M123456789", - "sys_firmware_version": "XF6_4.0.05.04", - "sys_bootloader_version": "0220", - "sys_hardware_version": "RHG3006 v1", - "omci_software_version": "\t\t1.0.0.1_41032\t\t\n", - "sys_uptime": "12:16:41", - "sys_cpu_usage": "97%", - "sys_reboot_cause": "Web Reboot", - "sys_memory_usage": "51.94%", - "sys_wireless_driver_version": "17.10.188.75;17.10.188.75", - "sys_wireless_driver_version_5g": "17.10.188.75;17.10.188.75", - "vf_internet_key_online_since": "", - "vf_internet_key_ip_addr": "0.0.0.0", - "vf_internet_key_system": "0.0.0.0", - "vf_internet_key_mode": "Auto", - "sys_voip_version": "v02.01.00_01.13a\n", - "sys_date_time": "20.10.2024 | 03:44 pm", - "sys_build_time": "Sun Jun 23 17:55:49 CST 2024\n", - "sys_model_name": "RHG3006", - "inter_ip_address": "1.1.1.1", - "inter_gateway": "1.1.1.2", - "inter_primary_dns": "1.1.1.3", - "inter_secondary_dns": "1.1.1.4", - "inter_firewall": "601036", - "inter_wan_ip_address": "1.1.1.1", - "inter_ipv6_link_local_address": "", - "inter_ipv6_link_global_address": "", - "inter_ipv6_gateway": "", - "inter_ipv6_prefix_delegation": "", - "inter_ipv6_dns_address1": "", - "inter_ipv6_dns_address2": "", - "lan_ip_network": "192.168.0.1/24", - "lan_default_gateway": "192.168.0.1", - "lan_subnet_address_subnet1": "", - "lan_mac_address": "11:22:33:44:55:66", - "lan_dhcp_server": "601036", - "lan_dhcpv6_server": "601036", - "lan_router_advertisement": "601036", - "lan_ipv6_default_gateway": "fe80::1", - "lan_port1_switch_mode": "1301722", - "lan_port2_switch_mode": "1301722", - "lan_port3_switch_mode": "1301722", - "lan_port4_switch_mode": "1301722", - "lan_port1_switch_speed": "10", - "lan_port2_switch_speed": "100", - "lan_port3_switch_speed": "1000", - "lan_port4_switch_speed": "1000", - "lan_port1_switch_status": "1301724", - "lan_port2_switch_status": "1301724", - "lan_port3_switch_status": "1301724", - "lan_port4_switch_status": "1301724", - "wifi_status": "601036", - "wifi_name": "Wifi-Main-Network", - "wifi_mac_address": "AA:BB:CC:DD:EE:FF", - "wifi_security": "401027", - "wifi_channel": "8", - "wifi_bandwidth": "573", - "guest_wifi_status": "601037", - "guest_wifi_name": "Wifi-Guest", - "guest_wifi_mac_addr": "AA:BB:CC:DD:EE:GG", - "guest_wifi_security": "401027", - "guest_wifi_channel": "N/A", - "guest_wifi_ip": "192.168.2.1", - "guest_wifi_subnet_addr": "255.255.255.0", - "guest_wifi_dhcp_server": "192.168.2.1", - "wifi_status_5g": "601036", - "wifi_name_5g": "Wifi-Main-Network", - "wifi_mac_address_5g": "AA:BB:CC:DD:EE:HH", - "wifi_security_5g": "401027", - "wifi_channel_5g": "36", - "wifi_bandwidth_5g": "4803", - "guest_wifi_status_5g": "601037", - "guest_wifi_name_5g": "Wifi-Guest", - "guest_wifi_mac_addr_5g": "AA:BB:CC:DD:EE:II", - "guest_wifi_channel_5g": "N/A", - "guest_wifi_security_5g": "401027", - "guest_wifi_ip_5g": "192.168.2.1", - "guest_wifi_subnet_addr_5g": "255.255.255.0", - "guest_wifi_dhcp_server_5g": "192.168.2.1", -} +DEVICE_1_MAC = "xx:xx:xx:xx:xx:xx" diff --git a/tests/components/vodafone_station/fixtures/get_sensor_data.json b/tests/components/vodafone_station/fixtures/get_sensor_data.json new file mode 100644 index 00000000000..6a6229ebd18 --- /dev/null +++ b/tests/components/vodafone_station/fixtures/get_sensor_data.json @@ -0,0 +1,81 @@ +{ + "sys_serial_number": "m123456789", + "sys_firmware_version": "XF6_4.0.05.04", + "sys_bootloader_version": "0220", + "sys_hardware_version": "RHG3006 v1", + "omci_software_version": "\t\t1.0.0.1_41032\t\t\n", + "sys_uptime": "12:16:41", + "sys_cpu_usage": "97%", + "sys_reboot_cause": "Web Reboot", + "sys_memory_usage": "51.94%", + "sys_wireless_driver_version": "17.10.188.75;17.10.188.75", + "sys_wireless_driver_version_5g": "17.10.188.75;17.10.188.75", + "vf_internet_key_online_since": "", + "vf_internet_key_ip_addr": "0.0.0.0", + "vf_internet_key_system": "0.0.0.0", + "vf_internet_key_mode": "Auto", + "sys_voip_version": "v02.01.00_01.13a\n", + "sys_date_time": "20.10.2024 | 03:44 pm", + "sys_build_time": "Sun Jun 23 17:55:49 CST 2024\n", + "sys_model_name": "RHG3006", + "inter_ip_address": "1.1.1.1", + "inter_gateway": "1.1.1.2", + "inter_primary_dns": "1.1.1.3", + "inter_secondary_dns": "1.1.1.4", + "inter_firewall": "601036", + "inter_wan_ip_address": "1.1.1.1", + "inter_ipv6_link_local_address": "", + "inter_ipv6_link_global_address": "", + "inter_ipv6_gateway": "", + "inter_ipv6_prefix_delegation": "", + "inter_ipv6_dns_address1": "", + "inter_ipv6_dns_address2": "", + "lan_ip_network": "192.168.0.1/24", + "lan_default_gateway": "192.168.0.1", + "lan_subnet_address_subnet1": "", + "lan_mac_address": "11:22:33:44:55:66", + "lan_dhcp_server": "601036", + "lan_dhcpv6_server": "601036", + "lan_router_advertisement": "601036", + "lan_ipv6_default_gateway": "fe80::1", + "lan_port1_switch_mode": "1301722", + "lan_port2_switch_mode": "1301722", + "lan_port3_switch_mode": "1301722", + "lan_port4_switch_mode": "1301722", + "lan_port1_switch_speed": "10", + "lan_port2_switch_speed": "100", + "lan_port3_switch_speed": "1000", + "lan_port4_switch_speed": "1000", + "lan_port1_switch_status": "1301724", + "lan_port2_switch_status": "1301724", + "lan_port3_switch_status": "1301724", + "lan_port4_switch_status": "1301724", + "wifi_status": "601036", + "wifi_name": "Wifi-Main-Network", + "wifi_mac_address": "AA:BB:CC:DD:EE:FF", + "wifi_security": "401027", + "wifi_channel": "8", + "wifi_bandwidth": "573", + "guest_wifi_status": "601037", + "guest_wifi_name": "Wifi-Guest", + "guest_wifi_mac_addr": "AA:BB:CC:DD:EE:GG", + "guest_wifi_security": "401027", + "guest_wifi_channel": "N/A", + "guest_wifi_ip": "192.168.2.1", + "guest_wifi_subnet_addr": "255.255.255.0", + "guest_wifi_dhcp_server": "192.168.2.1", + "wifi_status_5g": "601036", + "wifi_name_5g": "Wifi-Main-Network", + "wifi_mac_address_5g": "AA:BB:CC:DD:EE:HH", + "wifi_security_5g": "401027", + "wifi_channel_5g": "36", + "wifi_bandwidth_5g": "4803", + "guest_wifi_status_5g": "601037", + "guest_wifi_name_5g": "Wifi-Guest", + "guest_wifi_mac_addr_5g": "AA:BB:CC:DD:EE:II", + "guest_wifi_channel_5g": "N/A", + "guest_wifi_security_5g": "401027", + "guest_wifi_ip_5g": "192.168.2.1", + "guest_wifi_subnet_addr_5g": "255.255.255.0", + "guest_wifi_dhcp_server_5g": "192.168.2.1" +} diff --git a/tests/components/vodafone_station/snapshots/test_button.ambr b/tests/components/vodafone_station/snapshots/test_button.ambr new file mode 100644 index 00000000000..dc7953ac42a --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_button.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_all_entities[button.vodafone_station_m123456789_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vodafone_station_m123456789_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'm123456789_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.vodafone_station_m123456789_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Vodafone Station (m123456789) Restart', + }), + 'context': , + 'entity_id': 'button.vodafone_station_m123456789_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..834c8b14459 --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_tracker', + 'unique_id': 'xx:xx:xx:xx:xx:xx', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'host_name': 'WifiDevice0', + 'ip': '192.168.1.10', + 'mac': 'xx:xx:xx:xx:xx:xx', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- diff --git a/tests/components/vodafone_station/snapshots/test_sensor.ambr b/tests/components/vodafone_station/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..eb1676938b5 --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_sensor.ambr @@ -0,0 +1,246 @@ +# serializer version: 1 +# name: test_all_entities[sensor.vodafone_station_m123456789_active_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'dsl', + 'fiber', + 'internet_key', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vodafone_station_m123456789_active_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active connection', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_connection', + 'unique_id': 'm123456789_inter_ip_address', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_active_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vodafone Station (m123456789) Active connection', + 'options': list([ + 'dsl', + 'fiber', + 'internet_key', + ]), + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_active_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_cpu_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_cpu_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CPU usage', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_cpu_usage', + 'unique_id': 'm123456789_sys_cpu_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_cpu_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) CPU usage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_cpu_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_memory_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_memory_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Memory usage', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_memory_usage', + 'unique_id': 'm123456789_sys_memory_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_memory_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) Memory usage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_memory_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.94', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_reboot_cause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_reboot_cause', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reboot cause', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_reboot_cause', + 'unique_id': 'm123456789_sys_reboot_cause', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_reboot_cause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) Reboot cause', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_reboot_cause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Web Reboot', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_uptime', + 'unique_id': 'm123456789_sys_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Vodafone Station (m123456789) Uptime', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-19T20:19:00+00:00', + }) +# --- diff --git a/tests/components/vodafone_station/test_button.py b/tests/components/vodafone_station/test_button.py new file mode 100644 index 00000000000..d5f377d3f6f --- /dev/null +++ b/tests/components/vodafone_station/test_button.py @@ -0,0 +1,48 @@ +"""Tests for Vodafone Station button platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.BUTTON] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_pressing_button( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test device restart button.""" + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.vodafone_station_m123456789_restart"}, + blocking=True, + ) + mock_vodafone_station_router.restart_router.assert_called_once() diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 3a54f250871..68f8247bdf9 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -1,8 +1,13 @@ """Tests for Vodafone Station config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock -from aiovodafone import exceptions as aiovodafone_exceptions +from aiovodafone import ( + AlreadyLogged, + CannotAuthenticate, + CannotConnect, + ModelNotSupported, +) import pytest from homeassistant.components.device_tracker import CONF_CONSIDER_HOME @@ -12,39 +17,36 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import MOCK_USER_DATA - from tests.common import MockConfigEntry -async def test_user(hass: HomeAssistant) -> None: +async def test_user( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: """Test starting a flow by user.""" - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry" - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_USERNAME] == "fake_username" - assert result["data"][CONF_PASSWORD] == "fake_password" - assert not result["result"].unique_id - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + } + assert not result["result"].unique_id assert mock_setup_entry.called @@ -52,14 +54,20 @@ async def test_user(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("side_effect", "error"), [ - (aiovodafone_exceptions.CannotConnect, "cannot_connect"), - (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), - (aiovodafone_exceptions.AlreadyLogged, "already_logged"), - (aiovodafone_exceptions.ModelNotSupported, "model_not_supported"), + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (AlreadyLogged, "already_logged"), + (ModelNotSupported, "model_not_supported"), (ConnectionResetError, "unknown"), ], ) -async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: +async def test_exception_connection( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + side_effect: Exception, + error: str, +) -> None: """Test starting a flow by user with a connection error.""" result = await hass.config_entries.flow.async_init( @@ -68,178 +76,153 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "user" - with patch( - "aiovodafone.api.VodafoneStationSercommApi.login", - side_effect=side_effect, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA - ) + mock_vodafone_station_router.login.side_effect = side_effect - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] is not None - assert result["errors"]["base"] == error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) - # Should be recoverable after hits error - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", - return_value={ - "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", - "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", - }, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - }, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "fake_host" - assert result2["data"] == { - "host": "fake_host", - "username": "fake_username", - "password": "fake_password", - } + mock_vodafone_station_router.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "fake_host" + assert result["data"] == { + "host": "fake_host", + "username": "fake_username", + "password": "fake_password", + } -async def test_reauth_successful(hass: HomeAssistant) -> None: +async def test_duplicate_entry( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting a flow by user with a duplicate entry.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_successful( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test starting a reauthentication flow.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "other_fake_password", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" @pytest.mark.parametrize( ("side_effect", "error"), [ - (aiovodafone_exceptions.CannotConnect, "cannot_connect"), - (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), - (aiovodafone_exceptions.AlreadyLogged, "already_logged"), + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (AlreadyLogged, "already_logged"), (ConnectionResetError, "unknown"), ], ) -async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: +async def test_reauth_not_successful( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: """Test starting a reauthentication flow but no connection found.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) - - result = await mock_config.start_reauth_flow(hass) + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - side_effect=side_effect, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "other_fake_password", - }, - ) + mock_vodafone_station_router.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] is not None - assert result["errors"]["base"] == error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} - # Should be recoverable after hits error - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", - return_value={ - "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", - "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", - }, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "fake_password", - }, - ) - await hass.async_block_till_done() + mock_vodafone_station_router.login.side_effect = None - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "fake_password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "fake_password" -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test options flow.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(mock_config.entry_id) - await hass.async_block_till_done() + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_CONSIDER_HOME: 37, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { diff --git a/tests/components/vodafone_station/test_device_tracker.py b/tests/components/vodafone_station/test_device_tracker.py new file mode 100644 index 00000000000..5133d0da980 --- /dev/null +++ b/tests/components/vodafone_station/test_device_tracker.py @@ -0,0 +1,64 @@ +"""Define tests for the Vodafone Station device tracker.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.vodafone_station.const import SCAN_INTERVAL +from homeassistant.components.vodafone_station.coordinator import CONSIDER_HOME_SECONDS +from homeassistant.const import STATE_HOME, STATE_NOT_HOME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .const import DEVICE_1_MAC + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.DEVICE_TRACKER] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_consider_home( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if device is considered not_home when disconnected.""" + await setup_integration(hass, mock_config_entry) + + device_tracker = "device_tracker.vodafone_station_xx_xx_xx_xx_xx_xx" + + state = hass.states.get(device_tracker) + assert state + assert state.state == STATE_HOME + + mock_vodafone_station_router.get_devices_data.return_value[ + DEVICE_1_MAC + ].connected = False + + freezer.tick(SCAN_INTERVAL + CONSIDER_HOME_SECONDS) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(device_tracker) + assert state + assert state.state == STATE_NOT_HOME diff --git a/tests/components/vodafone_station/test_diagnostics.py b/tests/components/vodafone_station/test_diagnostics.py index 02918d81912..5a4a46ce693 100644 --- a/tests/components/vodafone_station/test_diagnostics.py +++ b/tests/components/vodafone_station/test_diagnostics.py @@ -2,16 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock from syrupy import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.vodafone_station.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .const import DEVICE_DATA_QUERY, MOCK_USER_DATA, SENSOR_DATA_QUERY +from . import setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -20,29 +18,17 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - with ( - patch("aiovodafone.api.VodafoneStationSercommApi.login"), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_devices_data", - return_value=DEVICE_DATA_QUERY, - ), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_sensor_data", - return_value=SENSOR_DATA_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", diff --git a/tests/components/vodafone_station/test_sensor.py b/tests/components/vodafone_station/test_sensor.py new file mode 100644 index 00000000000..ddf97824c75 --- /dev/null +++ b/tests/components/vodafone_station/test_sensor.py @@ -0,0 +1,129 @@ +"""Tests for Vodafone Station sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from aiovodafone import CannotAuthenticate +from aiovodafone.exceptions import AlreadyLogged, CannotConnect +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.vodafone_station.const import LINE_TYPES, SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("connection_type", "index"), + [ + ("dsl_ipaddr", 0), + ("fiber_ipaddr", 1), + ("vf_internet_key_ip_addr", 2), + ], +) +async def test_active_connection_type( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + connection_type: str, + index: int, +) -> None: + """Test device connection type.""" + await setup_integration(hass, mock_config_entry) + + active_connection_entity = "sensor.vodafone_station_m123456789_active_connection" + + state = hass.states.get(active_connection_entity) + assert state + assert state.state == STATE_UNKNOWN + + mock_vodafone_station_router.get_sensor_data.return_value[connection_type] = ( + "1.1.1.1" + ) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(active_connection_entity) + assert state + assert state.state == LINE_TYPES[index] + + +@pytest.mark.freeze_time("2023-12-02T13:00:00+00:00") +async def test_uptime( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test device uptime shift.""" + await setup_integration(hass, mock_config_entry) + + uptime = "2024-11-19T20:19:00+00:00" + uptime_entity = "sensor.vodafone_station_m123456789_uptime" + + state = hass.states.get(uptime_entity) + assert state + assert state.state == uptime + + mock_vodafone_station_router.get_sensor_data.return_value["sys_uptime"] = "12:17:23" + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(uptime_entity) + assert state + assert state.state == uptime + + +@pytest.mark.parametrize( + "side_effect", + [ + CannotConnect, + CannotAuthenticate, + AlreadyLogged, + ConnectionResetError, + ], +) +async def test_coordinator_client_connector_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test ClientConnectorError on coordinator update.""" + await setup_integration(hass, mock_config_entry) + + mock_vodafone_station_router.get_devices_data.side_effect = side_effect + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.vodafone_station_m123456789_uptime") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py index cbca8997797..99707297230 100644 --- a/tests/components/voip/conftest.py +++ b/tests/components/voip/conftest.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from voip_utils import CallInfo +from voip_utils.sip import get_sip_endpoint from homeassistant.components.voip import DOMAIN from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices @@ -55,8 +56,7 @@ async def voip_devices(hass: HomeAssistant, setup_voip: None) -> VoIPDevices: def call_info() -> CallInfo: """Fake call info.""" return CallInfo( - caller_ip="192.168.1.210", - caller_sip_port=5060, + caller_endpoint=get_sip_endpoint("192.168.1.210", 5060), caller_rtp_port=5004, server_ip="192.168.1.10", headers={ diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py index 55359b8407d..4e2e129d4be 100644 --- a/tests/components/voip/test_devices.py +++ b/tests/components/voip/test_devices.py @@ -2,12 +2,15 @@ from __future__ import annotations +import pytest from voip_utils import CallInfo from homeassistant.components.voip import DOMAIN from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry async def test_device_registry_info( @@ -21,10 +24,10 @@ async def test_device_registry_info( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device is not None - assert device.name == call_info.caller_ip + assert device.name == call_info.caller_endpoint.host assert device.manufacturer == "Grandstream" assert device.model == "HT801" assert device.sw_version == "1.0.17.5" @@ -36,7 +39,7 @@ async def test_device_registry_info( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device.sw_version == "2.0.0.0" @@ -53,7 +56,7 @@ async def test_device_registry_info_from_unknown_phone( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device.manufacturer is None assert device.model == "Unknown" @@ -76,3 +79,53 @@ async def test_remove_device_registry_entry( assert hass.states.get("switch.192_168_1_210_allow_calls") is None assert voip_device.voip_id not in voip_devices.devices + + +@pytest.fixture +async def legacy_dev_reg_entry( + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + call_info: CallInfo, +) -> None: + """Fixture to run before we set up the VoIP integration via fixture.""" + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, call_info.caller_ip)}, + ) + entity_registry.async_get_or_create( + "switch", + DOMAIN, + f"{call_info.caller_ip}-allow_calls", + device_id=device.id, + config_entry=config_entry, + ) + return device + + +async def test_device_registry_migration( + hass: HomeAssistant, + legacy_dev_reg_entry: dr.DeviceEntry, + voip_devices: VoIPDevices, + call_info: CallInfo, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry migrates old devices.""" + voip_device = voip_devices.async_get_or_create(call_info) + new_id = call_info.caller_endpoint.uri + assert voip_device.voip_id == new_id + + device = device_registry.async_get_device(identifiers={(DOMAIN, new_id)}) + assert device is not None + assert device.id == legacy_dev_reg_entry.id + assert device.identifiers == {(DOMAIN, new_id)} + assert device.name == call_info.caller_endpoint.host + assert device.manufacturer == "Grandstream" + assert device.model == "HT801" + assert device.sw_version == "1.0.17.5" + + assert ( + entity_registry.async_get_entity_id("switch", DOMAIN, f"{new_id}-allow_calls") + is not None + ) diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py index 9c3708f970c..85e9e250ab9 100644 --- a/tests/components/volumio/test_config_flow.py +++ b/tests/components/volumio/test_config_flow.py @@ -4,11 +4,11 @@ from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.volumio.config_flow import CannotConnectError from homeassistant.components.volumio.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -21,7 +21,7 @@ TEST_CONNECTION = { } -TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 4e0f860366c..09a0a711582 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -8,14 +8,13 @@ from unittest.mock import AsyncMock, MagicMock import pytest import voluptuous as vol -from homeassistant.components import water_heater from homeassistant.components.water_heater import ( - ATTR_OPERATION_LIST, - ATTR_OPERATION_MODE, DOMAIN, SERVICE_SET_OPERATION_MODE, SET_TEMPERATURE_SCHEMA, WaterHeaterEntity, + WaterHeaterEntityDescription, + WaterHeaterEntityEntityDescription, WaterHeaterEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -30,8 +29,6 @@ from tests.common import ( MockModule, MockPlatform, async_mock_service, - help_test_all, - import_and_test_deprecated_constant_enum, mock_integration, mock_platform, ) @@ -211,49 +208,13 @@ async def test_operation_mode_validation( water_heater_entity.set_operation_mode.assert_has_calls([mock.call("eco")]) -def test_all() -> None: - """Test module.__all__ is correctly set.""" - help_test_all(water_heater) - - @pytest.mark.parametrize( - ("enum"), - [ - WaterHeaterEntityFeature.TARGET_TEMPERATURE, - WaterHeaterEntityFeature.OPERATION_MODE, - WaterHeaterEntityFeature.AWAY_MODE, - ], + ("class_name", "expected_log"), + [(WaterHeaterEntityDescription, False), (WaterHeaterEntityEntityDescription, True)], ) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: WaterHeaterEntityFeature, +async def test_deprecated_entity_description( + caplog: pytest.LogCaptureFixture, class_name: type, expected_log: bool ) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, water_heater, enum, "SUPPORT_", "2025.1" - ) - - -def test_deprecated_supported_features_ints( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecated supported features ints.""" - - class MockWaterHeaterEntity(WaterHeaterEntity): - _attr_operation_list = ["mode1", "mode2"] - _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_current_operation = "mode1" - _attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE.value - - entity = MockWaterHeaterEntity() - entity.hass = hass - assert entity.supported_features_compat is WaterHeaterEntityFeature(2) - assert "MockWaterHeaterEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "WaterHeaterEntityFeature.OPERATION_MODE" in caplog.text - caplog.clear() - assert entity.supported_features_compat is WaterHeaterEntityFeature(2) - assert "is using deprecated supported features values" not in caplog.text - assert entity.state_attributes[ATTR_OPERATION_MODE] == "mode1" - assert entity.capability_attributes[ATTR_OPERATION_LIST] == ["mode1", "mode2"] + """Test deprecated WaterHeaterEntityEntityDescription logs warning.""" + class_name(key="test") + assert ("is a deprecated class" in caplog.text) is expected_log diff --git a/tests/components/watergate/__init__.py b/tests/components/watergate/__init__.py new file mode 100644 index 00000000000..c69129e4720 --- /dev/null +++ b/tests/components/watergate/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the Watergate integration.""" + +from homeassistant.core import HomeAssistant + + +async def init_integration(hass: HomeAssistant, mock_entry) -> None: + """Set up the Watergate integration in Home Assistant.""" + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/watergate/conftest.py b/tests/components/watergate/conftest.py new file mode 100644 index 00000000000..6d40a4b7152 --- /dev/null +++ b/tests/components/watergate/conftest.py @@ -0,0 +1,85 @@ +"""Fixtures for watergate platform tests.""" + +from collections.abc import Generator + +import pytest + +from homeassistant.components.watergate.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS + +from .const import ( + DEFAULT_DEVICE_STATE, + DEFAULT_NETWORKING_STATE, + DEFAULT_SERIAL_NUMBER, + DEFAULT_TELEMETRY_STATE, + MOCK_CONFIG, + MOCK_WEBHOOK_ID, +) + +from tests.common import AsyncMock, MockConfigEntry, patch + + +@pytest.fixture +def mock_watergate_client() -> Generator[AsyncMock]: + """Fixture to mock WatergateLocalApiClient.""" + with ( + patch( + "homeassistant.components.watergate.WatergateLocalApiClient", + autospec=True, + ) as mock_client_main, + patch( + "homeassistant.components.watergate.config_flow.WatergateLocalApiClient", + new=mock_client_main, + ), + ): + mock_client_instance = mock_client_main.return_value + + mock_client_instance.async_get_device_state = AsyncMock( + return_value=DEFAULT_DEVICE_STATE + ) + mock_client_instance.async_get_networking = AsyncMock( + return_value=DEFAULT_NETWORKING_STATE + ) + mock_client_instance.async_get_telemetry_data = AsyncMock( + return_value=DEFAULT_TELEMETRY_STATE + ) + yield mock_client_instance + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.watergate.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_webhook_id_generation() -> Generator[None]: + """Fixture to mock webhook_id generation.""" + with patch( + "homeassistant.components.watergate.config_flow.webhook_generate_id", + return_value=MOCK_WEBHOOK_ID, + ): + yield + + +@pytest.fixture +def mock_entry() -> MockConfigEntry: + """Create full mocked entry to be used in config_flow tests.""" + return MockConfigEntry( + domain=DOMAIN, + title="Sonic", + data=MOCK_CONFIG, + entry_id="12345", + unique_id=DEFAULT_SERIAL_NUMBER, + ) + + +@pytest.fixture +def user_input() -> dict[str, str]: + """Create user input for config_flow tests.""" + return { + CONF_IP_ADDRESS: "192.168.1.100", + } diff --git a/tests/components/watergate/const.py b/tests/components/watergate/const.py new file mode 100644 index 00000000000..0f7cc12c14b --- /dev/null +++ b/tests/components/watergate/const.py @@ -0,0 +1,42 @@ +"""Constants for the Watergate tests.""" + +from watergate_local_api.models import DeviceState, NetworkingData, TelemetryData +from watergate_local_api.models.water_meter import WaterMeter + +from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME, CONF_WEBHOOK_ID + +MOCK_WEBHOOK_ID = "webhook_id" + +MOCK_CONFIG = { + CONF_NAME: "Sonic", + CONF_IP_ADDRESS: "http://localhost", + CONF_WEBHOOK_ID: MOCK_WEBHOOK_ID, +} + +DEFAULT_SERIAL_NUMBER = "a63182948ce2896a" + +DEFAULT_DEVICE_STATE = DeviceState( + "open", + "on", + True, + True, + "battery", + "1.0.0", + 100, + WaterMeter(1.2, 100), + DEFAULT_SERIAL_NUMBER, +) + +DEFAULT_NETWORKING_STATE = NetworkingData( + True, + True, + "192.168.1.127", + "192.168.1.1", + "255.255.255.0", + "Sonic", + -50, + 2137, + 1910, +) + +DEFAULT_TELEMETRY_STATE = TelemetryData(0.0, 100, 28.32, None, []) diff --git a/tests/components/watergate/snapshots/test_sensor.ambr b/tests/components/watergate/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a58c7c0eab8 --- /dev/null +++ b/tests/components/watergate/snapshots/test_sensor.ambr @@ -0,0 +1,506 @@ +# serializer version: 1 +# name: test_sensor[sensor.sonic_mqtt_up_since-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sonic_mqtt_up_since', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'MQTT up since', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mqtt_up_since', + 'unique_id': 'a63182948ce2896a.mqtt_up_since', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.sonic_mqtt_up_since-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Sonic MQTT up since', + }), + 'context': , + 'entity_id': 'sensor.sonic_mqtt_up_since', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-01-09T11:59:58+00:00', + }) +# --- +# name: test_sensor[sensor.sonic_power_supply_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'battery', + 'external', + 'battery_external', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sonic_power_supply_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power supply mode', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_supply_mode', + 'unique_id': 'a63182948ce2896a.power_supply_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.sonic_power_supply_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Sonic Power supply mode', + 'options': list([ + 'battery', + 'external', + 'battery_external', + ]), + }), + 'context': , + 'entity_id': 'sensor.sonic_power_supply_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'battery', + }) +# --- +# name: test_sensor[sensor.sonic_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sonic_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a63182948ce2896a.rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensor[sensor.sonic_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Sonic Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.sonic_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-50', + }) +# --- +# name: test_sensor[sensor.sonic_up_since-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sonic_up_since', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Up since', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'up_since', + 'unique_id': 'a63182948ce2896a.up_since', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.sonic_up_since-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Sonic Up since', + }), + 'context': , + 'entity_id': 'sensor.sonic_up_since', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-01-09T11:58:20+00:00', + }) +# --- +# name: test_sensor[sensor.sonic_volume_flow_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sonic_volume_flow_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volume flow rate', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a63182948ce2896a.water_flow_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sonic_volume_flow_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Sonic Volume flow rate', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sonic_volume_flow_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[sensor.sonic_water_meter_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sonic_water_meter_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water meter duration', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_meter_duration', + 'unique_id': 'a63182948ce2896a.water_meter_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sonic_water_meter_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Sonic Water meter duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sonic_water_meter_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensor[sensor.sonic_water_meter_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sonic_water_meter_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water meter volume', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_meter_volume', + 'unique_id': 'a63182948ce2896a.water_meter_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sonic_water_meter_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Sonic Water meter volume', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sonic_water_meter_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2', + }) +# --- +# name: test_sensor[sensor.sonic_water_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sonic_water_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water pressure', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_pressure', + 'unique_id': 'a63182948ce2896a.water_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sonic_water_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Sonic Water pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sonic_water_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensor[sensor.sonic_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sonic_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water temperature', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_temperature', + 'unique_id': 'a63182948ce2896a.water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.sonic_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Sonic Water temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sonic_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.32', + }) +# --- +# name: test_sensor[sensor.sonic_wi_fi_up_since-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sonic_wi_fi_up_since', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi up since', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_up_since', + 'unique_id': 'a63182948ce2896a.wifi_up_since', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.sonic_wi_fi_up_since-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Sonic Wi-Fi up since', + }), + 'context': , + 'entity_id': 'sensor.sonic_wi_fi_up_since', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-01-09T11:59:57+00:00', + }) +# --- diff --git a/tests/components/watergate/snapshots/test_valve.ambr b/tests/components/watergate/snapshots/test_valve.ambr new file mode 100644 index 00000000000..1df1a0c748d --- /dev/null +++ b/tests/components/watergate/snapshots/test_valve.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_change_valve_state_snapshot + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Sonic', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.sonic', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/watergate/test_config_flow.py b/tests/components/watergate/test_config_flow.py new file mode 100644 index 00000000000..176047f5e23 --- /dev/null +++ b/tests/components/watergate/test_config_flow.py @@ -0,0 +1,107 @@ +"""Tests for the Watergate config flow.""" + +from collections.abc import Generator + +import pytest +from watergate_local_api import WatergateApiException + +from homeassistant.components.watergate.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID +from homeassistant.data_entry_flow import FlowResultType + +from .const import DEFAULT_DEVICE_STATE, DEFAULT_SERIAL_NUMBER, MOCK_WEBHOOK_ID + +from tests.common import AsyncMock, HomeAssistant, MockConfigEntry + + +async def test_step_user_form( + hass: HomeAssistant, + mock_watergate_client: Generator[AsyncMock], + mock_webhook_id_generation: Generator[None], + user_input: dict[str, str], +) -> None: + """Test checking if registration form works end to end.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert CONF_IP_ADDRESS in result["data_schema"].schema + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Sonic" + assert result["data"] == {**user_input, CONF_WEBHOOK_ID: MOCK_WEBHOOK_ID} + assert result["result"].unique_id == DEFAULT_SERIAL_NUMBER + + +@pytest.mark.parametrize( + "client_result", + [AsyncMock(return_value=None), AsyncMock(side_effect=WatergateApiException)], +) +async def test_step_user_form_with_exception( + hass: HomeAssistant, + mock_watergate_client: Generator[AsyncMock], + user_input: dict[str, str], + client_result: AsyncMock, + mock_webhook_id_generation: Generator[None], +) -> None: + """Test checking if errors will be displayed when Exception is thrown while checking device state.""" + mock_watergate_client.async_get_device_state = client_result + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"][CONF_IP_ADDRESS] == "cannot_connect" + + mock_watergate_client.async_get_device_state = AsyncMock( + return_value=DEFAULT_DEVICE_STATE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Sonic" + assert result["data"] == {**user_input, CONF_WEBHOOK_ID: MOCK_WEBHOOK_ID} + + +async def test_abort_if_id_is_not_unique( + hass: HomeAssistant, + mock_watergate_client: Generator[AsyncMock], + mock_entry: MockConfigEntry, + user_input: dict[str, str], +) -> None: + """Test checking if we will inform user that this entity is already registered.""" + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert CONF_IP_ADDRESS in result["data_schema"].schema + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/watergate/test_init.py b/tests/components/watergate/test_init.py new file mode 100644 index 00000000000..71eb99d6470 --- /dev/null +++ b/tests/components/watergate/test_init.py @@ -0,0 +1,81 @@ +"""Tests for the Watergate integration init module.""" + +from collections.abc import Generator +from unittest.mock import patch + +from homeassistant.components.valve import ValveState +from homeassistant.components.watergate.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import init_integration +from .const import MOCK_WEBHOOK_ID + +from tests.common import ANY, AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator + + +async def test_async_setup_entry( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], +) -> None: + """Test setting up the Watergate integration.""" + hass.config.internal_url = "http://hassio.local" + + with ( + patch("homeassistant.components.watergate.async_register") as mock_webhook, + ): + await init_integration(hass, mock_entry) + + assert mock_entry.state is ConfigEntryState.LOADED + + mock_webhook.assert_called_once_with( + hass, + DOMAIN, + "Watergate", + MOCK_WEBHOOK_ID, + ANY, + ) + mock_watergate_client.async_set_webhook_url.assert_called_once_with( + f"http://hassio.local/api/webhook/{MOCK_WEBHOOK_ID}" + ) + mock_watergate_client.async_get_device_state.assert_called_once() + + +async def test_handle_webhook( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], +) -> None: + """Test handling webhook events.""" + await init_integration(hass, mock_entry) + + entity_id = "valve.sonic" + + registered_entity = hass.states.get(entity_id) + assert registered_entity + assert registered_entity.state == ValveState.OPEN + + valve_change_data = { + "type": "valve", + "data": {"state": "closed"}, + } + client = await hass_client_no_auth() + await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=valve_change_data) + + await hass.async_block_till_done() # Ensure the webhook is processed + + assert hass.states.get(entity_id).state == ValveState.CLOSED + + valve_change_data = { + "type": "valve", + "data": {"state": "open"}, + } + + await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=valve_change_data) + + await hass.async_block_till_done() # Ensure the webhook is processed + + assert hass.states.get(entity_id).state == ValveState.OPEN diff --git a/tests/components/watergate/test_sensor.py b/tests/components/watergate/test_sensor.py new file mode 100644 index 00000000000..78e375857ed --- /dev/null +++ b/tests/components/watergate/test_sensor.py @@ -0,0 +1,150 @@ +"""Tests for the Watergate valve platform.""" + +from collections.abc import Generator + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration +from .const import DEFAULT_NETWORKING_STATE, DEFAULT_TELEMETRY_STATE, MOCK_WEBHOOK_ID + +from tests.common import AsyncMock, MockConfigEntry, patch, snapshot_platform +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test states of the sensor.""" + freezer.move_to("2021-01-09 12:00:00+00:00") + with patch("homeassistant.components.watergate.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, mock_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +async def test_diagnostics_are_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], +) -> None: + """Test if all diagnostic entities are disabled by default.""" + with patch("homeassistant.components.watergate.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, mock_entry) + + entries = [ + entry + for entry in entity_registry.entities.get_entries_for_config_entry_id( + mock_entry.entry_id + ) + if entry.entity_category == EntityCategory.DIAGNOSTIC + ] + + assert len(entries) == 5 + for entry in entries: + assert entry.disabled + + +async def test_telemetry_webhook( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], +) -> None: + """Test if water flow webhook is handled correctly.""" + await init_integration(hass, mock_entry) + + def assert_state(entity_id: str, expected_state: str): + state = hass.states.get(entity_id) + assert state.state == str(expected_state) + + assert_state("sensor.sonic_volume_flow_rate", DEFAULT_TELEMETRY_STATE.flow) + assert_state("sensor.sonic_water_pressure", DEFAULT_TELEMETRY_STATE.pressure) + assert_state( + "sensor.sonic_water_temperature", DEFAULT_TELEMETRY_STATE.water_temperature + ) + + telemetry_change_data = { + "type": "telemetry", + "data": {"flow": 2137, "pressure": 1910, "temperature": 20}, + } + client = await hass_client_no_auth() + await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=telemetry_change_data) + + await hass.async_block_till_done() + + assert_state("sensor.sonic_volume_flow_rate", "2.137") + assert_state("sensor.sonic_water_pressure", "1910") + assert_state("sensor.sonic_water_temperature", "20") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_wifi_webhook( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], +) -> None: + """Test if water flow webhook is handled correctly.""" + await init_integration(hass, mock_entry) + + def assert_state(entity_id: str, expected_state: str): + state = hass.states.get(entity_id) + assert state.state == str(expected_state) + + assert_state("sensor.sonic_signal_strength", DEFAULT_NETWORKING_STATE.rssi) + + wifi_change_data = { + "type": "wifi-changed", + "data": { + "ip": "192.168.2.137", + "gateway": "192.168.2.1", + "ssid": "Sonic 2", + "rssi": -70, + "subnet": "255.255.255.0", + }, + } + client = await hass_client_no_auth() + await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=wifi_change_data) + + await hass.async_block_till_done() + + assert_state("sensor.sonic_signal_strength", "-70") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_power_supply_webhook( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], +) -> None: + """Test if water flow webhook is handled correctly.""" + await init_integration(hass, mock_entry) + entity_id = "sensor.sonic_power_supply_mode" + registered_entity = hass.states.get(entity_id) + assert registered_entity + assert registered_entity.state == "battery" + + power_supply_change_data = { + "type": "power-supply-changed", + "data": {"supply": "external_battery"}, + } + client = await hass_client_no_auth() + await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=power_supply_change_data) + + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "battery_external" diff --git a/tests/components/watergate/test_valve.py b/tests/components/watergate/test_valve.py new file mode 100644 index 00000000000..b22f6967665 --- /dev/null +++ b/tests/components/watergate/test_valve.py @@ -0,0 +1,72 @@ +"""Tests for the Watergate valve platform.""" + +from collections.abc import Generator + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import AsyncMock, MockConfigEntry + + +async def test_change_valve_state_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_watergate_client: Generator[AsyncMock], + mock_entry: MockConfigEntry, +) -> None: + """Test entities become unavailable after failed update.""" + await init_integration(hass, mock_entry) + + entity_id = "valve.sonic" + + registered_entity = hass.states.get(entity_id) + assert registered_entity + assert registered_entity.state == ValveState.OPEN + assert registered_entity == snapshot + + +async def test_change_valve_state( + hass: HomeAssistant, + mock_watergate_client: Generator[AsyncMock], + mock_entry: MockConfigEntry, +) -> None: + """Test entities become unavailable after failed update.""" + await init_integration(hass, mock_entry) + + entity_id = "valve.sonic" + + registered_entity = hass.states.get(entity_id) + assert registered_entity + assert registered_entity.state == ValveState.OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + registered_entity = hass.states.get(entity_id) + assert registered_entity + assert registered_entity.state == ValveState.CLOSING + + mock_watergate_client.async_set_valve_state.assert_called_once_with("closed") + mock_watergate_client.async_set_valve_state.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + registered_entity = hass.states.get(entity_id) + assert registered_entity + assert registered_entity.state == ValveState.OPENING + + mock_watergate_client.async_set_valve_state.assert_called_once_with("open") diff --git a/tests/components/waze_travel_time/test_init.py b/tests/components/waze_travel_time/test_init.py index 9c59278ff99..89bccc00985 100644 --- a/tests/components/waze_travel_time/test_init.py +++ b/tests/components/waze_travel_time/test_init.py @@ -44,6 +44,8 @@ async def test_service_get_travel_times(hass: HomeAssistant) -> None: "destination": "location2", "vehicle_type": "car", "region": "us", + "units": "imperial", + "incl_filter": ["IncludeThis"], }, blocking=True, return_response=True, @@ -51,17 +53,11 @@ async def test_service_get_travel_times(hass: HomeAssistant) -> None: assert response_data == { "routes": [ { - "distance": 300, + "distance": pytest.approx(186.4113), "duration": 150, "name": "E1337 - Teststreet", "street_names": ["E1337", "IncludeThis", "Teststreet"], }, - { - "distance": 500, - "duration": 600, - "name": "E0815 - Otherstreet", - "street_names": ["E0815", "ExcludeThis", "Otherstreet"], - }, ] } diff --git a/tests/components/weatherkit/__init__.py b/tests/components/weatherkit/__init__.py index 99c856a7e37..bc7c31fe8d8 100644 --- a/tests/components/weatherkit/__init__.py +++ b/tests/components/weatherkit/__init__.py @@ -1,5 +1,6 @@ """Tests for the Apple WeatherKit integration.""" +from contextlib import contextmanager from unittest.mock import patch from apple_weatherkit import DataSetType @@ -26,20 +27,13 @@ EXAMPLE_CONFIG_DATA = { } -async def init_integration( - hass: HomeAssistant, +@contextmanager +def mock_weather_response( is_night_time: bool = False, has_hourly_forecast: bool = True, has_daily_forecast: bool = True, -) -> MockConfigEntry: - """Set up the WeatherKit integration in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Home", - unique_id="0123456", - data=EXAMPLE_CONFIG_DATA, - ) - +): + """Mock a successful WeatherKit API response.""" weather_response = load_json_object_fixture("weatherkit/weather_response.json") available_data_sets = [DataSetType.CURRENT_WEATHER] @@ -68,8 +62,22 @@ async def init_integration( return_value=available_data_sets, ), ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + yield + + +async def init_integration( + hass: HomeAssistant, +) -> MockConfigEntry: + """Set up the WeatherKit integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="0123456", + data=EXAMPLE_CONFIG_DATA, + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/weatherkit/test_coordinator.py b/tests/components/weatherkit/test_coordinator.py index eff142f3d94..7cc78179f44 100644 --- a/tests/components/weatherkit/test_coordinator.py +++ b/tests/components/weatherkit/test_coordinator.py @@ -4,30 +4,93 @@ from datetime import timedelta from unittest.mock import patch from apple_weatherkit.client import WeatherKitApiClientError +from freezegun.api import FrozenDateTimeFactory from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util.dt import utcnow -from . import init_integration +from . import init_integration, mock_weather_response from tests.common import async_fire_time_changed -async def test_failed_updates(hass: HomeAssistant) -> None: - """Test that we properly handle failed updates.""" - await init_integration(hass) +async def test_update_uses_stale_data_before_threshold( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that stale data from the last successful update is used if an update failure occurs before the threshold.""" + with mock_weather_response(): + await init_integration(hass) + + state = hass.states.get("weather.home") + assert state + assert state.state != STATE_UNAVAILABLE + + initial_state = state.state + + # Expect stale data to be used before one hour with patch( "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", side_effect=WeatherKitApiClientError, ): - async_fire_time_changed( - hass, - utcnow() + timedelta(minutes=5), - ) + freezer.tick(timedelta(minutes=59)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("weather.home") + assert state + assert state.state == initial_state + + +async def test_update_becomes_unavailable_after_threshold( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the entity becomes unavailable if an update failure occurs after the threshold.""" + with mock_weather_response(): + await init_integration(hass) + + # Expect state to be unavailable after one hour + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientError, + ): + freezer.tick(timedelta(hours=1, minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("weather.home") assert state assert state.state == STATE_UNAVAILABLE + + +async def test_update_recovers_after_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a successful update after repeated failures recovers the entity's state.""" + with mock_weather_response(): + await init_integration(hass) + + # Trigger a failure after threshold + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientError, + ): + freezer.tick(timedelta(hours=1, minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Expect that a successful update recovers the entity + + with mock_weather_response(): + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("weather.home") + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/weatherkit/test_sensor.py b/tests/components/weatherkit/test_sensor.py index 6c6999c6bfd..6ded9a779d5 100644 --- a/tests/components/weatherkit/test_sensor.py +++ b/tests/components/weatherkit/test_sensor.py @@ -6,7 +6,7 @@ import pytest from homeassistant.core import HomeAssistant -from . import init_integration +from . import init_integration, mock_weather_response @pytest.mark.parametrize( @@ -20,7 +20,8 @@ async def test_sensor_values( hass: HomeAssistant, entity_name: str, expected_value: Any ) -> None: """Test that various sensor values match what we expect.""" - await init_integration(hass) + with mock_weather_response(): + await init_integration(hass) state = hass.states.get(entity_name) assert state diff --git a/tests/components/weatherkit/test_weather.py b/tests/components/weatherkit/test_weather.py index ba20276c22e..ec4ce2ba3b3 100644 --- a/tests/components/weatherkit/test_weather.py +++ b/tests/components/weatherkit/test_weather.py @@ -23,12 +23,13 @@ from homeassistant.components.weatherkit.const import ATTRIBUTION from homeassistant.const import ATTR_ATTRIBUTION, ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant -from . import init_integration +from . import init_integration, mock_weather_response async def test_current_weather(hass: HomeAssistant) -> None: """Test states of the current weather.""" - await init_integration(hass) + with mock_weather_response(): + await init_integration(hass) state = hass.states.get("weather.home") assert state @@ -49,7 +50,8 @@ async def test_current_weather(hass: HomeAssistant) -> None: async def test_current_weather_nighttime(hass: HomeAssistant) -> None: """Test that the condition is clear-night when it's sunny and night time.""" - await init_integration(hass, is_night_time=True) + with mock_weather_response(is_night_time=True): + await init_integration(hass) state = hass.states.get("weather.home") assert state @@ -58,7 +60,8 @@ async def test_current_weather_nighttime(hass: HomeAssistant) -> None: async def test_daily_forecast_missing(hass: HomeAssistant) -> None: """Test that daily forecast is not supported when WeatherKit doesn't support it.""" - await init_integration(hass, has_daily_forecast=False) + with mock_weather_response(has_daily_forecast=False): + await init_integration(hass) state = hass.states.get("weather.home") assert state @@ -69,7 +72,8 @@ async def test_daily_forecast_missing(hass: HomeAssistant) -> None: async def test_hourly_forecast_missing(hass: HomeAssistant) -> None: """Test that hourly forecast is not supported when WeatherKit doesn't support it.""" - await init_integration(hass, has_hourly_forecast=False) + with mock_weather_response(has_hourly_forecast=False): + await init_integration(hass) state = hass.states.get("weather.home") assert state @@ -86,7 +90,8 @@ async def test_hourly_forecast( hass: HomeAssistant, snapshot: SnapshotAssertion, service: str ) -> None: """Test states of the hourly forecast.""" - await init_integration(hass) + with mock_weather_response(): + await init_integration(hass) response = await hass.services.async_call( WEATHER_DOMAIN, @@ -109,7 +114,8 @@ async def test_daily_forecast( hass: HomeAssistant, snapshot: SnapshotAssertion, service: str ) -> None: """Test states of the daily forecast.""" - await init_integration(hass) + with mock_weather_response(): + await init_integration(hass) response = await hass.services.async_call( WEATHER_DOMAIN, diff --git a/tests/components/webostv/__init__.py b/tests/components/webostv/__init__.py index d6c096f9d3a..d9a0a135023 100644 --- a/tests/components/webostv/__init__.py +++ b/tests/components/webostv/__init__.py @@ -1,9 +1,8 @@ -"""Tests for the WebOS TV integration.""" +"""Tests for the LG webOS TV integration.""" from homeassistant.components.webostv.const import DOMAIN from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .const import CLIENT_KEY, FAKE_UUID, HOST, TV_NAME @@ -25,11 +24,7 @@ async def setup_webostv( ) entry.add_to_hass(hass) - await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: HOST}}, - ) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index a30ae933cca..bf007f5b936 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -1,4 +1,4 @@ -"""Common fixtures and objects for the LG webOS integration tests.""" +"""Common fixtures and objects for the LG webOS TV integration tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch @@ -7,7 +7,15 @@ import pytest from homeassistant.components.webostv.const import LIVE_TV_APP_ID -from .const import CHANNEL_1, CHANNEL_2, CLIENT_KEY, FAKE_UUID, MOCK_APPS, MOCK_INPUTS +from .const import ( + CHANNEL_1, + CHANNEL_2, + CLIENT_KEY, + FAKE_UUID, + MOCK_APPS, + MOCK_INPUTS, + TV_MODEL, +) @pytest.fixture @@ -22,13 +30,19 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="client") def client_fixture(): """Patch of client library for tests.""" - with patch( - "homeassistant.components.webostv.WebOsClient", autospec=True - ) as mock_client_class: + with ( + patch( + "homeassistant.components.webostv.WebOsClient", autospec=True + ) as mock_client_class, + patch( + "homeassistant.components.webostv.config_flow.WebOsClient", + new=mock_client_class, + ), + ): client = mock_client_class.return_value client.hello_info = {"deviceUUID": FAKE_UUID} client.software_info = {"major_ver": "major", "minor_ver": "minor"} - client.system_info = {"modelName": "TVFAKE"} + client.system_info = {"modelName": TV_MODEL} client.client_key = CLIENT_KEY client.apps = MOCK_APPS client.inputs = MOCK_INPUTS diff --git a/tests/components/webostv/const.py b/tests/components/webostv/const.py index afaed224e83..a63a4fe3289 100644 --- a/tests/components/webostv/const.py +++ b/tests/components/webostv/const.py @@ -1,11 +1,13 @@ -"""Constants for LG webOS Smart TV tests.""" +"""Constants for LG webOS TV tests.""" from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.webostv.const import LIVE_TV_APP_ID +from homeassistant.util import slugify FAKE_UUID = "some-fake-uuid" -TV_NAME = "fake_webos" -ENTITY_ID = f"{MP_DOMAIN}.{TV_NAME}" +TV_MODEL = "MODEL" +TV_NAME = f"LG webOS TV {TV_MODEL}" +ENTITY_ID = f"{MP_DOMAIN}.{slugify(TV_NAME)}" HOST = "1.2.3.4" CLIENT_KEY = "some-secret" diff --git a/tests/components/webostv/snapshots/test_diagnostics.ambr b/tests/components/webostv/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a9bd6e91ee0 --- /dev/null +++ b/tests/components/webostv/snapshots/test_diagnostics.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'client': dict({ + 'apps': dict({ + 'com.webos.app.livetv': dict({ + 'icon': '**REDACTED**', + 'id': 'com.webos.app.livetv', + 'largeIcon': '**REDACTED**', + 'title': 'Live TV', + }), + }), + 'current_app_id': 'com.webos.app.livetv', + 'current_channel': dict({ + 'channelId': 'ch1id', + 'channelName': 'Channel 1', + 'channelNumber': '1', + }), + 'hello_info': dict({ + 'deviceUUID': '**REDACTED**', + }), + 'inputs': dict({ + 'in1': dict({ + 'appId': 'app0', + 'id': 'in1', + 'label': 'Input01', + }), + 'in2': dict({ + 'appId': 'app1', + 'id': 'in2', + 'label': 'Input02', + }), + }), + 'is_connected': True, + 'is_on': True, + 'is_registered': True, + 'software_info': dict({ + 'major_ver': 'major', + 'minor_ver': 'minor', + }), + 'sound_output': 'speaker', + 'system_info': dict({ + 'modelName': 'MODEL', + }), + }), + 'entry': dict({ + 'data': dict({ + 'client_secret': '**REDACTED**', + 'host': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'webostv', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'LG webOS TV MODEL', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..78c0bd517a6 --- /dev/null +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_entity_attributes + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': 'LG webOS TV MODEL', + 'is_volume_muted': False, + 'media_content_type': , + 'media_title': 'Channel 1', + 'sound_output': 'speaker', + 'source': 'Live TV', + 'source_list': list([ + 'Input01', + 'Input02', + 'Live TV', + ]), + 'supported_features': , + 'volume_level': 0.37, + }), + 'context': , + 'entity_id': 'media_player.lg_webos_tv_model', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_attributes.1 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'webostv', + 'some-fake-uuid', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'LG', + 'model': 'MODEL', + 'model_id': None, + 'name': 'LG webOS TV MODEL', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'major.minor', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 9b2983aab47..34ab39618d8 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -1,63 +1,48 @@ -"""Test the WebOS Tv config flow.""" - -import dataclasses -from unittest.mock import Mock +"""Test the LG webOS TV config flow.""" from aiowebostv import WebOsTvPairError import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN, LIVE_TV_APP_ID from homeassistant.config_entries import SOURCE_SSDP -from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, CONF_SOURCE +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from . import setup_webostv -from .const import CLIENT_KEY, FAKE_UUID, HOST, MOCK_APPS, MOCK_INPUTS, TV_NAME +from .const import ( + CLIENT_KEY, + FAKE_UUID, + HOST, + MOCK_APPS, + MOCK_INPUTS, + TV_MODEL, + TV_NAME, +) pytestmark = pytest.mark.usefixtures("mock_setup_entry") -MOCK_USER_CONFIG = { - CONF_HOST: HOST, - CONF_NAME: TV_NAME, -} +MOCK_USER_CONFIG = {CONF_HOST: HOST} -MOCK_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{HOST}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "LG Webostv", - ssdp.ATTR_UPNP_UDN: f"uuid:{FAKE_UUID}", + ATTR_UPNP_FRIENDLY_NAME: f"[LG] webOS TV {TV_MODEL}", + ATTR_UPNP_UDN: f"uuid:{FAKE_UUID}", }, ) async def test_form(hass: HomeAssistant, client) -> None: - """Test we get the form.""" - assert client - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: config_entries.SOURCE_USER}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: config_entries.SOURCE_USER}, - data=MOCK_USER_CONFIG, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pairing" - + """Test successful user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -72,10 +57,10 @@ async def test_form(hass: HomeAssistant, client) -> None: result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TV_NAME + config_entry = result["result"] + assert config_entry.unique_id == FAKE_UUID @pytest.mark.parametrize( @@ -109,26 +94,52 @@ async def test_options_flow_live_tv_in_apps( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SOURCES: ["Live TV", "Input01", "Input02"]}, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"] -async def test_options_flow_cannot_retrieve(hass: HomeAssistant, client) -> None: - """Test options config flow cannot retrieve sources.""" +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (WebOsTvPairError, "error_pairing"), + (ConnectionResetError, "cannot_connect"), + ], +) +async def test_options_flow_errors( + hass: HomeAssistant, client, side_effect, error +) -> None: + """Test options config flow errors.""" entry = await setup_webostv(hass) - client.connect = Mock(side_effect=ConnectionRefusedError()) + client.connect.side_effect = side_effect result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_retrieve"} + assert result["errors"] == {"base": error} + + # recover + client.connect.side_effect = None + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=None, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SOURCES: ["Input01", "Input02"]}, + ) + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"][CONF_SOURCES] == ["Input01", "Input02"] async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: @@ -139,14 +150,22 @@ async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = Mock(side_effect=ConnectionRefusedError()) - result2 = await hass.config_entries.flow.async_configure( + client.connect.side_effect = ConnectionResetError + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # recover + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME async def test_form_pairexception(hass: HomeAssistant, client) -> None: @@ -157,20 +176,27 @@ async def test_form_pairexception(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = Mock(side_effect=WebOsTvPairError("error")) - result2 = await hass.config_entries.flow.async_configure( + client.connect.side_effect = WebOsTvPairError + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "error_pairing" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "error_pairing"} + + # recover + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME async def test_entry_already_configured(hass: HomeAssistant, client) -> None: """Test entry already configured.""" await setup_webostv(hass) - assert client result = await hass.config_entries.flow.async_init( DOMAIN, @@ -184,8 +210,6 @@ async def test_entry_already_configured(hass: HomeAssistant, client) -> None: async def test_form_ssdp(hass: HomeAssistant, client) -> None: """Test that the ssdp confirmation form is served.""" - assert client - result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO ) @@ -194,11 +218,18 @@ async def test_form_ssdp(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "pairing" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME + config_entry = result["result"] + assert config_entry.unique_id == FAKE_UUID + async def test_ssdp_in_progress(hass: HomeAssistant, client) -> None: """Test abort if ssdp paring is already in progress.""" - assert client - result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -209,55 +240,20 @@ async def test_ssdp_in_progress(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "pairing" - result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_in_progress" - - -async def test_ssdp_update_uuid(hass: HomeAssistant, client) -> None: - """Test that ssdp updates existing host entry uuid.""" - entry = await setup_webostv(hass, None) - assert client - assert entry.unique_id is None - + # Start another ssdp flow to make sure it aborts as already in progress result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert entry.unique_id == MOCK_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN][5:] - - -async def test_ssdp_not_update_uuid(hass: HomeAssistant, client) -> None: - """Test that ssdp not updates different host.""" - entry = await setup_webostv(hass, None) - assert client - assert entry.unique_id is None - - discovery_info = dataclasses.replace(MOCK_DISCOVERY_INFO) - discovery_info.ssdp_location = "http://1.2.3.5" - - result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "pairing" - assert entry.unique_id is None + assert result["reason"] == "already_in_progress" async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: """Test abort if uuid is already configured, verify host update.""" - entry = await setup_webostv(hass, MOCK_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN][5:]) - assert client - assert entry.unique_id == MOCK_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN][5:] + entry = await setup_webostv(hass, MOCK_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN][5:]) + assert entry.unique_id == MOCK_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN][5:] assert entry.data[CONF_HOST] == HOST result = await hass.config_entries.flow.async_init( @@ -269,11 +265,9 @@ async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - user_config = { - CONF_HOST: "new_host", - CONF_NAME: TV_NAME, - } + user_config = {CONF_HOST: "new_host"} + # Start another flow to make sure it aborts and updates host result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -288,19 +282,14 @@ async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == "new_host" -async def test_reauth_successful( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_reauth_successful(hass: HomeAssistant, client) -> None: """Test that the reauthorization is successful.""" entry = await setup_webostv(hass) - assert client result = await entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" @@ -311,7 +300,7 @@ async def test_reauth_successful( assert result["step_id"] == "reauth_confirm" assert entry.data[CONF_CLIENT_SECRET] == CLIENT_KEY - monkeypatch.setattr(client, "client_key", "new_key") + client.client_key = "new_key" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -322,18 +311,15 @@ async def test_reauth_successful( @pytest.mark.parametrize( - ("side_effect", "reason"), + ("side_effect", "error"), [ (WebOsTvPairError, "error_pairing"), - (ConnectionRefusedError, "reauth_unsuccessful"), + (ConnectionResetError, "cannot_connect"), ], ) -async def test_reauth_errors( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch, side_effect, reason -) -> None: +async def test_reauth_errors(hass: HomeAssistant, client, side_effect, error) -> None: """Test reauthorization errors.""" entry = await setup_webostv(hass) - assert client result = await entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" @@ -343,10 +329,93 @@ async def test_reauth_errors( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - monkeypatch.setattr(client, "connect", Mock(side_effect=side_effect)) + client.connect.side_effect = side_effect() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + client.connect.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_successful(hass: HomeAssistant, client) -> None: + """Test that the reconfigure is successful.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "new_host" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (WebOsTvPairError, "error_pairing"), + (ConnectionResetError, "cannot_connect"), + ], +) +async def test_reconfigure_errors( + hass: HomeAssistant, client, side_effect, error +) -> None: + """Test reconfigure errors.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + client.connect.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_wrong_device(hass: HomeAssistant, client) -> None: + """Test abort if reconfigure host is wrong webOS TV device.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + client.hello_info = {"deviceUUID": "wrong_uuid"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/webostv/test_device_trigger.py b/tests/components/webostv/test_device_trigger.py index 41045969335..c14e8f4542a 100644 --- a/tests/components/webostv/test_device_trigger.py +++ b/tests/components/webostv/test_device_trigger.py @@ -1,4 +1,4 @@ -"""The tests for WebOS TV device triggers.""" +"""The tests for LG webOS TV device triggers.""" import pytest @@ -104,14 +104,14 @@ async def test_if_fires_on_turn_on_request( assert service_calls[2].data["id"] == 0 -async def test_failure_scenarios( +async def test_invalid_trigger_raises( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client ) -> None: - """Test failure scenarios.""" + """Test invalid trigger platform or device id raises.""" await setup_webostv(hass) # Test wrong trigger platform type - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match="Unhandled trigger type: wrong.type"): await device_trigger.async_attach_trigger( hass, {"type": "wrong.type", "device_id": "invalid_device_id"}, None, {} ) @@ -128,7 +128,26 @@ async def test_failure_scenarios( }, ) - entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) + +@pytest.mark.parametrize( + ("domain", "entry_state"), + [ + (DOMAIN, ConfigEntryState.NOT_LOADED), + ("fake", ConfigEntryState.LOADED), + ], +) +async def test_invalid_entry_raises( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + domain: str, + entry_state: ConfigEntryState, +) -> None: + """Test device id not loaded or from another domain raises.""" + await setup_webostv(hass) + + entry = MockConfigEntry(domain=domain, state=entry_state, data={}) + entry.runtime_data = None entry.add_to_hass(hass) device = device_registry.async_get_or_create( diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index 3d7cb00e021..0cf815ce9e2 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -1,6 +1,8 @@ -"""Tests for the diagnostics data provided by LG webOS Smart TV.""" +"""Tests for the diagnostics data provided by LG webOS TV.""" + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from . import setup_webostv @@ -10,56 +12,13 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, client + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + client, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" entry = await setup_webostv(hass) - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { - "client": { - "is_registered": True, - "is_connected": True, - "current_app_id": "com.webos.app.livetv", - "current_channel": { - "channelId": "ch1id", - "channelName": "Channel 1", - "channelNumber": "1", - }, - "apps": { - "com.webos.app.livetv": { - "icon": REDACTED, - "id": "com.webos.app.livetv", - "largeIcon": REDACTED, - "title": "Live TV", - } - }, - "inputs": { - "in1": {"appId": "app0", "id": "in1", "label": "Input01"}, - "in2": {"appId": "app1", "id": "in2", "label": "Input02"}, - }, - "system_info": {"modelName": "TVFAKE"}, - "software_info": {"major_ver": "major", "minor_ver": "minor"}, - "hello_info": {"deviceUUID": "**REDACTED**"}, - "sound_output": "speaker", - "is_on": True, - }, - "entry": { - "entry_id": entry.entry_id, - "version": 1, - "minor_version": 1, - "domain": "webostv", - "title": "fake_webos", - "data": { - "client_secret": "**REDACTED**", - "host": "**REDACTED**", - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - "created_at": entry.created_at.isoformat(), - "modified_at": entry.modified_at.isoformat(), - "discovery_keys": {}, - }, - } + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) diff --git a/tests/components/webostv/test_init.py b/tests/components/webostv/test_init.py index e2638c86f5e..cd8f443c8fd 100644 --- a/tests/components/webostv/test_init.py +++ b/tests/components/webostv/test_init.py @@ -1,24 +1,21 @@ """The tests for the LG webOS TV platform.""" -from unittest.mock import Mock - from aiowebostv import WebOsTvPairError -import pytest -from homeassistant.components.webostv.const import DOMAIN +from homeassistant.components.media_player import ATTR_INPUT_SOURCE_LIST +from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_CLIENT_SECRET +from homeassistant.const import CONF_CLIENT_SECRET, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from . import setup_webostv +from .const import ENTITY_ID -async def test_reauth_setup_entry( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_reauth_setup_entry(hass: HomeAssistant, client) -> None: """Test reauth flow triggered by setup entry.""" - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=WebOsTvPairError)) + client.is_connected.return_value = False + client.connect.side_effect = WebOsTvPairError entry = await setup_webostv(hass) assert entry.state is ConfigEntryState.SETUP_ERROR @@ -35,12 +32,46 @@ async def test_reauth_setup_entry( assert flow["context"].get("entry_id") == entry.entry_id -async def test_key_update_setup_entry( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_key_update_setup_entry(hass: HomeAssistant, client) -> None: """Test key update from setup entry.""" - monkeypatch.setattr(client, "client_key", "new_key") + client.client_key = "new_key" entry = await setup_webostv(hass) assert entry.state is ConfigEntryState.LOADED assert entry.data[CONF_CLIENT_SECRET] == "new_key" + + +async def test_update_options(hass: HomeAssistant, client) -> None: + """Test update options triggers reload.""" + config_entry = await setup_webostv(hass) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.update_listeners is not None + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + assert sources == ["Input01", "Input02", "Live TV"] + + # remove Input01 and reload + new_options = config_entry.options.copy() + new_options[CONF_SOURCES] = ["Input02", "Live TV"] + hass.config_entries.async_update_entry(config_entry, options=new_options) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + assert sources == ["Input02", "Live TV"] + + +async def test_disconnect_on_stop(hass: HomeAssistant, client) -> None: + """Test we disconnect the client and clear callbacks when Home Assistants stops.""" + config_entry = await setup_webostv(hass) + + assert client.disconnect.call_count == 0 + assert client.clear_state_update_callbacks.call_count == 0 + assert config_entry.state is ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert client.disconnect.call_count == 1 + assert client.clear_state_update_callbacks.call_count == 1 + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index e4c02e680bd..d5241dbe668 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -1,11 +1,13 @@ -"""The tests for the LG webOS media player platform.""" +"""The tests for the LG webOS TV media player platform.""" from datetime import timedelta from http import HTTPStatus -from unittest.mock import Mock from aiowebostv import WebOsTvPairError +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props from homeassistant.components import automation from homeassistant.components.media_player import ( @@ -19,7 +21,6 @@ from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, - MediaPlayerDeviceClass, MediaPlayerEntityFeature, MediaPlayerState, MediaType, @@ -42,9 +43,9 @@ from homeassistant.components.webostv.media_player import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_COMMAND, - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + CONF_CLIENT_SECRET, ENTITY_MATCH_NONE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, @@ -58,13 +59,11 @@ from homeassistant.const import ( SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, STATE_OFF, - STATE_ON, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from . import setup_webostv from .const import CHANNEL_2, ENTITY_ID, TV_NAME @@ -144,7 +143,7 @@ async def test_media_play_pause(hass: HomeAssistant, client) -> None: ], ) async def test_media_next_previous_track( - hass: HomeAssistant, client, service, client_call, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, client, service, client_call ) -> None: """Test media next/previous track services.""" await setup_webostv(hass) @@ -157,7 +156,7 @@ async def test_media_next_previous_track( getattr(client, client_call[1]).assert_called_once() # check next/previous for not Live TV channels - monkeypatch.setattr(client, "current_app_id", "in1") + client.current_app_id = "in1" data = {ATTR_ENTITY_ID: ENTITY_ID} await hass.services.async_call(MP_DOMAIN, service, data, True) @@ -166,7 +165,7 @@ async def test_media_next_previous_track( async def test_select_source_with_empty_source_list( - hass: HomeAssistant, client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, client ) -> None: """Ensure we don't call client methods when we don't have sources.""" await setup_webostv(hass) @@ -176,11 +175,14 @@ async def test_select_source_with_empty_source_list( ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "nonexistent", } - await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) + with pytest.raises( + HomeAssistantError, + match=f"Source nonexistent not found in the sources list for {TV_NAME}", + ): + await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) client.launch_app.assert_not_called() client.set_input.assert_not_called() - assert f"Source nonexistent not found for {TV_NAME}" in caplog.text async def test_select_app_source(hass: HomeAssistant, client) -> None: @@ -270,14 +272,11 @@ async def test_select_sound_output(hass: HomeAssistant, client) -> None: async def test_device_info_startup_off( - hass: HomeAssistant, - client, - monkeypatch: pytest.MonkeyPatch, - device_registry: dr.DeviceRegistry, + hass: HomeAssistant, client, device_registry: dr.DeviceRegistry ) -> None: """Test device info when device is off at startup.""" - monkeypatch.setattr(client, "system_info", None) - monkeypatch.setattr(client, "is_on", False) + client.system_info = None + client.is_on = False entry = await setup_webostv(hass) await client.mock_state_update() @@ -296,8 +295,8 @@ async def test_device_info_startup_off( async def test_entity_attributes( hass: HomeAssistant, client, - monkeypatch: pytest.MonkeyPatch, device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test entity attributes.""" entry = await setup_webostv(hass) @@ -305,28 +304,17 @@ async def test_entity_attributes( # Attributes when device is on state = hass.states.get(ENTITY_ID) - attrs = state.attributes - - assert state.state == STATE_ON - assert state.name == TV_NAME - assert attrs[ATTR_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - assert attrs[ATTR_MEDIA_VOLUME_MUTED] is False - assert attrs[ATTR_MEDIA_VOLUME_LEVEL] == 0.37 - assert attrs[ATTR_INPUT_SOURCE] == "Live TV" - assert attrs[ATTR_INPUT_SOURCE_LIST] == ["Input01", "Input02", "Live TV"] - assert attrs[ATTR_MEDIA_CONTENT_TYPE] == MediaType.CHANNEL - assert attrs[ATTR_MEDIA_TITLE] == "Channel 1" - assert attrs[ATTR_SOUND_OUTPUT] == "speaker" + assert state == snapshot(exclude=props("entity_picture")) # Volume level not available - monkeypatch.setattr(client, "volume", None) + client.volume = None await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes assert attrs.get(ATTR_MEDIA_VOLUME_LEVEL) is None # Channel change - monkeypatch.setattr(client, "current_channel", CHANNEL_2) + client.current_channel = CHANNEL_2 await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes @@ -334,17 +322,11 @@ async def test_entity_attributes( # Device Info device = device_registry.async_get_device(identifiers={(DOMAIN, entry.unique_id)}) - - assert device - assert device.identifiers == {(DOMAIN, entry.unique_id)} - assert device.manufacturer == "LG" - assert device.name == TV_NAME - assert device.sw_version == "major.minor" - assert device.model == "TVFAKE" + assert device == snapshot # Sound output when off - monkeypatch.setattr(client, "sound_output", None) - monkeypatch.setattr(client, "is_on", False) + client.sound_output = None + client.is_on = False await client.mock_state_update() state = hass.states.get(ENTITY_ID) @@ -388,9 +370,7 @@ async def test_play_media(hass: HomeAssistant, client, media_id, ch_id) -> None: client.set_channel.assert_called_once_with(ch_id) -async def test_update_sources_live_tv_find( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: """Test finding live TV app id in update sources.""" await setup_webostv(hass) await client.mock_state_update() @@ -402,14 +382,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 3 # Live TV is current app - apps = { + client.apps = { LIVE_TV_APP_ID: { "title": "Live TV", "id": "some_id", }, } - monkeypatch.setattr(client, "apps", apps) - monkeypatch.setattr(client, "current_app_id", "some_id") + client.current_app_id = "some_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -417,14 +396,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 3 # Live TV is is in inputs - inputs = { + client.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", "appId": LIVE_TV_APP_ID, }, } - monkeypatch.setattr(client, "inputs", inputs) await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -432,14 +410,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV is current input - inputs = { + client.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", "appId": "some_id", }, } - monkeypatch.setattr(client, "inputs", inputs) await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -447,7 +424,7 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV not found - monkeypatch.setattr(client, "current_app_id", "other_id") + client.current_app_id = "other_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -455,8 +432,8 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV not found in sources/apps but is current app - monkeypatch.setattr(client, "apps", {}) - monkeypatch.setattr(client, "current_app_id", LIVE_TV_APP_ID) + client.apps = {} + client.current_app_id = LIVE_TV_APP_ID await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -464,7 +441,7 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Bad update, keep old update - monkeypatch.setattr(client, "inputs", {}) + client.inputs = {} await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -473,56 +450,84 @@ async def test_update_sources_live_tv_find( async def test_client_disconnected( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test error not raised when client is disconnected.""" - await setup_webostv(hass) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=TimeoutError)) - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) - await hass.async_block_till_done() - - -async def test_control_error_handling( hass: HomeAssistant, client, caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, + freezer: FrozenDateTimeFactory, +) -> None: + """Test error not raised when client is disconnected.""" + await setup_webostv(hass) + client.is_connected.return_value = False + client.connect.side_effect = TimeoutError + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert "TimeoutError" not in caplog.text + + +async def test_client_key_update_on_connect( + hass: HomeAssistant, client, freezer: FrozenDateTimeFactory +) -> None: + """Test client key update upon connect.""" + config_entry = await setup_webostv(hass) + + assert config_entry.data[CONF_CLIENT_SECRET] == client.client_key + + client.is_connected.return_value = False + client.client_key = "new_key" + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert config_entry.data[CONF_CLIENT_SECRET] == client.client_key + + +@pytest.mark.parametrize( + ("is_on", "exception", "error_message"), + [ + ( + True, + WebOsTvCommandError("Some error"), + f"Communication error while calling async_media_play for device {TV_NAME}: Some error", + ), + ( + True, + WebOsTvCommandError("Some other error"), + f"Communication error while calling async_media_play for device {TV_NAME}: Some other error", + ), + ( + False, + None, + f"Error calling async_media_play for device {TV_NAME}: Device is off and cannot be controlled", + ), + ], +) +async def test_control_error_handling( + hass: HomeAssistant, + client, + is_on: bool, + exception: Exception, + error_message: str, ) -> None: """Test control errors handling.""" await setup_webostv(hass) - monkeypatch.setattr(client, "play", Mock(side_effect=WebOsTvCommandError)) - data = {ATTR_ENTITY_ID: ENTITY_ID} + client.play.side_effect = exception + client.is_on = is_on + await client.mock_state_update() - # Device on, raise HomeAssistantError - with pytest.raises(HomeAssistantError) as exc: + data = {ATTR_ENTITY_ID: ENTITY_ID} + with pytest.raises(HomeAssistantError, match=error_message): await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) - assert ( - str(exc.value) - == f"Error calling async_media_play on entity {ENTITY_ID}, state:on" - ) - assert client.play.call_count == 1 - - # Device off, log a warning - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "play", Mock(side_effect=TimeoutError)) - await client.mock_state_update() - await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) - - assert client.play.call_count == 1 - assert ( - f"Error calling async_media_play on entity {ENTITY_ID}, state:off, error:" - " TimeoutError()" in caplog.text - ) + assert client.play.call_count == int(is_on) -async def test_supported_features( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - monkeypatch.setattr(client, "sound_output", "lineout") + client.sound_output = "lineout" await setup_webostv(hass) await client.mock_state_update() @@ -533,7 +538,7 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step - monkeypatch.setattr(client, "sound_output", "external_speaker") + client.sound_output = "external_speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME attrs = hass.states.get(ENTITY_ID).attributes @@ -541,7 +546,7 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step, set - monkeypatch.setattr(client, "sound_output", "speaker") + client.sound_output = "speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.VOLUME_SET attrs = hass.states.get(ENTITY_ID).attributes @@ -577,12 +582,10 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported -async def test_cached_supported_features( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_cached_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None supported = ( SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.TURN_ON ) @@ -610,8 +613,8 @@ async def test_cached_supported_features( ) # TV on, support volume mute, step - monkeypatch.setattr(client, "is_on", True) - monkeypatch.setattr(client, "sound_output", "external_speaker") + client.is_on = True + client.sound_output = "external_speaker" await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -620,8 +623,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -630,8 +633,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV on, support volume mute, step, set - monkeypatch.setattr(client, "is_on", True) - monkeypatch.setattr(client, "sound_output", "speaker") + client.is_on = True + client.sound_output = "speaker" await client.mock_state_update() supported = ( @@ -642,8 +645,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step, set - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None await client.mock_state_update() supported = ( @@ -684,12 +687,10 @@ async def test_cached_supported_features( ) -async def test_supported_features_no_cache( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_supported_features_no_cache(hass: HomeAssistant, client) -> None: """Test supported features if device is off and no cache.""" - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.is_on = False + client.sound_output = None await setup_webostv(hass) supported = ( @@ -729,11 +730,10 @@ async def test_get_image_http( client, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http.""" url = "http://something/valid_icon" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -755,11 +755,10 @@ async def test_get_image_http_error( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http error.""" url = "http://something/icon_error" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -782,11 +781,10 @@ async def test_get_image_https( client, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http.""" url = "https://something/valid_icon_https" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -803,16 +801,17 @@ async def test_get_image_https( async def test_reauth_reconnect( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, client, freezer: FrozenDateTimeFactory ) -> None: """Test reauth flow triggered by reconnect.""" entry = await setup_webostv(hass) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=WebOsTvPairError)) + client.is_connected.return_value = False + client.connect.side_effect = WebOsTvPairError assert entry.state is ConfigEntryState.LOADED - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED @@ -829,27 +828,22 @@ async def test_reauth_reconnect( assert flow["context"].get("entry_id") == entry.entry_id -async def test_update_media_state( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_update_media_state(hass: HomeAssistant, client) -> None: """Test updating media state.""" await setup_webostv(hass) - data = {"foregroundAppInfo": [{"playState": "playing"}]} - monkeypatch.setattr(client, "media_state", data) + client.media_state = [{"playState": "playing"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PLAYING - data = {"foregroundAppInfo": [{"playState": "paused"}]} - monkeypatch.setattr(client, "media_state", data) + client.media_state = [{"playState": "paused"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PAUSED - data = {"foregroundAppInfo": [{"playState": "unloaded"}]} - monkeypatch.setattr(client, "media_state", data) + client.media_state = [{"playState": "unloaded"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.IDLE - monkeypatch.setattr(client, "is_on", False) + client.is_on = False await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == STATE_OFF diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index 75c2e148310..fd56f0ea0bb 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -1,8 +1,8 @@ -"""The tests for the WebOS TV notify platform.""" +"""The tests for the LG webOS TV notify platform.""" -from unittest.mock import Mock, call +from unittest.mock import call -from aiowebostv import WebOsTvPairError +from aiowebostv import WebOsTvCommandError import pytest from homeassistant.components.notify import ( @@ -13,23 +13,26 @@ from homeassistant.components.notify import ( from homeassistant.components.webostv import DOMAIN from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from homeassistant.util import slugify from . import setup_webostv from .const import TV_NAME ICON_PATH = "/some/path" MESSAGE = "one, two, testing, testing" +SERVICE_NAME = slugify(TV_NAME) async def test_notify(hass: HomeAssistant, client) -> None: """Test sending a message.""" await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) + assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -44,7 +47,7 @@ async def test_notify(hass: HomeAssistant, client) -> None: await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -59,7 +62,7 @@ async def test_notify(hass: HomeAssistant, client) -> None: await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: "only message, no data", }, @@ -72,94 +75,54 @@ async def test_notify(hass: HomeAssistant, client) -> None: ) -async def test_notify_not_connected( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test sending a message when client is not connected.""" - await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) - - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - await hass.services.async_call( - NOTIFY_DOMAIN, - TV_NAME, - { - ATTR_MESSAGE: MESSAGE, - ATTR_DATA: { - ATTR_ICON: ICON_PATH, - }, - }, - blocking=True, - ) - assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 2 - client.send_message.assert_called_with(MESSAGE, icon_path=ICON_PATH) - - -async def test_icon_not_found( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - client, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test notify icon not found error.""" - await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) - - monkeypatch.setattr(client, "send_message", Mock(side_effect=FileNotFoundError)) - await hass.services.async_call( - NOTIFY_DOMAIN, - TV_NAME, - { - ATTR_MESSAGE: MESSAGE, - ATTR_DATA: { - ATTR_ICON: ICON_PATH, - }, - }, - blocking=True, - ) - assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 1 - client.send_message.assert_called_with(MESSAGE, icon_path=ICON_PATH) - assert f"Icon {ICON_PATH} not found" in caplog.text - - @pytest.mark.parametrize( - ("side_effect", "error"), + ("is_on", "exception", "error_message"), [ - (WebOsTvPairError, "Pairing with TV failed"), - (ConnectionRefusedError, "TV unreachable"), + ( + True, + WebOsTvCommandError("Some error"), + f"Communication error while sending notification to device {TV_NAME}: Some error", + ), + ( + True, + FileNotFoundError("Some other error"), + f"Icon {ICON_PATH} not found when sending notification for device {TV_NAME}", + ), + ( + False, + None, + f"Error sending notification to device {TV_NAME}: Device is off and cannot be controlled", + ), ], ) -async def test_connection_errors( +async def test_errors( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, client, - monkeypatch: pytest.MonkeyPatch, - side_effect, - error, + is_on: bool, + exception: Exception, + error_message: str, ) -> None: - """Test connection errors scenarios.""" + """Test error scenarios.""" await setup_webostv(hass) - assert hass.services.has_service("notify", TV_NAME) + client.is_on = is_on - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=side_effect)) - await hass.services.async_call( - NOTIFY_DOMAIN, - TV_NAME, - { - ATTR_MESSAGE: MESSAGE, - ATTR_DATA: { - ATTR_ICON: ICON_PATH, + assert hass.services.has_service("notify", SERVICE_NAME) + + client.send_message.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_NAME, + { + ATTR_MESSAGE: MESSAGE, + ATTR_DATA: { + ATTR_ICON: ICON_PATH, + }, }, - }, - blocking=True, - ) - assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 1 - client.send_message.assert_not_called() - assert error in caplog.text + blocking=True, + ) + + assert client.send_message.call_count == int(is_on) async def test_no_discovery_info( @@ -175,4 +138,4 @@ async def test_no_discovery_info( await hass.async_block_till_done() assert NOTIFY_DOMAIN in hass.config.components assert f"Failed to initialize notification service {DOMAIN}" in caplog.text - assert not hass.services.has_service("notify", TV_NAME) + assert not hass.services.has_service("notify", SERVICE_NAME) diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index d7eeae28ea3..c7decafff73 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -1,4 +1,4 @@ -"""The tests for WebOS TV automation triggers.""" +"""The tests for LG webOS TV automation triggers.""" from unittest.mock import patch @@ -118,10 +118,10 @@ async def test_webostv_turn_on_trigger_entity_id( assert service_calls[1].data["id"] == 0 -async def test_wrong_trigger_platform_type( +async def test_unknown_trigger_platform_type( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, client ) -> None: - """Test wrong trigger platform type.""" + """Test unknown trigger platform type.""" await setup_webostv(hass) await async_setup_component( @@ -131,7 +131,7 @@ async def test_wrong_trigger_platform_type( automation.DOMAIN: [ { "trigger": { - "platform": "webostv.wrong_type", + "platform": "webostv.unknown", "entity_id": ENTITY_ID, }, "action": { @@ -146,10 +146,7 @@ async def test_wrong_trigger_platform_type( }, ) - assert ( - "ValueError: Unknown webOS Smart TV trigger platform webostv.wrong_type" - in caplog.text - ) + assert "Unknown trigger platform: webostv.unknown" in caplog.text async def test_trigger_invalid_entity_id( @@ -185,7 +182,4 @@ async def test_trigger_invalid_entity_id( }, ) - assert ( - f"ValueError: Entity {invalid_entity} is not a valid webostv entity" - in caplog.text - ) + assert f"Entity {invalid_entity} is not a valid {DOMAIN} entity" in caplog.text diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c1a043f915b..22e839d84e4 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2390,6 +2390,9 @@ async def test_execute_script( ), ], ) +@pytest.mark.parametrize( + "ignore_translations", ["component.test.exceptions.test_error.message"] +) async def test_execute_script_err_localization( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py index 6ecb64ffdf4..dbdeb0726dd 100644 --- a/tests/components/weheat/conftest.py +++ b/tests/components/weheat/conftest.py @@ -81,7 +81,7 @@ def mock_user_id() -> Generator[AsyncMock]: """Mock the user API call.""" with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ) as user_mock, ): @@ -93,7 +93,7 @@ def mock_weheat_discover(mock_heat_pump_info) -> Generator[AsyncMock]: """Mock an Weheat discovery.""" with ( patch( - "homeassistant.components.weheat.HeatPumpDiscovery.discover_active", + "homeassistant.components.weheat.HeatPumpDiscovery.async_discover_active", autospec=True, ) as mock_discover, ): @@ -121,6 +121,14 @@ def mock_weheat_heat_pump_instance() -> MagicMock: mock_heat_pump_instance.cop = 4.5 mock_heat_pump_instance.heat_pump_state = HeatPump.State.HEATING mock_heat_pump_instance.energy_total = 12345 + mock_heat_pump_instance.energy_output = 56789 + mock_heat_pump_instance.compressor_rpm = 4500 + mock_heat_pump_instance.compressor_percentage = 100 + mock_heat_pump_instance.indoor_unit_water_pump_state = False + mock_heat_pump_instance.indoor_unit_auxiliary_pump_state = False + mock_heat_pump_instance.indoor_unit_dhw_valve_or_pump_state = None + mock_heat_pump_instance.indoor_unit_gas_boiler_state = False + mock_heat_pump_instance.indoor_unit_electric_heater_state = True return mock_heat_pump_instance diff --git a/tests/components/weheat/snapshots/test_binary_sensor.ambr b/tests/components/weheat/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..08d609ca610 --- /dev/null +++ b/tests/components/weheat/snapshots/test_binary_sensor.ambr @@ -0,0 +1,188 @@ +# serializer version: 1 +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxilary_water_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_model_indoor_unit_auxilary_water_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Indoor unit auxilary water pump', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_unit_auxiliary_pump_state', + 'unique_id': '0000-1111-2222-3333_indoor_unit_auxiliary_pump_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxilary_water_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Model Indoor unit auxilary water pump', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_indoor_unit_auxilary_water_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_electric_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_model_indoor_unit_electric_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Indoor unit electric heater', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_unit_electric_heater_state', + 'unique_id': '0000-1111-2222-3333_indoor_unit_electric_heater_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_electric_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Model Indoor unit electric heater', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_indoor_unit_electric_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_gas_boiler_heating_allowed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_model_indoor_unit_gas_boiler_heating_allowed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indoor unit gas boiler heating allowed', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_unit_gas_boiler_state', + 'unique_id': '0000-1111-2222-3333_indoor_unit_gas_boiler_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_gas_boiler_heating_allowed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Model Indoor unit gas boiler heating allowed', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_indoor_unit_gas_boiler_heating_allowed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_water_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_model_indoor_unit_water_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Indoor unit water pump', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_unit_water_pump_state', + 'unique_id': '0000-1111-2222-3333_indoor_unit_water_pump_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_water_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Model Indoor unit water pump', + }), + 'context': , + 'entity_id': 'binary_sensor.test_model_indoor_unit_water_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/weheat/snapshots/test_sensor.ambr b/tests/components/weheat/snapshots/test_sensor.ambr index 3bd4a254598..1a54711d6c5 100644 --- a/tests/components/weheat/snapshots/test_sensor.ambr +++ b/tests/components/weheat/snapshots/test_sensor.ambr @@ -123,6 +123,106 @@ 'state': '33', }) # --- +# name: test_all_entities[sensor.test_model_compressor_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_compressor_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Compressor speed', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_rpm', + 'unique_id': '0000-1111-2222-3333_compressor_rpm', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_all_entities[sensor.test_model_compressor_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Model Compressor speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.test_model_compressor_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4500', + }) +# --- +# name: test_all_entities[sensor.test_model_compressor_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_compressor_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Compressor usage', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_percentage', + 'unique_id': '0000-1111-2222-3333_compressor_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_model_compressor_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Model Compressor usage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_model_compressor_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_all_entities[sensor.test_model_cop-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -604,6 +704,57 @@ 'state': '21', }) # --- +# name: test_all_entities[sensor.test_model_total_energy_output-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_total_energy_output', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy output', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_output', + 'unique_id': '0000-1111-2222-3333_energy_output', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_total_energy_output-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test Model Total energy output', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_total_energy_output', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '56789', + }) +# --- # name: test_all_entities[sensor.test_model_water_inlet_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/weheat/test_binary_sensor.py b/tests/components/weheat/test_binary_sensor.py new file mode 100644 index 00000000000..5769fc9a1a8 --- /dev/null +++ b/tests/components/weheat/test_binary_sensor.py @@ -0,0 +1,50 @@ +"""Tests for the weheat sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from weheat.abstractions.discovery import HeatPumpDiscovery + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.weheat.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_create_binary_entities( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating entities.""" + mock_weheat_discover.return_value = [mock_heat_pump_info] + + with patch("homeassistant.components.weheat.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 4 diff --git a/tests/components/weheat/test_config_flow.py b/tests/components/weheat/test_config_flow.py index b33dd0a8db8..45f2285fd03 100644 --- a/tests/components/weheat/test_config_flow.py +++ b/tests/components/weheat/test_config_flow.py @@ -47,7 +47,7 @@ async def test_full_flow( with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ) as mock_weheat, ): @@ -89,7 +89,7 @@ async def test_duplicate_unique_id( with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ), ): diff --git a/tests/components/weheat/test_init.py b/tests/components/weheat/test_init.py new file mode 100644 index 00000000000..af5e2b8411b --- /dev/null +++ b/tests/components/weheat/test_init.py @@ -0,0 +1,85 @@ +"""Tests for the weheat initialization.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from weheat.abstractions.discovery import HeatPumpDiscovery + +from homeassistant.components.weheat import UnauthorizedException +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import ClientResponseError + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Weheat setup.""" + mock_weheat_discover.return_value = [mock_heat_pump_info] + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.parametrize( + ("setup_exception", "expected_setup_state"), + [ + (HTTPStatus.BAD_REQUEST, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.UNAUTHORIZED, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.FORBIDDEN, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.GATEWAY_TIMEOUT, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_fail( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, + setup_exception: Exception, + expected_setup_state: ConfigEntryState, +) -> None: + """Test the Weheat setup with invalid token setup.""" + with ( + patch( + "homeassistant.components.weheat.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + Mock(real_url="http://example.com"), None, status=setup_exception + ), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_setup_state + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_fail_discover( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Weheat setup with and error from the heat pump discovery.""" + mock_weheat_discover.side_effect = UnauthorizedException() + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/weheat/test_sensor.py b/tests/components/weheat/test_sensor.py index d9055addc67..f3eec282704 100644 --- a/tests/components/weheat/test_sensor.py +++ b/tests/components/weheat/test_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery @@ -34,14 +33,13 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 12), (True, 14)]) +@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 15), (True, 17)]) async def test_create_entities( hass: HomeAssistant, mock_weheat_discover: AsyncMock, mock_weheat_heat_pump: AsyncMock, mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, has_dhw: bool, nr_of_entities: int, ) -> None: diff --git a/tests/components/wemo/test_light_bridge.py b/tests/components/wemo/test_light_bridge.py index 48be2823750..4deddeaba94 100644 --- a/tests/components/wemo/test_light_bridge.py +++ b/tests/components/wemo/test_light_bridge.py @@ -11,7 +11,7 @@ from homeassistant.components.homeassistant import ( ) from homeassistant.components.light import ( ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, ColorMode, @@ -116,7 +116,7 @@ async def test_light_update_entity( blocking=True, ) state = hass.states.get(wemo_entity.entity_id) - assert state.attributes.get(ATTR_COLOR_TEMP) == 432 + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) == 2314 assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ColorMode.COLOR_TEMP] assert state.attributes.get(ATTR_COLOR_MODE) == ColorMode.COLOR_TEMP assert state.state == STATE_ON diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 50620b20b8b..c302922fe25 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -39,7 +39,12 @@ def fixture_brand(request: pytest.FixtureRequest) -> tuple[str, Brand]: @pytest.fixture(name="mock_auth_api") def fixture_mock_auth_api(): """Set up Auth fixture.""" - with mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth: + with ( + mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth, + mock.patch( + "homeassistant.components.whirlpool.config_flow.Auth", new=mock_auth + ), + ): mock_auth.return_value.do_auth = AsyncMock() mock_auth.return_value.is_access_token_valid.return_value = True yield mock_auth @@ -48,9 +53,15 @@ def fixture_mock_auth_api(): @pytest.fixture(name="mock_appliances_manager_api") def fixture_mock_appliances_manager_api(): """Set up AppliancesManager fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.AppliancesManager" - ) as mock_appliances_manager: + with ( + mock.patch( + "homeassistant.components.whirlpool.AppliancesManager" + ) as mock_appliances_manager, + mock.patch( + "homeassistant.components.whirlpool.config_flow.AppliancesManager", + new=mock_appliances_manager, + ), + ): mock_appliances_manager.return_value.fetch_appliances = AsyncMock() mock_appliances_manager.return_value.aircons = [ {"SAID": MOCK_SAID1, "NAME": "TestZone"}, @@ -81,9 +92,15 @@ def fixture_mock_appliances_manager_laundry_api(): @pytest.fixture(name="mock_backend_selector_api") def fixture_mock_backend_selector_api(): """Set up BackendSelector fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.BackendSelector" - ) as mock_backend_selector: + with ( + mock.patch( + "homeassistant.components.whirlpool.BackendSelector" + ) as mock_backend_selector, + mock.patch( + "homeassistant.components.whirlpool.config_flow.BackendSelector", + new=mock_backend_selector, + ), + ): yield mock_backend_selector diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index 1240e1303e1..94a34c96e2c 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -1,9 +1,10 @@ """Test the Whirlpool Sixth Sense config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch import aiohttp from aiohttp.client_exceptions import ClientConnectionError +import pytest from homeassistant import config_entries from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN @@ -19,7 +20,10 @@ CONFIG_INPUT = { } -async def test_form(hass: HomeAssistant, region, brand) -> None: +@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") +async def test_form( + hass: HomeAssistant, region, brand, mock_backend_selector_api: MagicMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -28,28 +32,9 @@ async def test_form(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.BackendSelector" - ) as mock_backend_selector, - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons", - return_value=["test"], - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), - ): + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True + ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, @@ -65,92 +50,99 @@ async def test_form(hass: HomeAssistant, region, brand) -> None: "brand": brand[0], } assert len(mock_setup_entry.mock_calls) == 1 - mock_backend_selector.assert_called_once_with(brand[1], region[1]) + mock_backend_selector_api.assert_called_once_with(brand[1], region[1]) -async def test_form_invalid_auth(hass: HomeAssistant, region, brand) -> None: +async def test_form_invalid_auth( + hass: HomeAssistant, region, brand, mock_auth_api: MagicMock +) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=False, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, - ) + + mock_auth_api.return_value.is_access_token_valid.return_value = False + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect(hass: HomeAssistant, region, brand) -> None: +async def test_form_cannot_connect( + hass: HomeAssistant, + region, + brand, + mock_auth_api: MagicMock, +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=aiohttp.ClientConnectionError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) + + mock_auth_api.return_value.do_auth.side_effect = aiohttp.ClientConnectionError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT + | { + "region": region[0], + "brand": brand[0], + }, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_auth_timeout(hass: HomeAssistant, region, brand) -> None: +async def test_form_auth_timeout( + hass: HomeAssistant, + region, + brand, + mock_auth_api: MagicMock, +) -> None: """Test we handle auth timeout error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=TimeoutError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) + + mock_auth_api.return_value.do_auth.side_effect = TimeoutError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT + | { + "region": region[0], + "brand": brand[0], + }, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_generic_auth_exception(hass: HomeAssistant, region, brand) -> None: +async def test_form_generic_auth_exception( + hass: HomeAssistant, + region, + brand, + mock_auth_api: MagicMock, +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) + + mock_auth_api.return_value.do_auth.side_effect = Exception + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT + | { + "region": region[0], + "brand": brand[0], + }, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} +@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") async def test_form_already_configured(hass: HomeAssistant, region, brand) -> None: """Test we handle cannot connect error.""" mock_entry = MockConfigEntry( @@ -167,36 +159,24 @@ async def test_form_already_configured(hass: HomeAssistant, region, brand) -> No assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons", - return_value=["test"], - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT + | { + "region": region[0], + "brand": brand[0], + }, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" -async def test_no_appliances_flow(hass: HomeAssistant, region, brand) -> None: +@pytest.mark.usefixtures("mock_auth_api") +async def test_no_appliances_flow( + hass: HomeAssistant, region, brand, mock_appliances_manager_api: MagicMock +) -> None: """Test we get an error with no appliances.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -205,27 +185,19 @@ async def test_no_appliances_flow(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, - ) - await hass.async_block_till_done() + mock_appliances_manager_api.return_value.aircons = [] + mock_appliances_manager_api.return_value.washer_dryers = [] + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "no_appliances"} +@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: """Test a successful reauth flow.""" mock_entry = MockConfigEntry( @@ -241,24 +213,8 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ), - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons", - return_value=["test"], - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -276,7 +232,10 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: } -async def test_reauth_flow_auth_error(hass: HomeAssistant, region, brand) -> None: +@pytest.mark.usefixtures("mock_appliances_manager_api") +async def test_reauth_flow_auth_error( + hass: HomeAssistant, region, brand, mock_auth_api: MagicMock +) -> None: """Test an authorization error reauth flow.""" mock_entry = MockConfigEntry( @@ -290,16 +249,10 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant, region, brand) -> Non assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ), - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=False, - ), + + mock_auth_api.return_value.is_access_token_valid.return_value = False + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -311,8 +264,9 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant, region, brand) -> Non assert result2["errors"] == {"base": "invalid_auth"} +@pytest.mark.usefixtures("mock_appliances_manager_api") async def test_reauth_flow_connnection_error( - hass: HomeAssistant, region, brand + hass: HomeAssistant, region, brand, mock_auth_api: MagicMock ) -> None: """Test a connection error reauth flow.""" @@ -329,25 +283,14 @@ async def test_reauth_flow_connnection_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=ClientConnectionError, - ), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=False, - ), + mock_auth_api.return_value.do_auth.side_effect = ClientConnectionError + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/wilight/__init__.py b/tests/components/wilight/__init__.py index acaf2aef2a8..8b32d7b1633 100644 --- a/tests/components/wilight/__init__.py +++ b/tests/components/wilight/__init__.py @@ -2,19 +2,19 @@ from pywilight.const import DOMAIN -from homeassistant.components import ssdp -from homeassistant.components.ssdp import ( - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_MODEL_NUMBER, - ATTR_UPNP_SERIAL, -) from homeassistant.components.wilight.config_flow import ( CONF_MODEL_NAME, CONF_SERIAL_NUMBER, ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -34,7 +34,7 @@ UPNP_MAC_ADDRESS = "5C:CF:7F:8B:CA:56" UPNP_MANUFACTURER_NOT_WILIGHT = "Test" CONF_COMPONENTS = "components" -MOCK_SSDP_DISCOVERY_INFO_P_B = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_P_B = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -46,7 +46,7 @@ MOCK_SSDP_DISCOVERY_INFO_P_B = ssdp.SsdpServiceInfo( }, ) -MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -58,7 +58,7 @@ MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = ssdp.SsdpServiceInfo( }, ) -MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 39c8340a78e..b61a54150e4 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -5,10 +5,11 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.withings.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import setup_integration from .conftest import CLIENT_ID, USER_ID @@ -293,3 +294,65 @@ async def test_config_flow_with_invalid_credentials( assert result assert result["type"] is FlowResultType.ABORT assert result["reason"] == "oauth_error" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_dhcp( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check DHCP discovery.""" + + service_info = DhcpServiceInfo( + hostname="device", + ip="192.168.0.1", + macaddress="0024e4bd30de", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=service_info + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://account.withings.com/oauth2_user/authorize2?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://wbsapi.withings.net/v2/oauth2", + json={ + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": 600, + }, + }, + ) + with patch( + "homeassistant.components.withings.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index e07e1f90cb4..d88af39488b 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -10,6 +10,7 @@ from aiohttp.hdrs import METH_HEAD from aiowithings import ( NotificationCategory, WithingsAuthenticationFailedError, + WithingsConnectionError, WithingsUnauthorizedError, ) from freezegun.api import FrozenDateTimeFactory @@ -532,6 +533,59 @@ async def test_cloud_disconnect_retry( assert mock_async_active_subscription.call_count == 4 +async def test_internet_timeout_then_restore( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we can recover from internet disconnects.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch.object(cloud, "async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), + patch( + "homeassistant.components.withings.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook", + ), + patch( + "homeassistant.components.withings.webhook_generate_url", + ), + ): + await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + + assert cloud.async_active_subscription(hass) is True + assert cloud.async_is_connected(hass) is True + assert withings.revoke_notification_configurations.call_count == 3 + assert withings.subscribe_notification.call_count == 6 + + await hass.async_block_till_done() + + withings.list_notification_configurations.side_effect = WithingsConnectionError + + async_mock_cloud_connection_status(hass, False) + await hass.async_block_till_done() + + assert withings.revoke_notification_configurations.call_count == 3 + withings.list_notification_configurations.side_effect = None + + async_mock_cloud_connection_status(hass, True) + await hass.async_block_till_done() + + assert withings.subscribe_notification.call_count == 12 + + @pytest.mark.parametrize( ("body", "expected_code"), [ diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index c60e080f6d4..ddf4a4f452a 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -6,13 +6,13 @@ import pytest from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.wiz.config_flow import CONF_DEVICE from homeassistant.components.wiz.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( FAKE_DIMMABLE_BULB, @@ -32,7 +32,7 @@ from . import ( from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname="wiz_abcabc", ip=FAKE_IP, macaddress=FAKE_MAC, diff --git a/tests/components/wiz/test_light.py b/tests/components/wiz/test_light.py index 1fb87b30a5f..5c74d407238 100644 --- a/tests/components/wiz/test_light.py +++ b/tests/components/wiz/test_light.py @@ -4,7 +4,7 @@ from pywizlight import PilotBuilder from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -91,7 +91,7 @@ async def test_rgbww_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 153, ATTR_BRIGHTNESS: 128}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6535, ATTR_BRIGHTNESS: 128}, blocking=True, ) pilot: PilotBuilder = bulb.turn_on.mock_calls[0][1][0] @@ -99,7 +99,7 @@ async def test_rgbww_light(hass: HomeAssistant) -> None: await async_push_update(hass, bulb, {"mac": FAKE_MAC, **pilot.pilot_params}) state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes[ATTR_COLOR_TEMP] == 153 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 6535 bulb.turn_on.reset_mock() await hass.services.async_call( @@ -148,7 +148,7 @@ async def test_rgbw_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 153, ATTR_BRIGHTNESS: 128}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6535, ATTR_BRIGHTNESS: 128}, blocking=True, ) pilot: PilotBuilder = bulb.turn_on.mock_calls[0][1][0] @@ -162,7 +162,7 @@ async def test_turnable_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 153, ATTR_BRIGHTNESS: 128}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6535, ATTR_BRIGHTNESS: 128}, blocking=True, ) pilot: PilotBuilder = bulb.turn_on.mock_calls[0][1][0] @@ -171,7 +171,7 @@ async def test_turnable_light(hass: HomeAssistant) -> None: await async_push_update(hass, bulb, {"mac": FAKE_MAC, **pilot.pilot_params}) state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes[ATTR_COLOR_TEMP] == 153 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 6535 async def test_old_firmware_dimmable_light(hass: HomeAssistant) -> None: diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index a1cf515a24b..15db188af5e 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, MagicMock import pytest from wled import WLEDConnectionError -from homeassistant.components import zeroconf from homeassistant.components.wled.const import CONF_KEEP_MAIN_LIGHT, DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -43,7 +43,7 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -87,7 +87,7 @@ async def test_zeroconf_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -132,7 +132,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -175,7 +175,7 @@ async def test_zeroconf_without_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -200,7 +200,7 @@ async def test_zeroconf_with_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/wmspro/test_config_flow.py b/tests/components/wmspro/test_config_flow.py index 782dc051c8c..2c628bbc296 100644 --- a/tests/components/wmspro/test_config_flow.py +++ b/tests/components/wmspro/test_config_flow.py @@ -4,12 +4,12 @@ from unittest.mock import AsyncMock, patch import aiohttp -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.wmspro.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import setup_config_entry diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 1bf0f176fe9..51d4b899d25 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -653,3 +653,48 @@ async def test_form_with_categories(hass: HomeAssistant) -> None: "language": "de", "category": ["half_day"], } + + +async def test_options_form_removes_subdiv(hass: HomeAssistant) -> None: + """Test we get the form in options when removing a configured subdivision.""" + + entry = await init_integration( + hass, + { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "language": "de", + "province": "BW", + }, + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "language": "de", + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "language": "de", + } diff --git a/tests/components/worldclock/test_sensor.py b/tests/components/worldclock/test_sensor.py index a8e3e41e649..f901f605730 100644 --- a/tests/components/worldclock/test_sensor.py +++ b/tests/components/worldclock/test_sensor.py @@ -4,15 +4,9 @@ from datetime import tzinfo import pytest -from homeassistant.components.worldclock.const import ( - CONF_TIME_FORMAT, - DEFAULT_NAME, - DOMAIN, -) +from homeassistant.components.worldclock.const import CONF_TIME_FORMAT, DEFAULT_NAME from homeassistant.const import CONF_NAME, CONF_TIME_ZONE -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component +from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry @@ -24,31 +18,6 @@ async def time_zone() -> tzinfo | None: return await dt_util.async_get_time_zone("America/New_York") -async def test_time_imported_from_yaml( - hass: HomeAssistant, time_zone: tzinfo | None, issue_registry: ir.IssueRegistry -) -> None: - """Test the time at a different location.""" - config = {"sensor": {"platform": "worldclock", "time_zone": "America/New_York"}} - - assert await async_setup_component( - hass, - "sensor", - config, - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.worldclock_sensor") - assert state is not None - - assert state.state == dt_util.now(time_zone=time_zone).strftime("%H:%M") - - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" - ) - assert issue - assert issue.issue_domain == DOMAIN - - async def test_time_from_config_entry( hass: HomeAssistant, time_zone: tzinfo | None, loaded_entry: MockConfigEntry ) -> None: diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py index 6bca226d621..30faa2dd441 100644 --- a/tests/components/wyoming/test_config_flow.py +++ b/tests/components/wyoming/test_config_flow.py @@ -9,10 +9,10 @@ from wyoming.info import Info from homeassistant import config_entries from homeassistant.components.wyoming.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import EMPTY_INFO, SATELLITE_INFO, STT_INFO, TTS_INFO @@ -178,11 +178,11 @@ async def test_hassio_addon_discovery( async def test_hassio_addon_already_configured(hass: HomeAssistant) -> None: """Test we abort discovery if the add-on is already configured.""" - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, - data={"host": "mock-piper", "port": "10200"}, - unique_id="1234", - ).add_to_hass(hass) + data={"host": "mock-piper", "port": 10200}, + ) + entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -191,6 +191,7 @@ async def test_hassio_addon_already_configured(hass: HomeAssistant) -> None: ) assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "already_configured" + assert entry.unique_id == "1234" async def test_hassio_addon_cannot_connect(hass: HomeAssistant) -> None: @@ -297,3 +298,29 @@ async def test_zeroconf_discovery_no_services( assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "no_services" + + +async def test_zeroconf_discovery_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config flow initiated by Supervisor.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"host": "127.0.0.1", "port": 12345}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert entry.unique_id == "test_zeroconf_name._wyoming._tcp.local._Test Satellite" diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index 141e245815e..eb5cf976cb8 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -7,11 +7,11 @@ from unittest.mock import Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.xiaomi_aqara import config_flow, const from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo ZEROCONF_NAME = "name" ZEROCONF_PROP = "properties" @@ -409,7 +409,7 @@ async def test_zeroconf_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -456,7 +456,7 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -476,7 +476,7 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", diff --git a/tests/components/xiaomi_ble/test_device_trigger.py b/tests/components/xiaomi_ble/test_device_trigger.py index 218a382ada5..f415a968f25 100644 --- a/tests/components/xiaomi_ble/test_device_trigger.py +++ b/tests/components/xiaomi_ble/test_device_trigger.py @@ -52,7 +52,7 @@ async def test_event_button_press(hass: HomeAssistant) -> None: hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) @@ -78,7 +78,7 @@ async def test_event_unlock_outside_the_door(hass: HomeAssistant) -> None: hass, make_advertisement( mac, - b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t" b" \x02\x00\x01\x80|D/a", + b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t \x02\x00\x01\x80|D/a", ), ) @@ -104,7 +104,7 @@ async def test_event_successful_fingerprint_match_the_door(hass: HomeAssistant) hass, make_advertisement( mac, - b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7" b"\x06\x00\x05\xff\xff\xff\xff\x00", + b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7\x06\x00\x05\xff\xff\xff\xff\x00", ), ) @@ -153,7 +153,7 @@ async def test_event_dimmer_rotate(hass: HomeAssistant) -> None: inject_bluetooth_service_info_bleak( hass, make_advertisement( - mac, b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f" b"\x13Q\x00\x00\x00\xd6" + mac, b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f\x13Q\x00\x00\x00\xd6" ), ) @@ -182,7 +182,7 @@ async def test_get_triggers_button( hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) @@ -406,7 +406,7 @@ async def test_if_fires_on_button_press( hass, make_advertisement( mac, - b"XY\x97\tf\xbc\x9c\xe3D\xefT\x01" b"\x08\x12\x05\x00\x00\x00q^\xbe\x90", + b"XY\x97\tf\xbc\x9c\xe3D\xefT\x01\x08\x12\x05\x00\x00\x00q^\xbe\x90", ), ) @@ -442,7 +442,7 @@ async def test_if_fires_on_button_press( hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) await hass.async_block_till_done() diff --git a/tests/components/xiaomi_ble/test_event.py b/tests/components/xiaomi_ble/test_event.py index 1de5859c35e..7f31fe048aa 100644 --- a/tests/components/xiaomi_ble/test_event.py +++ b/tests/components/xiaomi_ble/test_event.py @@ -23,8 +23,7 @@ from tests.components.bluetooth import ( "54:EF:44:E3:9C:BC", make_advertisement( "54:EF:44:E3:9C:BC", - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' - b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), "5b51a7c91cde6707c9ef18dfda143a58", [ @@ -114,7 +113,7 @@ from tests.components.bluetooth import ( "F8:24:41:C5:98:8B", make_advertisement( "F8:24:41:C5:98:8B", - b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f" b"\x13Q\x00\x00\x00\xd6", + b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f\x13Q\x00\x00\x00\xd6", ), "b853075158487ca39a5b5ea9", [ @@ -221,7 +220,7 @@ async def test_xiaomi_fingerprint(hass: HomeAssistant) -> None: hass, make_advertisement( "D7:1F:44:EB:8A:91", - b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7" b"\x06\x00\x05\xff\xff\xff\xff\x00", + b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7\x06\x00\x05\xff\xff\xff\xff\x00", ), ) @@ -264,7 +263,7 @@ async def test_xiaomi_lock(hass: HomeAssistant) -> None: hass, make_advertisement( "D7:1F:44:EB:8A:91", - b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t" b" \x02\x00\x01\x80|D/a", + b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t \x02\x00\x01\x80|D/a", ), ) diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 146526c69a5..92fe53a8fc7 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -9,11 +9,11 @@ from miio import DeviceException import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.xiaomi_miio import const from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import TEST_MAC @@ -434,7 +434,7 @@ async def test_zeroconf_gateway_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -477,7 +477,7 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -497,7 +497,7 @@ async def test_zeroconf_no_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=None, ip_addresses=[], hostname="mock_hostname", @@ -517,7 +517,7 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -801,7 +801,7 @@ async def zeroconf_device_success( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 76321a1a0a8..e58f21e387b 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -21,8 +21,7 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, - STATE_CLEANING, - STATE_ERROR, + VacuumActivity, ) from homeassistant.components.xiaomi_miio.const import ( CONF_FLOW_TYPE, @@ -264,7 +263,7 @@ async def test_xiaomi_vacuum_services( # Check state attributes state = hass.states.get(entity_id) - assert state.state == STATE_ERROR + assert state.state == VacuumActivity.ERROR assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14204 assert state.attributes.get(ATTR_ERROR) == "Error message" assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-80" @@ -450,7 +449,7 @@ async def test_xiaomi_specific_services( # Check state attributes state = hass.states.get(entity_id) - assert state.state == STATE_CLEANING + assert state.state == VacuumActivity.CLEANING assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14204 assert state.attributes.get(ATTR_ERROR) is None assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-30" diff --git a/tests/components/yale/test_lock.py b/tests/components/yale/test_lock.py index f0fe018759c..f6b96120d0d 100644 --- a/tests/components/yale/test_lock.py +++ b/tests/components/yale/test_lock.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util @@ -29,6 +29,7 @@ from .mocks import ( _mock_lock_from_fixture, _mock_lock_with_unlatch, _mock_operative_yale_lock_detail, + async_setup_component, ) from tests.common import async_fire_time_changed @@ -418,8 +419,14 @@ async def test_open_throws_hass_service_not_supported_error( hass: HomeAssistant, ) -> None: """Test open throws correct error on entity does not support this service error.""" + # Fetch translations + await async_setup_component(hass, "homeassistant", {}) mocked_lock_detail = await _mock_operative_yale_lock_detail(hass) await _create_yale_with_devices(hass, [mocked_lock_detail]) - data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} - with pytest.raises(HomeAssistantError, match="does not support this service"): + entity_id = "lock.a6697750d607098bae8d6baa11ef8063_name" + data = {ATTR_ENTITY_ID: entity_id} + with pytest.raises( + ServiceNotSupported, + match=f"Entity {entity_id} does not support action {LOCK_DOMAIN}.{SERVICE_OPEN}", + ): await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) diff --git a/tests/components/yale_smart_alarm/conftest.py b/tests/components/yale_smart_alarm/conftest.py index 7a7abcac67c..91c64c7a7a7 100644 --- a/tests/components/yale_smart_alarm/conftest.py +++ b/tests/components/yale_smart_alarm/conftest.py @@ -20,7 +20,6 @@ from tests.common import MockConfigEntry, load_fixture ENTRY_CONFIG = { "username": "test-username", "password": "new-test-password", - "name": "Yale Smart Alarm", "area_id": "1", } OPTIONS_CONFIG = {"lock_code_digits": 6} @@ -35,51 +34,64 @@ async def patch_platform_constant() -> list[Platform]: @pytest.fixture async def load_config_entry( hass: HomeAssistant, - get_data: YaleSmartAlarmData, - get_all_data: YaleSmartAlarmData, + get_client: Mock, load_platforms: list[Platform], ) -> tuple[MockConfigEntry, Mock]: """Set up the Yale Smart Living integration in Home Assistant.""" with patch("homeassistant.components.yale_smart_alarm.PLATFORMS", load_platforms): config_entry = MockConfigEntry( + title=ENTRY_CONFIG["username"], domain=DOMAIN, source=SOURCE_USER, data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", unique_id="username", - version=1, + version=2, + minor_version=2, ) config_entry.add_to_hass(hass) - - cycle = get_data.cycle["data"] - data = {"data": cycle["device_status"]} - with patch( "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", - autospec=True, - ) as mock_client_class: - client = mock_client_class.return_value - client.auth = Mock() - client.auth.get_authenticated = Mock(return_value=data) - client.auth.post_authenticated = Mock(return_value={"code": "000"}) - client.auth.put_authenticated = Mock(return_value={"code": "000"}) - client.lock_api = YaleDoorManAPI(client.auth) - locks = [ - YaleLock(device, lock_api=client.lock_api) - for device in cycle["device_status"] - if device["type"] == YaleLock.DEVICE_TYPE - ] - client.get_locks.return_value = locks - client.get_all.return_value = get_all_data - client.get_information.return_value = get_data - client.get_armed_status.return_value = YALE_STATE_ARM_FULL - + return_value=get_client, + ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - return (config_entry, client) + return (config_entry, get_client) + + +@pytest.fixture(name="get_client") +async def mock_client( + get_data: YaleSmartAlarmData, + get_all_data: YaleSmartAlarmData, +) -> Mock: + """Mock the Yale client.""" + cycle = get_data.cycle["data"] + data = {"data": cycle["device_status"]} + + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + autospec=True, + ) as mock_client_class: + client = mock_client_class.return_value + client.auth = Mock() + client.auth.get_authenticated = Mock(return_value=data) + client.auth.post_authenticated = Mock(return_value={"code": "000"}) + client.auth.put_authenticated = Mock(return_value={"code": "000"}) + client.lock_api = YaleDoorManAPI(client.auth) + locks = [ + YaleLock(device, lock_api=client.lock_api) + for device in cycle["device_status"] + if device["type"] == YaleLock.DEVICE_TYPE + ] + client.get_locks.return_value = locks + client.get_all.return_value = get_all_data + client.get_information.return_value = get_data + client.get_armed_status.return_value = YALE_STATE_ARM_FULL + + return client @pytest.fixture(name="loaded_fixture", scope="package") diff --git a/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr index 749e62252f3..fcdb7baca03 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_alarm_control_panel[load_platforms0][alarm_control_panel.yale_smart_alarm-entry] +# name: test_alarm_control_panel[load_platforms0][alarm_control_panel.test_username-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,7 +11,7 @@ 'disabled_by': None, 'domain': 'alarm_control_panel', 'entity_category': None, - 'entity_id': 'alarm_control_panel.yale_smart_alarm', + 'entity_id': 'alarm_control_panel.test_username', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -32,17 +32,17 @@ 'unit_of_measurement': None, }) # --- -# name: test_alarm_control_panel[load_platforms0][alarm_control_panel.yale_smart_alarm-state] +# name: test_alarm_control_panel[load_platforms0][alarm_control_panel.test_username-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'changed_by': None, 'code_arm_required': False, 'code_format': None, - 'friendly_name': 'Yale Smart Alarm', + 'friendly_name': 'test-username', 'supported_features': , }), 'context': , - 'entity_id': 'alarm_control_panel.yale_smart_alarm', + 'entity_id': 'alarm_control_panel.test_username', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr index ed7e847439c..e519a880de9 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr @@ -281,7 +281,7 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_battery-entry] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -293,7 +293,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.yale_smart_alarm_battery', + 'entity_id': 'binary_sensor.test_username_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -314,21 +314,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_battery-state] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Yale Smart Alarm Battery', + 'friendly_name': 'test-username Battery', }), 'context': , - 'entity_id': 'binary_sensor.yale_smart_alarm_battery', + 'entity_id': 'binary_sensor.test_username_battery', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_jam-entry] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_jam-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -340,7 +340,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.yale_smart_alarm_jam', + 'entity_id': 'binary_sensor.test_username_jam', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -361,21 +361,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_jam-state] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_jam-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Yale Smart Alarm Jam', + 'friendly_name': 'test-username Jam', }), 'context': , - 'entity_id': 'binary_sensor.yale_smart_alarm_jam', + 'entity_id': 'binary_sensor.test_username_jam', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_power_loss-entry] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_power_loss-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -387,7 +387,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.yale_smart_alarm_power_loss', + 'entity_id': 'binary_sensor.test_username_power_loss', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -408,21 +408,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_power_loss-state] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_power_loss-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Yale Smart Alarm Power loss', + 'friendly_name': 'test-username Power loss', }), 'context': , - 'entity_id': 'binary_sensor.yale_smart_alarm_power_loss', + 'entity_id': 'binary_sensor.test_username_power_loss', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_tamper-entry] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_tamper-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -434,7 +434,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.yale_smart_alarm_tamper', + 'entity_id': 'binary_sensor.test_username_tamper', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -455,14 +455,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_tamper-state] +# name: test_binary_sensor[load_platforms0][binary_sensor.test_username_tamper-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Yale Smart Alarm Tamper', + 'friendly_name': 'test-username Tamper', }), 'context': , - 'entity_id': 'binary_sensor.yale_smart_alarm_tamper', + 'entity_id': 'binary_sensor.test_username_tamper', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/yale_smart_alarm/snapshots/test_button.ambr b/tests/components/yale_smart_alarm/snapshots/test_button.ambr index 8abceb0affa..951caced170 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_button.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_button.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_button[load_platforms0][button.yale_smart_alarm_panic_button-entry] +# name: test_button[load_platforms0][button.test_username_panic_button-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,7 +11,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': None, - 'entity_id': 'button.yale_smart_alarm_panic_button', + 'entity_id': 'button.test_username_panic_button', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -32,13 +32,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_button[load_platforms0][button.yale_smart_alarm_panic_button-state] +# name: test_button[load_platforms0][button.test_username_panic_button-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Yale Smart Alarm Panic button', + 'friendly_name': 'test-username Panic button', }), 'context': , - 'entity_id': 'button.yale_smart_alarm_panic_button', + 'entity_id': 'button.test_username_panic_button', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/yale_smart_alarm/test_alarm_control_panel.py b/tests/components/yale_smart_alarm/test_alarm_control_panel.py index 4e8330df071..0280223b72a 100644 --- a/tests/components/yale_smart_alarm/test_alarm_control_panel.py +++ b/tests/components/yale_smart_alarm/test_alarm_control_panel.py @@ -2,16 +2,27 @@ from __future__ import annotations +from copy import deepcopy from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from yalesmartalarmclient import YaleSmartAlarmData -from homeassistant.const import Platform +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_DISARM, + AlarmControlPanelState, +) +from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.parametrize( @@ -27,3 +38,111 @@ async def test_alarm_control_panel( """Test the Yale Smart Alarm alarm_control_panel.""" entry = load_config_entry[0] await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.ALARM_CONTROL_PANEL]], +) +async def test_alarm_control_panel_service_calls( + hass: HomeAssistant, + get_data: YaleSmartAlarmData, + load_config_entry: tuple[MockConfigEntry, Mock], +) -> None: + """Test the Yale Smart Alarm alarm_control_panel action calls.""" + + client = load_config_entry[1] + + data = deepcopy(get_data.cycle) + data["data"] = data["data"].pop("device_status") + + client.auth.get_authenticated = Mock(return_value=data) + client.disarm = Mock(return_value=True) + client.arm_partial = Mock(return_value=True) + client.arm_full = Mock(return_value=True) + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.test_username", ATTR_CODE: "123456"}, + blocking=True, + ) + client.disarm.assert_called_once() + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.DISARMED + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_HOME, + {ATTR_ENTITY_ID: "alarm_control_panel.test_username", ATTR_CODE: "123456"}, + blocking=True, + ) + client.arm_partial.assert_called_once() + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.ARMED_HOME + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + {ATTR_ENTITY_ID: "alarm_control_panel.test_username", ATTR_CODE: "123456"}, + blocking=True, + ) + client.arm_full.assert_called_once() + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.ARMED_AWAY + + client.disarm = Mock(side_effect=ConnectionError("no connection")) + + with pytest.raises( + HomeAssistantError, + match="Could not set alarm for test-username: no connection", + ): + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.test_username", ATTR_CODE: "123456"}, + blocking=True, + ) + + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.ARMED_AWAY + + client.disarm = Mock(return_value=False) + + with pytest.raises( + HomeAssistantError, + match="Could not change alarm, check system ready for arming", + ): + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.test_username", ATTR_CODE: "123456"}, + blocking=True, + ) + + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.ARMED_AWAY + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.ALARM_CONTROL_PANEL]], +) +async def test_alarm_control_panel_not_available( + hass: HomeAssistant, + get_data: YaleSmartAlarmData, + load_config_entry: tuple[MockConfigEntry, Mock], + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Yale Smart Alarm alarm_control_panel not being available.""" + + client = load_config_entry[1] + client.get_armed_status = Mock(return_value=None) + + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == AlarmControlPanelState.ARMED_AWAY + + freezer.tick(3600) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("alarm_control_panel.test_username") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/yale_smart_alarm/test_button.py b/tests/components/yale_smart_alarm/test_button.py index ad6074345d3..cb28e60ab22 100644 --- a/tests/components/yale_smart_alarm/test_button.py +++ b/tests/components/yale_smart_alarm/test_button.py @@ -37,7 +37,7 @@ async def test_button( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.yale_smart_alarm_panic_button", + ATTR_ENTITY_ID: "button.test_username_panic_button", }, blocking=True, ) @@ -50,7 +50,7 @@ async def test_button( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.yale_smart_alarm_panic_button", + ATTR_ENTITY_ID: "button.test_username_panic_button", }, blocking=True, ) diff --git a/tests/components/yale_smart_alarm/test_config_flow.py b/tests/components/yale_smart_alarm/test_config_flow.py index e5b59f79463..0b008d4c696 100644 --- a/tests/components/yale_smart_alarm/test_config_flow.py +++ b/tests/components/yale_smart_alarm/test_config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from yalesmartalarmclient.exceptions import AuthenticationError, UnknownError @@ -48,7 +48,6 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", } assert len(mock_setup_entry.mock_calls) == 1 @@ -112,7 +111,6 @@ async def test_form_invalid_auth( assert result2["data"] == { "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", } @@ -120,15 +118,16 @@ async def test_form_invalid_auth( async def test_reauth_flow(hass: HomeAssistant) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( + title="test-username", domain=DOMAIN, unique_id="test-username", data={ "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", }, version=2, + minor_version=2, ) entry.add_to_hass(hass) @@ -159,7 +158,6 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert entry.data == { "username": "test-username", "password": "new-test-password", - "name": "Yale Smart Alarm", "area_id": "1", } @@ -181,15 +179,16 @@ async def test_reauth_flow_error( ) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( + title="test-username", domain=DOMAIN, unique_id="test-username", data={ "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", }, version=2, + minor_version=2, ) entry.add_to_hass(hass) @@ -234,7 +233,6 @@ async def test_reauth_flow_error( assert entry.data == { "username": "test-username", "password": "new-test-password", - "name": "Yale Smart Alarm", "area_id": "1", } @@ -242,15 +240,16 @@ async def test_reauth_flow_error( async def test_reconfigure(hass: HomeAssistant) -> None: """Test reconfigure config flow.""" entry = MockConfigEntry( + title="test-username", domain=DOMAIN, unique_id="test-username", data={ "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", }, version=2, + minor_version=2, ) entry.add_to_hass(hass) @@ -281,7 +280,6 @@ async def test_reconfigure(hass: HomeAssistant) -> None: assert entry.data == { "username": "test-username", "password": "new-test-password", - "name": "Yale Smart Alarm", "area_id": "2", } @@ -289,27 +287,29 @@ async def test_reconfigure(hass: HomeAssistant) -> None: async def test_reconfigure_username_exist(hass: HomeAssistant) -> None: """Test reconfigure config flow abort other username already exist.""" entry = MockConfigEntry( + title="test-username", domain=DOMAIN, unique_id="test-username", data={ "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", }, version=2, + minor_version=2, ) entry.add_to_hass(hass) entry2 = MockConfigEntry( + title="other-username", domain=DOMAIN, unique_id="other-username", data={ "username": "other-username", "password": "test-password", - "name": "Yale Smart Alarm 2", "area_id": "1", }, version=2, + minor_version=2, ) entry2.add_to_hass(hass) @@ -362,7 +362,6 @@ async def test_reconfigure_username_exist(hass: HomeAssistant) -> None: assert result["reason"] == "reconfigure_successful" assert entry.data == { "username": "other-new-username", - "name": "Yale Smart Alarm", "password": "test-password", "area_id": "1", } @@ -382,15 +381,16 @@ async def test_reconfigure_flow_error( ) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( + title="test-username", domain=DOMAIN, unique_id="test-username", data={ "username": "test-username", "password": "test-password", - "name": "Yale Smart Alarm", "area_id": "1", }, version=2, + minor_version=2, ) entry.add_to_hass(hass) @@ -438,49 +438,34 @@ async def test_reconfigure_flow_error( assert result["reason"] == "reconfigure_successful" assert entry.data == { "username": "test-username", - "name": "Yale Smart Alarm", "password": "new-test-password", "area_id": "1", } -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + load_config_entry: tuple[MockConfigEntry, Mock], +) -> None: """Test options config flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="test-username", - data={ - "username": "test-username", - "password": "test-password", - "name": "Yale Smart Alarm", - "area_id": "1", - }, - version=2, - ) - entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", - return_value=True, - ), - patch( - "homeassistant.components.yale_smart_alarm.async_setup_entry", - return_value=True, - ), - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = load_config_entry[0] result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"lock_code_digits": 6}, - ) + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + return_value=load_config_entry[1], + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"lock_code_digits": 4}, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {"lock_code_digits": 6} + assert result["data"] == {"lock_code_digits": 4} + + assert entry.state == config_entries.ConfigEntryState.LOADED diff --git a/tests/components/yale_smart_alarm/test_coordinator.py b/tests/components/yale_smart_alarm/test_coordinator.py index 386e4ad72f7..8d30e8ad21a 100644 --- a/tests/components/yale_smart_alarm/test_coordinator.py +++ b/tests/components/yale_smart_alarm/test_coordinator.py @@ -48,7 +48,8 @@ async def test_coordinator_setup_errors( options=OPTIONS_CONFIG, entry_id="1", unique_id="username", - version=1, + version=2, + minor_version=2, ) config_entry.add_to_hass(hass) @@ -61,7 +62,7 @@ async def test_coordinator_setup_errors( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert not state @@ -74,7 +75,7 @@ async def test_coordinator_setup_and_update_errors( client = load_config_entry[1] - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == AlarmControlPanelState.ARMED_AWAY client.reset_mock() @@ -82,7 +83,7 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == STATE_UNAVAILABLE client.reset_mock() @@ -90,7 +91,7 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == STATE_UNAVAILABLE client.reset_mock() @@ -98,7 +99,7 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=3)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == STATE_UNAVAILABLE client.reset_mock() @@ -106,7 +107,7 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=4)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == STATE_UNAVAILABLE client.reset_mock() @@ -116,7 +117,7 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == AlarmControlPanelState.ARMED_AWAY client.reset_mock() @@ -124,5 +125,5 @@ async def test_coordinator_setup_and_update_errors( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) await hass.async_block_till_done(wait_background_tasks=True) client.get_information.assert_called_once() - state = hass.states.get("alarm_control_panel.yale_smart_alarm") + state = hass.states.get("alarm_control_panel.test_username") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/yale_smart_alarm/test_init.py b/tests/components/yale_smart_alarm/test_init.py new file mode 100644 index 00000000000..c499320c29c --- /dev/null +++ b/tests/components/yale_smart_alarm/test_init.py @@ -0,0 +1,99 @@ +"""Test for Yale Smart Alarm component Init.""" + +from __future__ import annotations + +from unittest.mock import Mock, patch + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.yale_smart_alarm.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ENTRY_CONFIG, OPTIONS_CONFIG + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + get_client: Mock, +) -> None: + """Test setup entry.""" + entry = MockConfigEntry( + title=ENTRY_CONFIG["username"], + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="username", + version=2, + minor_version=2, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + return_value=get_client, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_migrate_entry( + hass: HomeAssistant, + get_client: Mock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrate entry unique id.""" + config = { + "username": "test-username", + "password": "new-test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + } + options = {"lock_code_digits": 6, "code": "123456"} + entry = MockConfigEntry( + title=ENTRY_CONFIG["username"], + domain=DOMAIN, + source=SOURCE_USER, + data=config, + options=options, + entry_id="1", + unique_id="username", + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + lock = entity_registry.async_get_or_create( + LOCK_DOMAIN, + DOMAIN, + "1111", + config_entry=entry, + has_entity_name=True, + original_name="Device1", + ) + + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + return_value=get_client, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 2 + assert entry.minor_version == 2 + assert entry.data == ENTRY_CONFIG + assert entry.options == OPTIONS_CONFIG + + lock_entity_id = entity_registry.async_get_entity_id(LOCK_DOMAIN, DOMAIN, "1111") + lock = entity_registry.async_get(lock_entity_id) + + assert lock.options == {"lock": {"default_code": "123456"}} diff --git a/tests/components/yale_smart_alarm/test_switch.py b/tests/components/yale_smart_alarm/test_switch.py index b189a3fd003..369f8f8f10c 100644 --- a/tests/components/yale_smart_alarm/test_switch.py +++ b/tests/components/yale_smart_alarm/test_switch.py @@ -8,8 +8,12 @@ import pytest from syrupy.assertion import SnapshotAssertion from yalesmartalarmclient import YaleSmartAlarmData -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, Platform +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -44,3 +48,15 @@ async def test_switch( state = hass.states.get("switch.device1_autolock") assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.device1_autolock", + }, + blocking=True, + ) + + state = hass.states.get("switch.device1_autolock") + assert state.state == STATE_ON diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 7629d2401c2..51645dee49e 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -7,12 +7,16 @@ from aiomusiccast import MusicCastConnectionException import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.yamaha_musiccast.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -103,7 +107,7 @@ def mock_valid_discovery_information(): with patch( "homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[ - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1:9000/MediaRenderer/desc.xml", @@ -265,13 +269,13 @@ async def test_ssdp_discovery_failed(hass: HomeAssistant, mock_ssdp_no_yamaha) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "123456789", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "123456789", }, ), ) @@ -287,13 +291,13 @@ async def test_ssdp_discovery_successful_add_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "1234567890", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "1234567890", }, ), ) @@ -329,13 +333,13 @@ async def test_ssdp_discovery_existing_device_update( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "1234567890", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "1234567890", }, ), ) diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index bdd8cdda312..f534b214b1c 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -9,7 +9,6 @@ from async_upnp_client.utils import CaseInsensitiveDict from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS -from homeassistant.components import zeroconf from homeassistant.components.yeelight import ( CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH_TYPE, @@ -21,6 +20,7 @@ from homeassistant.components.yeelight import ( ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME from homeassistant.core import callback +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo FAIL_TO_BIND_IP = "1.2.3.4" @@ -43,7 +43,7 @@ CAPABILITIES = { ID_DECIMAL = f"{int(ID, 16):08d}" -ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], port=54321, diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 1acb553af3d..a3f83cc03aa 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.yeelight.config_flow import ( MODEL_UNKNOWN, CannotConnect, @@ -30,6 +29,12 @@ from homeassistant.components.yeelight.const import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import ( CAPABILITIES, @@ -57,7 +62,7 @@ DEFAULT_CONFIG = { CONF_NIGHTLIGHT_SWITCH: DEFAULT_NIGHTLIGHT_SWITCH, } -SSDP_INFO = ssdp.SsdpServiceInfo( +SSDP_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={}, @@ -493,13 +498,13 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -525,7 +530,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ) @@ -543,7 +548,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -560,7 +565,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -574,19 +579,19 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), @@ -648,19 +653,19 @@ async def test_discovered_by_dhcp_or_homekit(hass: HomeAssistant, source, data) [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), @@ -894,19 +899,19 @@ async def test_discovery_adds_missing_ip_id_only(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 518537262b2..56162d4d9d1 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -24,17 +24,17 @@ from yeelight.main import _MODEL_SPECS from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, FLASH_LONG, FLASH_SHORT, SERVICE_TURN_OFF, SERVICE_TURN_ON, + ColorMode, LightEntityFeature, ) from homeassistant.components.yeelight.const import ( @@ -58,6 +58,7 @@ from homeassistant.components.yeelight.const import ( YEELIGHT_TEMPERATURE_TRANSACTION, ) from homeassistant.components.yeelight.light import ( + ATTR_KELVIN, ATTR_MINUTES, ATTR_MODE, EFFECT_CANDLE_FLICKER, @@ -106,7 +107,6 @@ from homeassistant.util.color import ( color_RGB_to_hs, color_RGB_to_xy, color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, ) from . import ( @@ -288,7 +288,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - # turn_on color_temp brightness = 100 - color_temp = 200 + color_temp = 5000 transition = 1 mocked_bulb.last_properties["power"] = "off" await hass.services.async_call( @@ -297,7 +297,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - { ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: brightness, - ATTR_COLOR_TEMP: color_temp, + ATTR_COLOR_TEMP_KELVIN: color_temp, ATTR_FLASH: FLASH_LONG, ATTR_EFFECT: EFFECT_STOP, ATTR_TRANSITION: transition, @@ -315,7 +315,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main ) mocked_bulb.async_set_color_temp.assert_called_once_with( - color_temperature_mired_to_kelvin(color_temp), + color_temp, duration=transition * 1000, light_type=LightType.Main, ) @@ -326,7 +326,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - # turn_on color_temp - flash short brightness = 100 - color_temp = 200 + color_temp = 5000 transition = 1 mocked_bulb.async_start_music.reset_mock() mocked_bulb.async_set_brightness.reset_mock() @@ -341,7 +341,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - { ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: brightness, - ATTR_COLOR_TEMP: color_temp, + ATTR_COLOR_TEMP_KELVIN: color_temp, ATTR_FLASH: FLASH_SHORT, ATTR_EFFECT: EFFECT_STOP, ATTR_TRANSITION: transition, @@ -359,7 +359,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main ) mocked_bulb.async_set_color_temp.assert_called_once_with( - color_temperature_mired_to_kelvin(color_temp), + color_temp, duration=transition * 1000, light_type=LightType.Main, ) @@ -690,7 +690,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant) -> None: await hass.services.async_call( "light", SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP: 250}, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) assert mocked_bulb.async_set_hsv.mock_calls == [] @@ -706,7 +706,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant) -> None: await hass.services.async_call( "light", SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP: 250}, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) assert mocked_bulb.async_set_hsv.mock_calls == [] @@ -719,7 +719,7 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant) -> None: await hass.services.async_call( "light", SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP: 250}, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) assert mocked_bulb.async_set_hsv.mock_calls == [] @@ -931,9 +931,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -962,9 +960,7 @@ async def test_device_types( "rgb_color": (255, 121, 0), "xy_color": (0.62, 0.368), "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -992,9 +988,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1028,9 +1022,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1065,9 +1057,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1102,9 +1092,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1138,9 +1126,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1173,12 +1159,8 @@ async def test_device_types( "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, "effect": None, "supported_features": SUPPORT_YEELIGHT, - "min_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1204,12 +1186,8 @@ async def test_device_types( "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, "effect": None, "supported_features": SUPPORT_YEELIGHT, - "min_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1217,17 +1195,15 @@ async def test_device_types( model_specs["color_temp"]["min"] ), "brightness": nl_br, - "color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), + "color_temp_kelvin": model_specs["color_temp"]["min"], "color_temp": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), "color_mode": "color_temp", "supported_color_modes": ["color_temp"], - "hs_color": (28.391, 65.659), - "rgb_color": (255, 167, 88), - "xy_color": (0.524, 0.388), + "hs_color": (28.395, 65.723), + "rgb_color": (255, 167, 87), + "xy_color": (0.525, 0.388), }, ) @@ -1245,12 +1221,8 @@ async def test_device_types( "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, - "min_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1279,12 +1251,8 @@ async def test_device_types( "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, - "min_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) - ), + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": model_specs["color_temp"]["max"], "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1292,17 +1260,15 @@ async def test_device_types( model_specs["color_temp"]["min"] ), "brightness": nl_br, - "color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) - ), + "color_temp_kelvin": model_specs["color_temp"]["min"], "color_temp": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), "color_mode": "color_temp", "supported_color_modes": ["color_temp"], - "hs_color": (28.391, 65.659), - "rgb_color": (255, 167, 88), - "xy_color": (0.524, 0.388), + "hs_color": (28.395, 65.723), + "rgb_color": (255, 167, 87), + "xy_color": (0.525, 0.388), }, ) # Background light - color mode CT @@ -1315,16 +1281,18 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(6500) - ), + "max_color_temp_kelvin": 6500, "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, "color_temp_kelvin": bg_ct, "color_temp": bg_ct_kelvin, "color_mode": "color_temp", - "supported_color_modes": ["color_temp", "hs", "rgb"], + "supported_color_modes": [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ], "hs_color": (27.001, 19.243), "rgb_color": (255, 228, 206), "xy_color": (0.371, 0.349), @@ -1343,9 +1311,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(6500) - ), + "max_color_temp_kelvin": 6500, "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, @@ -1355,7 +1321,11 @@ async def test_device_types( "color_temp": None, "color_temp_kelvin": None, "color_mode": "hs", - "supported_color_modes": ["color_temp", "hs", "rgb"], + "supported_color_modes": [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ], }, name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", @@ -1371,9 +1341,7 @@ async def test_device_types( "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, - "max_color_temp_kelvin": color_temperature_mired_to_kelvin( - color_temperature_kelvin_to_mired(6500) - ), + "max_color_temp_kelvin": 6500, "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, @@ -1383,7 +1351,11 @@ async def test_device_types( "color_temp": None, "color_temp_kelvin": None, "color_mode": "rgb", - "supported_color_modes": ["color_temp", "hs", "rgb"], + "supported_color_modes": [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.RGB, + ], }, name=f"{UNIQUE_FRIENDLY_NAME} Ambilight", entity_id=f"{ENTITY_LIGHT}_ambilight", diff --git a/tests/components/youless/snapshots/test_sensor.ambr b/tests/components/youless/snapshots/test_sensor.ambr index bcfd0139e5c..3424a264f48 100644 --- a/tests/components/youless/snapshots/test_sensor.ambr +++ b/tests/components/youless/snapshots/test_sensor.ambr @@ -24,7 +24,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:transmission-tower-import', 'original_name': 'Energy delivery high', 'platform': 'youless', 'previous_unique_id': None, @@ -39,6 +39,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Energy delivery high', + 'icon': 'mdi:transmission-tower-import', 'state_class': , 'unit_of_measurement': , }), @@ -75,7 +76,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:transmission-tower-import', 'original_name': 'Energy delivery low', 'platform': 'youless', 'previous_unique_id': None, @@ -90,6 +91,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Energy delivery low', + 'icon': 'mdi:transmission-tower-import', 'state_class': , 'unit_of_measurement': , }), @@ -126,7 +128,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:transmission-tower-export', 'original_name': 'Energy high', 'platform': 'youless', 'previous_unique_id': None, @@ -141,6 +143,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Energy high', + 'icon': 'mdi:transmission-tower-export', 'state_class': , 'unit_of_measurement': , }), @@ -177,7 +180,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:transmission-tower-export', 'original_name': 'Energy low', 'platform': 'youless', 'previous_unique_id': None, @@ -192,6 +195,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Energy low', + 'icon': 'mdi:transmission-tower-export', 'state_class': , 'unit_of_measurement': , }), @@ -228,7 +232,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:transmission-tower-export', 'original_name': 'Energy total', 'platform': 'youless', 'previous_unique_id': None, @@ -243,6 +247,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Energy total', + 'icon': 'mdi:transmission-tower-export', 'state_class': , 'unit_of_measurement': , }), @@ -279,7 +284,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:meter-electric', 'original_name': 'Extra total', 'platform': 'youless', 'previous_unique_id': None, @@ -294,6 +299,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Extra total', + 'icon': 'mdi:meter-electric', 'state_class': , 'unit_of_measurement': , }), @@ -330,7 +336,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:lightning-bolt', 'original_name': 'Extra usage', 'platform': 'youless', 'previous_unique_id': None, @@ -345,6 +351,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Extra usage', + 'icon': 'mdi:lightning-bolt', 'state_class': , 'unit_of_measurement': , }), @@ -456,7 +463,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_1_power-entry] @@ -507,7 +514,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_1_voltage-entry] @@ -558,7 +565,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_2_current-entry] @@ -609,7 +616,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_2_power-entry] @@ -660,7 +667,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_2_voltage-entry] @@ -711,7 +718,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_3_current-entry] @@ -762,7 +769,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_3_power-entry] @@ -813,7 +820,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.phase_3_voltage-entry] @@ -864,7 +871,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.power_usage-entry] @@ -892,7 +899,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': None, + 'original_icon': 'mdi:meter-electric', 'original_name': 'Power Usage', 'platform': 'youless', 'previous_unique_id': None, @@ -907,6 +914,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Power Usage', + 'icon': 'mdi:meter-electric', 'state_class': , 'unit_of_measurement': , }), diff --git a/tests/components/youtube/snapshots/test_sensor.ambr b/tests/components/youtube/snapshots/test_sensor.ambr index dce546b4803..f4549e89c8c 100644 --- a/tests/components/youtube/snapshots/test_sensor.ambr +++ b/tests/components/youtube/snapshots/test_sensor.ambr @@ -4,7 +4,7 @@ 'attributes': ReadOnlyDict({ 'entity_picture': 'https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg', 'friendly_name': 'Google for Developers Latest upload', - 'published_at': datetime.datetime(2023, 5, 11, 0, 20, 46, tzinfo=datetime.timezone.utc), + 'published_at': datetime.datetime(2023, 5, 11, 0, 20, 46, tzinfo=TzInfo(UTC)), 'video_id': 'wysukDrMdqU', }), 'context': , diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index be78964f231..3586f54a59a 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -24,9 +24,18 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.generated import zeroconf as zc_gen from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import ATTR_COMPONENT, async_setup_component -from tests.common import MockConfigEntry, MockModule, mock_integration +from tests.common import ( + MockConfigEntry, + MockModule, + import_and_test_deprecated_constant, + mock_integration, +) NON_UTF8_VALUE = b"ABCDEF\x8a" NON_ASCII_KEY = b"non-ascii-key\x8a" @@ -1655,3 +1664,35 @@ async def test_zeroconf_rediscover_no_match( assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "ATTR_PROPERTIES_ID", + "homeassistant.helpers.service_info.zeroconf.ATTR_PROPERTIES_ID", + ATTR_PROPERTIES_ID, + ), + ( + "ZeroconfServiceInfo", + "homeassistant.helpers.service_info.zeroconf.ZeroconfServiceInfo", + ZeroconfServiceInfo, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + zeroconf, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index a9f4c51d75d..1b280ea499a 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch import warnings import pytest +import zhaquirks import zigpy from zigpy.application import ControllerApplication import zigpy.backups @@ -38,7 +39,7 @@ FIXTURE_GRP_NAME = "fixture group" COUNTER_NAMES = ["counter_1", "counter_2", "counter_3"] -@pytest.fixture(scope="module", autouse=True) +@pytest.fixture(scope="package", autouse=True) def globally_load_quirks(): """Load quirks automatically so that ZHA tests run deterministically in isolation. @@ -47,8 +48,6 @@ def globally_load_quirks(): run. """ - import zhaquirks # pylint: disable=import-outside-toplevel - zhaquirks.setup() diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index e0229ebe049..573a04e9b57 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -20,9 +20,7 @@ from zigpy.exceptions import NetworkNotFormed import zigpy.types from homeassistant import config_entries -from homeassistant.components import ssdp, usb, zeroconf from homeassistant.components.hassio import AddonError, AddonState -from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.const import ( CONF_BAUDRATE, @@ -43,6 +41,13 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -162,7 +167,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "tubeszb-cc2652-poe", "tubeszb-cc2652-poe", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -175,7 +180,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "network": "ethernet", "board": "esp32-poe", "platform": "ESP32", - "maс": "8c4b14c33c24", + "mac": "8c4b14c33c24", "version": "2023.12.8", }, ), @@ -185,7 +190,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "tubeszb-efr32-poe", "tubeszb-efr32-poe", RadioType.ezsp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-efr32-poe.local.", @@ -198,7 +203,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "network": "ethernet", "board": "esp32-poe", "platform": "ESP32", - "maс": "8c4b14c33c24", + "mac": "8c4b14c33c24", "version": "2023.12.8", }, ), @@ -208,7 +213,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "TubeZB", "tubeszb-cc2652-poe", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -229,7 +234,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "Some Zigbee Gateway (12345)", "aabbccddeeff", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="some-zigbee-gateway-12345.local.", @@ -248,7 +253,7 @@ async def test_zeroconf_discovery( entry_name: str, unique_id: str, radio_type: RadioType, - service_info: zeroconf.ZeroconfServiceInfo, + service_info: ZeroconfServiceInfo, hass: HomeAssistant, ) -> None: """Test zeroconf flow -- radio detected.""" @@ -290,7 +295,7 @@ async def test_legacy_zeroconf_discovery_zigate( setup_entry_mock, hass: HomeAssistant ) -> None: """Test zeroconf flow -- zigate radio detected.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="_zigate-zigbee-gateway.local.", @@ -339,7 +344,7 @@ async def test_legacy_zeroconf_discovery_zigate( async def test_zeroconf_discovery_bad_payload(hass: HomeAssistant) -> None: """Test zeroconf flow with a bad payload.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="some.hostname", @@ -367,7 +372,7 @@ async def test_legacy_zeroconf_discovery_ip_change_ignored(hass: HomeAssistant) ) entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -397,7 +402,7 @@ async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( hass: HomeAssistant, ) -> None: """Test discovery aborts if ZHA was set up after the confirmation dialog is shown.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tube._tube_zb_gw._tcp.local.", @@ -429,7 +434,7 @@ async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_discovery_via_usb(hass: HomeAssistant) -> None: """Test usb flow -- radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -475,7 +480,7 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: @patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}", return_value=True) async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None: """Test zigate usb flow -- radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="0403", vid="6015", @@ -528,7 +533,7 @@ async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None ) async def test_discovery_via_usb_no_radio(hass: HomeAssistant) -> None: """Test usb flow -- no radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/null", pid="AAAA", vid="AAAA", @@ -561,7 +566,7 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} ).add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -595,7 +600,7 @@ async def test_discovery_via_usb_path_does_not_change(hass: HomeAssistant) -> No ) entry.add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -622,7 +627,7 @@ async def test_discovery_via_usb_deconz_already_discovered(hass: HomeAssistant) """Test usb flow -- deconz discovered.""" result = await hass.config_entries.flow.async_init( "deconz", - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -634,7 +639,7 @@ async def test_discovery_via_usb_deconz_already_discovered(hass: HomeAssistant) context={"source": SOURCE_SSDP}, ) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -656,7 +661,7 @@ async def test_discovery_via_usb_deconz_already_setup(hass: HomeAssistant) -> No """Test usb flow -- deconz setup.""" MockConfigEntry(domain="deconz", data={}).add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -680,7 +685,7 @@ async def test_discovery_via_usb_deconz_ignored(hass: HomeAssistant) -> None: domain="deconz", source=config_entries.SOURCE_IGNORE, data={} ).add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -708,7 +713,7 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non ) entry.add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -732,7 +737,7 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="_tube_zb_gw._tcp.local.", @@ -1196,7 +1201,7 @@ async def test_onboarding_auto_formation_new_hardware( """Test auto network formation with new hardware during onboarding.""" mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device)) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", diff --git a/tests/components/zha/test_entity.py b/tests/components/zha/test_entity.py new file mode 100644 index 00000000000..add98bb96bf --- /dev/null +++ b/tests/components/zha/test_entity.py @@ -0,0 +1,47 @@ +"""Test ZHA entities.""" + +from zigpy.profiles import zha +from zigpy.zcl.clusters import general + +from homeassistant.components.zha.helpers import get_zha_gateway +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + + +async def test_device_registry_via_device( + hass: HomeAssistant, + setup_zha, + zigpy_device_mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test ZHA `via_device` is set correctly.""" + + await setup_zha() + gateway = get_zha_gateway(hass) + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device = gateway.get_or_create_device(zigpy_device) + await gateway.async_device_initialized(zigpy_device) + await hass.async_block_till_done(wait_background_tasks=True) + + reg_coordinator_device = device_registry.async_get_device( + identifiers={("zha", str(gateway.state.node_info.ieee))} + ) + + reg_device = device_registry.async_get_device( + identifiers={("zha", str(zha_device.ieee))} + ) + + assert reg_device.via_device_id == reg_coordinator_device.id diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 0a51aaa6dba..59494dd0d09 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -11,12 +11,12 @@ import zigpy.config from zigpy.config import CONF_DEVICE_PATH import zigpy.types -from homeassistant.components.usb import UsbServiceInfo from homeassistant.components.zha import radio_manager from homeassistant.components.zha.const import DOMAIN from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index c2925161748..af81ac0d586 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -1,17 +1,14 @@ """Test ZHA repairs.""" -from collections.abc import Callable from http import HTTPStatus -import logging from unittest.mock import Mock, call, patch import pytest -from universal_silabs_flasher.const import ApplicationType -from universal_silabs_flasher.flasher import Flasher from zigpy.application import ControllerApplication import zigpy.backups from zigpy.exceptions import NetworkSettingsInconsistent +from homeassistant.components.homeassistant_hardware.util import ApplicationType from homeassistant.components.homeassistant_sky_connect.const import ( # pylint: disable=hass-component-root-import DOMAIN as SKYCONNECT_DOMAIN, ) @@ -25,7 +22,6 @@ from homeassistant.components.zha.repairs.wrong_silabs_firmware import ( ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, HardwareType, _detect_radio_hardware, - probe_silabs_firmware_type, warn_on_wrong_silabs_firmware, ) from homeassistant.config_entries import ConfigEntryState @@ -41,15 +37,6 @@ SKYCONNECT_DEVICE = "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8 CONNECT_ZBT1_DEVICE = "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0" -def set_flasher_app_type(app_type: ApplicationType) -> Callable[[Flasher], None]: - """Set the app type on the flasher.""" - - def replacement(self: Flasher) -> None: - self.app_type = app_type - - return replacement - - def test_detect_radio_hardware(hass: HomeAssistant) -> None: """Test logic to detect radio hardware.""" skyconnect_config_entry = MockConfigEntry( @@ -143,9 +130,8 @@ async def test_multipan_firmware_repair( # ZHA fails to set up with ( patch( - "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", - side_effect=set_flasher_app_type(ApplicationType.CPC), - autospec=True, + "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type", + return_value=ApplicationType.CPC, ), patch( "homeassistant.components.zha.Gateway.async_initialize", @@ -194,9 +180,8 @@ async def test_multipan_firmware_no_repair_on_probe_failure( # ZHA fails to set up with ( patch( - "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", - side_effect=set_flasher_app_type(None), - autospec=True, + "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type", + return_value=None, ), patch( "homeassistant.components.zha.Gateway.async_initialize", @@ -231,9 +216,8 @@ async def test_multipan_firmware_retry_on_probe_ezsp( # ZHA fails to set up with ( patch( - "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", - side_effect=set_flasher_app_type(ApplicationType.EZSP), - autospec=True, + "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type", + return_value=ApplicationType.EZSP, ), patch( "homeassistant.components.zha.Gateway.async_initialize", @@ -260,37 +244,12 @@ async def test_no_warn_on_socket(hass: HomeAssistant) -> None: """Test that no warning is issued when the device is a socket.""" with patch( "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type", - autospec=True, ) as mock_probe: await warn_on_wrong_silabs_firmware(hass, device="socket://1.2.3.4:5678") mock_probe.assert_not_called() -async def test_probe_failure_exception_handling( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that probe failures are handled gracefully.""" - logger = logging.getLogger( - "homeassistant.components.zha.repairs.wrong_silabs_firmware" - ) - orig_level = logger.level - - with ( - caplog.at_level(logging.DEBUG), - patch( - "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", - side_effect=RuntimeError(), - ) as mock_probe_app_type, - ): - logger.setLevel(logging.DEBUG) - await probe_silabs_firmware_type("/dev/ttyZigbee") - logger.setLevel(orig_level) - - mock_probe_app_type.assert_awaited() - assert "Failed to probe application type" in caplog.text - - async def test_inconsistent_settings_keep_new( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 37b1dde7316..bcdc0c3ce16 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -133,9 +133,9 @@ def climate_radio_thermostat_ct100_plus_state_fixture() -> dict[str, Any]: name="climate_radio_thermostat_ct100_plus_different_endpoints_state", scope="package", ) -def climate_radio_thermostat_ct100_plus_different_endpoints_state_fixture() -> ( - dict[str, Any] -): +def climate_radio_thermostat_ct100_plus_different_endpoints_state_fixture() -> dict[ + str, Any +]: """Load the thermostat fixture state with values on different endpoints. This device is a radio thermostat ct100. @@ -336,9 +336,9 @@ def lock_id_lock_as_id150_state_fixture() -> dict[str, Any]: @pytest.fixture( name="climate_radio_thermostat_ct101_multiple_temp_units_state", scope="package" ) -def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture() -> ( - dict[str, Any] -): +def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture() -> dict[ + str, Any +]: """Load the climate multiple temp units node state fixture data.""" return load_json_object_fixture( "climate_radio_thermostat_ct101_multiple_temp_units_state.json", DOMAIN diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 0807e9e09a5..a3f70e92dcf 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3176,10 +3176,10 @@ async def test_set_raw_config_parameter( args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "endpoint.set_raw_config_parameter_value" assert args["nodeId"] == multisensor_6.node_id - assert args["options"]["parameter"] == 102 - assert args["options"]["value"] == 1 - assert args["options"]["valueSize"] == 2 - assert args["options"]["valueFormat"] == 1 + assert args["parameter"] == 102 + assert args["value"] == 1 + assert args["valueSize"] == 2 + assert args["valueFormat"] == 1 # Reset the mock for async_send_command_no_wait instead client.async_send_command_no_wait.reset_mock() @@ -3250,7 +3250,7 @@ async def test_get_raw_config_parameter( args = client.async_send_command.call_args[0][0] assert args["command"] == "endpoint.get_raw_config_parameter_value" assert args["nodeId"] == multisensor_6.node_id - assert args["options"]["parameter"] == 102 + assert args["parameter"] == 102 client.async_send_command.reset_mock() @@ -5195,3 +5195,131 @@ async def test_get_integration_settings( assert msg["result"] == { CONF_INSTALLER_MODE: installer_mode, } + + +async def test_cancel_secure_bootstrap_s2( + hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator +) -> None: + """Test that the cancel_secure_bootstrap_s2 WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + + # Test successful cancellation + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/cancel_secure_bootstrap_s2", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "controller.cancel_secure_bootstrap_s2" + + # Test FailedZWaveCommand is caught + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_cancel_secure_bootstrap_s2", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/cancel_secure_bootstrap_s2", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/cancel_secure_bootstrap_s2", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + # Test sending command with invalid entry ID fails + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/cancel_secure_bootstrap_s2", + ENTRY_ID: "invalid_entry_id", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + +async def test_subscribe_s2_inclusion( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: + """Test the subscribe_s2_inclusion websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/subscribe_s2_inclusion", + ENTRY_ID: entry.entry_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] is None + + # Test receiving DSK request event + event = Event( + type="validate dsk and enter pin", + data={ + "source": "controller", + "event": "validate dsk and enter pin", + "dsk": "test_dsk", + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"] == { + "event": "validate dsk and enter pin", + "dsk": "test_dsk", + } + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/subscribe_s2_inclusion", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + # Test invalid config entry id + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/subscribe_s2_inclusion", + ENTRY_ID: "INVALID", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index b60515cacd4..e7239c23de6 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -16,13 +16,13 @@ from serial.tools.list_ports_common import ListPortInfo from zwave_js_server.version import VersionInfo from homeassistant import config_entries -from homeassistant.components import usb -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -33,7 +33,7 @@ ADDON_DISCOVERY_INFO = { } -USB_DISCOVERY_INFO = usb.UsbServiceInfo( +USB_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zwave", pid="AAAA", vid="AAAA", @@ -42,7 +42,7 @@ USB_DISCOVERY_INFO = usb.UsbServiceInfo( manufacturer="test", ) -NORTEK_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( +NORTEK_ZIGBEE_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zigbee", pid="8A2A", vid="10C4", @@ -51,7 +51,7 @@ NORTEK_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( manufacturer="nortek", ) -CP2652_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( +CP2652_ZIGBEE_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zigbee", pid="EA60", vid="10C4", diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 4c725c6dc29..21a6c0a8fae 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -7,10 +7,10 @@ from zwave_js_server.event import Event from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_SUPPORTED_COLOR_MODES, @@ -51,8 +51,8 @@ async def test_light( assert state assert state.state == STATE_OFF - assert state.attributes[ATTR_MIN_MIREDS] == 153 - assert state.attributes[ATTR_MAX_MIREDS] == 370 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] @@ -130,7 +130,7 @@ async def test_light( assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_MODE] == "color_temp" assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert state.attributes[ATTR_COLOR_TEMP] == 370 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2702 assert state.attributes[ATTR_RGB_COLOR] is not None # Test turning on with same brightness @@ -256,7 +256,7 @@ async def test_light( assert state.attributes[ATTR_COLOR_MODE] == "hs" assert state.attributes[ATTR_BRIGHTNESS] == 255 assert state.attributes[ATTR_RGB_COLOR] == (255, 76, 255) - assert state.attributes[ATTR_COLOR_TEMP] is None + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] is None client.async_send_command.reset_mock() @@ -293,7 +293,7 @@ async def test_light( await hass.services.async_call( "light", "turn_on", - {"entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, ATTR_COLOR_TEMP: 170}, + {"entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, ATTR_COLOR_TEMP_KELVIN: 5881}, blocking=True, ) @@ -358,14 +358,14 @@ async def test_light( assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_MODE] == "color_temp" assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert state.attributes[ATTR_COLOR_TEMP] == 170 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 5881 assert ATTR_RGB_COLOR in state.attributes # Test turning on with same color temp await hass.services.async_call( "light", "turn_on", - {"entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, ATTR_COLOR_TEMP: 170}, + {"entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, ATTR_COLOR_TEMP_KELVIN: 5881}, blocking=True, ) @@ -379,7 +379,7 @@ async def test_light( "turn_on", { "entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, - ATTR_COLOR_TEMP: 170, + ATTR_COLOR_TEMP_KELVIN: 5881, ATTR_TRANSITION: 35, }, blocking=True, diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 41477f18b97..14084a6b846 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -255,11 +255,10 @@ async def test_set_config_parameter( assert args["command"] == "endpoint.set_raw_config_parameter_value" assert args["nodeId"] == 52 assert args["endpoint"] == 0 - options = args["options"] - assert options["parameter"] == 2 - assert options["value"] == 1 - assert options["valueSize"] == 2 - assert options["valueFormat"] == 1 + assert args["parameter"] == 2 + assert args["value"] == 1 + assert args["valueSize"] == 2 + assert args["valueFormat"] == 1 client.async_send_command_no_wait.reset_mock() @@ -284,11 +283,10 @@ async def test_set_config_parameter( assert args["command"] == "endpoint.set_raw_config_parameter_value" assert args["nodeId"] == 2 assert args["endpoint"] == 1 - options = args["options"] - assert options["parameter"] == 32 - assert options["value"] == 1 - assert options["valueSize"] == 2 - assert options["valueFormat"] == 1 + assert args["parameter"] == 32 + assert args["value"] == 1 + assert args["valueSize"] == 2 + assert args["valueFormat"] == 1 client.async_send_command_no_wait.reset_mock() client.async_send_command.reset_mock() diff --git a/tests/components/zwave_me/test_config_flow.py b/tests/components/zwave_me/test_config_flow.py index a71df8751b6..f784d7db2db 100644 --- a/tests/components/zwave_me/test_config_flow.py +++ b/tests/components/zwave_me/test_config_flow.py @@ -4,14 +4,14 @@ from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.zwave_me.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.14"), ip_addresses=[ip_address("192.168.1.14")], hostname="mock_hostname", diff --git a/tests/conftest.py b/tests/conftest.py index b858073a5e4..de627925941 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any, cast from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch from aiohttp import client +from aiohttp.resolver import AsyncResolver from aiohttp.test_utils import ( BaseTestServer, TestClient, @@ -32,6 +33,7 @@ import bcrypt import freezegun import multidict import pytest +import pytest_asyncio import pytest_socket import requests_mock import respx @@ -42,7 +44,7 @@ from homeassistant import block_async_io from homeassistant.exceptions import ServiceNotFound # Setup patching of recorder functions before any other Home Assistant imports -from . import patch_recorder # noqa: F401, isort:skip +from . import patch_recorder # Setup patching of dt_util time functions before any other Home Assistant imports from . import patch_time # noqa: F401, isort:skip @@ -88,7 +90,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.translation import _TranslationsCacheData from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util, location +from homeassistant.util import dt as dt_util, location as location_util from homeassistant.util.async_ import create_eager_task, get_scheduled_timer_handles from homeassistant.util.json import json_loads @@ -100,6 +102,7 @@ from .typing import ( MqttMockHAClient, MqttMockHAClientGenerator, MqttMockPahoClient, + RecorderInstanceContextManager, RecorderInstanceGenerator, WebSocketGenerator, ) @@ -248,7 +251,9 @@ def check_real[**_P, _R](func: Callable[_P, Coroutine[Any, Any, _R]]): # Guard a few functions that would make network connections -location.async_detect_location_info = check_real(location.async_detect_location_info) +location_util.async_detect_location_info = check_real( + location_util.async_detect_location_info +) @pytest.fixture(name="caplog") @@ -411,7 +416,9 @@ def verify_cleanup( try: # Verify respx.mock has been cleaned up - assert not respx.mock.routes, "respx.mock routes not cleaned up, maybe the test needs to be decorated with @respx.mock" + assert not respx.mock.routes, ( + "respx.mock routes not cleaned up, maybe the test needs to be decorated with @respx.mock" + ) finally: # Clear mock routes not break subsequent tests respx.mock.clear() @@ -579,7 +586,7 @@ async def hass( exceptions.append( Exception( "Received exception handler without exception, " - f"but with message: {context["message"]}" + f"but with message: {context['message']}" ) ) orig_exception_handler(loop, context) @@ -923,7 +930,13 @@ def fail_on_log_exception( @pytest.fixture def mqtt_config_entry_data() -> dict[str, Any] | None: - """Fixture to allow overriding MQTT config.""" + """Fixture to allow overriding MQTT entry data.""" + return None + + +@pytest.fixture +def mqtt_config_entry_options() -> dict[str, Any] | None: + """Fixture to allow overriding MQTT entry options.""" return None @@ -1000,6 +1013,7 @@ async def mqtt_mock( mock_hass_config: None, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, mqtt_mock_entry: MqttMockHAClientGenerator, ) -> AsyncGenerator[MqttMockHAClient]: """Fixture to mock MQTT component.""" @@ -1011,6 +1025,7 @@ async def _mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Fixture to mock a delayed setup of the MQTT config entry.""" # Local import to avoid processing MQTT modules when running a testcase @@ -1018,17 +1033,19 @@ async def _mqtt_mock_entry( from homeassistant.components import mqtt # pylint: disable=import-outside-toplevel if mqtt_config_entry_data is None: - mqtt_config_entry_data = { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: {}, - } + mqtt_config_entry_data = {mqtt.CONF_BROKER: "mock-broker"} + if mqtt_config_entry_options is None: + mqtt_config_entry_options = {mqtt.CONF_BIRTH_MESSAGE: {}} await hass.async_block_till_done() entry = MockConfigEntry( data=mqtt_config_entry_data, + options=mqtt_config_entry_options, domain=mqtt.DOMAIN, title="MQTT", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -1044,7 +1061,6 @@ async def _mqtt_mock_entry( # Assert that MQTT is setup assert real_mqtt_instance is not None, "MQTT was not setup correctly" - mock_mqtt_instance.conf = real_mqtt_instance.conf # For diagnostics mock_mqtt_instance._mqttc = mqtt_client_mock # connected set to True to get a more realistic behavior when subscribing @@ -1139,6 +1155,7 @@ async def mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Set up an MQTT config entry.""" @@ -1155,7 +1172,7 @@ async def mqtt_mock_entry( return await mqtt_mock_entry(_async_setup_config_entry) async with _mqtt_mock_entry( - hass, mqtt_client_mock, mqtt_config_entry_data + hass, mqtt_client_mock, mqtt_config_entry_data, mqtt_config_entry_options ) as mqtt_mock_entry: yield _setup_mqtt_entry @@ -1192,7 +1209,12 @@ def mock_get_source_ip() -> Generator[_patch]: @pytest.fixture(autouse=True, scope="session") def translations_once() -> Generator[_patch]: - """Only load translations once per session.""" + """Only load translations once per session. + + Warning: having this as a session fixture can cause issues with tests that + create mock integrations, overriding the real integration translations + with empty ones. Translations should be reset after such tests (see #131628) + """ cache = _TranslationsCacheData({}, {}) patcher = patch( "homeassistant.helpers.translation._TranslationsCacheData", @@ -1215,6 +1237,30 @@ def disable_translations_once( translations_once.start() +@pytest_asyncio.fixture(autouse=True, scope="session", loop_scope="session") +async def mock_zeroconf_resolver() -> AsyncGenerator[_patch]: + """Mock out the zeroconf resolver.""" + patcher = patch( + "homeassistant.helpers.aiohttp_client._async_make_resolver", + return_value=AsyncResolver(), + ) + patcher.start() + try: + yield patcher + finally: + patcher.stop() + + +@pytest.fixture +def disable_mock_zeroconf_resolver( + mock_zeroconf_resolver: _patch, +) -> Generator[None]: + """Disable the zeroconf resolver.""" + mock_zeroconf_resolver.stop() + yield + mock_zeroconf_resolver.start() + + @pytest.fixture def mock_zeroconf() -> Generator[MagicMock]: """Mock zeroconf.""" @@ -1491,7 +1537,7 @@ async def async_test_recorder( enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, enable_migrate_event_ids: bool, -) -> AsyncGenerator[RecorderInstanceGenerator]: +) -> AsyncGenerator[RecorderInstanceContextManager]: """Yield context manager to setup recorder instance.""" # pylint: disable-next=import-outside-toplevel from homeassistant.components import recorder @@ -1657,7 +1703,7 @@ async def async_test_recorder( @pytest.fixture async def async_setup_recorder_instance( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> AsyncGenerator[RecorderInstanceGenerator]: """Yield callable to setup recorder instance.""" @@ -1670,7 +1716,7 @@ async def async_setup_recorder_instance( expected_setup_result: bool = True, wait_recorder: bool = True, wait_recorder_setup: bool = True, - ) -> AsyncGenerator[recorder.Recorder]: + ) -> recorder.Recorder: """Set up and return recorder instance.""" return await stack.enter_async_context( @@ -1689,7 +1735,7 @@ async def async_setup_recorder_instance( @pytest.fixture async def recorder_mock( recorder_config: dict[str, Any] | None, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, hass: HomeAssistant, ) -> AsyncGenerator[recorder.Recorder]: """Fixture with in-memory recorder.""" @@ -1894,7 +1940,7 @@ def service_calls(hass: HomeAssistant) -> Generator[list[ServiceCall]]: return_response: bool = False, ) -> ServiceResponse: calls.append( - ServiceCall(domain, service, service_data, context, return_response) + ServiceCall(hass, domain, service, service_data, context, return_response) ) try: return await _original_async_call( diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index e70bee104c9..b9259596c65 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -12,13 +12,12 @@ from script.hassfest.requirements import validate_requirements_format def integration(): """Fixture for hassfest integration model.""" return Integration( - path=Path("homeassistant/components/test"), + path=Path("homeassistant/components/test").absolute(), _config=Config( root=Path(".").absolute(), specific_integrations=None, action="validate", requirements=True, - core_integrations_path=Path("homeassistant/components"), ), _manifest={ "domain": "test", diff --git a/tests/hassfest/test_version.py b/tests/hassfest/test_version.py index 30677356101..20c3d93bda5 100644 --- a/tests/hassfest/test_version.py +++ b/tests/hassfest/test_version.py @@ -16,13 +16,12 @@ from script.hassfest.model import Config, Integration def integration(): """Fixture for hassfest integration model.""" integration = Integration( - "", + Path(), _config=Config( root=Path(".").absolute(), specific_integrations=None, action="validate", requirements=True, - core_integrations_path=Path("homeassistant/components"), ), ) integration._manifest = { diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 1788da74c3b..13cb25bc516 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -21,7 +21,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.aiohttp_client as client +from homeassistant.helpers import aiohttp_client as client from homeassistant.util.color import RGBColor from homeassistant.util.ssl import SSLCipherList @@ -390,3 +390,16 @@ async def test_client_session_immutable_headers(hass: HomeAssistant) -> None: with pytest.raises(AttributeError): session.headers.update({"user-agent": "bla"}) + + +@pytest.mark.usefixtures("disable_mock_zeroconf_resolver") +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_async_mdnsresolver( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test async_mdnsresolver.""" + resp = aioclient_mock.post("http://localhost/xyz", json={"x": 1}) + session = client.async_create_clientsession(hass) + resp = await session.post("http://localhost/xyz", json={"x": 1}) + assert resp.status == 200 + assert await resp.json() == {"x": 1} diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 74f55c86a6c..c69f039027e 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -7,6 +7,13 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -18,6 +25,27 @@ from homeassistant.util.dt import utcnow from tests.common import ANY, async_capture_events, flush_store +@pytest.fixture +async def mock_temperature_humidity_entity(hass: HomeAssistant) -> None: + """Mock temperature and humidity sensors.""" + hass.states.async_set( + "sensor.mock_temperature", + "20", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set( + "sensor.mock_humidity", + "50", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + + async def test_list_areas(area_registry: ar.AreaRegistry) -> None: """Make sure that we can read areas.""" area_registry.async_create("mock") @@ -31,6 +59,7 @@ async def test_create_area( hass: HomeAssistant, freezer: FrozenDateTimeFactory, area_registry: ar.AreaRegistry, + mock_temperature_humidity_entity: None, ) -> None: """Make sure that we can create an area.""" update_events = async_capture_events(hass, ar.EVENT_AREA_REGISTRY_UPDATED) @@ -48,6 +77,8 @@ async def test_create_area( picture=None, created_at=utcnow(), modified_at=utcnow(), + temperature_entity_id=None, + humidity_entity_id=None, ) assert len(area_registry.areas) == 1 @@ -67,6 +98,8 @@ async def test_create_area( aliases={"alias_1", "alias_2"}, labels={"label1", "label2"}, picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert area2 == ar.AreaEntry( @@ -79,6 +112,8 @@ async def test_create_area( picture="/image/example.png", created_at=utcnow(), modified_at=utcnow(), + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert len(area_registry.areas) == 2 assert area.created_at != area2.created_at @@ -164,6 +199,7 @@ async def test_update_area( floor_registry: fr.FloorRegistry, label_registry: lr.LabelRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Make sure that we can read areas.""" created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00") @@ -184,6 +220,8 @@ async def test_update_area( labels={"label1", "label2"}, name="mock1", picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert updated_area != area @@ -197,6 +235,8 @@ async def test_update_area( picture="/image/example.png", created_at=created_at, modified_at=modified_at, + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert len(area_registry.areas) == 1 @@ -274,6 +314,55 @@ async def test_update_area_with_normalized_name_already_in_use( assert len(area_registry.areas) == 2 +@pytest.mark.parametrize( + ("create_kwargs", "error_message"), + [ + ( + {"temperature_entity_id": "sensor.invalid"}, + "Entity sensor.invalid does not exist", + ), + ( + {"temperature_entity_id": "light.kitchen"}, + "Entity light.kitchen is not a temperature sensor", + ), + ( + {"temperature_entity_id": "sensor.random"}, + "Entity sensor.random is not a temperature sensor", + ), + ( + {"humidity_entity_id": "sensor.invalid"}, + "Entity sensor.invalid does not exist", + ), + ( + {"humidity_entity_id": "light.kitchen"}, + "Entity light.kitchen is not a humidity sensor", + ), + ( + {"humidity_entity_id": "sensor.random"}, + "Entity sensor.random is not a humidity sensor", + ), + ], +) +async def test_update_area_entity_validation( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + mock_temperature_humidity_entity: None, + create_kwargs: dict[str, Any], + error_message: str, +) -> None: + """Make sure that we can't update an area with an invalid entity.""" + area = area_registry.async_create("mock") + hass.states.async_set("light.kitchen", "on", {}) + hass.states.async_set("sensor.random", "3", {}) + + with pytest.raises(ValueError) as e_info: + area_registry.async_update(area.id, **create_kwargs) + assert str(e_info.value) == error_message + + assert area.temperature_entity_id is None + assert area.humidity_entity_id is None + + async def test_load_area(hass: HomeAssistant, area_registry: ar.AreaRegistry) -> None: """Make sure that we can load/save data correctly.""" area1 = area_registry.async_create("mock1") @@ -298,6 +387,8 @@ async def test_loading_area_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored areas on start.""" + created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00") + modified_at = datetime.fromisoformat("2024-02-01T01:00:00+00:00") hass_storage[ar.STORAGE_KEY] = { "version": ar.STORAGE_VERSION_MAJOR, "minor_version": ar.STORAGE_VERSION_MINOR, @@ -311,8 +402,10 @@ async def test_loading_area_from_storage( "labels": ["mock-label1", "mock-label2"], "name": "mock", "picture": "blah", - "created_at": utcnow().isoformat(), - "modified_at": utcnow().isoformat(), + "created_at": created_at.isoformat(), + "modified_at": modified_at.isoformat(), + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", } ] }, @@ -322,6 +415,20 @@ async def test_loading_area_from_storage( registry = ar.async_get(hass) assert len(registry.areas) == 1 + area = registry.areas["12345A"] + assert area == ar.AreaEntry( + aliases={"alias_1", "alias_2"}, + floor_id="first_floor", + icon="mdi:garage", + id="12345A", + labels={"mock-label1", "mock-label2"}, + name="mock", + picture="blah", + created_at=created_at, + modified_at=modified_at, + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", + ) @pytest.mark.parametrize("load_registries", [False]) @@ -359,6 +466,8 @@ async def test_migration_from_1_1( "picture": None, "created_at": "1970-01-01T00:00:00+00:00", "modified_at": "1970-01-01T00:00:00+00:00", + "temperature_entity_id": None, + "humidity_entity_id": None, } ] }, diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index de7edf42dc2..fc2df8552e7 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -9,12 +9,12 @@ import voluptuous as vol from homeassistant.config import YAML_CONFIG_FILE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.check_config import ( CheckConfigError, HomeAssistantConfig, async_check_ha_config_file, ) -import homeassistant.helpers.config_validation as cv from homeassistant.requirements import RequirementsNotFound from tests.common import ( diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 1ec78b20535..b8c8c8a18c8 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -30,7 +30,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.typing import WebSocketGenerator diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 13e28bb8840..172aa393538 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,6 +1,8 @@ """Tests for the Config Entry Flow helper.""" -from collections.abc import Generator +import asyncio +from collections.abc import Callable, Generator +from contextlib import contextmanager from unittest.mock import Mock, PropertyMock, patch import pytest @@ -13,22 +15,44 @@ from homeassistant.helpers import config_entry_flow from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform +@contextmanager +def _make_discovery_flow_conf( + has_discovered_devices: Callable[[], asyncio.Future[bool] | bool], +) -> Generator[None]: + with patch.dict(config_entries.HANDLERS): + config_entry_flow.register_discovery_flow( + "test", "Test", has_discovered_devices + ) + yield + + @pytest.fixture -def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: - """Register a handler.""" +def async_discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with an async discovery function.""" handler_conf = {"discovered": False} async def has_discovered_devices(hass: HomeAssistant) -> bool: """Mock if we have discovered devices.""" return handler_conf["discovered"] - with patch.dict(config_entries.HANDLERS): - config_entry_flow.register_discovery_flow( - "test", "Test", has_discovered_devices - ) + with _make_discovery_flow_conf(has_discovered_devices): yield handler_conf +@pytest.fixture +def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with a async friendly callback function.""" + handler_conf = {"discovered": False} + + def has_discovered_devices(hass: HomeAssistant) -> bool: + """Mock if we have discovered devices.""" + return handler_conf["discovered"] + + with _make_discovery_flow_conf(has_discovered_devices): + yield handler_conf + handler_conf = {"discovered": False} + + @pytest.fixture def webhook_flow_conf(hass: HomeAssistant) -> Generator[None]: """Register a handler.""" @@ -95,6 +119,33 @@ async def test_user_has_confirmation( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY +async def test_user_has_confirmation_async_discovery_flow( + hass: HomeAssistant, async_discovery_flow_conf: dict[str, bool] +) -> None: + """Test user requires confirmation to setup with an async has_discovered_devices.""" + async_discovery_flow_conf["discovered"] = True + mock_platform(hass, "test.config_flow", None) + + result = await hass.config_entries.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER}, data={} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "confirm" + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0]["flow_id"] == result["flow_id"] + assert progress[0]["context"] == { + "confirm_only": True, + "source": config_entries.SOURCE_USER, + "unique_id": "test", + } + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + @pytest.mark.parametrize( "source", [ diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 4cf7e851af3..a74055c59ec 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -295,7 +295,7 @@ def _get_value( return obj.value if isinstance(obj, DeprecatedConstantEnum): - return obj.enum.value + return obj.enum if isinstance(obj, DeprecatedAlias): return obj.value diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 837400d502d..08b984a0477 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -23,13 +23,7 @@ from homeassistant.helpers import ( ) from homeassistant.util.dt import utcnow -from tests.common import ( - MockConfigEntry, - async_capture_events, - flush_store, - help_test_all, - import_and_test_deprecated_constant_enum, -) +from tests.common import MockConfigEntry, async_capture_events, flush_store @pytest.fixture @@ -1482,7 +1476,9 @@ async def test_removing_area_id( async def test_specifying_via_device_create( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test specifying a via_device and removal of the hub device.""" config_entry_1 = MockConfigEntry() @@ -1513,9 +1509,32 @@ async def test_specifying_via_device_create( light = device_registry.async_get_device(identifiers={("hue", "456")}) assert light.via_device_id is None + # A device with a non existing via_device reference should create + light_via_nonexisting_parent_device = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + connections=set(), + identifiers={("hue", "789")}, + manufacturer="manufacturer", + model="light", + via_device=("hue", "non_existing_123"), + ) + assert { + "calls `device_registry.async_get_or_create` " + "referencing a non existing `via_device` " + '("hue","non_existing_123")' in caplog.text + } + assert light_via_nonexisting_parent_device is not None + assert light_via_nonexisting_parent_device.via_device_id is None + nonexisting_parent_device = device_registry.async_get_device( + identifiers={("hue", "non_existing_123")} + ) + assert nonexisting_parent_device is None + async def test_specifying_via_device_update( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test specifying a via_device and updating.""" config_entry_1 = MockConfigEntry() @@ -1529,6 +1548,7 @@ async def test_specifying_via_device_update( identifiers={("hue", "456")}, manufacturer="manufacturer", model="light", + name="Light", via_device=("hue", "0123"), ) @@ -1552,6 +1572,26 @@ async def test_specifying_via_device_update( ) assert light.via_device_id == via.id + assert light.name == "Light" + + # Try updating with a non existing via device + light = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + connections=set(), + identifiers={("hue", "456")}, + manufacturer="manufacturer", + model="light", + name="New light", + via_device=("hue", "non_existing_abc"), + ) + assert { + "calls `device_registry.async_get_or_create` " + "referencing a non existing `via_device` " + '("hue","non_existing_123")' in caplog.text + } + # Assert the name was updated correctly + assert light.via_device_id == via.id + assert light.name == "New light" async def test_loading_saving_data( @@ -2236,7 +2276,9 @@ async def test_cleanup_startup(hass: HomeAssistant) -> None: @pytest.mark.parametrize("load_registries", [False]) -async def test_cleanup_entity_registry_change(hass: HomeAssistant) -> None: +async def test_cleanup_entity_registry_change( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test we run a cleanup when entity registry changes. Don't pre-load the registries as the debouncer will then not be waiting for @@ -2244,8 +2286,14 @@ async def test_cleanup_entity_registry_change(hass: HomeAssistant) -> None: """ await dr.async_load(hass) await er.async_load(hass) + dev_reg = dr.async_get(hass) ent_reg = er.async_get(hass) + entry = dev_reg.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + with patch( "homeassistant.helpers.device_registry.Debouncer.async_schedule_call" ) as mock_call: @@ -2259,7 +2307,7 @@ async def test_cleanup_entity_registry_change(hass: HomeAssistant) -> None: assert len(mock_call.mock_calls) == 0 # Device ID update triggers - ent_reg.async_get_or_create("light", "hue", "e1", device_id="bla") + ent_reg.async_get_or_create("light", "hue", "e1", device_id=entry.id) await hass.async_block_till_done() assert len(mock_call.mock_calls) == 1 @@ -2858,20 +2906,6 @@ async def test_loading_invalid_configuration_url_from_storage( assert entry.configuration_url == "invalid" -def test_all() -> None: - """Test module.__all__ is correctly set.""" - help_test_all(dr) - - -@pytest.mark.parametrize(("enum"), list(dr.DeviceEntryDisabler)) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: dr.DeviceEntryDisabler, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum(caplog, dr, enum, "DISABLED_", "2025.1") - - async def test_removing_labels( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 2bf441f70fd..5e8c9fc88f7 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -11,7 +11,7 @@ from typing import Any from unittest.mock import MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory -from propcache import cached_property +from propcache.api import cached_property import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 9723b91eb9a..20c243d0701 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_component import EntityComponent, async_update from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, @@ -189,13 +189,14 @@ async def test_extract_from_service_available_device(hass: HomeAssistant) -> Non ] ) - call_1 = ServiceCall("test", "service", data={"entity_id": ENTITY_MATCH_ALL}) + call_1 = ServiceCall(hass, "test", "service", data={"entity_id": ENTITY_MATCH_ALL}) assert sorted( ent.entity_id for ent in (await component.async_extract_from_service(call_1)) ) == ["test_domain.test_1", "test_domain.test_3"] call_2 = ServiceCall( + hass, "test", "service", data={"entity_id": ["test_domain.test_3", "test_domain.test_4"]}, @@ -256,17 +257,18 @@ async def test_extract_from_service_fails_if_no_entity_id(hass: HomeAssistant) - ) assert ( - await component.async_extract_from_service(ServiceCall("test", "service")) == [] + await component.async_extract_from_service(ServiceCall(hass, "test", "service")) + == [] ) assert ( await component.async_extract_from_service( - ServiceCall("test", "service", {"entity_id": ENTITY_MATCH_NONE}) + ServiceCall(hass, "test", "service", {"entity_id": ENTITY_MATCH_NONE}) ) == [] ) assert ( await component.async_extract_from_service( - ServiceCall("test", "service", {"area_id": ENTITY_MATCH_NONE}) + ServiceCall(hass, "test", "service", {"area_id": ENTITY_MATCH_NONE}) ) == [] ) @@ -283,6 +285,7 @@ async def test_extract_from_service_filter_out_non_existing_entities( ) call = ServiceCall( + hass, "test", "service", {"entity_id": ["test_domain.test_2", "test_domain.non_exist"]}, @@ -299,7 +302,7 @@ async def test_extract_from_service_no_group_expand(hass: HomeAssistant) -> None await component.async_setup({}) await component.async_add_entities([MockEntity(entity_id="group.test_group")]) - call = ServiceCall("test", "service", {"entity_id": ["group.test_group"]}) + call = ServiceCall(hass, "test", "service", {"entity_id": ["group.test_group"]}) extracted = await component.async_extract_from_service(call, expand_group=False) assert len(extracted) == 1 @@ -465,7 +468,7 @@ async def test_extract_all_omit_entity_id( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) - call = ServiceCall("test", "service") + call = ServiceCall(hass, "test", "service") assert ( sorted( @@ -485,7 +488,7 @@ async def test_extract_all_use_match_all( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) - call = ServiceCall("test", "service", {"entity_id": "all"}) + call = ServiceCall(hass, "test", "service", {"entity_id": "all"}) assert sorted( ent.entity_id for ent in await component.async_extract_from_service(call) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index e80006dff84..eb076eb9f25 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -38,7 +38,7 @@ from homeassistant.helpers.entity_component import ( ) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, @@ -869,6 +869,7 @@ async def test_setup_entry( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) entity_platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -1886,6 +1887,7 @@ async def test_setup_entry_with_entities_that_block_forever( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -1934,6 +1936,7 @@ async def test_cancellation_is_not_blocked( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 97f7e1dcc56..19289b09f95 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -72,11 +72,18 @@ def test_get_or_create_suggested_object_id(entity_registry: er.EntityRegistry) - def test_get_or_create_updates_data( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test that we update data in get_or_create.""" orig_config_entry = MockConfigEntry(domain="light") + orig_config_entry.add_to_hass(hass) + orig_device_entry = device_registry.async_get_or_create( + config_entry_id=orig_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) created = datetime.fromisoformat("2024-02-14T12:00:00.0+00:00") freezer.move_to(created) @@ -86,7 +93,7 @@ def test_get_or_create_updates_data( "5678", capabilities={"max": 100}, config_entry=orig_config_entry, - device_id="mock-dev-id", + device_id=orig_device_entry.id, disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, has_entity_name=True, @@ -99,7 +106,7 @@ def test_get_or_create_updates_data( unit_of_measurement="initial-unit_of_measurement", ) - assert set(entity_registry.async_device_ids()) == {"mock-dev-id"} + assert set(entity_registry.async_device_ids()) == {orig_device_entry.id} assert orig_entry == er.RegistryEntry( "light.hue_5678", @@ -109,7 +116,7 @@ def test_get_or_create_updates_data( config_entry_id=orig_config_entry.entry_id, created_at=created, device_class=None, - device_id="mock-dev-id", + device_id=orig_device_entry.id, disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, has_entity_name=True, @@ -127,6 +134,11 @@ def test_get_or_create_updates_data( ) new_config_entry = MockConfigEntry(domain="light") + new_config_entry.add_to_hass(hass) + new_device_entry = device_registry.async_get_or_create( + config_entry_id=new_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, + ) modified = created + timedelta(minutes=5) freezer.move_to(modified) @@ -136,7 +148,7 @@ def test_get_or_create_updates_data( "5678", capabilities={"new-max": 150}, config_entry=new_config_entry, - device_id="new-mock-dev-id", + device_id=new_device_entry.id, disabled_by=er.RegistryEntryDisabler.USER, entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=False, @@ -159,7 +171,7 @@ def test_get_or_create_updates_data( config_entry_id=new_config_entry.entry_id, created_at=created, device_class=None, - device_id="new-mock-dev-id", + device_id=new_device_entry.id, disabled_by=er.RegistryEntryDisabler.HASS, # Should not be updated entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=False, @@ -176,7 +188,7 @@ def test_get_or_create_updates_data( unit_of_measurement="updated-unit_of_measurement", ) - assert set(entity_registry.async_device_ids()) == {"new-mock-dev-id"} + assert set(entity_registry.async_device_ids()) == {new_device_entry.id} modified = created + timedelta(minutes=5) freezer.move_to(modified) @@ -262,10 +274,18 @@ def test_create_triggers_save(entity_registry: er.EntityRegistry) -> None: async def test_loading_saving_data( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test that we load/save data correctly.""" mock_config = MockConfigEntry(domain="light") + mock_config.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) orig_entry1 = entity_registry.async_get_or_create("light", "hue", "1234") orig_entry2 = entity_registry.async_get_or_create( @@ -274,7 +294,7 @@ async def test_loading_saving_data( "5678", capabilities={"max": 100}, config_entry=mock_config, - device_id="mock-dev-id", + device_id=device_entry.id, disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, hidden_by=er.RegistryEntryHider.INTEGRATION, @@ -338,7 +358,7 @@ async def test_loading_saving_data( assert new_entry2.capabilities == {"max": 100} assert new_entry2.config_entry_id == mock_config.entry_id assert new_entry2.device_class == "user-class" - assert new_entry2.device_id == "mock-dev-id" + assert new_entry2.device_id == device_entry.id assert new_entry2.disabled_by is er.RegistryEntryDisabler.HASS assert new_entry2.entity_category == "config" assert new_entry2.icon == "hass:user-icon" @@ -596,11 +616,13 @@ async def test_updating_config_entry_id( """Test that we update config entry id in registry.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) mock_config_1 = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config_1.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config_1 ) mock_config_2 = MockConfigEntry(domain="light", entry_id="mock-id-2") + mock_config_2.add_to_hass(hass) entry2 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config_2 ) @@ -627,6 +649,7 @@ async def test_removing_config_entry_id( """Test that we update config entry id in registry.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config @@ -650,11 +673,14 @@ async def test_removing_config_entry_id( async def test_deleted_entity_removing_config_entry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test that we update config entry id in registry on deleted entity.""" mock_config1 = MockConfigEntry(domain="light", entry_id="mock-id-1") mock_config2 = MockConfigEntry(domain="light", entry_id="mock-id-2") + mock_config1.add_to_hass(hass) + mock_config2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config1 @@ -959,9 +985,12 @@ async def test_migration_1_11( } -async def test_update_entity_unique_id(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity's unique_id is updated.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config @@ -987,10 +1016,12 @@ async def test_update_entity_unique_id(entity_registry: er.EntityRegistry) -> No async def test_update_entity_unique_id_conflict( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test migration raises when unique_id already in use.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1079,9 +1110,12 @@ async def test_update_entity_entity_id_entity_id( assert entity_registry.async_get(state_entity_id) is None -async def test_update_entity(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test updating entity.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1106,9 +1140,12 @@ async def test_update_entity(entity_registry: er.EntityRegistry) -> None: entry = updated_entry -async def test_update_entity_options(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity_options( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test updating entity.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1161,6 +1198,7 @@ async def test_disabled_by(entity_registry: er.EntityRegistry) -> None: async def test_disabled_by_config_entry_pref( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test config entry preference setting disabled_by.""" @@ -1169,6 +1207,7 @@ async def test_disabled_by_config_entry_pref( entry_id="mock-id-1", pref_disable_new_entities=True, ) + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "AAAA", config_entry=mock_config ) @@ -1741,6 +1780,35 @@ def test_entity_registry_items() -> None: assert entities.get_entry(entry2.id) is None +async def test_config_entry_does_not_exist(entity_registry: er.EntityRegistry) -> None: + """Test adding an entity linked to an unknown config entry.""" + mock_config = MockConfigEntry( + domain="light", + entry_id="mock-id-1", + pref_disable_new_entities=True, + ) + with pytest.raises(ValueError): + entity_registry.async_get_or_create( + "light", "hue", "1234", config_entry=mock_config + ) + + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id + with pytest.raises(ValueError): + entity_registry.async_update_entity( + entity_id, config_entry_id=mock_config.entry_id + ) + + +async def test_device_does_not_exist(entity_registry: er.EntityRegistry) -> None: + """Test adding an entity linked to an unknown device.""" + with pytest.raises(ValueError): + entity_registry.async_get_or_create("light", "hue", "1234", device_id="blah") + + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id + with pytest.raises(ValueError): + entity_registry.async_update_entity(entity_id, device_id="blah") + + async def test_disabled_by_str_not_allowed(entity_registry: er.EntityRegistry) -> None: """Test we need to pass disabled by type.""" with pytest.raises(ValueError): @@ -1818,6 +1886,7 @@ def test_migrate_entity_to_new_platform( ) -> None: """Test migrate_entity_to_new_platform.""" orig_config_entry = MockConfigEntry(domain="light") + orig_config_entry.add_to_hass(hass) orig_unique_id = "5678" orig_entry = entity_registry.async_get_or_create( @@ -1840,6 +1909,7 @@ def test_migrate_entity_to_new_platform( ) new_config_entry = MockConfigEntry(domain="light") + new_config_entry.add_to_hass(hass) new_unique_id = "1234" assert entity_registry.async_update_entity_platform( @@ -1894,6 +1964,7 @@ async def test_restore_entity( """Make sure entity registry id is stable and entity_id is reused if possible.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) @@ -1988,6 +2059,8 @@ async def test_async_migrate_entry_delete_self( """Test async_migrate_entry.""" config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") + config_entry1.add_to_hass(hass) + config_entry2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) @@ -2023,6 +2096,8 @@ async def test_async_migrate_entry_delete_other( """Test async_migrate_entry.""" config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") + config_entry1.add_to_hass(hass) + config_entry2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a0014587cd0..a8691771580 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -13,8 +13,8 @@ from freezegun.api import FrozenDateTimeFactory import jinja2 import pytest +from homeassistant import core as ha from homeassistant.const import MATCH_ALL -import homeassistant.core as ha from homeassistant.core import ( Event, EventStateChangedData, @@ -52,7 +52,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.template import Template, result_as_boolean from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_fire_time_changed_exact diff --git a/tests/helpers/test_httpx_client.py b/tests/helpers/test_httpx_client.py index 684778fe1b1..4b9f2fa2bf6 100644 --- a/tests/helpers/test_httpx_client.py +++ b/tests/helpers/test_httpx_client.py @@ -7,7 +7,7 @@ import pytest from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.core import HomeAssistant -import homeassistant.helpers.httpx_client as client +from homeassistant.helpers import httpx_client as client from tests.common import MockModule, extract_stack_to_frame, mock_integration diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index cd36fe18933..57e151ba8eb 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1,15 +1,17 @@ """Tests for the llm helpers.""" +from datetime import timedelta from decimal import Decimal from unittest.mock import patch import pytest import voluptuous as vol +from homeassistant.components import calendar from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler from homeassistant.components.script.config import ScriptConfig -from homeassistant.core import Context, HomeAssistant, State +from homeassistant.core import Context, HomeAssistant, State, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, @@ -22,8 +24,9 @@ from homeassistant.helpers import ( selector, ) from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_mock_service @pytest.fixture @@ -39,6 +42,14 @@ def llm_context() -> llm.LLMContext: ) +class MyAPI(llm.API): + """Test API.""" + + async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: + """Return a list of tools.""" + return llm.APIInstance(self, "", [], llm_context) + + async def test_get_api_no_existing( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: @@ -50,11 +61,6 @@ async def test_get_api_no_existing( async def test_register_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: """Test registering an llm api.""" - class MyAPI(llm.API): - async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: - """Return a list of tools.""" - return llm.APIInstance(self, "", [], llm_context) - api = MyAPI(hass=hass, id="test", name="Test") llm.async_register_api(hass, api) @@ -66,6 +72,59 @@ async def test_register_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> llm.async_register_api(hass, api) +async def test_unregister_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test unregistering an llm api.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + with pytest.raises(HomeAssistantError): + assert await llm.async_get_api(hass, "test", llm_context) + + +async def test_reregister_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test unregistering an llm api then re-registering with the same id.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + + +async def test_unregister_twice( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test unregistering an llm api twice.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + + # Unregistering twice is a bug that should not happen + with pytest.raises(KeyError): + unreg() + + +async def test_multiple_apis(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test registering multiple APIs.""" + + unreg1 = llm.async_register_api(hass, MyAPI(hass=hass, id="test-1", name="Test 1")) + llm.async_register_api(hass, MyAPI(hass=hass, id="test-2", name="Test 2")) + + # Verify both Apis are registered + assert await llm.async_get_api(hass, "test-1", llm_context) + assert await llm.async_get_api(hass, "test-2", llm_context) + + # Unregister and verify only one is left + unreg1() + + with pytest.raises(HomeAssistantError): + assert await llm.async_get_api(hass, "test-1", llm_context) + + assert await llm.async_get_api(hass, "test-2", llm_context) + + async def test_call_tool_no_existing( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: @@ -306,6 +365,7 @@ async def test_assist_api_tools( "HassSetPosition", "HassStartTimer", "HassCancelTimer", + "HassCancelAllTimers", "HassIncreaseTimer", "HassDecreaseTimer", "HassPauseTimer", @@ -516,10 +576,6 @@ async def test_assist_api_prompt( ) ) exposed_entities_prompt = """An overview of the areas and the devices in this smart home: -- names: script_with_no_fields - domain: script - state: 'off' - description: This is another test script - names: Kitchen domain: light state: 'on' @@ -655,7 +711,10 @@ async def test_script_tool( "script": { "test_script": { "description": "This is a test script", - "sequence": [], + "sequence": [ + {"variables": {"result": {"drinks": 2}}}, + {"stop": True, "response_variable": "result"}, + ], "fields": { "beer": {"description": "Number of beers", "required": True}, "wine": {"selector": {"number": {"min": 0, "max": 3}}}, @@ -691,7 +750,7 @@ async def test_script_tool( api = await llm.async_get_api(hass, "assist", llm_context) tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] - assert len(tools) == 1 + assert len(tools) == 2 tool = tools[0] assert tool.name == "test_script" @@ -718,6 +777,7 @@ async def test_script_tool( "script_with_no_fields": ("This is another test script", vol.Schema({})), } + # Test script with response tool_input = llm.ToolInput( tool_name="test_script", tool_args={ @@ -730,26 +790,56 @@ async def test_script_tool( }, ) - with patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + with patch( + "homeassistant.core.ServiceRegistry.async_call", + side_effect=hass.services.async_call, + ) as mock_service_call: response = await api.async_call_tool(tool_input) mock_service_call.assert_awaited_once_with( "script", - "turn_on", + "test_script", { - "entity_id": "script.test_script", - "variables": { - "beer": "3", - "wine": 0, - "where": area.id, - "area_list": [area.id], - "floor": floor.floor_id, - "floor_list": [floor.floor_id], - }, + "beer": "3", + "wine": 0, + "where": area.id, + "area_list": [area.id], + "floor": floor.floor_id, + "floor_list": [floor.floor_id], }, context=context, + blocking=True, + return_response=True, ) - assert response == {"success": True} + assert response == { + "success": True, + "result": {"drinks": 2}, + } + + # Test script with no response + tool_input = llm.ToolInput( + tool_name="script_with_no_fields", + tool_args={}, + ) + + with patch( + "homeassistant.core.ServiceRegistry.async_call", + side_effect=hass.services.async_call, + ) as mock_service_call: + response = await api.async_call_tool(tool_input) + + mock_service_call.assert_awaited_once_with( + "script", + "script_with_no_fields", + {}, + context=context, + blocking=True, + return_response=True, + ) + assert response == { + "success": True, + "result": {}, + } # Test reload script with new parameters config = { @@ -781,7 +871,7 @@ async def test_script_tool( api = await llm.async_get_api(hass, "assist", llm_context) tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] - assert len(tools) == 1 + assert len(tools) == 2 tool = tools[0] assert tool.name == "test_script" @@ -1075,3 +1165,96 @@ async def test_selector_serializer( assert selector_serializer(selector.FileSelector({"accept": ".txt"})) == { "type": "string" } + + +async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: + """Test the calendar get events tool.""" + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("calendar.test_calendar", "on", {"friendly_name": "Test"}) + async_expose_entity(hass, "conversation", "calendar.test_calendar", True) + context = Context() + llm_context = llm.LLMContext( + platform="test_platform", + context=context, + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + api = await llm.async_get_api(hass, "assist", llm_context) + assert [tool for tool in api.tools if tool.name == "calendar_get_events"] + + calls = async_mock_service( + hass, + domain=calendar.DOMAIN, + service=calendar.SERVICE_GET_EVENTS, + schema=calendar.SERVICE_GET_EVENTS_SCHEMA, + response={ + "calendar.test_calendar": { + "events": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ] + } + }, + supports_response=SupportsResponse.ONLY, + ) + + tool_input = llm.ToolInput( + tool_name="calendar_get_events", + tool_args={"calendar": "calendar.test_calendar", "range": "today"}, + ) + now = dt_util.now() + with patch("homeassistant.util.dt.now", return_value=now): + response = await api.async_call_tool(tool_input) + + assert len(calls) == 1 + call = calls[0] + assert call.domain == calendar.DOMAIN + assert call.service == calendar.SERVICE_GET_EVENTS + assert call.data == { + "entity_id": ["calendar.test_calendar"], + "start_date_time": now, + "end_date_time": dt_util.start_of_local_day() + timedelta(days=1), + } + + assert response == { + "success": True, + "result": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + "all_day": True, + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ], + } + + tool_input.tool_args["range"] = "week" + with patch("homeassistant.util.dt.now", return_value=now): + response = await api.async_call_tool(tool_input) + + assert len(calls) == 2 + call = calls[1] + assert call.data == { + "entity_id": ["calendar.test_calendar"], + "start_date_time": now, + "end_date_time": dt_util.start_of_local_day() + timedelta(days=7), + } diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index f67519905a1..f3cbb982ad0 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -44,7 +44,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, @@ -4118,6 +4118,14 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4135,6 +4143,7 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: "label_if_then", "label_if_else", "label_parallel", + "label_sequence", } # Test we cache results. assert script_obj.referenced_labels is script_obj.referenced_labels @@ -4220,6 +4229,14 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4236,6 +4253,7 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: "floor_if_then", "floor_if_else", "floor_parallel", + "floor_sequence", } # Test we cache results. assert script_obj.referenced_floors is script_obj.referenced_floors @@ -4321,6 +4339,14 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4337,6 +4363,7 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: "area_if_then", "area_if_else", "area_parallel", + "area_sequence", # 'area_service_template', # no area extraction from template } # Test we cache results. @@ -4437,6 +4464,14 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.sequence"}, + } + ], + }, ] ), "Test Name", @@ -4456,6 +4491,7 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: "light.if_then", "light.if_else", "light.parallel", + "light.sequence", # "light.service_template", # no entity extraction from template "scene.hello", "sensor.condition", @@ -4554,6 +4590,14 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "sequence-device"}, + } + ], + }, ] ), "Test Name", @@ -4575,6 +4619,7 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "if-then", "if-else", "parallel-device", + "sequence-device", } # Test we cache results. assert script_obj.referenced_devices is script_obj.referenced_devices @@ -5632,6 +5677,91 @@ async def test_stop_action_subscript( ) +@pytest.mark.parametrize( + ("var", "response"), + [(1, "If: Then"), (2, "Testing 123")], +) +async def test_stop_action_response_variables( + hass: HomeAssistant, + var: int, + response: str, +) -> None: + """Test setting stop response_variable in a subscript.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"variables": {"output": {"value": "Testing 123"}}}, + { + "if": { + "condition": "template", + "value_template": "{{ var == 1 }}", + }, + "then": [ + {"variables": {"output": {"value": "If: Then"}}}, + {"stop": "In the name of love", "response_variable": "output"}, + ], + }, + {"stop": "In the name of love", "response_variable": "output"}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + run_vars = MappingProxyType({"var": var}) + result = await script_obj.async_run(run_vars, context=Context()) + assert result.service_response == {"value": response} + + +@pytest.mark.parametrize( + ("var", "if_result", "choice", "response"), + [(1, True, "then", "If: Then"), (2, False, "else", "If: Else")], +) +async def test_stop_action_nested_response_variables( + hass: HomeAssistant, + var: int, + if_result: bool, + choice: str, + response: str, +) -> None: + """Test setting stop response_variable in a subscript.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"variables": {"output": {"value": "Testing 123"}}}, + { + "if": { + "condition": "template", + "value_template": "{{ var == 1 }}", + }, + "then": [ + {"variables": {"output": {"value": "If: Then"}}}, + {"stop": "In the name of love", "response_variable": "output"}, + ], + "else": [ + {"variables": {"output": {"value": "If: Else"}}}, + {"stop": "In the name of love", "response_variable": "output"}, + ], + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + run_vars = MappingProxyType({"var": var}) + result = await script_obj.async_run(run_vars, context=Context()) + assert result.service_response == {"value": response} + + expected_trace = { + "0": [ + { + "variables": {"var": var, "output": {"value": "Testing 123"}}, + } + ], + "1": [{"result": {"choice": choice}}], + "1/if": [{"result": {"result": if_result}}], + "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], + f"1/{choice}/0": [{"variables": {"output": {"value": response}}}], + f"1/{choice}/1": [{"result": {"stop": "In the name of love", "error": False}}], + } + assert_action_trace(expected_trace) + + async def test_stop_action_with_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index f73808a0625..d07bb7458e9 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -7,7 +7,7 @@ import pytest import voluptuous as vol from homeassistant.helpers import selector -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util FAKE_UUID = "a266a680b608c32770e6c45bfe6b8411" @@ -77,7 +77,7 @@ def _test_selector( "selector": {selector_type: selector_instance.config} } # Test serialized selector can be dumped to YAML - yaml.dump(selector_instance.serialize()) + yaml_util.dump(selector_instance.serialize()) @pytest.mark.parametrize( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index d0e1aa34340..142f7a23f81 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -36,11 +36,11 @@ from homeassistant.core import ( ) from homeassistant.helpers import ( area_registry as ar, + config_validation as cv, device_registry as dr, entity_registry as er, service, ) -import homeassistant.helpers.config_validation as cv from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component from homeassistant.util.yaml.loader import parse_yaml @@ -122,6 +122,8 @@ def floor_area_mock(hass: HomeAssistant) -> None: floor_id="test-floor", icon=None, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) area_in_floor_a = ar.AreaEntry( id="area-a", @@ -130,6 +132,8 @@ def floor_area_mock(hass: HomeAssistant) -> None: floor_id="floor-a", icon=None, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) mock_area_registry( hass, @@ -284,6 +288,8 @@ def label_mock(hass: HomeAssistant) -> None: icon=None, labels={"label_area"}, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) area_without_labels = ar.AreaEntry( id="area-no-labels", @@ -293,6 +299,8 @@ def label_mock(hass: HomeAssistant) -> None: icon=None, labels=set(), picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) mock_area_registry( hass, @@ -642,11 +650,11 @@ async def test_extract_entity_ids(hass: HomeAssistant) -> None: order=None, ) - call = ServiceCall("light", "turn_on", {ATTR_ENTITY_ID: "light.Bowl"}) + call = ServiceCall(hass, "light", "turn_on", {ATTR_ENTITY_ID: "light.Bowl"}) assert {"light.bowl"} == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {ATTR_ENTITY_ID: "group.test"}) + call = ServiceCall(hass, "light", "turn_on", {ATTR_ENTITY_ID: "group.test"}) assert {"light.ceiling", "light.kitchen"} == await service.async_extract_entity_ids( hass, call @@ -659,7 +667,7 @@ async def test_extract_entity_ids(hass: HomeAssistant) -> None: assert ( await service.async_extract_entity_ids( hass, - ServiceCall("light", "turn_on", {ATTR_ENTITY_ID: ENTITY_MATCH_NONE}), + ServiceCall(hass, "light", "turn_on", {ATTR_ENTITY_ID: ENTITY_MATCH_NONE}), ) == set() ) @@ -669,20 +677,22 @@ async def test_extract_entity_ids_from_area( hass: HomeAssistant, floor_area_mock ) -> None: """Test extract_entity_ids method with areas.""" - call = ServiceCall("light", "turn_on", {"area_id": "own-area"}) + call = ServiceCall(hass, "light", "turn_on", {"area_id": "own-area"}) assert { "light.in_own_area", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"area_id": "test-area"}) + call = ServiceCall(hass, "light", "turn_on", {"area_id": "test-area"}) assert { "light.in_area", "light.assigned_to_area", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"area_id": ["test-area", "diff-area"]}) + call = ServiceCall( + hass, "light", "turn_on", {"area_id": ["test-area", "diff-area"]} + ) assert { "light.in_area", @@ -692,7 +702,7 @@ async def test_extract_entity_ids_from_area( assert ( await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"area_id": ENTITY_MATCH_NONE}) + hass, ServiceCall(hass, "light", "turn_on", {"area_id": ENTITY_MATCH_NONE}) ) == set() ) @@ -703,13 +713,13 @@ async def test_extract_entity_ids_from_devices( ) -> None: """Test extract_entity_ids method with devices.""" assert await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"device_id": "device-no-area-id"}) + hass, ServiceCall(hass, "light", "turn_on", {"device_id": "device-no-area-id"}) ) == { "light.no_area", } assert await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"device_id": "device-area-a-id"}) + hass, ServiceCall(hass, "light", "turn_on", {"device_id": "device-area-a-id"}) ) == { "light.in_area_a", "light.in_area_b", @@ -717,7 +727,8 @@ async def test_extract_entity_ids_from_devices( assert ( await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"device_id": "non-existing-id"}) + hass, + ServiceCall(hass, "light", "turn_on", {"device_id": "non-existing-id"}), ) == set() ) @@ -726,14 +737,16 @@ async def test_extract_entity_ids_from_devices( @pytest.mark.usefixtures("floor_area_mock") async def test_extract_entity_ids_from_floor(hass: HomeAssistant) -> None: """Test extract_entity_ids method with floors.""" - call = ServiceCall("light", "turn_on", {"floor_id": "test-floor"}) + call = ServiceCall(hass, "light", "turn_on", {"floor_id": "test-floor"}) assert { "light.in_area", "light.assigned_to_area", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"floor_id": ["test-floor", "floor-a"]}) + call = ServiceCall( + hass, "light", "turn_on", {"floor_id": ["test-floor", "floor-a"]} + ) assert { "light.in_area", @@ -743,7 +756,7 @@ async def test_extract_entity_ids_from_floor(hass: HomeAssistant) -> None: assert ( await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"floor_id": ENTITY_MATCH_NONE}) + hass, ServiceCall(hass, "light", "turn_on", {"floor_id": ENTITY_MATCH_NONE}) ) == set() ) @@ -752,13 +765,13 @@ async def test_extract_entity_ids_from_floor(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("label_mock") async def test_extract_entity_ids_from_labels(hass: HomeAssistant) -> None: """Test extract_entity_ids method with labels.""" - call = ServiceCall("light", "turn_on", {"label_id": "my-label"}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": "my-label"}) assert { "light.with_my_label", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"label_id": "label1"}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": "label1"}) assert { "light.with_label1_from_device", @@ -767,14 +780,14 @@ async def test_extract_entity_ids_from_labels(hass: HomeAssistant) -> None: "light.with_label1_and_label2_from_device", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"label_id": ["label2"]}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": ["label2"]}) assert { "light.with_labels_from_device", "light.with_label1_and_label2_from_device", } == await service.async_extract_entity_ids(hass, call) - call = ServiceCall("light", "turn_on", {"label_id": ["label_area"]}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": ["label_area"]}) assert { "light.with_labels_from_device", @@ -782,7 +795,7 @@ async def test_extract_entity_ids_from_labels(hass: HomeAssistant) -> None: assert ( await service.async_extract_entity_ids( - hass, ServiceCall("light", "turn_on", {"label_id": ENTITY_MATCH_NONE}) + hass, ServiceCall(hass, "light", "turn_on", {"label_id": ENTITY_MATCH_NONE}) ) == set() ) @@ -1274,12 +1287,14 @@ async def test_register_with_mixed_case(hass: HomeAssistant) -> None: async def test_call_with_required_features(hass: HomeAssistant, mock_entities) -> None: """Test service calls invoked only if entity has required features.""" + # Set up homeassistant component to fetch the translations + await async_setup_component(hass, "homeassistant", {}) test_service_mock = AsyncMock(return_value=None) await service.entity_service_call( hass, mock_entities, HassJob(test_service_mock), - ServiceCall("test_domain", "test_service", {"entity_id": "all"}), + ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A], ) @@ -1293,13 +1308,17 @@ async def test_call_with_required_features(hass: HomeAssistant, mock_entities) - # Test we raise if we target entity ID that does not support the service test_service_mock.reset_mock() - with pytest.raises(exceptions.HomeAssistantError): + with pytest.raises( + exceptions.ServiceNotSupported, + match="Entity light.living_room does not " + "support action test_domain.test_service", + ): await service.entity_service_call( hass, mock_entities, HassJob(test_service_mock), ServiceCall( - "test_domain", "test_service", {"entity_id": "light.living_room"} + hass, "test_domain", "test_service", {"entity_id": "light.living_room"} ), required_features=[SUPPORT_A], ) @@ -1315,7 +1334,7 @@ async def test_call_with_both_required_features( hass, mock_entities, HassJob(test_service_mock), - ServiceCall("test_domain", "test_service", {"entity_id": "all"}), + ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A | SUPPORT_B], ) @@ -1334,7 +1353,7 @@ async def test_call_with_one_of_required_features( hass, mock_entities, HassJob(test_service_mock), - ServiceCall("test_domain", "test_service", {"entity_id": "all"}), + ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A, SUPPORT_C], ) @@ -1355,7 +1374,9 @@ async def test_call_with_sync_func(hass: HomeAssistant, mock_entities) -> None: hass, mock_entities, HassJob(test_service_mock), - ServiceCall("test_domain", "test_service", {"entity_id": "light.kitchen"}), + ServiceCall( + hass, "test_domain", "test_service", {"entity_id": "light.kitchen"} + ), ) assert test_service_mock.call_count == 1 @@ -1368,6 +1389,7 @@ async def test_call_with_sync_attr(hass: HomeAssistant, mock_entities) -> None: mock_entities, "sync_method", ServiceCall( + hass, "test_domain", "test_service", {"entity_id": "light.kitchen", "area_id": "abcd"}, @@ -1386,6 +1408,7 @@ async def test_call_context_user_not_exist(hass: HomeAssistant) -> None: {}, Mock(), ServiceCall( + hass, "test_domain", "test_service", context=Context(user_id="non-existing"), @@ -1413,6 +1436,7 @@ async def test_call_context_target_all( mock_entities, Mock(), ServiceCall( + hass, "test_domain", "test_service", data={"entity_id": ENTITY_MATCH_ALL}, @@ -1441,6 +1465,7 @@ async def test_call_context_target_specific( mock_entities, Mock(), ServiceCall( + hass, "test_domain", "test_service", {"entity_id": "light.kitchen"}, @@ -1468,6 +1493,7 @@ async def test_call_context_target_specific_no_auth( mock_entities, Mock(), ServiceCall( + hass, "test_domain", "test_service", {"entity_id": "light.kitchen"}, @@ -1488,7 +1514,7 @@ async def test_call_no_context_target_all( mock_entities, Mock(), ServiceCall( - "test_domain", "test_service", data={"entity_id": ENTITY_MATCH_ALL} + hass, "test_domain", "test_service", data={"entity_id": ENTITY_MATCH_ALL} ), ) @@ -1507,6 +1533,7 @@ async def test_call_no_context_target_specific( mock_entities, Mock(), ServiceCall( + hass, "test_domain", "test_service", {"entity_id": ["light.kitchen", "light.non-existing"]}, @@ -1528,7 +1555,7 @@ async def test_call_with_match_all( hass, mock_entities, Mock(), - ServiceCall("test_domain", "test_service", {"entity_id": "all"}), + ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}), ) assert len(mock_handle_entity_call.mock_calls) == 4 @@ -1545,7 +1572,7 @@ async def test_call_with_omit_entity_id( hass, mock_entities, Mock(), - ServiceCall("test_domain", "test_service"), + ServiceCall(hass, "test_domain", "test_service"), ) assert len(mock_handle_entity_call.mock_calls) == 0 @@ -1791,7 +1818,7 @@ async def test_extract_from_service_available_device(hass: HomeAssistant) -> Non MockEntity(name="test_4", entity_id="test_domain.test_4", available=False), ] - call_1 = ServiceCall("test", "service", data={"entity_id": ENTITY_MATCH_ALL}) + call_1 = ServiceCall(hass, "test", "service", data={"entity_id": ENTITY_MATCH_ALL}) assert [ ent.entity_id @@ -1799,6 +1826,7 @@ async def test_extract_from_service_available_device(hass: HomeAssistant) -> Non ] == ["test_domain.test_1", "test_domain.test_3"] call_2 = ServiceCall( + hass, "test", "service", data={"entity_id": ["test_domain.test_3", "test_domain.test_4"]}, @@ -1814,6 +1842,7 @@ async def test_extract_from_service_available_device(hass: HomeAssistant) -> Non hass, entities, ServiceCall( + hass, "test", "service", data={"entity_id": ENTITY_MATCH_NONE}, @@ -1829,7 +1858,7 @@ async def test_extract_from_service_empty_if_no_entity_id(hass: HomeAssistant) - MockEntity(name="test_1", entity_id="test_domain.test_1"), MockEntity(name="test_2", entity_id="test_domain.test_2"), ] - call = ServiceCall("test", "service") + call = ServiceCall(hass, "test", "service") assert [ ent.entity_id @@ -1847,6 +1876,7 @@ async def test_extract_from_service_filter_out_non_existing_entities( ] call = ServiceCall( + hass, "test", "service", {"entity_id": ["test_domain.test_2", "test_domain.non_exist"]}, @@ -1868,12 +1898,14 @@ async def test_extract_from_service_area_id( MockEntity(name="diff_area", entity_id="light.diff_area"), ] - call = ServiceCall("light", "turn_on", {"area_id": "test-area"}) + call = ServiceCall(hass, "light", "turn_on", {"area_id": "test-area"}) extracted = await service.async_extract_entities(hass, entities, call) assert len(extracted) == 1 assert extracted[0].entity_id == "light.in_area" - call = ServiceCall("light", "turn_on", {"area_id": ["test-area", "diff-area"]}) + call = ServiceCall( + hass, "light", "turn_on", {"area_id": ["test-area", "diff-area"]} + ) extracted = await service.async_extract_entities(hass, entities, call) assert len(extracted) == 2 assert sorted(ent.entity_id for ent in extracted) == [ @@ -1882,6 +1914,7 @@ async def test_extract_from_service_area_id( ] call = ServiceCall( + hass, "light", "turn_on", {"area_id": ["test-area", "diff-area"], "device_id": "device-no-area-id"}, @@ -1906,17 +1939,17 @@ async def test_extract_from_service_label_id(hass: HomeAssistant) -> None: ), ] - call = ServiceCall("light", "turn_on", {"label_id": "label_area"}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": "label_area"}) extracted = await service.async_extract_entities(hass, entities, call) assert len(extracted) == 1 assert extracted[0].entity_id == "light.with_labels_from_device" - call = ServiceCall("light", "turn_on", {"label_id": "my-label"}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": "my-label"}) extracted = await service.async_extract_entities(hass, entities, call) assert len(extracted) == 1 assert extracted[0].entity_id == "light.with_my_label" - call = ServiceCall("light", "turn_on", {"label_id": ["my-label", "label1"]}) + call = ServiceCall(hass, "light", "turn_on", {"label_id": ["my-label", "label1"]}) extracted = await service.async_extract_entities(hass, entities, call) assert len(extracted) == 2 assert sorted(ent.entity_id for ent in extracted) == [ @@ -1925,6 +1958,7 @@ async def test_extract_from_service_label_id(hass: HomeAssistant) -> None: ] call = ServiceCall( + hass, "light", "turn_on", {"label_id": ["my-label", "label1"], "device_id": "device-no-labels"}, @@ -1943,6 +1977,7 @@ async def test_entity_service_call_warn_referenced( ) -> None: """Test we only warn for referenced entities in entity_service_call.""" call = ServiceCall( + hass, "light", "turn_on", { @@ -1966,6 +2001,7 @@ async def test_async_extract_entities_warn_referenced( ) -> None: """Test we only warn for referenced entities in async_extract_entities.""" call = ServiceCall( + hass, "light", "turn_on", { @@ -1991,6 +2027,7 @@ async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: device_no_entities = dr.DeviceEntry(id="device-no-entities", config_entries={"abc"}) call = ServiceCall( + hass, "homeassistant", "reload_config_entry", { @@ -2036,17 +2073,33 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloader = service.ReloadServiceHelper(reload_service_handler, reload_targets) tasks = [ # This reload task will start executing first, (target1) - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), # These reload tasks will be deduplicated to (target2, target3, target4, target1) # while the first task is reloaded, note that target1 can't be deduplicated # because it's already being reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), ] await asyncio.gather(*tasks) assert reloaded == unordered( @@ -2057,13 +2110,21 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloaded.clear() tasks = [ # This reload task will start executing first, (target1) - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), # These reload tasks will be deduplicated to (target2, target3, target4, all) # while the first task is reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test")), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service(ServiceCall(hass, "test", "test")), ] await asyncio.gather(*tasks) assert reloaded == unordered(["target1", "target2", "target3", "target4", "all"]) @@ -2072,13 +2133,21 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloaded.clear() tasks = [ # This reload task will start executing first, (all) - reloader.execute_service(ServiceCall("test", "test")), + reloader.execute_service(ServiceCall(hass, "test", "test")), # These reload tasks will be deduplicated to (target1, target2, target3, target4) # while the first task is reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), ] await asyncio.gather(*tasks) assert reloaded == unordered(["all", "target1", "target2", "target3", "target4"]) @@ -2087,21 +2156,45 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloaded.clear() tasks = [ # This reload task will start executing first, (target1) - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), # These reload tasks will be deduplicated to (target2, target3, target4, target1) # while the first task is reloaded, note that target1 can't be deduplicated # because it's already being reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), ] await asyncio.gather(*tasks) assert reloaded == unordered( @@ -2112,14 +2205,22 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloaded.clear() tasks = [ # This reload task will start executing first, (target1) - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), # These reload tasks will be deduplicated to (target2, target3, target4, all) # while the first task is reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test")), - reloader.execute_service(ServiceCall("test", "test")), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service(ServiceCall(hass, "test", "test")), + reloader.execute_service(ServiceCall(hass, "test", "test")), ] await asyncio.gather(*tasks) assert reloaded == unordered(["target1", "target2", "target3", "target4", "all"]) @@ -2128,17 +2229,33 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: reloaded.clear() tasks = [ # This reload task will start executing first, (all) - reloader.execute_service(ServiceCall("test", "test")), + reloader.execute_service(ServiceCall(hass, "test", "test")), # These reload tasks will be deduplicated to (target1, target2, target3, target4) # while the first task is reloaded. - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), - reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target1"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target2"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target3"}) + ), + reloader.execute_service( + ServiceCall(hass, "test", "test", {"target": "target4"}) + ), ] await asyncio.gather(*tasks) assert reloaded == unordered(["all", "target1", "target2", "target3", "target4"]) diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index 54c26997422..973d68b1f5c 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -10,7 +10,7 @@ import pytest from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET from homeassistant.core import HomeAssistant from homeassistant.helpers import sun -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util def test_next_events(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py index 16b5b8b652b..ad140834199 100644 --- a/tests/helpers/test_system_info.py +++ b/tests/helpers/test_system_info.py @@ -9,17 +9,7 @@ import pytest from homeassistant.components import hassio from homeassistant.const import __version__ as current_version from homeassistant.core import HomeAssistant -from homeassistant.helpers.system_info import async_get_system_info, is_official_image - - -async def test_is_official_image() -> None: - """Test is_official_image.""" - is_official_image.cache_clear() - with patch("homeassistant.helpers.system_info.os.path.isfile", return_value=True): - assert is_official_image() is True - is_official_image.cache_clear() - with patch("homeassistant.helpers.system_info.os.path.isfile", return_value=False): - assert is_official_image() is False +from homeassistant.helpers.system_info import async_get_system_info async def test_get_system_info(hass: HomeAssistant) -> None: @@ -93,10 +83,9 @@ async def test_container_installationtype(hass: HomeAssistant) -> None: assert info["installation_type"] == "Unsupported Third Party Container" -async def test_getuser_keyerror(hass: HomeAssistant) -> None: - """Test getuser keyerror.""" - with patch( - "homeassistant.helpers.system_info.cached_get_user", side_effect=KeyError - ): +@pytest.mark.parametrize("error", [KeyError, OSError]) +async def test_getuser_oserror(hass: HomeAssistant, error: Exception) -> None: + """Test getuser oserror.""" + with patch("homeassistant.helpers.system_info.cached_get_user", side_effect=error): info = await async_get_system_info(hass) assert info["user"] is None diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 628aea20900..b3a30806cbd 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -50,7 +50,7 @@ from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import TemplateVarsType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import UnitSystem @@ -1970,7 +1970,7 @@ def test_is_state(hass: HomeAssistant) -> None: def test_is_state_attr(hass: HomeAssistant) -> None: """Test is_state_attr method.""" - hass.states.async_set("test.object", "available", {"mode": "on"}) + hass.states.async_set("test.object", "available", {"mode": "on", "exists": None}) tpl = template.Template( """ {% if is_state_attr("test.object", "mode", "on") %}yes{% else %}no{% endif %} @@ -2003,6 +2003,22 @@ def test_is_state_attr(hass: HomeAssistant) -> None: ) assert tpl.async_render() == "test.object" + tpl = template.Template( + """ +{% if is_state_attr("test.object", "exists", None) %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{% if is_state_attr("test.object", "noexist", None) %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "no" + def test_state_attr(hass: HomeAssistant) -> None: """Test state_attr method.""" @@ -2110,6 +2126,7 @@ async def test_state_translated( hass.states.async_set("domain.is_unknown", "unknown", attributes={}) config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) entity_registry.async_get_or_create( "light", "hue", diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index d4a78807e2b..3593db9cf87 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -74,7 +74,7 @@ def test_load_translations_files_by_language( "name": "Other 4", "unit_of_measurement": "quantities", }, - "outlet": {"name": "Outlet " "{placeholder}"}, + "outlet": {"name": "Outlet {placeholder}"}, } }, "something": "else", diff --git a/tests/patch_recorder.py b/tests/patch_recorder.py index 4993e84fc30..e0e66de19a5 100644 --- a/tests/patch_recorder.py +++ b/tests/patch_recorder.py @@ -6,7 +6,7 @@ from contextlib import contextmanager import sys # Patch recorder util session scope -from homeassistant.helpers import recorder as recorder_helper # noqa: E402 +from homeassistant.helpers import recorder as recorder_helper # Make sure homeassistant.components.recorder.util is not already imported assert "homeassistant.components.recorder.util" not in sys.modules diff --git a/tests/ruff.toml b/tests/ruff.toml index bbfbfe1305d..c56b8f68ffc 100644 --- a/tests/ruff.toml +++ b/tests/ruff.toml @@ -10,6 +10,10 @@ extend-ignore = [ "SLF001", # Private member accessed: Tests do often test internals a lot ] +[lint.flake8-tidy-imports.banned-api] +"async_timeout".msg = "use asyncio.timeout instead" +"pytz".msg = "use zoneinfo instead" + [lint.isort] known-first-party = [ "homeassistant", diff --git a/tests/syrupy.py b/tests/syrupy.py index a3b3f763063..5b1e5faa23d 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -109,6 +109,8 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data = cls._serializable_issue_registry_entry(data) elif isinstance(data, dict) and "flow_id" in data and "handler" in data: serializable_data = cls._serializable_flow_result(data) + elif isinstance(data, dict) and set(data) == {"conversation_id", "response"}: + serializable_data = cls._serializable_conversation_result(data) elif isinstance(data, vol.Schema): serializable_data = voluptuous_serialize.convert(data) elif isinstance(data, ConfigEntry): @@ -200,6 +202,11 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): """Prepare a Home Assistant flow result for serialization.""" return FlowResultSnapshot(data | {"flow_id": ANY}) + @classmethod + def _serializable_conversation_result(cls, data: dict) -> SerializableData: + """Prepare a Home Assistant conversation result for serialization.""" + return data | {"conversation_id": ANY} + @classmethod def _serializable_issue_registry_entry( cls, data: ir.IssueEntry @@ -376,7 +383,7 @@ def override_syrupy_finish(self: SnapshotSession) -> int: with open(".pytest_syrupy_worker_count", "w", encoding="utf-8") as f: f.write(os.getenv("PYTEST_XDIST_WORKER_COUNT")) with open( - f".pytest_syrupy_{os.getenv("PYTEST_XDIST_WORKER")}_result", + f".pytest_syrupy_{os.getenv('PYTEST_XDIST_WORKER')}_result", "w", encoding="utf-8", ) as f: diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 44a05c0540e..10ea64a6a61 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -19,7 +19,29 @@ from .common import get_test_config_dir ( None, '{"path": "test"}', - backup_restore.RestoreBackupFileContent(backup_file_path=Path("test")), + None, + ), + ( + None, + '{"path": "test", "password": "psw", "remove_after_restore": false, "restore_database": false, "restore_homeassistant": true}', + backup_restore.RestoreBackupFileContent( + backup_file_path=Path("test"), + password="psw", + remove_after_restore=False, + restore_database=False, + restore_homeassistant=True, + ), + ), + ( + None, + '{"path": "test", "password": null, "remove_after_restore": true, "restore_database": true, "restore_homeassistant": false}', + backup_restore.RestoreBackupFileContent( + backup_file_path=Path("test"), + password=None, + remove_after_restore=True, + restore_database=True, + restore_homeassistant=False, + ), ), ], ) @@ -35,11 +57,14 @@ def test_reading_the_instruction_contents( return_value=content, side_effect=side_effect, ), + mock.patch("pathlib.Path.unlink", autospec=True) as unlink_mock, ): - read_content = backup_restore.restore_backup_file_content( - Path(get_test_config_dir()) - ) + config_path = Path(get_test_config_dir()) + read_content = backup_restore.restore_backup_file_content(config_path) assert read_content == expected + unlink_mock.assert_called_once_with( + config_path / ".HA_RESTORE", missing_ok=True + ) def test_restoring_backup_that_does_not_exist() -> None: @@ -49,7 +74,11 @@ def test_restoring_backup_that_does_not_exist() -> None: mock.patch( "homeassistant.backup_restore.restore_backup_file_content", return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path + backup_file_path=backup_file_path, + password=None, + remove_after_restore=False, + restore_database=True, + restore_homeassistant=True, ), ), mock.patch("pathlib.Path.read_text", side_effect=FileNotFoundError), @@ -78,7 +107,11 @@ def test_restoring_backup_that_is_not_a_file() -> None: mock.patch( "homeassistant.backup_restore.restore_backup_file_content", return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path + backup_file_path=backup_file_path, + password=None, + remove_after_restore=False, + restore_database=True, + restore_homeassistant=True, ), ), mock.patch("pathlib.Path.exists", return_value=True), @@ -102,7 +135,11 @@ def test_aborting_for_older_versions() -> None: mock.patch( "homeassistant.backup_restore.restore_backup_file_content", return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path + backup_file_path=backup_file_path, + password=None, + remove_after_restore=False, + restore_database=True, + restore_homeassistant=True, ), ), mock.patch("securetar.SecureTarFile"), @@ -117,14 +154,78 @@ def test_aborting_for_older_versions() -> None: assert backup_restore.restore_backup(config_dir) is True -def test_removal_of_current_configuration_when_restoring() -> None: +@pytest.mark.parametrize( + ( + "restore_backup_content", + "expected_removed_files", + "expected_removed_directories", + "expected_copied_files", + "expected_copied_trees", + ), + [ + ( + backup_restore.RestoreBackupFileContent( + backup_file_path=None, + password=None, + remove_after_restore=False, + restore_database=True, + restore_homeassistant=True, + ), + ( + ".HA_RESTORE", + ".HA_VERSION", + "home-assistant_v2.db", + "home-assistant_v2.db-wal", + ), + ("tmp_backups", "www"), + (), + ("data",), + ), + ( + backup_restore.RestoreBackupFileContent( + backup_file_path=None, + password=None, + restore_database=False, + remove_after_restore=False, + restore_homeassistant=True, + ), + (".HA_RESTORE", ".HA_VERSION"), + ("tmp_backups", "www"), + (), + ("data",), + ), + ( + backup_restore.RestoreBackupFileContent( + backup_file_path=None, + password=None, + restore_database=True, + remove_after_restore=False, + restore_homeassistant=False, + ), + ("home-assistant_v2.db", "home-assistant_v2.db-wal"), + (), + ("home-assistant_v2.db", "home-assistant_v2.db-wal"), + (), + ), + ], +) +def test_removal_of_current_configuration_when_restoring( + restore_backup_content: backup_restore.RestoreBackupFileContent, + expected_removed_files: tuple[str, ...], + expected_removed_directories: tuple[str, ...], + expected_copied_files: tuple[str, ...], + expected_copied_trees: tuple[str, ...], +) -> None: """Test that we are removing the current configuration directory.""" config_dir = Path(get_test_config_dir()) - backup_file_path = Path(config_dir, "backups", "test.tar") + restore_backup_content.backup_file_path = Path(config_dir, "backups", "test.tar") mock_config_dir = [ {"path": Path(config_dir, ".HA_RESTORE"), "is_file": True}, {"path": Path(config_dir, ".HA_VERSION"), "is_file": True}, + {"path": Path(config_dir, "home-assistant_v2.db"), "is_file": True}, + {"path": Path(config_dir, "home-assistant_v2.db-wal"), "is_file": True}, {"path": Path(config_dir, "backups"), "is_file": False}, + {"path": Path(config_dir, "tmp_backups"), "is_file": False}, {"path": Path(config_dir, "www"), "is_file": False}, ] @@ -140,12 +241,10 @@ def test_removal_of_current_configuration_when_restoring() -> None: with ( mock.patch( "homeassistant.backup_restore.restore_backup_file_content", - return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path - ), + return_value=restore_backup_content, ), mock.patch("securetar.SecureTarFile"), - mock.patch("homeassistant.backup_restore.TemporaryDirectory"), + mock.patch("homeassistant.backup_restore.TemporaryDirectory") as temp_dir_mock, mock.patch("homeassistant.backup_restore.HA_VERSION", "2013.09.17"), mock.patch("pathlib.Path.read_text", _patched_path_read_text), mock.patch("pathlib.Path.is_file", _patched_path_is_file), @@ -154,17 +253,33 @@ def test_removal_of_current_configuration_when_restoring() -> None: "pathlib.Path.iterdir", return_value=[x["path"] for x in mock_config_dir], ), - mock.patch("pathlib.Path.unlink") as unlink_mock, - mock.patch("shutil.rmtree") as rmtreemock, + mock.patch("pathlib.Path.unlink", autospec=True) as unlink_mock, + mock.patch("shutil.copy") as copy_mock, + mock.patch("shutil.copytree") as copytree_mock, + mock.patch("shutil.rmtree") as rmtree_mock, ): - assert backup_restore.restore_backup(config_dir) is True - assert unlink_mock.call_count == 2 - assert ( - rmtreemock.call_count == 1 - ) # We have 2 directories in the config directory, but backups is kept + temp_dir_mock.return_value.__enter__.return_value = "tmp" - removed_directories = {Path(call.args[0]) for call in rmtreemock.mock_calls} - assert removed_directories == {Path(config_dir, "www")} + assert backup_restore.restore_backup(config_dir) is True + + tmp_ha = Path("tmp", "homeassistant") + assert copy_mock.call_count == len(expected_copied_files) + copied_files = {Path(call.args[0]) for call in copy_mock.mock_calls} + assert copied_files == {Path(tmp_ha, "data", f) for f in expected_copied_files} + + assert copytree_mock.call_count == len(expected_copied_trees) + copied_trees = {Path(call.args[0]) for call in copytree_mock.mock_calls} + assert copied_trees == {Path(tmp_ha, t) for t in expected_copied_trees} + + assert unlink_mock.call_count == len(expected_removed_files) + removed_files = {Path(call.args[0]) for call in unlink_mock.mock_calls} + assert removed_files == {Path(config_dir, f) for f in expected_removed_files} + + assert rmtree_mock.call_count == len(expected_removed_directories) + removed_directories = {Path(call.args[0]) for call in rmtree_mock.mock_calls} + assert removed_directories == { + Path(config_dir, d) for d in expected_removed_directories + } def test_extracting_the_contents_of_a_backup_file() -> None: @@ -177,8 +292,8 @@ def test_extracting_the_contents_of_a_backup_file() -> None: getmembers_mock = mock.MagicMock( return_value=[ + tarfile.TarInfo(name="../data/test"), tarfile.TarInfo(name="data"), - tarfile.TarInfo(name="data/../test"), tarfile.TarInfo(name="data/.HA_VERSION"), tarfile.TarInfo(name="data/.storage"), tarfile.TarInfo(name="data/www"), @@ -190,7 +305,11 @@ def test_extracting_the_contents_of_a_backup_file() -> None: mock.patch( "homeassistant.backup_restore.restore_backup_file_content", return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path + backup_file_path=backup_file_path, + password=None, + remove_after_restore=False, + restore_database=True, + restore_homeassistant=True, ), ), mock.patch( @@ -205,11 +324,59 @@ def test_extracting_the_contents_of_a_backup_file() -> None: mock.patch("pathlib.Path.read_text", _patched_path_read_text), mock.patch("pathlib.Path.is_file", return_value=False), mock.patch("pathlib.Path.iterdir", return_value=[]), + mock.patch("shutil.copytree"), ): assert backup_restore.restore_backup(config_dir) is True - assert getmembers_mock.call_count == 1 assert extractall_mock.call_count == 2 assert { member.name for member in extractall_mock.mock_calls[-1].kwargs["members"] - } == {".HA_VERSION", ".storage", "www"} + } == {"data", "data/.HA_VERSION", "data/.storage", "data/www"} + + +@pytest.mark.parametrize( + ("remove_after_restore", "unlink_calls"), [(True, 1), (False, 0)] +) +def test_remove_backup_file_after_restore( + remove_after_restore: bool, unlink_calls: int +) -> None: + """Test removing a backup file after restore.""" + config_dir = Path(get_test_config_dir()) + backup_file_path = Path(config_dir, "backups", "test.tar") + + with ( + mock.patch( + "homeassistant.backup_restore.restore_backup_file_content", + return_value=backup_restore.RestoreBackupFileContent( + backup_file_path=backup_file_path, + password=None, + remove_after_restore=remove_after_restore, + restore_database=True, + restore_homeassistant=True, + ), + ), + mock.patch("homeassistant.backup_restore._extract_backup"), + mock.patch("pathlib.Path.unlink", autospec=True) as mock_unlink, + ): + assert backup_restore.restore_backup(config_dir) is True + assert mock_unlink.call_count == unlink_calls + for call in mock_unlink.mock_calls: + assert call.args[0] == backup_file_path + + +@pytest.mark.parametrize( + ("password", "expected"), + [ + ("test", b"\xf0\x9b\xb9\x1f\xdc,\xff\xd5x\xd6\xd6\x8fz\x19.\x0f"), + ("lorem ipsum...", b"#\xe0\xfc\xe0\xdb?_\x1f,$\rQ\xf4\xf5\xd8\xfb"), + ], +) +def test_pw_to_key(password: str | None, expected: bytes | None) -> None: + """Test password to key conversion.""" + assert backup_restore.password_to_key(password) == expected + + +def test_pw_to_key_none() -> None: + """Test password to key conversion.""" + with pytest.raises(AttributeError): + backup_restore.password_to_key(None) diff --git a/tests/test_block_async_io.py b/tests/test_block_async_io.py index dc2b096f595..f42fbb9f4ef 100644 --- a/tests/test_block_async_io.py +++ b/tests/test_block_async_io.py @@ -261,7 +261,7 @@ async def test_protect_path_read_bytes(caplog: pytest.LogCaptureFixture) -> None block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data_not_exist").read_bytes(), # noqa: ASYNC230 + Path("/config/data_not_exist").read_bytes(), ): pass @@ -274,7 +274,7 @@ async def test_protect_path_read_text(caplog: pytest.LogCaptureFixture) -> None: block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data_not_exist").read_text(encoding="utf8"), # noqa: ASYNC230 + Path("/config/data_not_exist").read_text(encoding="utf8"), ): pass @@ -287,7 +287,7 @@ async def test_protect_path_write_bytes(caplog: pytest.LogCaptureFixture) -> Non block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data/not/exist").write_bytes(b"xxx"), # noqa: ASYNC230 + Path("/config/data/not/exist").write_bytes(b"xxx"), ): pass @@ -300,7 +300,7 @@ async def test_protect_path_write_text(caplog: pytest.LogCaptureFixture) -> None block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data/not/exist").write_text("xxx", encoding="utf8"), # noqa: ASYNC230 + Path("/config/data/not/exist").write_text("xxx", encoding="utf8"), ): pass @@ -429,6 +429,12 @@ async def test_protect_loop_load_verify_locations( context.load_verify_locations("/dev/null") assert "Detected blocking call to load_verify_locations" in caplog.text + # ignore with only cadata + caplog.clear() + with pytest.raises(ssl.SSLError): + context.load_verify_locations(cadata="xxx") + assert "Detected blocking call to load_verify_locations" not in caplog.text + async def test_protect_loop_load_cert_chain( hass: HomeAssistant, caplog: pytest.LogCaptureFixture diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index a32d7d1e50b..5adfe4fc40b 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -12,8 +12,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant import bootstrap, loader, runner -import homeassistant.config as config_util +from homeassistant import bootstrap, config as config_util, loader, runner from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( BASE_PLATFORMS, @@ -1392,9 +1391,13 @@ async def test_bootstrap_does_not_preload_stage_1_integrations() -> None: assert process.returncode == 0 decoded_stdout = stdout.decode() + disallowed_integrations = bootstrap.STAGE_1_INTEGRATIONS.copy() + # zeroconf is a top level dep now + disallowed_integrations.remove("zeroconf") + # Ensure no stage1 integrations have been imported # as a side effect of importing the pre-imports - for integration in bootstrap.STAGE_1_INTEGRATIONS: + for integration in disallowed_integrations: assert f"homeassistant.components.{integration}" not in decoded_stdout diff --git a/tests/test_config.py b/tests/test_config.py index c8c5b081119..569af3238d0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,8 +15,7 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol import yaml -from homeassistant import loader -import homeassistant.config as config_util +from homeassistant import config as config_util, loader from homeassistant.const import CONF_PACKAGES, __version__ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigValidationError, HomeAssistantError diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index aba85a35349..3ea1a16e898 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -16,7 +16,6 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries, data_entry_flow, loader -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, @@ -41,12 +40,13 @@ from homeassistant.helpers import entity_registry as er, frame, issue_registry a from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from .common import ( @@ -2645,9 +2645,7 @@ async def test_unique_id_from_discovery_in_setup_retry( VERSION = 1 - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> FlowResult: + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: """Test dhcp step.""" await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @@ -2683,7 +2681,7 @@ async def test_unique_id_from_discovery_in_setup_retry( discovery_result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=host, macaddress=unique_id, @@ -7266,9 +7264,9 @@ async def test_unique_id_collision_issues( mock_setup_entry = AsyncMock(return_value=True) for i in range(3): mock_integration( - hass, MockModule(f"test{i+1}", async_setup_entry=mock_setup_entry) + hass, MockModule(f"test{i + 1}", async_setup_entry=mock_setup_entry) ) - mock_platform(hass, f"test{i+1}.config_flow", None) + mock_platform(hass, f"test{i + 1}.config_flow", None) test2_group_1: list[MockConfigEntry] = [] test2_group_2: list[MockConfigEntry] = [] diff --git a/tests/test_const.py b/tests/test_const.py index ca598de39e1..a039545a004 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -8,7 +8,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant import const -from homeassistant.components import alarm_control_panel, lock, sensor +from homeassistant.components import alarm_control_panel, lock from .common import ( extract_stack_to_frame, @@ -29,164 +29,9 @@ def test_all() -> None: help_test_all(const) -@pytest.mark.parametrize( - ("enum", "constant_prefix"), - _create_tuples(const.EntityCategory, "ENTITY_CATEGORY_") - + _create_tuples( - [ - sensor.SensorDeviceClass.AQI, - sensor.SensorDeviceClass.BATTERY, - sensor.SensorDeviceClass.CO, - sensor.SensorDeviceClass.CO2, - sensor.SensorDeviceClass.CURRENT, - sensor.SensorDeviceClass.DATE, - sensor.SensorDeviceClass.ENERGY, - sensor.SensorDeviceClass.FREQUENCY, - sensor.SensorDeviceClass.GAS, - sensor.SensorDeviceClass.HUMIDITY, - sensor.SensorDeviceClass.ILLUMINANCE, - sensor.SensorDeviceClass.MONETARY, - sensor.SensorDeviceClass.NITROGEN_DIOXIDE, - sensor.SensorDeviceClass.NITROGEN_MONOXIDE, - sensor.SensorDeviceClass.NITROUS_OXIDE, - sensor.SensorDeviceClass.OZONE, - sensor.SensorDeviceClass.PM1, - sensor.SensorDeviceClass.PM10, - sensor.SensorDeviceClass.PM25, - sensor.SensorDeviceClass.POWER_FACTOR, - sensor.SensorDeviceClass.POWER, - sensor.SensorDeviceClass.PRESSURE, - sensor.SensorDeviceClass.SIGNAL_STRENGTH, - sensor.SensorDeviceClass.SULPHUR_DIOXIDE, - sensor.SensorDeviceClass.TEMPERATURE, - sensor.SensorDeviceClass.TIMESTAMP, - sensor.SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, - sensor.SensorDeviceClass.VOLTAGE, - ], - "DEVICE_CLASS_", - ) - + _create_tuples(const.UnitOfApparentPower, "POWER_") - + _create_tuples( - [ - const.UnitOfPower.WATT, - const.UnitOfPower.KILO_WATT, - const.UnitOfPower.BTU_PER_HOUR, - ], - "POWER_", - ) - + _create_tuples( - [ - const.UnitOfEnergy.KILO_WATT_HOUR, - const.UnitOfEnergy.MEGA_WATT_HOUR, - const.UnitOfEnergy.WATT_HOUR, - ], - "ENERGY_", - ) - + _create_tuples(const.UnitOfElectricCurrent, "ELECTRIC_CURRENT_") - + _create_tuples( - [ - const.UnitOfElectricPotential.MILLIVOLT, - const.UnitOfElectricPotential.VOLT, - ], - "ELECTRIC_POTENTIAL_", - ) - + _create_tuples(const.UnitOfTemperature, "TEMP_") - + _create_tuples(const.UnitOfTime, "TIME_") - + _create_tuples( - [ - const.UnitOfLength.MILLIMETERS, - const.UnitOfLength.CENTIMETERS, - const.UnitOfLength.METERS, - const.UnitOfLength.KILOMETERS, - const.UnitOfLength.INCHES, - const.UnitOfLength.FEET, - const.UnitOfLength.MILES, - ], - "LENGTH_", - ) - + _create_tuples(const.UnitOfFrequency, "FREQUENCY_") - + _create_tuples(const.UnitOfPressure, "PRESSURE_") - + _create_tuples( - [ - const.UnitOfVolume.CUBIC_FEET, - const.UnitOfVolume.CUBIC_METERS, - const.UnitOfVolume.LITERS, - const.UnitOfVolume.MILLILITERS, - const.UnitOfVolume.GALLONS, - ], - "VOLUME_", - ) - + _create_tuples( - [ - const.UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - const.UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, - ], - "VOLUME_FLOW_RATE_", - ) - + _create_tuples( - [ - const.UnitOfMass.GRAMS, - const.UnitOfMass.KILOGRAMS, - const.UnitOfMass.MILLIGRAMS, - const.UnitOfMass.MICROGRAMS, - const.UnitOfMass.OUNCES, - const.UnitOfMass.POUNDS, - ], - "MASS_", - ) - + _create_tuples(const.UnitOfIrradiance, "IRRADIATION_") - + _create_tuples( - [ - const.UnitOfPrecipitationDepth.INCHES, - const.UnitOfPrecipitationDepth.MILLIMETERS, - const.UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - const.UnitOfVolumetricFlux.INCHES_PER_HOUR, - ], - "PRECIPITATION_", - ) - + _create_tuples( - [ - const.UnitOfSpeed.FEET_PER_SECOND, - const.UnitOfSpeed.METERS_PER_SECOND, - const.UnitOfSpeed.KILOMETERS_PER_HOUR, - const.UnitOfSpeed.KNOTS, - const.UnitOfSpeed.MILES_PER_HOUR, - ], - "SPEED_", - ) - + _create_tuples( - [ - const.UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, - const.UnitOfVolumetricFlux.INCHES_PER_DAY, - const.UnitOfVolumetricFlux.INCHES_PER_HOUR, - ], - "SPEED_", - ) - + _create_tuples(const.UnitOfInformation, "DATA_") - + _create_tuples(const.UnitOfDataRate, "DATA_RATE_"), -) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: Enum, - constant_prefix: str, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, const, enum, constant_prefix, "2025.1" - ) - - @pytest.mark.parametrize( ("replacement", "constant_name", "breaks_in_version"), [ - (const.UnitOfLength.YARDS, "LENGTH_YARD", "2025.1"), - (const.UnitOfSoundPressure.DECIBEL, "SOUND_PRESSURE_DB", "2025.1"), - ( - const.UnitOfSoundPressure.WEIGHTED_DECIBEL_A, - "SOUND_PRESSURE_WEIGHTED_DBA", - "2025.1", - ), - (const.UnitOfVolume.FLUID_OUNCES, "VOLUME_FLUID_OUNCE", "2025.1"), (const.UnitOfArea.SQUARE_METERS, "AREA_SQUARE_METERS", "2025.12"), ], ) diff --git a/tests/test_core.py b/tests/test_core.py index df2d916e166..ceab3ce327c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -20,6 +20,7 @@ import pytest from pytest_unordered import unordered import voluptuous as vol +from homeassistant import core as ha from homeassistant.const import ( ATTR_FRIENDLY_NAME, EVENT_CALL_SERVICE, @@ -35,7 +36,6 @@ from homeassistant.const import ( EVENT_STATE_REPORTED, MATCH_ALL, ) -import homeassistant.core as ha from homeassistant.core import ( CoreState, HassJob, @@ -59,8 +59,8 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers.json import json_dumps from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from homeassistant.util.read_only_dict import ReadOnlyDict from .common import ( @@ -68,7 +68,6 @@ from .common import ( async_mock_service, help_test_all, import_and_test_deprecated_alias, - import_and_test_deprecated_constant_enum, ) PST = dt_util.get_time_zone("America/Los_Angeles") @@ -1563,10 +1562,10 @@ async def test_statemachine_avoids_updating_attributes(hass: HomeAssistant) -> N def test_service_call_repr() -> None: """Test ServiceCall repr.""" - call = ha.ServiceCall("homeassistant", "start") + call = ha.ServiceCall(None, "homeassistant", "start") assert str(call) == f"" - call2 = ha.ServiceCall("homeassistant", "start", {"fast": "yes"}) + call2 = ha.ServiceCall(None, "homeassistant", "start", {"fast": "yes"}) assert ( str(call2) == f"" @@ -2980,22 +2979,6 @@ def test_all() -> None: help_test_all(ha) -@pytest.mark.parametrize( - ("enum"), - [ - ha.ConfigSource.DISCOVERED, - ha.ConfigSource.YAML, - ha.ConfigSource.STORAGE, - ], -) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: ha.ConfigSource, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum(caplog, ha, enum, "SOURCE_", "2025.1") - - def test_deprecated_config(caplog: pytest.LogCaptureFixture) -> None: """Test deprecated Config class.""" import_and_test_deprecated_alias(caplog, ha, "Config", Config, "2025.11") diff --git a/tests/test_core_config.py b/tests/test_core_config.py index cd77e3608dd..2723c8e7196 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -46,7 +46,7 @@ from homeassistant.util.unit_system import ( UnitSystem, ) -from .common import MockUser, async_capture_events +from .common import MockEntityPlatform, MockUser, async_capture_events def test_core_config_schema() -> None: @@ -181,7 +181,8 @@ def test_validate_stun_or_turn_url() -> None: invalid_urls = ( "custom_stun_server", "custom_stun_server:3478", - "bum:custom_stun_server:3478" "http://blah.com:80", + "bum:custom_stun_server:3478", + "http://blah.com:80", ) valid_urls = ( @@ -221,6 +222,7 @@ async def _compute_state(hass: HomeAssistant, config: dict[str, Any]) -> State | entity = Entity() entity.entity_id = "test.test" entity.hass = hass + entity.platform = MockEntityPlatform(hass) entity.schedule_update_ha_state() await hass.async_block_till_done() @@ -1080,3 +1082,27 @@ async def test_set_time_zone_deprecated(hass: HomeAssistant) -> None: ), ): await hass.config.set_time_zone("America/New_York") + + +async def test_core_config_schema_imperial_unit( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test core config schema.""" + await async_process_ha_core_config( + hass, + { + "latitude": 60, + "longitude": 50, + "elevation": 25, + "name": "Home", + "unit_system": "imperial", + "time_zone": "America/New_York", + "currency": "USD", + "country": "US", + "language": "en", + "radius": 150, + }, + ) + + issue = issue_registry.async_get_issue("homeassistant", "imperial_unit_system") + assert issue diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 32020ac0d76..74a55cb4989 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -13,11 +13,7 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.util.decorator import Registry -from .common import ( - async_capture_events, - help_test_all, - import_and_test_deprecated_constant_enum, -) +from .common import async_capture_events class MockFlowManager(data_entry_flow.FlowManager): @@ -985,22 +981,6 @@ async def test_find_flows_by_init_data_type(manager: MockFlowManager) -> None: assert len(manager.async_progress()) == 0 -def test_all() -> None: - """Test module.__all__ is correctly set.""" - help_test_all(data_entry_flow) - - -@pytest.mark.parametrize(("enum"), list(data_entry_flow.FlowResultType)) -def test_deprecated_constants( - caplog: pytest.LogCaptureFixture, - enum: data_entry_flow.FlowResultType, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, data_entry_flow, enum, "RESULT_TYPE_", "2025.1" - ) - - def test_section_in_serializer() -> None: """Test section with custom_serializer.""" assert cv.custom_serializer( diff --git a/tests/test_loader.py b/tests/test_loader.py index a39bd63ad0d..4c3c4eb309f 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -547,6 +547,7 @@ def test_integration_properties(hass: HomeAssistant) -> None: ], "mqtt": ["hue/discovery"], "version": "1.0.0", + "quality_scale": "gold", }, ) assert integration.name == "Philips Hue" @@ -585,6 +586,7 @@ def test_integration_properties(hass: HomeAssistant) -> None: assert integration.is_built_in is True assert integration.overwrites_built_in is False assert integration.version == "1.0.0" + assert integration.quality_scale == "gold" integration = loader.Integration( hass, @@ -595,6 +597,7 @@ def test_integration_properties(hass: HomeAssistant) -> None: "domain": "hue", "dependencies": ["test-dep"], "requirements": ["test-req==1.0.0"], + "quality_scale": "gold", }, ) assert integration.is_built_in is False @@ -607,6 +610,7 @@ def test_integration_properties(hass: HomeAssistant) -> None: assert integration.ssdp is None assert integration.mqtt is None assert integration.version is None + assert integration.quality_scale == "custom" integration = loader.Integration( hass, diff --git a/tests/testing_config/custom_sentences/en/beer.yaml b/tests/testing_config/custom_sentences/en/beer.yaml index f318e0221b2..7222ffcb0ca 100644 --- a/tests/testing_config/custom_sentences/en/beer.yaml +++ b/tests/testing_config/custom_sentences/en/beer.yaml @@ -3,11 +3,11 @@ intents: OrderBeer: data: - sentences: - - "I'd like to order a {beer_style} [please]" + - "[I'd like to ]order a {beer_style} [please]" OrderFood: data: - sentences: - - "I'd like to order {food_name:name} [please]" + - "[I'd like to ]order {food_name:name} [please]" lists: beer_style: values: diff --git a/tests/typing.py b/tests/typing.py index 7b61949a9c4..5bcb1a01104 100644 --- a/tests/typing.py +++ b/tests/typing.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +from contextlib import AbstractAsyncContextManager from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock @@ -30,6 +31,10 @@ type MqttMockHAClient = MagicMock """MagicMock for `homeassistant.components.mqtt.MQTT`.""" type MqttMockHAClientGenerator = Callable[..., Coroutine[Any, Any, MqttMockHAClient]] """MagicMock generator for `homeassistant.components.mqtt.MQTT`.""" +type RecorderInstanceContextManager = Callable[ + ..., AbstractAsyncContextManager[Recorder] +] +"""ContextManager for `homeassistant.components.recorder.Recorder`.""" type RecorderInstanceGenerator = Callable[..., Coroutine[Any, Any, Recorder]] """Instance generator for `homeassistant.components.recorder.Recorder`.""" type WebSocketGenerator = Callable[..., Coroutine[Any, Any, MockHAClientWebSocket]] diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 165552b8792..bcaf392b513 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -6,7 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util GAMUT = color_util.GamutType( color_util.XYPoint(0.704, 0.296), diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 0e8432bbb83..96ba8d0a325 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime, timedelta import pytest -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util DEFAULT_TIME_ZONE = dt_util.get_default_time_zone() TEST_TIME_ZONE = "America/Los_Angeles" @@ -116,10 +116,14 @@ def test_utc_from_timestamp() -> None: ) -def test_timestamp_to_utc() -> None: +def test_timestamp_to_utc(caplog: pytest.LogCaptureFixture) -> None: """Test we can convert a utc datetime to a timestamp.""" utc_now = dt_util.utcnow() assert dt_util.utc_to_timestamp(utc_now) == utc_now.timestamp() + assert ( + "utc_to_timestamp is a deprecated function which will be removed " + "in HA Core 2026.1. Use datetime.timestamp instead" in caplog.text + ) def test_as_timestamp() -> None: diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 759f0d6e5ea..111b086b48b 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util def test_raise_if_invalid_filename() -> None: diff --git a/tests/util/test_location.py b/tests/util/test_location.py index 3af3ad2765a..ecb54eeeaa9 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -7,7 +7,7 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.util.location as location_util +from homeassistant.util import location as location_util from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index 795444c89bd..e5b85f35693 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -14,7 +14,7 @@ from homeassistant.core import ( is_callback, is_callback_check_partial, ) -import homeassistant.util.logging as logging_util +from homeassistant.util import logging as logging_util async def test_logging_with_queue_handler() -> None: diff --git a/tests/util/test_network.py b/tests/util/test_network.py index 4bb6f94e684..c234a517640 100644 --- a/tests/util/test_network.py +++ b/tests/util/test_network.py @@ -2,7 +2,7 @@ from ipaddress import ip_address -import homeassistant.util.network as network_util +from homeassistant.util import network as network_util def test_is_loopback() -> None: diff --git a/tests/util/test_package.py b/tests/util/test_package.py index b7497d620cd..e3635dd2bea 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -410,3 +410,47 @@ def test_check_package_previous_failed_install() -> None: with patch("homeassistant.util.package.version", return_value=None): assert not package.is_installed(installed_package) assert not package.is_installed(f"{installed_package}=={installed_version}") + + +@pytest.mark.parametrize("dockerenv", [True, False], ids=["dockerenv", "not_dockerenv"]) +@pytest.mark.parametrize( + "containerenv", [True, False], ids=["containerenv", "not_containerenv"] +) +@pytest.mark.parametrize( + "kubernetes_service_host", [True, False], ids=["kubernetes", "not_kubernetes"] +) +@pytest.mark.parametrize( + "is_official_image", [True, False], ids=["official_image", "not_official_image"] +) +async def test_is_docker_env( + dockerenv: bool, + containerenv: bool, + kubernetes_service_host: bool, + is_official_image: bool, +) -> None: + """Test is_docker_env.""" + + def new_path_mock(path: str): + mock = Mock() + if path == "/.dockerenv": + mock.exists.return_value = dockerenv + elif path == "/run/.containerenv": + mock.exists.return_value = containerenv + return mock + + env = {} + if kubernetes_service_host: + env["KUBERNETES_SERVICE_HOST"] = "True" + + package.is_docker_env.cache_clear() + with ( + patch("homeassistant.util.package.Path", side_effect=new_path_mock), + patch( + "homeassistant.util.package.is_official_image", + return_value=is_official_image, + ), + patch.dict(os.environ, env), + ): + assert package.is_docker_env() is any( + [dockerenv, containerenv, kubernetes_service_host, is_official_image] + ) diff --git a/tests/util/test_system_info.py b/tests/util/test_system_info.py new file mode 100644 index 00000000000..270e91d37db --- /dev/null +++ b/tests/util/test_system_info.py @@ -0,0 +1,15 @@ +"""Tests for the system info helper.""" + +from unittest.mock import patch + +from homeassistant.util.system_info import is_official_image + + +async def test_is_official_image() -> None: + """Test is_official_image.""" + is_official_image.cache_clear() + with patch("homeassistant.util.system_info.os.path.isfile", return_value=True): + assert is_official_image() is True + is_official_image.cache_clear() + with patch("homeassistant.util.system_info.os.path.isfile", return_value=False): + assert is_official_image() is False diff --git a/tests/util/test_ulid.py b/tests/util/test_ulid.py index dc0f21ce3c7..6f9911fe557 100644 --- a/tests/util/test_ulid.py +++ b/tests/util/test_ulid.py @@ -2,7 +2,7 @@ import uuid -import homeassistant.util.ulid as ulid_util +from homeassistant.util import ulid as ulid_util async def test_ulid_util_uuid_hex() -> None: diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 4d1eda3d8de..1336364f4cb 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -435,12 +435,28 @@ _CONVERTED_VALUE: dict[ ElectricPotentialConverter: [ (5, UnitOfElectricPotential.VOLT, 5000, UnitOfElectricPotential.MILLIVOLT), (5, UnitOfElectricPotential.VOLT, 5e6, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.VOLT, 5e-3, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.VOLT, 5e-6, UnitOfElectricPotential.MEGAVOLT), (5, UnitOfElectricPotential.MILLIVOLT, 0.005, UnitOfElectricPotential.VOLT), (5, UnitOfElectricPotential.MILLIVOLT, 5e3, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.MILLIVOLT, 5e-6, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.MILLIVOLT, 5e-9, UnitOfElectricPotential.MEGAVOLT), (5, UnitOfElectricPotential.MICROVOLT, 5e-3, UnitOfElectricPotential.MILLIVOLT), (5, UnitOfElectricPotential.MICROVOLT, 5e-6, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.MICROVOLT, 5e-9, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.MICROVOLT, 5e-12, UnitOfElectricPotential.MEGAVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e9, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e6, UnitOfElectricPotential.MILLIVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e3, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e-3, UnitOfElectricPotential.MEGAVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e12, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e9, UnitOfElectricPotential.MILLIVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e6, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e3, UnitOfElectricPotential.KILOVOLT), ], EnergyConverter: [ + (10, UnitOfEnergy.MILLIWATT_HOUR, 0.00001, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.WATT_HOUR, 10000, UnitOfEnergy.MILLIWATT_HOUR), (10, UnitOfEnergy.WATT_HOUR, 0.01, UnitOfEnergy.KILO_WATT_HOUR), (10, UnitOfEnergy.WATT_HOUR, 0.00001, UnitOfEnergy.MEGA_WATT_HOUR), (10, UnitOfEnergy.WATT_HOUR, 0.00000001, UnitOfEnergy.GIGA_WATT_HOUR), @@ -535,6 +551,7 @@ _CONVERTED_VALUE: dict[ (10, UnitOfPower.GIGA_WATT, 10e9, UnitOfPower.WATT), (10, UnitOfPower.TERA_WATT, 10e12, UnitOfPower.WATT), (10, UnitOfPower.WATT, 0.01, UnitOfPower.KILO_WATT), + (10, UnitOfPower.MILLIWATT, 0.01, UnitOfPower.WATT), ], PressureConverter: [ (1000, UnitOfPressure.HPA, 14.5037743897, UnitOfPressure.PSI), diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index b2c604acbcf..ddefe92de42 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -24,6 +24,8 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumetricFlux, ) +from homeassistant.core import HomeAssistant +from homeassistant.core_config import async_process_ha_core_config from homeassistant.exceptions import HomeAssistantError from homeassistant.util.unit_system import ( # pylint: disable=hass-deprecated-import _CONF_UNIT_SYSTEM_IMPERIAL, @@ -877,3 +879,35 @@ def test_imperial_converted_units(device_class: SensorDeviceClass) -> None: assert (device_class, unit) not in unit_system._conversions continue assert (device_class, unit) in unit_system._conversions + + +async def test_imperial_deprecated_log_warning( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test deprecated imperial unit system logs warning.""" + await async_process_ha_core_config( + hass, + { + "latitude": 60, + "longitude": 50, + "elevation": 25, + "name": "Home", + "unit_system": "imperial", + "time_zone": "America/New_York", + "currency": "USD", + "country": "US", + "language": "en", + "radius": 150, + }, + ) + + assert hass.config.latitude == 60 + assert hass.config.longitude == 50 + assert hass.config.elevation == 25 + assert hass.config.location_name == "Home" + assert hass.config.units is US_CUSTOMARY_SYSTEM + assert hass.config.time_zone == "America/New_York" + assert hass.config.currency == "USD" + assert hass.config.country == "US" + assert hass.config.language == "en" + assert hass.config.radius == 150 diff --git a/tests/util/test_uuid.py b/tests/util/test_uuid.py index e5a1022ef1d..9d78b634149 100644 --- a/tests/util/test_uuid.py +++ b/tests/util/test_uuid.py @@ -2,7 +2,7 @@ import uuid -import homeassistant.util.uuid as uuid_util +from homeassistant.util import uuid as uuid_util async def test_uuid_util_random_uuid_hex() -> None: diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 12a7eca5f9d..0346e21044f 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -15,7 +15,7 @@ import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from homeassistant.util.yaml import loader as yaml_loader from tests.common import extract_stack_to_frame @@ -86,7 +86,7 @@ def test_unhashable_key() -> None: def test_no_key() -> None: """Test item without a key.""" with pytest.raises(HomeAssistantError): - yaml.load_yaml(YAML_CONFIG_FILE) + yaml_util.load_yaml(YAML_CONFIG_FILE) @pytest.mark.usefixtures("try_both_loaders") @@ -386,13 +386,13 @@ def test_load_yaml_encoding_error(mock_open: Mock) -> None: @pytest.mark.usefixtures("try_both_dumpers") def test_dump() -> None: """The that the dump method returns empty None values.""" - assert yaml.dump({"a": None, "b": "b"}) == "a:\nb: b\n" + assert yaml_util.dump({"a": None, "b": "b"}) == "a:\nb: b\n" @pytest.mark.usefixtures("try_both_dumpers") def test_dump_unicode() -> None: """The that the dump method returns empty None values.""" - assert yaml.dump({"a": None, "b": "привет"}) == "a:\nb: привет\n" + assert yaml_util.dump({"a": None, "b": "привет"}) == "a:\nb: привет\n" @pytest.mark.parametrize("hass_config_yaml", ['key: [1, "2", 3]']) @@ -400,7 +400,7 @@ def test_dump_unicode() -> None: def test_representing_yaml_loaded_data() -> None: """Test we can represent YAML loaded data.""" data = load_yaml_config_file(YAML_CONFIG_FILE) - assert yaml.dump(data) == "key:\n- 1\n- '2'\n- 3\n" + assert yaml_util.dump(data) == "key:\n- 1\n- '2'\n- 3\n" @pytest.mark.parametrize("hass_config_yaml", ["key: thing1\nkey: thing2"]) @@ -413,7 +413,7 @@ def test_duplicate_key(caplog: pytest.LogCaptureFixture) -> None: @pytest.mark.parametrize( "hass_config_yaml_files", - [{YAML_CONFIG_FILE: "key: !secret a", yaml.SECRET_YAML: "a: 1\nb: !secret a"}], + [{YAML_CONFIG_FILE: "key: !secret a", yaml_util.SECRET_YAML: "a: 1\nb: !secret a"}], ) @pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") def test_no_recursive_secrets() -> None: @@ -426,8 +426,8 @@ def test_no_recursive_secrets() -> None: def test_input_class() -> None: """Test input class.""" - yaml_input = yaml.Input("hello") - yaml_input2 = yaml.Input("hello") + yaml_input = yaml_util.Input("hello") + yaml_input2 = yaml_util.Input("hello") assert yaml_input.name == "hello" assert yaml_input == yaml_input2 @@ -438,8 +438,8 @@ def test_input_class() -> None: @pytest.mark.usefixtures("try_both_loaders", "try_both_dumpers") def test_input() -> None: """Test loading inputs.""" - data = {"hello": yaml.Input("test_name")} - assert yaml.parse_yaml(yaml.dump(data)) == data + data = {"hello": yaml_util.Input("test_name")} + assert yaml_util.parse_yaml(yaml_util.dump(data)) == data @pytest.mark.skipif( @@ -448,7 +448,7 @@ def test_input() -> None: ) def test_c_loader_is_available_in_ci() -> None: """Verify we are testing the C loader in the CI.""" - assert yaml.loader.HAS_C_LOADER is True + assert yaml_util.loader.HAS_C_LOADER is True @pytest.mark.usefixtures("try_both_loaders") @@ -552,7 +552,7 @@ def test_string_used_as_vol_schema() -> None: @pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") def test_load_yaml_dict(expected_data: Any) -> None: """Test item without a key.""" - assert yaml.load_yaml_dict(YAML_CONFIG_FILE) == expected_data + assert yaml_util.load_yaml_dict(YAML_CONFIG_FILE) == expected_data @pytest.mark.parametrize("hass_config_yaml", ["abc", "123", "[]"]) diff --git a/tests/util/yaml/test_secrets.py b/tests/util/yaml/test_secrets.py index 35b5ae319c4..4d89bfb8712 100644 --- a/tests/util/yaml/test_secrets.py +++ b/tests/util/yaml/test_secrets.py @@ -8,7 +8,7 @@ import pytest from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from homeassistant.util.yaml import loader as yaml_loader from tests.common import get_test_config_dir, patch_yaml_files @@ -63,7 +63,7 @@ def default_config(filepaths: dict[str, Path]) -> YamlFile: def default_secrets(filepaths: dict[str, Path]) -> YamlFile: """Return the default secrets file for testing.""" return YamlFile( - path=filepaths["config"] / yaml.SECRET_YAML, + path=filepaths["config"] / yaml_util.SECRET_YAML, contents=( "http_pw: pwhttp\n" "comp1_un: un1\n" @@ -112,7 +112,8 @@ def test_secret_overrides_parent( path=filepaths["sub_folder"] / "sub.yaml", contents=default_config.contents ) sub_secrets = YamlFile( - path=filepaths["sub_folder"] / yaml.SECRET_YAML, contents="http_pw: override" + path=filepaths["sub_folder"] / yaml_util.SECRET_YAML, + contents="http_pw: override", ) loaded_file = load_config_file( @@ -133,7 +134,7 @@ def test_secrets_from_unrelated_fails( contents="http:\n api_password: !secret test", ) unrelated_secrets = YamlFile( - path=filepaths["unrelated"] / yaml.SECRET_YAML, contents="test: failure" + path=filepaths["unrelated"] / yaml_util.SECRET_YAML, contents="test: failure" ) with pytest.raises(HomeAssistantError, match="Secret test not defined"): load_config_file( @@ -162,7 +163,8 @@ def test_bad_logger_value( path=filepaths["config"] / YAML_CONFIG_FILE, contents="api_password: !secret pw" ) secrets_file = YamlFile( - path=filepaths["config"] / yaml.SECRET_YAML, contents="logger: info\npw: abc" + path=filepaths["config"] / yaml_util.SECRET_YAML, + contents="logger: info\npw: abc", ) with caplog.at_level(logging.ERROR): load_config_file(config_file.path, [config_file, secrets_file]) @@ -178,7 +180,7 @@ def test_secrets_are_not_dict( ) -> None: """Did secrets handle non-dict file.""" non_dict_secrets = YamlFile( - path=filepaths["config"] / yaml.SECRET_YAML, + path=filepaths["config"] / yaml_util.SECRET_YAML, contents="- http_pw: pwhttp\n comp1_un: un1\n comp1_pw: pw1\n", ) with pytest.raises(HomeAssistantError, match="Secrets is not a dictionary"):